diff --git a/assets/i18n/en.json b/assets/i18n/en.json index 8c9703d..b0af183 100644 --- a/assets/i18n/en.json +++ b/assets/i18n/en.json @@ -5,6 +5,10 @@ "showApp": "Show app", "quit": "Quit" }, + "search": { + "placeholder": "Search options...", + "noResults": "No results found" + }, "navigation": { "devices": "Devices", "profiles": "Manage Profiles", @@ -283,10 +287,8 @@ "button": "'Device' section" }, "newDisplay": { - "toggle": { - "title": "Create virtual display", - "description": "Create and mirror a virtual display instead of the main device screen" - }, + "title": "Create virtual display", + "description": "Create and mirror a virtual display instead of the main device screen", "resolution": { "title": "Resolution", "description": "Explicitly set the resolution for virtual display", 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..92ba65d --- /dev/null +++ b/lib/application/model/search_result.dart @@ -0,0 +1,25 @@ +class SearchResult { + final String label; + final String title; + final String description; + final String argument; + final String category; + + const SearchResult({ + required this.label, + required this.title, + required this.description, + required this.argument, + required this.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/init.dart b/lib/init.dart index c151666..45f2fb9 100644 --- a/lib/init.dart +++ b/lib/init.dart @@ -24,7 +24,7 @@ Future init() async { windowManager.setPreventClose(true); } windowManager.waitUntilReadyToShow().then((_) async { - await windowManager.setMinimumSize(const Size(700, 700)); + await windowManager.setMinimumSize(const Size(850, 700)); await windowManager.setTitle(_appName); await windowManager.show(); }); diff --git a/lib/main.dart b/lib/main.dart index 5fd5af4..b795cae 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/presentation/home/home_screen.dart b/lib/presentation/home/home_screen.dart index 713f690..f3596d0 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'; @@ -210,9 +211,11 @@ class _HomeScreenState extends AppModuleState with WindowListener, T height: 56, isBackButtonVisible: false, title: Text(context.translatedText(key: 'appName'), style: typography.bodyStrong), + content: SearchWidget(), captionControls: Padding( padding: const EdgeInsets.only(right: 16), child: Row( + mainAxisSize: MainAxisSize.min, mainAxisAlignment: MainAxisAlignment.end, crossAxisAlignment: CrossAxisAlignment.center, children: [ 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/virtualDisplay/virtual_display_screen.dart b/lib/presentation/scrcpy_config/virtualDisplay/virtual_display_screen.dart index 310be92..3d549bd 100644 --- a/lib/presentation/scrcpy_config/virtualDisplay/virtual_display_screen.dart +++ b/lib/presentation/scrcpy_config/virtualDisplay/virtual_display_screen.dart @@ -169,8 +169,8 @@ class _VirtualDisplayScreenState extends AppModuleState { children: [ ConfigItemBase( icon: WindowsIcons.explore_content, - titleKey: '${_newDisplay.label}.toggle.title', - descriptionKey: '${_newDisplay.label}.toggle.description', + titleKey: '${_newDisplay.label}.title', + descriptionKey: '${_newDisplay.label}.description', arg: _newDisplay.argument, child: ToggleSwitch(checked: isEnabled, onChanged: (checked) => _toggleEnabled(checked)), ), 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..6701438 100644 --- a/lib/presentation/scrcpy_config/widgets/config_item_base.dart +++ b/lib/presentation/scrcpy_config/widgets/config_item_base.dart @@ -3,77 +3,149 @@ 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, required this.child, }); + @override + State createState() => _ConfigItemBaseState(); +} + +class _ConfigItemBaseState extends AppModuleState with SingleTickerProviderStateMixin { + late final AnimationController _highlightController; + late final Animation _highlightAnimation; + final _itemKey = GlobalKey(); + @override String get module => 'config'; + @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(); + } + @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 highlightAlpha = _highlightAnimation.value * 0.12 * 255; + return Container( + key: _itemKey, + decoration: BoxDecoration( + color: highlightAlpha > 0 ? context.theme.accentColor.withAlpha(highlightAlpha.toInt()) : 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(key: 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(key: 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(key: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..8eb44ea --- /dev/null +++ b/lib/presentation/search/bloc/search_bloc.dart @@ -0,0 +1,56 @@ +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 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, + ); + }) + .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..cf7e092 --- /dev/null +++ b/lib/presentation/search/search_widget.dart @@ -0,0 +1,90 @@ +import 'package:fluent_ui/fluent_ui.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; +import 'package:scrcpy_buddy/application/model/search_result.dart'; +import 'package:scrcpy_buddy/presentation/extension/translation_extension.dart'; +import 'package:scrcpy_buddy/presentation/search/bloc/search_bloc.dart'; +import 'package:scrcpy_buddy/presentation/widgets/app_widgets.dart'; + +class SearchWidget extends StatefulWidget { + const SearchWidget({super.key}); + + @override + State createState() => _SearchWidgetState(); +} + +class _SearchWidgetState extends AppModuleState { + late final _searchBloc = context.read(); + final _controller = TextEditingController(); + bool _initialized = false; + + @override + String get module => 'home.search'; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (!_initialized) { + _initialized = true; + _searchBloc.add(InitializeSearch((key) => context.translatedText(key: key))); + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + void _onSelected(SearchResult result) { + _controller.clear(); + _searchBloc.add(ClearSearch()); + context.go("/scrcpyConfig.${result.category}", extra: result.label); + } + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: BlocBuilder( + builder: (context, state) { + final items = state is SearchLoaded ? state.results : []; + return ConstrainedBox( + constraints: BoxConstraints(maxWidth: 200), + child: AutoSuggestBox( + controller: _controller, + placeholder: translatedText(key: 'placeholder'), + items: items + .map( + (result) => AutoSuggestBoxItem( + value: result, + label: + "[${context.translatedText(key: "home.navigation.scrcpyConfig.${result.category}")}] ${result.title}", + ), + ) + .toList(growable: false), + onChanged: (text, reason) { + switch (reason) { + case TextChangedReason.suggestionChosen: + _controller.clear(); + break; + case TextChangedReason.userInput: + _searchBloc.add(UpdateSearchQuery(text)); + break; + case TextChangedReason.cleared: + _searchBloc.add(ClearSearch()); + break; + } + }, + onSelected: (item) { + if (item.value != null) { + _onSelected(item.value!); + } + }, + ), + ); + }, + ), + ); + } +} diff --git a/lib/routes.dart b/lib/routes.dart index 7443231..b0a0bf3 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,15 @@ 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: _configRouteBuilder(const AudioScreen())), + GoRoute(path: AppRoute.camera, builder: _configRouteBuilder(const CameraScreen())), + GoRoute(path: AppRoute.control, builder: _configRouteBuilder(const ControlScreen())), + GoRoute(path: AppRoute.device, builder: _configRouteBuilder(const DeviceScreen())), + GoRoute(path: AppRoute.recording, builder: _configRouteBuilder(const RecordingScreen())), + GoRoute(path: AppRoute.v4l2, builder: _configRouteBuilder(const V4l2Screen())), + GoRoute(path: AppRoute.video, builder: _configRouteBuilder(const VideoScreen())), + GoRoute(path: AppRoute.virtualDisplay, builder: _configRouteBuilder(const VirtualDisplayScreen())), + GoRoute(path: AppRoute.window, builder: _configRouteBuilder(const WindowScreen())), GoRoute(path: AppRoute.profiles, builder: (_, _) => const ProfilesScreen()), GoRoute(path: AppRoute.settings, builder: (_, _) => const SettingsScreen()), @@ -66,3 +67,6 @@ final router = GoRouter( ), ], ); + +Widget Function(BuildContext context, GoRouterState state) _configRouteBuilder(Widget child) => + (_, state) => HighlightProvider(highlightLabel: state.extra as String?, child: child); 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.