import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotiflac_android/constants/app_info.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/screens/settings/about_page.dart'; import 'package:spotiflac_android/screens/settings/app_settings_page.dart'; import 'package:spotiflac_android/screens/settings/appearance_settings_page.dart'; import 'package:spotiflac_android/screens/settings/backup_restore_page.dart'; import 'package:spotiflac_android/screens/settings/cache_management_page.dart'; import 'package:spotiflac_android/screens/settings/donate_page.dart'; import 'package:spotiflac_android/screens/settings/download_settings_page.dart'; import 'package:spotiflac_android/screens/settings/extensions_page.dart'; import 'package:spotiflac_android/screens/settings/files_settings_page.dart'; import 'package:spotiflac_android/screens/settings/library_settings_page.dart'; import 'package:spotiflac_android/screens/settings/log_screen.dart'; import 'package:spotiflac_android/screens/settings/lyrics_settings_page.dart'; import 'package:spotiflac_android/screens/settings/metadata_settings_page.dart'; import 'package:spotiflac_android/screens/settings/settings_search_catalog.dart'; import 'package:spotiflac_android/theme/app_tokens.dart'; import 'package:spotiflac_android/utils/adaptive_layout.dart'; import 'package:spotiflac_android/utils/nav_bar_inset.dart'; import 'package:spotiflac_android/widgets/animation_utils.dart'; import 'package:spotiflac_android/widgets/app_search_field.dart'; import 'package:spotiflac_android/widgets/app_sliver_header.dart'; import 'package:spotiflac_android/widgets/settings_group.dart'; /// One entry on the Settings tab. class _Destination { const _Destination({ required this.icon, required this.title, required this.subtitle, required this.pageBuilder, this.keywords = const [], this.searchEntries = const [], }); final IconData icon; final String title; final String subtitle; final Widget Function() pageBuilder; /// Extra search terms for things the title does not spell out (e.g. "SAF" /// for the Files page), so a user can find a page by what it does. final List keywords; final List searchEntries; bool matches(SettingsSearchQuery query) { return query.matches([title, subtitle, ...keywords]); } } class _SearchResult { const _SearchResult(this.destination, [this.entry]); final _Destination destination; final SettingsSearchEntry? entry; } /// Destinations that stay visually connected inside one settings card. class _Group { const _Group({required this.destinations}); final List<_Destination> destinations; } class SettingsTab extends ConsumerStatefulWidget { const SettingsTab({super.key}); @override ConsumerState createState() => _SettingsTabState(); } class _SettingsTabState extends ConsumerState { final TextEditingController _searchController = TextEditingController(); final FocusNode _searchFocusNode = FocusNode(); AppLocalizations? _cachedLocalizations; List<_Group>? _cachedGroups; String _query = ''; @override void dispose() { _searchController.dispose(); _searchFocusNode.dispose(); super.dispose(); } /// Related destinations share one card; whitespace separates each group /// without adding section labels that compete with the page title. List<_Group> _groups(BuildContext context) { final l10n = context.l10n; if (identical(_cachedLocalizations, l10n) && _cachedGroups != null) { return _cachedGroups!; } final searchCatalog = SettingsSearchCatalog(l10n); final groups = [ _Group( destinations: [ _Destination( icon: Icons.favorite_outline, title: l10n.settingsDonate, subtitle: l10n.settingsDonateSubtitle, keywords: const ['support', 'ko-fi', 'sponsor'], pageBuilder: () => const DonatePage(), ), ], ), _Group( destinations: [ _Destination( icon: Icons.extension_outlined, title: l10n.settingsExtensions, subtitle: l10n.settingsExtensionsSubtitle, keywords: const ['plugin', 'provider', 'priority', 'store'], searchEntries: searchCatalog.extensions, pageBuilder: () => const ExtensionsPage(), ), _Destination( icon: Icons.palette_outlined, title: l10n.settingsAppearance, subtitle: l10n.settingsAppearanceSubtitle, keywords: const [ 'theme', 'dark', 'light', 'amoled', 'color', 'language', 'locale', 'layout', 'grid', ], searchEntries: searchCatalog.appearance, pageBuilder: () => const AppearanceSettingsPage(), ), ], ), _Group( destinations: [ _Destination( icon: Icons.library_music_outlined, title: l10n.settingsLocalLibrary, subtitle: l10n.settingsLocalLibrarySubtitle, keywords: const [ 'scan', 'local', 'player', 'playback', 'duplicate', ], searchEntries: searchCatalog.library, pageBuilder: () => const LibrarySettingsPage(), ), _Destination( icon: Icons.sell_outlined, title: l10n.settingsMetadata, subtitle: l10n.settingsMetadataSubtitle, keywords: const ['tag', 'cover', 'artwork', 'isrc', 'provider'], searchEntries: searchCatalog.metadata, pageBuilder: () => const MetadataSettingsPage(), ), _Destination( icon: Icons.lyrics_outlined, title: l10n.settingsLyrics, subtitle: l10n.settingsLyricsSubtitle, keywords: const ['lrc', 'synced', 'provider'], searchEntries: searchCatalog.lyrics, pageBuilder: () => const LyricsSettingsPage(), ), ], ), _Group( destinations: [ _Destination( icon: Icons.download_outlined, title: l10n.settingsDownload, subtitle: l10n.settingsDownloadSubtitle, keywords: const [ 'quality', 'flac', 'concurrent', 'network', 'wifi', 'service', 'region', ], searchEntries: searchCatalog.download, pageBuilder: () => const DownloadSettingsPage(), ), _Destination( icon: Icons.folder_outlined, title: l10n.settingsFiles, subtitle: l10n.settingsFilesSubtitle, keywords: const [ 'saf', 'storage', 'folder', 'filename', 'path', 'permission', ], searchEntries: searchCatalog.files, pageBuilder: () => const FilesSettingsPage(), ), ], ), _Group( destinations: [ _Destination( icon: Icons.tune_outlined, title: l10n.settingsApp, subtitle: l10n.settingsAppSubtitle, keywords: const ['update', 'channel', 'debug', 'logging'], searchEntries: searchCatalog.app, pageBuilder: () => const AppSettingsPage(), ), _Destination( icon: Icons.storage_outlined, title: l10n.settingsCache, subtitle: l10n.settingsCacheSubtitle, keywords: const ['clear', 'space', 'image', 'temp'], searchEntries: searchCatalog.cache, pageBuilder: () => const CacheManagementPage(), ), _Destination( icon: Icons.settings_backup_restore, title: l10n.settingsBackup, subtitle: l10n.settingsBackupSubtitle, keywords: const ['export', 'import', 'restore', 'json'], searchEntries: searchCatalog.backup, pageBuilder: () => const BackupRestorePage(), ), _Destination( icon: Icons.article_outlined, title: l10n.logTitle, subtitle: l10n.settingsLogsSubtitle, keywords: const ['debug', 'error', 'report'], searchEntries: searchCatalog.logs, pageBuilder: () => const LogScreen(), ), ], ), _Group( destinations: [ _Destination( icon: Icons.info_outline, title: l10n.settingsAbout, subtitle: '${l10n.aboutVersion} ${AppInfo.displayVersion}', keywords: const ['version', 'license', 'contributor'], searchEntries: searchCatalog.about, pageBuilder: () => const AboutPage(), ), ], ), ]; _cachedLocalizations = l10n; _cachedGroups = groups; return groups; } Future _navigateTo(BuildContext context, Widget page) async { _searchFocusNode.unfocus(); _searchFocusNode.canRequestFocus = false; FocusManager.instance.primaryFocus?.unfocus(); await Navigator.of(context).push(slidePageRoute(page: page)); if (!mounted) return; // A route's focus scope remembers its previously focused child. Keep the // search field out of that restoration cycle while the child page is open, // then re-enable it without requesting focus when Settings becomes active. FocusManager.instance.primaryFocus?.unfocus(); WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; _searchFocusNode.canRequestFocus = true; _searchFocusNode.unfocus(); }); } Widget _itemFor(_Destination destination, {required bool showDivider}) { return SettingsItem( icon: destination.icon, title: destination.title, subtitle: destination.subtitle, showDivider: showDivider, onTap: () => _navigateTo(context, destination.pageBuilder()), ); } Widget _searchItemFor(_SearchResult result, {required bool showDivider}) { final entry = result.entry; if (entry == null) { return _itemFor(result.destination, showDivider: showDivider); } return SettingsItem( key: ValueKey( 'settings-search:${result.destination.title}:${entry.title}', ), icon: entry.icon, title: entry.title, subtitle: result.destination.title, showDivider: showDivider, onTap: () => _navigateTo(context, result.destination.pageBuilder()), ); } @override Widget build(BuildContext context) { final tokens = context.tokens; final bottomInset = context.navBarBottomInset; final wideInset = wideListInset(context); final query = SettingsSearchQuery(_query); final groups = _groups(context); final margin = EdgeInsets.fromLTRB( tokens.gapLg + wideInset, tokens.gapSm, tokens.gapLg + wideInset, tokens.gapSm, ); final List body; if (query.isEmpty) { body = [ for (final group in groups) SliverToBoxAdapter( child: SettingsGroup( margin: margin, children: [ for (var i = 0; i < group.destinations.length; i++) _itemFor( group.destinations[i], showDivider: i != group.destinations.length - 1, ), ], ), ), ]; } else { final matches = <_SearchResult>[ for (final group in groups) for (final destination in group.destinations) ...[ if (destination.matches(query)) _SearchResult(destination), for (final entry in destination.searchEntries) if (entry.matches(query)) _SearchResult(destination, entry), ], ]; body = matches.isEmpty ? [ SliverToBoxAdapter( child: Padding( padding: EdgeInsets.all(tokens.gapXl), child: Text( context.l10n.settingsSearchNoResults(_query.trim()), textAlign: TextAlign.center, style: Theme.of(context).textTheme.bodyMedium?.copyWith( color: Theme.of(context).colorScheme.onSurfaceVariant, ), ), ), ), ] : [ SliverToBoxAdapter( child: SettingsGroup( margin: margin, children: [ for (var i = 0; i < matches.length; i++) _searchItemFor( matches[i], showDivider: i != matches.length - 1, ), ], ), ), ]; } return CustomScrollView( slivers: [ AppSliverHeader.tabRoot(title: context.l10n.settingsTitle), SliverToBoxAdapter( child: Padding( padding: EdgeInsets.fromLTRB( tokens.gapLg + wideInset, tokens.gapSm, tokens.gapLg + wideInset, tokens.gapSm, ), child: AppSearchField( controller: _searchController, focusNode: _searchFocusNode, hintText: context.l10n.settingsSearchHint, clearTooltip: context.l10n.dialogClear, onChanged: (value) => setState(() => _query = value), onClear: () => setState(() => _query = ''), ), ), ), ...body, SliverToBoxAdapter(child: SizedBox(height: bottomInset)), const SliverFillRemaining(hasScrollBody: false, child: SizedBox()), ], ); } }