feat(settings): reveal nested search results

This commit is contained in:
zarzet
2026-08-01 19:23:58 +07:00
parent f7a0c8e993
commit bad48ea0fd
9 changed files with 327 additions and 29 deletions
+2 -1
View File
@@ -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);
}
}
+10 -2
View File
@@ -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,
);
}
}
@@ -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) {
@@ -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,
);
}
}
+38 -15
View File
@@ -183,16 +183,19 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
),
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(
@@ -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<String> 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'],
),
],
+14 -3
View File
@@ -259,11 +259,18 @@ class _SettingsTabState extends ConsumerState<SettingsTab> {
return groups;
}
Future<void> _navigateTo(BuildContext context, Widget page) async {
Future<void> _navigateTo(
BuildContext context,
Widget page, {
String? targetLabel,
}) async {
_searchFocusNode.unfocus();
_searchFocusNode.canRequestFocus = false;
FocusManager.instance.primaryFocus?.unfocus();
await Navigator.of(context).push(slidePageRoute<void>(page: page));
final destination = targetLabel == null
? page
: SettingsSearchHighlightScope(targetLabel: targetLabel, child: page);
await Navigator.of(context).push(slidePageRoute<void>(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<SettingsTab> {
title: entry.title,
subtitle: result.destination.title,
showDivider: showDivider,
onTap: () => _navigateTo(context, result.destination.pageBuilder()),
onTap: () => _navigateTo(
context,
result.destination.pageBuilder(),
targetLabel: entry.targetLabel,
),
);
}
+184 -3
View File
@@ -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<SettingsSearchHighlightScope> createState() =>
_SettingsSearchHighlightScopeState();
}
class _SettingsSearchHighlightScopeState
extends State<SettingsSearchHighlightScope> {
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<SettingsSearchTarget> createState() => _SettingsSearchTargetState();
}
class _SettingsSearchTargetState extends State<SettingsSearchTarget> {
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<void> _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<Widget> 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);
}
}
+47
View File
@@ -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<AnimatedContainer>(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<AnimatedContainer>(highlightFinder);
final fadedDecoration = faded.decoration! as BoxDecoration;
expect(fadedDecoration.color, Colors.transparent);
});
}