mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-08-02 17:18:36 +02:00
feat(settings): reveal nested search results
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'],
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user