From 67b4dc05f81c5e0fdd56481e7569dc4444628b08 Mon Sep 17 00:00:00 2001 From: Shripal Jain Date: Thu, 26 Feb 2026 21:43:17 +0100 Subject: [PATCH 1/8] Implement searching for scrcpy options - Still unpolished, this is the output from antigravity with Claude Opus 4.6 --- assets/i18n/en.json | 4 + .../arguments/control/gamepad_mode.dart | 2 +- .../arguments/control/keyboard_mode.dart | 2 +- .../scrcpy/arguments/control/mouse_mode.dart | 2 +- .../scrcpy/arguments/control/no_control.dart | 2 +- .../scrcpy/arguments/control/push_target.dart | 2 +- lib/application/model/search_result.dart | 43 +++++ .../profiles_bloc/profiles_bloc.dart | 1 - lib/application/scrcpy_bloc/scrcpy_bloc.dart | 1 - lib/main.dart | 6 +- lib/objectbox.g.dart | 48 ++--- lib/presentation/home/home_screen.dart | 3 + .../scrcpy_config/control/control_screen.dart | 3 +- .../scrcpy_config/video/video_screen.dart | 1 - .../scrcpy_config/widgets/config_item.dart | 3 + .../widgets/config_item_base.dart | 166 +++++++++++++----- .../widgets/highlight_provider.dart | 20 +++ lib/presentation/search/bloc/search_bloc.dart | 60 +++++++ .../search/bloc/search_event.dart | 28 +++ .../search/bloc/search_state.dart | 22 +++ lib/presentation/search/search_widget.dart | 106 +++++++++++ lib/routes.dart | 49 +++++- specs/search/implementation_plan.md | 162 +++++++++++++++++ specs/search/prompt.md | 61 +++++++ specs/search/task.md | 22 +++ 25 files changed, 714 insertions(+), 105 deletions(-) create mode 100644 lib/application/model/search_result.dart create mode 100644 lib/presentation/scrcpy_config/widgets/highlight_provider.dart create mode 100644 lib/presentation/search/bloc/search_bloc.dart create mode 100644 lib/presentation/search/bloc/search_event.dart create mode 100644 lib/presentation/search/bloc/search_state.dart create mode 100644 lib/presentation/search/search_widget.dart create mode 100644 specs/search/implementation_plan.md create mode 100644 specs/search/prompt.md create mode 100644 specs/search/task.md diff --git a/assets/i18n/en.json b/assets/i18n/en.json index 8c9703d..633aeef 100644 --- a/assets/i18n/en.json +++ b/assets/i18n/en.json @@ -1,5 +1,9 @@ { "appName": "scrcpy buddy 🤝", + "search": { + "placeholder": "Search options...", + "noResults": "No results found" + }, "home": { "tray": { "showApp": "Show app", diff --git a/lib/application/model/scrcpy/arguments/control/gamepad_mode.dart b/lib/application/model/scrcpy/arguments/control/gamepad_mode.dart index 7d7eeaa..30a5a9a 100644 --- a/lib/application/model/scrcpy/arguments/control/gamepad_mode.dart +++ b/lib/application/model/scrcpy/arguments/control/gamepad_mode.dart @@ -14,4 +14,4 @@ class GamepadMode extends ScrcpyCliArgument { @override final List? values = ['uhid', 'aoa', 'disabled']; -} \ No newline at end of file +} diff --git a/lib/application/model/scrcpy/arguments/control/keyboard_mode.dart b/lib/application/model/scrcpy/arguments/control/keyboard_mode.dart index 2eeaba0..f67d7eb 100644 --- a/lib/application/model/scrcpy/arguments/control/keyboard_mode.dart +++ b/lib/application/model/scrcpy/arguments/control/keyboard_mode.dart @@ -14,4 +14,4 @@ class KeyboardMode extends ScrcpyCliArgument { @override final List? values = ['sdk', 'uhid', 'aoa', 'disabled']; -} \ No newline at end of file +} diff --git a/lib/application/model/scrcpy/arguments/control/mouse_mode.dart b/lib/application/model/scrcpy/arguments/control/mouse_mode.dart index a2e2b30..e98fccd 100644 --- a/lib/application/model/scrcpy/arguments/control/mouse_mode.dart +++ b/lib/application/model/scrcpy/arguments/control/mouse_mode.dart @@ -14,4 +14,4 @@ class MouseMode extends ScrcpyCliArgument { @override final List? values = ['sdk', 'uhid', 'aoa', 'disabled']; -} \ No newline at end of file +} diff --git a/lib/application/model/scrcpy/arguments/control/no_control.dart b/lib/application/model/scrcpy/arguments/control/no_control.dart index 01730c4..54fd3b7 100644 --- a/lib/application/model/scrcpy/arguments/control/no_control.dart +++ b/lib/application/model/scrcpy/arguments/control/no_control.dart @@ -14,4 +14,4 @@ class NoControl extends ScrcpyCliArgument { @override final List? values = null; -} \ No newline at end of file +} diff --git a/lib/application/model/scrcpy/arguments/control/push_target.dart b/lib/application/model/scrcpy/arguments/control/push_target.dart index 2fb982c..a28d074 100644 --- a/lib/application/model/scrcpy/arguments/control/push_target.dart +++ b/lib/application/model/scrcpy/arguments/control/push_target.dart @@ -14,4 +14,4 @@ class PushTarget extends ScrcpyCliArgument { @override final List? values = null; -} \ No newline at end of file +} diff --git a/lib/application/model/search_result.dart b/lib/application/model/search_result.dart new file mode 100644 index 0000000..dc279bd --- /dev/null +++ b/lib/application/model/search_result.dart @@ -0,0 +1,43 @@ +import 'package:scrcpy_buddy/routes.dart'; + +class SearchResult { + final String label; + final String title; + final String description; + final String argument; + final String category; + final String route; + + const SearchResult({ + required this.label, + required this.title, + required this.description, + required this.argument, + required this.category, + required this.route, + }); + + static const _categoryToRoute = { + 'audio': AppRoute.audio, + 'camera': AppRoute.camera, + 'control': AppRoute.control, + 'device': AppRoute.device, + 'recording': AppRoute.recording, + 'v4l2': AppRoute.v4l2, + 'video': AppRoute.video, + 'virtualDisplay': AppRoute.virtualDisplay, + 'window': AppRoute.window, + }; + + static String? routeForCategory(String category) => _categoryToRoute[category]; + + bool matches(String query) { + final lowerQuery = query.toLowerCase(); + return title.toLowerCase().contains(lowerQuery) || + description.toLowerCase().contains(lowerQuery) || + argument.toLowerCase().contains(lowerQuery); + } + + @override + String toString() => 'SearchResult(label: $label, title: $title, argument: $argument)'; +} diff --git a/lib/application/profiles_bloc/profiles_bloc.dart b/lib/application/profiles_bloc/profiles_bloc.dart index 264fbb1..cc87c73 100644 --- a/lib/application/profiles_bloc/profiles_bloc.dart +++ b/lib/application/profiles_bloc/profiles_bloc.dart @@ -8,7 +8,6 @@ import 'package:scrcpy_buddy/objectbox.g.dart'; import 'package:streaming_shared_preferences/streaming_shared_preferences.dart'; part 'profiles_event.dart'; - part 'profiles_state.dart'; typedef _Emitter = Emitter; diff --git a/lib/application/scrcpy_bloc/scrcpy_bloc.dart b/lib/application/scrcpy_bloc/scrcpy_bloc.dart index db2726a..e17d4a7 100644 --- a/lib/application/scrcpy_bloc/scrcpy_bloc.dart +++ b/lib/application/scrcpy_bloc/scrcpy_bloc.dart @@ -10,7 +10,6 @@ import 'package:scrcpy_buddy/service/running_process_manager.dart'; import 'package:scrcpy_buddy/service/scrcpy_service.dart'; part 'scrcpy_event.dart'; - part 'scrcpy_state.dart'; typedef _Emitter = Emitter; diff --git a/lib/main.dart b/lib/main.dart index 46b3c6e..9e31f47 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -12,6 +12,7 @@ import 'package:scrcpy_buddy/application/scrcpy_bloc/scrcpy_bloc.dart'; import 'package:scrcpy_buddy/application/shared_prefs.dart'; import 'package:scrcpy_buddy/init.dart'; import 'package:scrcpy_buddy/presentation/devices/bloc/devices_bloc.dart'; +import 'package:scrcpy_buddy/presentation/search/bloc/search_bloc.dart'; import 'package:scrcpy_buddy/routes.dart'; import 'package:system_theme/system_theme.dart'; import 'package:window_manager/window_manager.dart'; @@ -91,7 +92,10 @@ class _MyAppState extends State { providers: [ BlocProvider(create: (_) => ProfilesBloc(_settings, _objectBox.profileBox, _argsMap)), BlocProvider(create: (context) => ScrcpyBloc(context.read(), context.read(), context.read())), - BlocProvider(create: (context) => DevicesBloc(context.read(), context.read(), _settings.adbExecutable)), + BlocProvider( + create: (context) => DevicesBloc(context.read(), context.read(), _settings.adbExecutable), + ), + BlocProvider(create: (_) => SearchBloc(_argsInstances)), ], child: child!, ); diff --git a/lib/objectbox.g.dart b/lib/objectbox.g.dart index f6df02e..f4243d5 100644 --- a/lib/objectbox.g.dart +++ b/lib/objectbox.g.dart @@ -9,8 +9,7 @@ import 'dart:typed_data'; import 'package:flat_buffers/flat_buffers.dart' as fb; -import 'package:objectbox/internal.dart' - as obx_int; // generated code can access "internal" functionality +import 'package:objectbox/internal.dart' as obx_int; // generated code can access "internal" functionality import 'package:objectbox/objectbox.dart' as obx; import 'package:objectbox_flutter_libs/objectbox_flutter_libs.dart'; @@ -25,24 +24,9 @@ final _entities = [ lastPropertyId: const obx_int.IdUid(3, 5841936062529600271), flags: 0, properties: [ - obx_int.ModelProperty( - id: const obx_int.IdUid(1, 6938377658329922369), - name: 'id', - type: 6, - flags: 1, - ), - obx_int.ModelProperty( - id: const obx_int.IdUid(2, 5064544623462811653), - name: 'name', - type: 9, - flags: 0, - ), - obx_int.ModelProperty( - id: const obx_int.IdUid(3, 5841936062529600271), - name: 'dbArgs', - type: 9, - flags: 0, - ), + obx_int.ModelProperty(id: const obx_int.IdUid(1, 6938377658329922369), name: 'id', type: 6, flags: 1), + obx_int.ModelProperty(id: const obx_int.IdUid(2, 5064544623462811653), name: 'name', type: 9, flags: 0), + obx_int.ModelProperty(id: const obx_int.IdUid(3, 5841936062529600271), name: 'dbArgs', type: 9, flags: 0), ], relations: [], backlinks: [], @@ -115,9 +99,7 @@ obx_int.ModelDefinition getObjectBoxModel() { object.id = id; }, objectToFB: (Profile object, fb.Builder fbb) { - final nameOffset = object.name == null - ? null - : fbb.writeString(object.name!); + final nameOffset = object.name == null ? null : fbb.writeString(object.name!); final dbArgsOffset = fbb.writeString(object.dbArgs); fbb.startTable(4); fbb.addInt64(0, object.id); @@ -132,12 +114,8 @@ obx_int.ModelDefinition getObjectBoxModel() { final object = Profile() ..id = const fb.Int64Reader().vTableGet(buffer, rootOffset, 4, 0) - ..name = const fb.StringReader( - asciiOptimization: true, - ).vTableGetNullable(buffer, rootOffset, 6) - ..dbArgs = const fb.StringReader( - asciiOptimization: true, - ).vTableGet(buffer, rootOffset, 8, ''); + ..name = const fb.StringReader(asciiOptimization: true).vTableGetNullable(buffer, rootOffset, 6) + ..dbArgs = const fb.StringReader(asciiOptimization: true).vTableGet(buffer, rootOffset, 8, ''); return object; }, @@ -150,17 +128,11 @@ obx_int.ModelDefinition getObjectBoxModel() { /// [Profile] entity fields to define ObjectBox queries. class Profile_ { /// See [Profile.id]. - static final id = obx.QueryIntegerProperty( - _entities[0].properties[0], - ); + static final id = obx.QueryIntegerProperty(_entities[0].properties[0]); /// See [Profile.name]. - static final name = obx.QueryStringProperty( - _entities[0].properties[1], - ); + static final name = obx.QueryStringProperty(_entities[0].properties[1]); /// See [Profile.dbArgs]. - static final dbArgs = obx.QueryStringProperty( - _entities[0].properties[2], - ); + static final dbArgs = obx.QueryStringProperty(_entities[0].properties[2]); } diff --git a/lib/presentation/home/home_screen.dart b/lib/presentation/home/home_screen.dart index 9ff8d75..9cf96e1 100644 --- a/lib/presentation/home/home_screen.dart +++ b/lib/presentation/home/home_screen.dart @@ -15,6 +15,7 @@ import 'package:scrcpy_buddy/presentation/home/widgets/console_dialog.dart'; import 'package:scrcpy_buddy/presentation/home/widgets/profile_button.dart'; import 'package:scrcpy_buddy/presentation/home/widgets/start_button.dart'; import 'package:scrcpy_buddy/presentation/home/widgets/stop_button.dart'; +import 'package:scrcpy_buddy/presentation/search/search_widget.dart'; import 'package:scrcpy_buddy/presentation/widgets/app_widgets.dart'; import 'package:scrcpy_buddy/routes.dart'; import 'package:scrcpy_buddy/service/navigation_service.dart'; @@ -215,6 +216,8 @@ class _HomeScreenState extends AppModuleState with WindowListener, T mainAxisAlignment: MainAxisAlignment.end, crossAxisAlignment: CrossAxisAlignment.center, children: [ + SearchWidget(), + const SizedBox(width: 8), ProfileButton(), const SizedBox(width: 8), StartButton(), diff --git a/lib/presentation/scrcpy_config/control/control_screen.dart b/lib/presentation/scrcpy_config/control/control_screen.dart index dc6b814..b27638c 100644 --- a/lib/presentation/scrcpy_config/control/control_screen.dart +++ b/lib/presentation/scrcpy_config/control/control_screen.dart @@ -1,5 +1,6 @@ import 'package:fluent_ui/fluent_ui.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:scrcpy_buddy/application/model/scrcpy/scrcpy_arg.dart'; import 'package:scrcpy_buddy/application/profiles_bloc/profiles_bloc.dart'; import 'package:scrcpy_buddy/presentation/extension/context_extension.dart'; import 'package:scrcpy_buddy/presentation/scrcpy_config/control/widgets/modes_info_bar.dart'; @@ -10,8 +11,6 @@ import 'package:scrcpy_buddy/presentation/scrcpy_config/widgets/config_text_box. import 'package:scrcpy_buddy/presentation/scrcpy_config/widgets/config_toggle.dart'; import 'package:scrcpy_buddy/presentation/widgets/app_widgets.dart'; -import 'package:scrcpy_buddy/application/model/scrcpy/scrcpy_arg.dart'; - class ControlScreen extends StatefulWidget { const ControlScreen({super.key}); diff --git a/lib/presentation/scrcpy_config/video/video_screen.dart b/lib/presentation/scrcpy_config/video/video_screen.dart index 127560a..4555877 100644 --- a/lib/presentation/scrcpy_config/video/video_screen.dart +++ b/lib/presentation/scrcpy_config/video/video_screen.dart @@ -1,6 +1,5 @@ import 'package:fluent_ui/fluent_ui.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:scrcpy_buddy/application/model/scrcpy/arguments/video/video_source.dart'; import 'package:scrcpy_buddy/application/model/scrcpy/scrcpy_arg.dart'; import 'package:scrcpy_buddy/application/profiles_bloc/profiles_bloc.dart'; import 'package:scrcpy_buddy/presentation/scrcpy_config/video/bit_rate_config.dart'; diff --git a/lib/presentation/scrcpy_config/widgets/config_item.dart b/lib/presentation/scrcpy_config/widgets/config_item.dart index 590e266..5b156dd 100644 --- a/lib/presentation/scrcpy_config/widgets/config_item.dart +++ b/lib/presentation/scrcpy_config/widgets/config_item.dart @@ -1,6 +1,7 @@ import 'package:fluent_ui/fluent_ui.dart'; import 'package:scrcpy_buddy/application/model/scrcpy/scrcpy_cli_argument.dart'; import 'package:scrcpy_buddy/presentation/scrcpy_config/widgets/config_item_base.dart'; +import 'package:scrcpy_buddy/presentation/scrcpy_config/widgets/highlight_provider.dart'; import 'package:scrcpy_buddy/presentation/widgets/app_widgets.dart'; class ConfigItem extends AppStatelessWidget { @@ -16,12 +17,14 @@ class ConfigItem extends AppStatelessWidget { @override Widget build(BuildContext context) { + final highlightLabel = HighlightProvider.of(context); return ConfigItemBase( icon: icon, defaultValueKey: hasDefault ? '${cliArgument.label}.default' : null, titleKey: '${cliArgument.label}.title', descriptionKey: '${cliArgument.label}.description', arg: cliArgument.argument, + isHighlighted: highlightLabel == cliArgument.label, child: child, ); } diff --git a/lib/presentation/scrcpy_config/widgets/config_item_base.dart b/lib/presentation/scrcpy_config/widgets/config_item_base.dart index 907fb7c..b660f30 100644 --- a/lib/presentation/scrcpy_config/widgets/config_item_base.dart +++ b/lib/presentation/scrcpy_config/widgets/config_item_base.dart @@ -1,20 +1,21 @@ import 'package:fluent_ui/fluent_ui.dart'; import 'package:scrcpy_buddy/presentation/extension/context_extension.dart'; import 'package:scrcpy_buddy/presentation/extension/translation_extension.dart'; -import 'package:scrcpy_buddy/presentation/widgets/app_widgets.dart'; -class ConfigItemBase extends AppStatelessWidget { +class ConfigItemBase extends StatefulWidget { final IconData? icon; final String titleKey; final String descriptionKey; final String? defaultValueKey; final String arg; final Widget child; + final bool isHighlighted; const ConfigItemBase({ super.key, this.icon, this.defaultValueKey, + this.isHighlighted = false, required this.titleKey, required this.descriptionKey, required this.arg, @@ -22,58 +23,129 @@ class ConfigItemBase extends AppStatelessWidget { }); @override - String get module => 'config'; + State createState() => _ConfigItemBaseState(); +} + +class _ConfigItemBaseState extends State with SingleTickerProviderStateMixin { + late final AnimationController _highlightController; + late final Animation _highlightAnimation; + final _itemKey = GlobalKey(); + + @override + void initState() { + super.initState(); + _highlightController = AnimationController(vsync: this, duration: const Duration(milliseconds: 2000)); + _highlightAnimation = CurvedAnimation(parent: _highlightController, curve: Curves.easeOut); + + if (widget.isHighlighted) { + _highlightController.value = 1.0; + WidgetsBinding.instance.addPostFrameCallback((_) { + _scrollToItem(); + // Start fading out after a short delay + Future.delayed(const Duration(milliseconds: 500), () { + if (mounted) _highlightController.reverse(); + }); + }); + } + } + + void _scrollToItem() { + final itemContext = _itemKey.currentContext; + if (itemContext != null) { + Scrollable.ensureVisible( + itemContext, + duration: const Duration(milliseconds: 400), + curve: Curves.easeInOut, + alignment: 0.3, + ); + } + } + + @override + void didUpdateWidget(ConfigItemBase oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.isHighlighted && !oldWidget.isHighlighted) { + _highlightController.value = 1.0; + WidgetsBinding.instance.addPostFrameCallback((_) { + _scrollToItem(); + Future.delayed(const Duration(milliseconds: 500), () { + if (mounted) _highlightController.reverse(); + }); + }); + } + } + + @override + void dispose() { + _highlightController.dispose(); + super.dispose(); + } + + String _translatedText(String key, {Map? translationParams}) { + return context.translatedText(module: 'config', key: key, translationParams: translationParams); + } @override Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), - child: Row( - children: [ - if (icon != null) ...[Icon(icon, size: 24), const SizedBox(width: 12)], - Expanded( - child: Column( - mainAxisSize: .min, - crossAxisAlignment: .stretch, - children: [ - Row( - children: [ - Text(translatedText(context, key: titleKey), style: context.typography.bodyStrong), - const SizedBox(width: 8), - Tooltip( - richMessage: TextSpan( - children: [ - TextSpan( - text: arg, - style: TextStyle(fontFamily: 'monospace'), - ), - if (defaultValueKey != null) + return AnimatedBuilder( + animation: _highlightAnimation, + builder: (context, child) { + final highlightOpacity = _highlightAnimation.value * 0.12; + return Container( + key: _itemKey, + decoration: BoxDecoration( + color: highlightOpacity > 0 ? context.theme.accentColor.withOpacity(highlightOpacity) : null, + borderRadius: BorderRadius.circular(4), + ), + child: child, + ); + }, + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), + child: Row( + children: [ + if (widget.icon != null) ...[Icon(widget.icon, size: 24), const SizedBox(width: 12)], + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Text(_translatedText(widget.titleKey), style: context.typography.bodyStrong), + const SizedBox(width: 8), + Tooltip( + richMessage: TextSpan( + children: [ TextSpan( - text: - '\n${context.translatedText( - key: 'config.defaultValue', - translationParams: {'value': translatedText(context, key: defaultValueKey!)}, - )}', + text: widget.arg, + style: TextStyle(fontFamily: 'monospace'), ), - ], + if (widget.defaultValueKey != null) + TextSpan( + text: + '\n${context.translatedText(key: 'config.defaultValue', translationParams: {'value': _translatedText(widget.defaultValueKey!)})}', + ), + ], + ), + child: CircleAvatar( + radius: 10, + backgroundColor: context.theme.resources.cardStrokeColorDefault, + foregroundColor: context.theme.resources.textFillColorPrimary, + child: WindowsIcon(WindowsIcons.help, size: 8), + ), ), - child: CircleAvatar( - radius: 10, - backgroundColor: context.theme.resources.cardStrokeColorDefault, - foregroundColor: context.theme.resources.textFillColorPrimary, - child: WindowsIcon(WindowsIcons.help, size: 8), - ), - ), - ], - ), - const SizedBox(height: 8), - Text(translatedText(context, key: descriptionKey), style: context.typography.body), - ], + ], + ), + const SizedBox(height: 8), + Text(_translatedText(widget.descriptionKey), style: context.typography.body), + ], + ), ), - ), - const SizedBox(width: 16), - Align(alignment: Alignment.centerRight, child: child), - ], + const SizedBox(width: 16), + Align(alignment: Alignment.centerRight, child: widget.child), + ], + ), ), ); } diff --git a/lib/presentation/scrcpy_config/widgets/highlight_provider.dart b/lib/presentation/scrcpy_config/widgets/highlight_provider.dart new file mode 100644 index 0000000..67946c9 --- /dev/null +++ b/lib/presentation/scrcpy_config/widgets/highlight_provider.dart @@ -0,0 +1,20 @@ +import 'package:fluent_ui/fluent_ui.dart'; + +/// An [InheritedWidget] that provides a highlight label down the widget tree. +/// +/// Used by [ConfigItem] to determine if it should be highlighted after +/// a search result navigation. +class HighlightProvider extends InheritedWidget { + final String? highlightLabel; + + const HighlightProvider({super.key, required this.highlightLabel, required super.child}); + + static String? of(BuildContext context) { + return context.dependOnInheritedWidgetOfExactType()?.highlightLabel; + } + + @override + bool updateShouldNotify(HighlightProvider oldWidget) { + return highlightLabel != oldWidget.highlightLabel; + } +} diff --git a/lib/presentation/search/bloc/search_bloc.dart b/lib/presentation/search/bloc/search_bloc.dart new file mode 100644 index 0000000..9624c44 --- /dev/null +++ b/lib/presentation/search/bloc/search_bloc.dart @@ -0,0 +1,60 @@ +import 'package:bloc/bloc.dart'; +import 'package:equatable/equatable.dart'; +import 'package:scrcpy_buddy/application/model/scrcpy/scrcpy_cli_argument.dart'; +import 'package:scrcpy_buddy/application/model/search_result.dart'; + +part 'search_event.dart'; +part 'search_state.dart'; + +typedef _Emitter = Emitter; + +class SearchBloc extends Bloc { + SearchBloc(this._allArgs) : super(const SearchInitial()) { + on(_onInitialize); + on(_onUpdateQuery); + on(_onClear); + } + + final List _allArgs; + List _allSearchItems = []; + + void _onInitialize(InitializeSearch event, _Emitter emit) { + _allSearchItems = _allArgs + .map((arg) { + final category = arg.label.split('.').first; + final route = SearchResult.routeForCategory(category); + if (route == null) return null; + + final title = event.translate('config.${arg.label}.title'); + final description = event.translate('config.${arg.label}.description'); + + return SearchResult( + label: arg.label, + title: title, + description: description, + argument: arg.argument, + category: category, + route: route, + ); + }) + .whereType() + .toList(growable: false); + + emit(SearchLoaded(query: '', results: const [])); + } + + void _onUpdateQuery(UpdateSearchQuery event, _Emitter emit) { + final query = event.query.trim(); + if (query.isEmpty) { + emit(SearchLoaded(query: '', results: const [])); + return; + } + + final results = _allSearchItems.where((item) => item.matches(query)).toList(growable: false); + emit(SearchLoaded(query: query, results: results)); + } + + void _onClear(ClearSearch event, _Emitter emit) { + emit(SearchLoaded(query: '', results: const [])); + } +} diff --git a/lib/presentation/search/bloc/search_event.dart b/lib/presentation/search/bloc/search_event.dart new file mode 100644 index 0000000..9755cbb --- /dev/null +++ b/lib/presentation/search/bloc/search_event.dart @@ -0,0 +1,28 @@ +part of 'search_bloc.dart'; + +sealed class SearchEvent extends Equatable { + const SearchEvent(); + + @override + List get props => []; +} + +final class InitializeSearch extends SearchEvent { + final String Function(String key) translate; + + const InitializeSearch(this.translate); + + @override + List get props => []; +} + +final class UpdateSearchQuery extends SearchEvent { + final String query; + + const UpdateSearchQuery(this.query); + + @override + List get props => [query]; +} + +final class ClearSearch extends SearchEvent {} diff --git a/lib/presentation/search/bloc/search_state.dart b/lib/presentation/search/bloc/search_state.dart new file mode 100644 index 0000000..1e8977d --- /dev/null +++ b/lib/presentation/search/bloc/search_state.dart @@ -0,0 +1,22 @@ +part of 'search_bloc.dart'; + +sealed class SearchState extends Equatable { + const SearchState(); +} + +final class SearchInitial extends SearchState { + const SearchInitial(); + + @override + List get props => []; +} + +final class SearchLoaded extends SearchState { + final String query; + final List results; + + const SearchLoaded({required this.query, required this.results}); + + @override + List get props => [query, results]; +} diff --git a/lib/presentation/search/search_widget.dart b/lib/presentation/search/search_widget.dart new file mode 100644 index 0000000..1afc79a --- /dev/null +++ b/lib/presentation/search/search_widget.dart @@ -0,0 +1,106 @@ +import 'package:fluent_ui/fluent_ui.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_i18n/flutter_i18n.dart'; +import 'package:go_router/go_router.dart'; +import 'package:scrcpy_buddy/application/model/search_result.dart'; +import 'package:scrcpy_buddy/presentation/extension/context_extension.dart'; +import 'package:scrcpy_buddy/presentation/search/bloc/search_bloc.dart'; + +class SearchWidget extends StatefulWidget { + const SearchWidget({super.key}); + + @override + State createState() => _SearchWidgetState(); +} + +class _SearchWidgetState extends State { + late final _searchBloc = context.read(); + final _controller = TextEditingController(); + bool _initialized = false; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (!_initialized) { + _initialized = true; + _searchBloc.add(InitializeSearch((key) => FlutterI18n.translate(context, key))); + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + void _onSelected(SearchResult result) { + _controller.clear(); + _searchBloc.add(ClearSearch()); + context.go(result.route, extra: result.label); + } + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + final items = state is SearchLoaded ? state.results : []; + return SizedBox( + width: 280, + child: AutoSuggestBox( + controller: _controller, + placeholder: FlutterI18n.translate(context, 'search.placeholder'), + items: items + .map( + (result) => AutoSuggestBoxItem( + value: result, + label: result.title, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(result.title, style: context.typography.bodyStrong), + const SizedBox(height: 2), + Text( + result.description, + style: context.typography.caption, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 2), + Text( + result.argument, + style: context.typography.caption?.copyWith( + fontFamily: 'monospace', + color: context.theme.accentColor, + ), + ), + ], + ), + ), + ), + ) + .toList(growable: false), + onChanged: (text, reason) { + if (reason == TextChangedReason.userInput) { + _searchBloc.add(UpdateSearchQuery(text)); + } + }, + onSelected: (item) { + if (item.value != null) { + _onSelected(item.value!); + } + }, + noResultsFoundBuilder: (context) => state is SearchLoaded && state.query.isNotEmpty + ? Padding( + padding: const EdgeInsets.all(8.0), + child: Text(FlutterI18n.translate(context, 'search.noResults'), style: context.typography.caption), + ) + : const SizedBox.shrink(), + ), + ); + }, + ); + } +} diff --git a/lib/routes.dart b/lib/routes.dart index 7443231..1733132 100644 --- a/lib/routes.dart +++ b/lib/routes.dart @@ -11,6 +11,7 @@ import 'package:scrcpy_buddy/presentation/scrcpy_config/recording_screen.dart'; import 'package:scrcpy_buddy/presentation/scrcpy_config/v4l2/v4l2_screen.dart'; import 'package:scrcpy_buddy/presentation/scrcpy_config/video/video_screen.dart'; import 'package:scrcpy_buddy/presentation/scrcpy_config/virtualDisplay/virtual_display_screen.dart'; +import 'package:scrcpy_buddy/presentation/scrcpy_config/widgets/highlight_provider.dart'; import 'package:scrcpy_buddy/presentation/scrcpy_config/window/window_screen.dart'; import 'package:scrcpy_buddy/presentation/settings/settings_screen.dart'; @@ -50,15 +51,45 @@ final router = GoRouter( routes: [ GoRoute(path: AppRoute.devices, builder: (_, _) => const DevicesScreen()), - GoRoute(path: AppRoute.audio, builder: (_, _) => const AudioScreen()), - GoRoute(path: AppRoute.camera, builder: (_, _) => const CameraScreen()), - GoRoute(path: AppRoute.control, builder: (_, _) => const ControlScreen()), - GoRoute(path: AppRoute.device, builder: (_, _) => const DeviceScreen()), - GoRoute(path: AppRoute.recording, builder: (_, _) => const RecordingScreen()), - GoRoute(path: AppRoute.v4l2, builder: (_, _) => const V4l2Screen()), - GoRoute(path: AppRoute.video, builder: (_, _) => const VideoScreen()), - GoRoute(path: AppRoute.virtualDisplay, builder: (_, _) => const VirtualDisplayScreen()), - GoRoute(path: AppRoute.window, builder: (_, _) => const WindowScreen()), + GoRoute( + path: AppRoute.audio, + builder: (_, state) => HighlightProvider(highlightLabel: state.extra as String?, child: const AudioScreen()), + ), + GoRoute( + path: AppRoute.camera, + builder: (_, state) => HighlightProvider(highlightLabel: state.extra as String?, child: const CameraScreen()), + ), + GoRoute( + path: AppRoute.control, + builder: (_, state) => + HighlightProvider(highlightLabel: state.extra as String?, child: const ControlScreen()), + ), + GoRoute( + path: AppRoute.device, + builder: (_, state) => HighlightProvider(highlightLabel: state.extra as String?, child: const DeviceScreen()), + ), + GoRoute( + path: AppRoute.recording, + builder: (_, state) => + HighlightProvider(highlightLabel: state.extra as String?, child: const RecordingScreen()), + ), + GoRoute( + path: AppRoute.v4l2, + builder: (_, state) => HighlightProvider(highlightLabel: state.extra as String?, child: const V4l2Screen()), + ), + GoRoute( + path: AppRoute.video, + builder: (_, state) => HighlightProvider(highlightLabel: state.extra as String?, child: const VideoScreen()), + ), + GoRoute( + path: AppRoute.virtualDisplay, + builder: (_, state) => + HighlightProvider(highlightLabel: state.extra as String?, child: const VirtualDisplayScreen()), + ), + GoRoute( + path: AppRoute.window, + builder: (_, state) => HighlightProvider(highlightLabel: state.extra as String?, child: const WindowScreen()), + ), GoRoute(path: AppRoute.profiles, builder: (_, _) => const ProfilesScreen()), GoRoute(path: AppRoute.settings, builder: (_, _) => const SettingsScreen()), diff --git a/specs/search/implementation_plan.md b/specs/search/implementation_plan.md new file mode 100644 index 0000000..2b2ea05 --- /dev/null +++ b/specs/search/implementation_plan.md @@ -0,0 +1,162 @@ +# Search Feature for Scrcpy Config Options + +Search across all available scrcpy CLI arguments from the app bar, navigate to the matching category screen, and highlight the selected option. + +## Proposed Changes + +### Search Model + +#### [NEW] [search_result.dart](file:///home/shripal17/Projects/scrcpy_buddy/lib/application/model/search_result.dart) + +A lightweight model holding data for each search result: + +```dart +class SearchResult { + final String label; // e.g. "video.noVideo" + final String title; // translated title text + final String description; // translated description text + final String argument; // CLI arg, e.g. "--no-video" + final String category; // e.g. "video" — extracted from label.split('.').first + final String route; // e.g. "/scrcpyConfig.video" — resolved from category +} +``` + +A static helper `categoryToRoute` map will resolve category names to `AppRoute` constants. + +--- + +### Search Bloc + +Following the existing Bloc pattern (`DevicesBloc`): sealed event/state classes, `Equatable`, `part` files. + +#### [NEW] [search_bloc.dart](file:///home/shripal17/Projects/scrcpy_buddy/lib/presentation/search/bloc/search_bloc.dart) + +``` +Events: + - InitializeSearch → builds the full searchable list from List + translations + - UpdateSearchQuery(q) → filters the list, emits new results + - ClearSearch → resets query and results + +States: + - SearchInitial + - SearchLoaded(query, results, allSearchItems) +``` + +- **`InitializeSearch`** iterates through all `ScrcpyCliArgument` instances (from `context.read>()`), resolves their translated title and description via the translation keys (`config.