diff --git a/lib/screens/settings/about_page.dart b/lib/screens/settings/about_page.dart index 1c8e4898..f55f24ea 100644 --- a/lib/screens/settings/about_page.dart +++ b/lib/screens/settings/about_page.dart @@ -641,7 +641,7 @@ class _AboutSettingsItem extends StatelessWidget { Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; - return Column( + final content = Column( mainAxisSize: MainAxisSize.min, children: [ InkWell( @@ -697,5 +697,6 @@ class _AboutSettingsItem extends StatelessWidget { ), ], ); + return SettingsSearchTarget(label: title, child: content); } } diff --git a/lib/screens/settings/app_settings_page.dart b/lib/screens/settings/app_settings_page.dart index 14f1fe54..cfdf821a 100644 --- a/lib/screens/settings/app_settings_page.dart +++ b/lib/screens/settings/app_settings_page.dart @@ -267,7 +267,7 @@ class _UpdateChannelSelector extends StatelessWidget { Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; - return Padding( + final content = Padding( padding: const EdgeInsets.all(20), child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -342,6 +342,10 @@ class _UpdateChannelSelector extends StatelessWidget { ], ), ); + return SettingsSearchTarget( + label: context.l10n.optionsUpdateChannel, + child: content, + ); } } @@ -363,7 +367,7 @@ class _VerificationBrowserModeSelector extends StatelessWidget { ? 'external_first' : 'in_app_first'; - return Column( + final content = Column( mainAxisSize: MainAxisSize.min, children: [ Padding( @@ -434,5 +438,9 @@ class _VerificationBrowserModeSelector extends StatelessWidget { ), ], ); + return SettingsSearchTarget( + label: context.l10n.extensionVerificationBrowserTitle, + child: content, + ); } } diff --git a/lib/screens/settings/appearance_settings_page.dart b/lib/screens/settings/appearance_settings_page.dart index 1035d31d..8e62b617 100644 --- a/lib/screens/settings/appearance_settings_page.dart +++ b/lib/screens/settings/appearance_settings_page.dart @@ -499,7 +499,7 @@ class _HistoryViewSelector extends StatelessWidget { @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; - return Padding( + final content = Padding( padding: const EdgeInsets.all(12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -537,6 +537,10 @@ class _HistoryViewSelector extends StatelessWidget { ], ), ); + return SettingsSearchTarget( + label: context.l10n.appearanceHistoryView, + child: content, + ); } } @@ -583,13 +587,17 @@ class _LanguageSelector extends StatelessWidget { @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; - return ListTile( + final content = ListTile( leading: Icon(Icons.language, color: colorScheme.onSurfaceVariant), title: Text(context.l10n.appearanceLanguage), subtitle: Text(_getLanguageName(currentLocale)), trailing: Icon(Icons.chevron_right, color: colorScheme.onSurfaceVariant), onTap: () => _showLanguagePicker(context), ); + return SettingsSearchTarget( + label: context.l10n.appearanceLanguage, + child: content, + ); } void _showLanguagePicker(BuildContext context) { diff --git a/lib/screens/settings/download_settings_page.dart b/lib/screens/settings/download_settings_page.dart index 5866c7eb..57812eac 100644 --- a/lib/screens/settings/download_settings_page.dart +++ b/lib/screens/settings/download_settings_page.dart @@ -1114,7 +1114,7 @@ class _MetadataSourceSelector extends ConsumerWidget { subtitle = context.l10n.optionsPrimaryProviderSubtitle; } - return Padding( + final content = Padding( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -1160,6 +1160,10 @@ class _MetadataSourceSelector extends ConsumerWidget { ], ), ); + return SettingsSearchTarget( + label: context.l10n.optionsPrimaryProvider, + child: content, + ); } } @@ -1181,7 +1185,7 @@ class _DefaultSearchTabSelector extends ConsumerWidget { final settings = ref.watch(settingsProvider); final current = settings.defaultSearchTab; - return Padding( + final content = Padding( padding: const EdgeInsets.fromLTRB(16, 16, 16, 20), child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -1228,5 +1232,9 @@ class _DefaultSearchTabSelector extends ConsumerWidget { ], ), ); + return SettingsSearchTarget( + label: context.l10n.optionsDefaultSearchTab, + child: content, + ); } } diff --git a/lib/screens/settings/extensions_page.dart b/lib/screens/settings/extensions_page.dart index 2cadda6f..8ae02351 100644 --- a/lib/screens/settings/extensions_page.dart +++ b/lib/screens/settings/extensions_page.dart @@ -183,16 +183,19 @@ class _ExtensionsPageState extends ConsumerState { ), SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.all(16), - child: FilledButton.icon( - onPressed: _installExtension, - icon: const Icon(Icons.add), - label: Text(context.l10n.extensionsInstallButton), - style: FilledButton.styleFrom( - padding: const EdgeInsets.symmetric(vertical: 16), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), + child: SettingsSearchTarget( + label: context.l10n.extensionsInstallButton, + child: Padding( + padding: const EdgeInsets.all(16), + child: FilledButton.icon( + onPressed: _installExtension, + icon: const Icon(Icons.add), + label: Text(context.l10n.extensionsInstallButton), + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), ), ), ), @@ -383,7 +386,7 @@ class _DownloadPriorityItem extends ConsumerWidget { (e) => e.enabled && e.hasDownloadProvider, ); - return InkWell( + final content = InkWell( onTap: hasDownloadExtensions ? () => Navigator.push( context, @@ -435,6 +438,10 @@ class _DownloadPriorityItem extends ConsumerWidget { ), ), ); + return SettingsSearchTarget( + label: context.l10n.extensionsDownloadPriority, + child: content, + ); } } @@ -450,7 +457,7 @@ class _MetadataPriorityItem extends ConsumerWidget { (e) => e.enabled && e.hasMetadataProvider, ); - return InkWell( + final content = InkWell( onTap: hasMetadataExtensions ? () => Navigator.push( context, @@ -502,6 +509,10 @@ class _MetadataPriorityItem extends ConsumerWidget { ), ), ); + return SettingsSearchTarget( + label: context.l10n.extensionsMetadataPriority, + child: content, + ); } } @@ -517,7 +528,7 @@ class _DownloadFallbackItem extends ConsumerWidget { (e) => e.enabled && e.hasDownloadProvider, ); - return InkWell( + final content = InkWell( onTap: hasDownloadExtensions ? () => Navigator.push( context, @@ -569,6 +580,10 @@ class _DownloadFallbackItem extends ConsumerWidget { ), ), ); + return SettingsSearchTarget( + label: context.l10n.extensionsFallbackTitle, + child: content, + ); } } @@ -599,7 +614,7 @@ class _SearchProviderSelector extends ConsumerWidget { currentProviderName = ext?.displayName ?? resolvedProviderId; } - return Column( + final content = Column( mainAxisSize: MainAxisSize.min, children: [ InkWell( @@ -656,6 +671,10 @@ class _SearchProviderSelector extends ConsumerWidget { ), ], ); + return SettingsSearchTarget( + label: context.l10n.extensionsSearchProvider, + child: content, + ); } void _showSearchProviderPicker( @@ -750,7 +769,7 @@ class _HomeFeedProviderSelector extends ConsumerWidget { currentProviderName = ext?.displayName ?? settings.homeFeedProvider!; } - return Column( + final content = Column( mainAxisSize: MainAxisSize.min, children: [ InkWell( @@ -796,6 +815,10 @@ class _HomeFeedProviderSelector extends ConsumerWidget { ), ], ); + return SettingsSearchTarget( + label: context.l10n.extensionsHomeFeedProvider, + child: content, + ); } void _showHomeFeedProviderPicker( diff --git a/lib/screens/settings/settings_search_catalog.dart b/lib/screens/settings/settings_search_catalog.dart index 32774fe0..66e80d02 100644 --- a/lib/screens/settings/settings_search_catalog.dart +++ b/lib/screens/settings/settings_search_catalog.dart @@ -11,13 +11,18 @@ class SettingsSearchEntry { required this.title, this.subtitle, this.keywords = const [], - }); + String? targetLabel, + }) : targetLabel = targetLabel ?? title; final IconData icon; final String title; final String? subtitle; final List keywords; + /// Label of the actual destination widget. Usually the result title, but a + /// hidden or toolbar-only action may point at its nearest visible section. + final String targetLabel; + bool matches(SettingsSearchQuery query) => query.matches([title, ?subtitle, ...keywords]); } @@ -542,6 +547,7 @@ class SettingsSearchCatalog { SettingsSearchEntry( icon: Icons.delete_sweep_outlined, title: l10n.cacheClearAll, + targetLabel: l10n.cacheSectionMaintenance, keywords: const ['delete all cache'], ), ], @@ -570,26 +576,31 @@ class SettingsSearchCatalog { icon: Icons.filter_list, title: l10n.logFilterLevel, subtitle: l10n.logFilterBySeverity, + targetLabel: l10n.logFilterSection, keywords: const ['severity', 'error warning debug'], ), SettingsSearchEntry( icon: Icons.search, title: l10n.logSearchHint, + targetLabel: l10n.logFilterSection, keywords: const ['find logs'], ), SettingsSearchEntry( icon: Icons.copy, title: l10n.logCopyLogs, + targetLabel: l10n.logFilterSection, keywords: const ['clipboard'], ), SettingsSearchEntry( icon: Icons.share, title: l10n.logShareLogs, + targetLabel: l10n.logFilterSection, keywords: const ['export logs'], ), SettingsSearchEntry( icon: Icons.delete_outline, title: l10n.logClearLogs, + targetLabel: l10n.logFilterSection, keywords: const ['delete logs'], ), ], diff --git a/lib/screens/settings/settings_tab.dart b/lib/screens/settings/settings_tab.dart index 49dc7c57..40fdd89c 100644 --- a/lib/screens/settings/settings_tab.dart +++ b/lib/screens/settings/settings_tab.dart @@ -259,11 +259,18 @@ class _SettingsTabState extends ConsumerState { return groups; } - Future _navigateTo(BuildContext context, Widget page) async { + Future _navigateTo( + BuildContext context, + Widget page, { + String? targetLabel, + }) async { _searchFocusNode.unfocus(); _searchFocusNode.canRequestFocus = false; FocusManager.instance.primaryFocus?.unfocus(); - await Navigator.of(context).push(slidePageRoute(page: page)); + final destination = targetLabel == null + ? page + : SettingsSearchHighlightScope(targetLabel: targetLabel, child: page); + await Navigator.of(context).push(slidePageRoute(page: destination)); if (!mounted) return; // A route's focus scope remembers its previously focused child. Keep the @@ -301,7 +308,11 @@ class _SettingsTabState extends ConsumerState { title: entry.title, subtitle: result.destination.title, showDivider: showDivider, - onTap: () => _navigateTo(context, result.destination.pageBuilder()), + onTap: () => _navigateTo( + context, + result.destination.pageBuilder(), + targetLabel: entry.targetLabel, + ), ); } diff --git a/lib/widgets/settings_group.dart b/lib/widgets/settings_group.dart index e1123c03..ad667282 100644 --- a/lib/widgets/settings_group.dart +++ b/lib/widgets/settings_group.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:spotiflac_android/theme/app_tokens.dart'; import 'package:spotiflac_android/utils/adaptive_layout.dart'; @@ -19,6 +21,182 @@ Color settingsGroupColor(BuildContext context) { ); } +/// Carries a Settings search result into its destination page. +/// +/// Descendant [SettingsSearchTarget] widgets compete to claim the matching +/// label. The first match scrolls into view and briefly highlights itself. +class SettingsSearchHighlightScope extends StatefulWidget { + const SettingsSearchHighlightScope({ + super.key, + required this.targetLabel, + required this.child, + }); + + final String targetLabel; + final Widget child; + + @override + State createState() => + _SettingsSearchHighlightScopeState(); +} + +class _SettingsSearchHighlightScopeState + extends State { + late _SettingsSearchHighlightController _controller; + + @override + void initState() { + super.initState(); + _controller = _SettingsSearchHighlightController(widget.targetLabel); + } + + @override + void didUpdateWidget(SettingsSearchHighlightScope oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.targetLabel != widget.targetLabel) { + _controller = _SettingsSearchHighlightController(widget.targetLabel); + } + } + + @override + Widget build(BuildContext context) { + return _SettingsSearchHighlightInherited( + controller: _controller, + child: widget.child, + ); + } +} + +class _SettingsSearchHighlightController { + _SettingsSearchHighlightController(this.targetLabel); + + final String targetLabel; + bool _claimed = false; + + bool matches(String label) => label == targetLabel; + + bool claim(String label) { + if (_claimed || !matches(label)) return false; + _claimed = true; + return true; + } +} + +class _SettingsSearchHighlightInherited extends InheritedWidget { + const _SettingsSearchHighlightInherited({ + required this.controller, + required super.child, + }); + + final _SettingsSearchHighlightController controller; + + static _SettingsSearchHighlightInherited? maybeOf(BuildContext context) { + return context + .dependOnInheritedWidgetOfExactType< + _SettingsSearchHighlightInherited + >(); + } + + @override + bool updateShouldNotify(_SettingsSearchHighlightInherited oldWidget) { + return controller != oldWidget.controller; + } +} + +/// Marks one settings control as a possible deep-link target from search. +class SettingsSearchTarget extends StatefulWidget { + const SettingsSearchTarget({ + super.key, + required this.label, + required this.child, + }); + + final String label; + final Widget child; + + @override + State createState() => _SettingsSearchTargetState(); +} + +class _SettingsSearchTargetState extends State { + Timer? _highlightTimer; + bool _handled = false; + bool _highlighted = false; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (_handled) return; + + final scope = _SettingsSearchHighlightInherited.maybeOf(context); + if (scope?.controller.claim(widget.label) != true) return; + _handled = true; + WidgetsBinding.instance.addPostFrameCallback((_) => _reveal()); + } + + Future _reveal() async { + if (!mounted) return; + setState(() => _highlighted = true); + + await Scrollable.ensureVisible( + context, + alignment: 0.32, + duration: const Duration(milliseconds: 480), + curve: Curves.easeOutCubic, + ); + if (!mounted) return; + + _highlightTimer = Timer(const Duration(milliseconds: 1800), () { + if (mounted) setState(() => _highlighted = false); + }); + } + + @override + void dispose() { + _highlightTimer?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + // Normal settings rendering stays allocation-light: only the one claimed + // search target creates an animated decoration and semantics node. + if (!_handled) return widget.child; + + final colorScheme = Theme.of(context).colorScheme; + return Semantics( + selected: _highlighted, + child: AnimatedContainer( + key: ValueKey('settings-highlight:${widget.label}'), + duration: const Duration(milliseconds: 320), + curve: Curves.easeOutCubic, + decoration: BoxDecoration( + color: _highlighted + ? colorScheme.primaryContainer.withValues(alpha: 0.72) + : Colors.transparent, + borderRadius: BorderRadius.circular(context.tokens.radiusCover), + border: _highlighted + ? Border.all( + color: colorScheme.primary.withValues(alpha: 0.72), + width: 1.5, + ) + : null, + boxShadow: _highlighted + ? [ + BoxShadow( + color: colorScheme.primary.withValues(alpha: 0.28), + blurRadius: 18, + spreadRadius: 2, + ), + ] + : const [], + ), + child: widget.child, + ), + ); + } +} + class SettingsGroup extends StatelessWidget { final List children; final EdgeInsetsGeometry? margin; @@ -95,7 +273,7 @@ class SettingsItem extends StatelessWidget { Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; - return Column( + final content = Column( mainAxisSize: MainAxisSize.min, children: [ InkWell( @@ -164,6 +342,7 @@ class SettingsItem extends StatelessWidget { ), ], ); + return SettingsSearchTarget(label: title, child: content); } } @@ -194,7 +373,7 @@ class SettingsSwitchItem extends StatelessWidget { final colorScheme = Theme.of(context).colorScheme; final isDisabled = !enabled || onChanged == null; - return Column( + final content = Column( mainAxisSize: MainAxisSize.min, children: [ Opacity( @@ -276,6 +455,7 @@ class SettingsSwitchItem extends StatelessWidget { ), ], ); + return SettingsSearchTarget(label: title, child: content); } } @@ -286,7 +466,7 @@ class SettingsSectionHeader extends StatelessWidget { @override Widget build(BuildContext context) { - return Padding( + final content = Padding( padding: const EdgeInsets.fromLTRB(32, 24, 32, 8), child: Text( title, @@ -296,6 +476,7 @@ class SettingsSectionHeader extends StatelessWidget { ), ), ); + return SettingsSearchTarget(label: title, child: content); } } diff --git a/test/settings_search_test.dart b/test/settings_search_test.dart index 78bde6f7..9b8cc042 100644 --- a/test/settings_search_test.dart +++ b/test/settings_search_test.dart @@ -6,6 +6,7 @@ import 'package:spotiflac_android/l10n/app_localizations.dart'; import 'package:spotiflac_android/screens/settings/about_page.dart'; import 'package:spotiflac_android/screens/settings/settings_tab.dart'; import 'package:spotiflac_android/theme/app_theme.dart'; +import 'package:spotiflac_android/widgets/settings_group.dart'; void main() { setUp(() { @@ -67,4 +68,50 @@ void main() { expect(find.text('Version'), findsOneWidget); }, ); + + testWidgets('search target scrolls into view and briefly glows', ( + tester, + ) async { + final scrollController = ScrollController(); + addTearDown(scrollController.dispose); + + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.light(), + home: SettingsSearchHighlightScope( + targetLabel: 'Target setting', + child: Scaffold( + body: CustomScrollView( + controller: scrollController, + slivers: const [ + SliverToBoxAdapter(child: SizedBox(height: 1200)), + SliverToBoxAdapter( + child: SettingsItem(title: 'Target setting'), + ), + SliverToBoxAdapter(child: SizedBox(height: 600)), + ], + ), + ), + ), + ), + ); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 600)); + + expect(scrollController.offset, greaterThan(0)); + final highlightFinder = find.byKey( + const ValueKey('settings-highlight:Target setting'), + ); + final highlighted = tester.widget(highlightFinder); + final highlightedDecoration = highlighted.decoration! as BoxDecoration; + expect(highlightedDecoration.color, isNot(Colors.transparent)); + expect(highlightedDecoration.boxShadow, isNotEmpty); + + await tester.pump(const Duration(milliseconds: 2300)); + await tester.pump(const Duration(milliseconds: 400)); + final faded = tester.widget(highlightFinder); + final fadedDecoration = faded.decoration! as BoxDecoration; + expect(fadedDecoration.color, Colors.transparent); + }); }