diff --git a/lib/screens/settings/settings_search_catalog.dart b/lib/screens/settings/settings_search_catalog.dart new file mode 100644 index 00000000..32774fe0 --- /dev/null +++ b/lib/screens/settings/settings_search_catalog.dart @@ -0,0 +1,655 @@ +import 'package:flutter/material.dart'; +import 'package:spotiflac_android/l10n/app_localizations.dart'; + +/// A control or submenu that can be discovered from the root Settings search. +/// +/// The catalog contains only presentation metadata. The owning settings page +/// remains responsible for reading and changing the actual setting value. +class SettingsSearchEntry { + const SettingsSearchEntry({ + required this.icon, + required this.title, + this.subtitle, + this.keywords = const [], + }); + + final IconData icon; + final String title; + final String? subtitle; + final List keywords; + + bool matches(SettingsSearchQuery query) => + query.matches([title, ?subtitle, ...keywords]); +} + +/// Normalizes the query once per keystroke, rather than once for every entry. +class SettingsSearchQuery { + SettingsSearchQuery(String value) + : terms = value + .trim() + .toLowerCase() + .split(RegExp(r'\s+')) + .where((term) => term.isNotEmpty) + .toList(growable: false); + + final List terms; + + bool get isEmpty => terms.isEmpty; + + bool matches(Iterable values) { + if (isEmpty) return true; + final haystack = values.join(' ').toLowerCase(); + return terms.every(haystack.contains); + } +} + +/// Localized index of controls found inside every first-level settings page. +class SettingsSearchCatalog { + SettingsSearchCatalog(AppLocalizations l10n) + : extensions = [ + SettingsSearchEntry( + icon: Icons.low_priority, + title: l10n.extensionsDownloadPriority, + subtitle: l10n.extensionsDownloadPrioritySubtitle, + keywords: const ['provider order', 'service order'], + ), + SettingsSearchEntry( + icon: Icons.alt_route, + title: l10n.extensionsFallbackTitle, + subtitle: l10n.extensionsFallbackSubtitle, + keywords: const ['provider fallback'], + ), + SettingsSearchEntry( + icon: Icons.manage_search, + title: l10n.extensionsMetadataPriority, + subtitle: l10n.extensionsMetadataPrioritySubtitle, + keywords: const ['metadata provider order'], + ), + SettingsSearchEntry( + icon: Icons.search, + title: l10n.extensionsSearchProvider, + subtitle: l10n.extensionsSearchProviderDescription, + keywords: const ['home search source'], + ), + SettingsSearchEntry( + icon: Icons.explore_outlined, + title: l10n.extensionsHomeFeedProvider, + subtitle: l10n.extensionsHomeFeedDescription, + keywords: const ['home provider', 'feed source'], + ), + SettingsSearchEntry( + icon: Icons.add, + title: l10n.extensionsInstallButton, + keywords: const ['add plugin', 'import extension'], + ), + ], + appearance = [ + SettingsSearchEntry( + icon: Icons.wallpaper, + title: l10n.appearanceDynamicColor, + subtitle: l10n.appearanceDynamicColorSubtitle, + keywords: const ['material you', 'system color'], + ), + SettingsSearchEntry( + icon: Icons.color_lens_outlined, + title: l10n.sectionColor, + keywords: const ['palette', 'seed color', 'accent'], + ), + SettingsSearchEntry( + icon: Icons.contrast, + title: l10n.sectionTheme, + subtitle: + '${l10n.appearanceThemeSystem}, ${l10n.appearanceThemeLight}, ' + '${l10n.appearanceThemeDark}', + keywords: const ['light mode', 'dark mode'], + ), + SettingsSearchEntry( + icon: Icons.brightness_2, + title: l10n.appearanceAmoledDark, + subtitle: l10n.appearanceAmoledDarkSubtitle, + keywords: const ['black theme', 'oled'], + ), + SettingsSearchEntry( + icon: Icons.animation, + title: l10n.appearanceHeroAnimations, + subtitle: l10n.appearanceHeroAnimationsSubtitle, + keywords: const ['transition', 'motion'], + ), + SettingsSearchEntry( + icon: Icons.blur_on, + title: l10n.appearanceForceBlur, + subtitle: l10n.appearanceForceBlurSubtitle, + keywords: const ['backdrop blur', 'performance'], + ), + SettingsSearchEntry( + icon: Icons.language, + title: l10n.appearanceLanguage, + keywords: const ['locale', 'translation'], + ), + SettingsSearchEntry( + icon: Icons.grid_view_outlined, + title: l10n.appearanceHistoryView, + subtitle: + '${l10n.appearanceHistoryViewList}, ' + '${l10n.appearanceHistoryViewGrid}', + keywords: const ['layout', 'list', 'grid'], + ), + ], + library = [ + SettingsSearchEntry( + icon: Icons.grid_view_rounded, + title: l10n.libraryDefaultView, + keywords: const ['library tab', 'default page'], + ), + SettingsSearchEntry( + icon: Icons.graphic_eq_rounded, + title: l10n.trackAudioQuality, + keywords: const ['quality label', 'bitrate', 'bit depth', 'kbps'], + ), + SettingsSearchEntry( + icon: Icons.library_music_outlined, + title: l10n.libraryEnableLocalLibrary, + subtitle: l10n.libraryEnableLocalLibrarySubtitle, + keywords: const ['local music'], + ), + SettingsSearchEntry( + icon: Icons.folder_outlined, + title: l10n.libraryFolder, + keywords: const ['local path', 'scan folder'], + ), + SettingsSearchEntry( + icon: Icons.content_copy_outlined, + title: l10n.libraryShowDuplicateIndicator, + subtitle: l10n.libraryShowDuplicateIndicatorSubtitle, + keywords: const ['duplicate badge'], + ), + SettingsSearchEntry( + icon: Icons.difference_outlined, + title: l10n.libraryReviewDuplicates, + subtitle: l10n.libraryReviewDuplicatesSubtitle, + keywords: const ['duplicate songs'], + ), + SettingsSearchEntry( + icon: Icons.autorenew_rounded, + title: l10n.libraryAutoScan, + keywords: const ['automatic scan', 'daily', 'weekly'], + ), + SettingsSearchEntry( + icon: Icons.refresh, + title: l10n.libraryScan, + subtitle: l10n.libraryScanSubtitle, + keywords: const ['rescan', 'refresh music'], + ), + SettingsSearchEntry( + icon: Icons.sync, + title: l10n.libraryForceFullScan, + subtitle: l10n.libraryForceFullScanSubtitle, + keywords: const ['rescan all'], + ), + SettingsSearchEntry( + icon: Icons.cleaning_services_outlined, + title: l10n.libraryCleanupMissingFiles, + subtitle: l10n.libraryCleanupMissingFilesSubtitle, + keywords: const ['remove missing'], + ), + SettingsSearchEntry( + icon: Icons.delete_outline, + title: l10n.libraryClear, + subtitle: l10n.libraryClearSubtitle, + keywords: const ['reset local library'], + ), + SettingsSearchEntry( + icon: Icons.open_in_new, + title: l10n.libraryExternalPlayer, + subtitle: l10n.libraryExternalPlayerSubtitle, + keywords: const ['playback app'], + ), + SettingsSearchEntry( + icon: Icons.play_circle_outline, + title: l10n.libraryBuiltInPreviewPlayer, + subtitle: l10n.libraryBuiltInPreviewPlayerSubtitle, + keywords: const ['internal player'], + ), + SettingsSearchEntry( + icon: Icons.graphic_eq, + title: l10n.libraryPlaybackNormalization, + subtitle: l10n.libraryPlaybackNormalizationSubtitle, + keywords: const ['replaygain', 'volume'], + ), + ], + metadata = [ + SettingsSearchEntry( + icon: Icons.sell_outlined, + title: l10n.optionsEmbedMetadata, + keywords: const ['tags', 'write metadata'], + ), + SettingsSearchEntry( + icon: Icons.people_alt_outlined, + title: l10n.optionsArtistTagMode, + keywords: const ['artist separator', 'multiple artists'], + ), + SettingsSearchEntry( + icon: Icons.image, + title: l10n.optionsMaxQualityCover, + subtitle: l10n.optionsMaxQualityCoverSubtitle, + keywords: const ['artwork', 'album art', 'cover size'], + ), + SettingsSearchEntry( + icon: Icons.graphic_eq, + title: l10n.optionsReplayGain, + keywords: const ['volume normalization', 'loudness'], + ), + SettingsSearchEntry( + icon: Icons.source_outlined, + title: l10n.metadataProvidersTitle, + subtitle: l10n.metadataProvidersSubtitle, + keywords: const ['metadata priority', 'tag provider'], + ), + SettingsSearchEntry( + icon: Icons.filter_list_outlined, + title: l10n.downloadDeduplication, + keywords: const ['duplicate download', 'same song'], + ), + SettingsSearchEntry( + icon: Icons.library_music_outlined, + title: l10n.downloadQualityVariants, + subtitle: l10n.downloadQualityVariantsDescription, + keywords: const ['different quality', 'multiple versions'], + ), + ], + lyrics = [ + SettingsSearchEntry( + icon: Icons.subtitles_outlined, + title: l10n.optionsEmbedLyrics, + keywords: const ['write lyrics', 'lrc'], + ), + SettingsSearchEntry( + icon: Icons.lyrics_outlined, + title: l10n.lyricsMode, + keywords: const ['synced', 'plain', 'lyrics type'], + ), + SettingsSearchEntry( + icon: Icons.source_outlined, + title: l10n.lyricsProvidersTitle, + keywords: const ['lyrics priority', 'lyrics source'], + ), + SettingsSearchEntry( + icon: Icons.translate_outlined, + title: l10n.downloadNeteaseIncludeTranslation, + keywords: const ['translated lyrics'], + ), + SettingsSearchEntry( + icon: Icons.text_fields_outlined, + title: l10n.downloadNeteaseIncludeRomanization, + keywords: const ['romanized lyrics', 'latin'], + ), + SettingsSearchEntry( + icon: Icons.record_voice_over_outlined, + title: l10n.downloadAppleQqMultiPerson, + keywords: const ['duet', 'multi singer'], + ), + SettingsSearchEntry( + icon: Icons.graphic_eq_outlined, + title: l10n.downloadAppleElrcWordSync, + keywords: const ['word synced', 'elrc'], + ), + SettingsSearchEntry( + icon: Icons.language_outlined, + title: l10n.downloadMusixmatchLanguage, + keywords: const ['lyrics locale'], + ), + ], + download = [ + SettingsSearchEntry( + icon: Icons.extension_outlined, + title: l10n.sectionService, + keywords: const ['download provider', 'default service'], + ), + SettingsSearchEntry( + icon: Icons.tune, + title: l10n.downloadAskBeforeDownload, + subtitle: l10n.downloadAskQualitySubtitle, + keywords: const ['quality picker', 'ask quality'], + ), + SettingsSearchEntry( + icon: Icons.audiotrack, + title: l10n.downloadLossyFormat, + keywords: const ['mp3', 'aac', 'opus'], + ), + SettingsSearchEntry( + icon: Icons.wifi, + title: l10n.settingsDownloadNetwork, + subtitle: l10n.settingsDownloadNetworkSubtitle, + keywords: const ['wifi only', 'mobile data'], + ), + SettingsSearchEntry( + icon: Icons.dynamic_feed_outlined, + title: l10n.settingsConcurrentDownloads, + subtitle: l10n.settingsConcurrentDownloadsSubtitle, + keywords: const ['parallel downloads', 'simultaneous'], + ), + SettingsSearchEntry( + icon: Icons.downloading_outlined, + title: l10n.downloadNativeWorker, + subtitle: l10n.downloadNativeWorkerSubtitle, + keywords: const ['background worker', 'android worker'], + ), + SettingsSearchEntry( + icon: Icons.security_outlined, + title: l10n.downloadNetworkCompatibilityMode, + keywords: const ['network fallback', 'connection compatibility'], + ), + SettingsSearchEntry( + icon: Icons.lan_outlined, + title: l10n.downloadAllowLocalNetwork, + keywords: const ['lan', 'private network'], + ), + SettingsSearchEntry( + icon: Icons.manage_search, + title: l10n.optionsPrimaryProvider, + subtitle: l10n.optionsPrimaryProviderSubtitle, + keywords: const ['search source', 'metadata source'], + ), + SettingsSearchEntry( + icon: Icons.tab_outlined, + title: l10n.optionsDefaultSearchTab, + subtitle: l10n.optionsDefaultSearchTabSubtitle, + keywords: const ['track album artist playlist'], + ), + SettingsSearchEntry( + icon: Icons.sync, + title: l10n.optionsAutoFallback, + subtitle: l10n.optionsAutoFallbackSubtitle, + keywords: const ['automatic provider fallback'], + ), + SettingsSearchEntry( + icon: Icons.extension_outlined, + title: l10n.downloadFallbackExtensions, + subtitle: l10n.downloadFallbackExtensionsSubtitle, + keywords: const ['provider order'], + ), + SettingsSearchEntry( + icon: Icons.public, + title: l10n.downloadSongLinkRegion, + keywords: const ['country', 'region'], + ), + SettingsSearchEntry( + icon: Icons.file_download_outlined, + title: l10n.settingsAutoExportFailed, + subtitle: l10n.settingsAutoExportFailedSubtitle, + keywords: const ['failed downloads', 'export errors'], + ), + ], + files = [ + SettingsSearchEntry( + icon: Icons.folder_outlined, + title: l10n.downloadDirectory, + keywords: const ['download folder', 'saf', 'storage path'], + ), + SettingsSearchEntry( + icon: Icons.text_fields, + title: l10n.downloadFilenameFormat, + keywords: const ['file naming', 'template'], + ), + SettingsSearchEntry( + icon: Icons.music_note_outlined, + title: l10n.downloadSingleFilenameFormat, + keywords: const ['single naming', 'file template'], + ), + SettingsSearchEntry( + icon: Icons.library_music_outlined, + title: l10n.downloadSeparateSinglesFolder, + keywords: const ['singles directory'], + ), + SettingsSearchEntry( + icon: Icons.folder_outlined, + title: l10n.downloadAlbumFolderStructure, + keywords: const ['album directory', 'folder template'], + ), + SettingsSearchEntry( + icon: Icons.create_new_folder_outlined, + title: l10n.downloadFolderOrganization, + keywords: const ['organize folders', 'directory layout'], + ), + SettingsSearchEntry( + icon: Icons.playlist_play_outlined, + title: l10n.downloadCreatePlaylistSourceFolder, + keywords: const ['playlist directory'], + ), + SettingsSearchEntry( + icon: Icons.person_search_outlined, + title: l10n.downloadUseAlbumArtistForFolders, + keywords: const ['album artist directory'], + ), + SettingsSearchEntry( + icon: Icons.filter_alt_outlined, + title: l10n.downloadArtistNameFilters, + keywords: const ['artist folder filter'], + ), + SettingsSearchEntry( + icon: Icons.person_outline, + title: l10n.downloadUsePrimaryArtistOnly, + keywords: const ['main artist folder'], + ), + SettingsSearchEntry( + icon: Icons.group_remove_outlined, + title: l10n.downloadFilterContributing, + keywords: const ['featured artist folder'], + ), + SettingsSearchEntry( + icon: Icons.folder_special_outlined, + title: l10n.allFilesAccess, + subtitle: l10n.allFilesAccessDescription, + keywords: const ['storage permission', 'manage files'], + ), + ], + app = [ + SettingsSearchEntry( + icon: Icons.extension, + title: l10n.optionsExtensionStore, + subtitle: l10n.optionsExtensionStoreSubtitle, + keywords: const ['repository', 'plugin store'], + ), + SettingsSearchEntry( + icon: Icons.open_in_browser, + title: l10n.extensionVerificationBrowserTitle, + keywords: const ['captcha', 'turnstile', 'external browser'], + ), + SettingsSearchEntry( + icon: Icons.system_update, + title: l10n.optionsCheckUpdates, + subtitle: l10n.optionsCheckUpdatesSubtitle, + keywords: const ['app update'], + ), + SettingsSearchEntry( + icon: Icons.new_releases, + title: l10n.optionsUpdateChannel, + keywords: const ['stable', 'preview', 'beta'], + ), + SettingsSearchEntry( + icon: Icons.cleaning_services_outlined, + title: l10n.cleanupOrphanedDownloads, + subtitle: l10n.cleanupOrphanedDownloadsSubtitle, + keywords: const ['stale queue', 'missing download'], + ), + SettingsSearchEntry( + icon: Icons.delete_forever, + title: l10n.optionsClearHistory, + subtitle: l10n.optionsClearHistorySubtitle, + keywords: const ['delete history'], + ), + SettingsSearchEntry( + icon: Icons.history_toggle_off_outlined, + title: l10n.settingsSaveDownloadHistory, + subtitle: l10n.settingsSaveDownloadHistorySubtitle, + keywords: const ['remember downloads'], + ), + SettingsSearchEntry( + icon: Icons.bug_report, + title: l10n.optionsDetailedLogging, + keywords: const ['debug logs', 'diagnostics'], + ), + ], + cache = [ + SettingsSearchEntry( + icon: Icons.folder_outlined, + title: l10n.cacheAppDirectory, + subtitle: l10n.cacheAppDirectoryDesc, + keywords: const ['application cache'], + ), + SettingsSearchEntry( + icon: Icons.timer_outlined, + title: l10n.cacheTempDirectory, + subtitle: l10n.cacheTempDirectoryDesc, + keywords: const ['temporary files'], + ), + SettingsSearchEntry( + icon: Icons.image_outlined, + title: l10n.cacheCoverImage, + subtitle: l10n.cacheCoverImageDesc, + keywords: const ['artwork cache'], + ), + SettingsSearchEntry( + icon: Icons.library_music_outlined, + title: l10n.cacheLibraryCover, + subtitle: l10n.cacheLibraryCoverDesc, + keywords: const ['local artwork cache'], + ), + SettingsSearchEntry( + icon: Icons.graphic_eq_outlined, + title: l10n.cacheAudioAnalysis, + subtitle: l10n.cacheAudioAnalysisDesc, + keywords: const ['spectrogram cache'], + ), + SettingsSearchEntry( + icon: Icons.explore_outlined, + title: l10n.cacheExploreFeed, + subtitle: l10n.cacheExploreFeedDesc, + keywords: const ['home feed cache'], + ), + SettingsSearchEntry( + icon: Icons.memory_outlined, + title: l10n.cacheTrackLookup, + subtitle: l10n.cacheTrackLookupDesc, + keywords: const ['search cache'], + ), + SettingsSearchEntry( + icon: Icons.cleaning_services_outlined, + title: l10n.cacheCleanupUnused, + subtitle: l10n.cacheCleanupUnusedDesc, + keywords: const ['clear cache', 'free space'], + ), + SettingsSearchEntry( + icon: Icons.delete_sweep_outlined, + title: l10n.cacheClearAll, + keywords: const ['delete all cache'], + ), + ], + backup = [ + SettingsSearchEntry( + icon: Icons.vpn_key_outlined, + title: l10n.backupIncludeSecrets, + subtitle: l10n.backupIncludeSecretsDescription, + keywords: const ['tokens', 'credentials'], + ), + SettingsSearchEntry( + icon: Icons.ios_share, + title: l10n.backupExportButton, + subtitle: l10n.backupExportSectionDescription, + keywords: const ['create backup', 'save settings'], + ), + SettingsSearchEntry( + icon: Icons.settings_backup_restore, + title: l10n.backupImportButton, + subtitle: l10n.backupImportSectionDescription, + keywords: const ['restore backup', 'load settings'], + ), + ], + logs = [ + SettingsSearchEntry( + icon: Icons.filter_list, + title: l10n.logFilterLevel, + subtitle: l10n.logFilterBySeverity, + keywords: const ['severity', 'error warning debug'], + ), + SettingsSearchEntry( + icon: Icons.search, + title: l10n.logSearchHint, + keywords: const ['find logs'], + ), + SettingsSearchEntry( + icon: Icons.copy, + title: l10n.logCopyLogs, + keywords: const ['clipboard'], + ), + SettingsSearchEntry( + icon: Icons.share, + title: l10n.logShareLogs, + keywords: const ['export logs'], + ), + SettingsSearchEntry( + icon: Icons.delete_outline, + title: l10n.logClearLogs, + keywords: const ['delete logs'], + ), + ], + about = [ + SettingsSearchEntry( + icon: Icons.info_outline, + title: l10n.aboutVersion, + keywords: const ['build number', 'app version'], + ), + SettingsSearchEntry( + icon: Icons.people_outline, + title: l10n.aboutContributors, + keywords: const ['developers', 'credits'], + ), + SettingsSearchEntry( + icon: Icons.translate, + title: l10n.aboutTranslators, + keywords: const ['translation credits'], + ), + SettingsSearchEntry( + icon: Icons.code, + title: l10n.aboutMobileSource, + keywords: const ['github', 'source code'], + ), + SettingsSearchEntry( + icon: Icons.bug_report_outlined, + title: l10n.aboutReportIssue, + subtitle: l10n.aboutReportIssueSubtitle, + keywords: const ['bug report'], + ), + SettingsSearchEntry( + icon: Icons.lightbulb_outline, + title: l10n.aboutFeatureRequest, + subtitle: l10n.aboutFeatureRequestSubtitle, + keywords: const ['suggestion', 'idea'], + ), + SettingsSearchEntry( + icon: Icons.telegram, + title: l10n.aboutTelegramChannel, + subtitle: l10n.aboutTelegramChannelSubtitle, + keywords: const ['social'], + ), + SettingsSearchEntry( + icon: Icons.forum_outlined, + title: l10n.aboutTelegramChat, + subtitle: l10n.aboutTelegramChatSubtitle, + keywords: const ['community', 'support chat'], + ), + ]; + + final List extensions; + final List appearance; + final List library; + final List metadata; + final List lyrics; + final List download; + final List files; + final List app; + final List cache; + final List backup; + final List logs; + final List about; +} diff --git a/lib/screens/settings/settings_tab.dart b/lib/screens/settings/settings_tab.dart index 73c8260a..49dc7c57 100644 --- a/lib/screens/settings/settings_tab.dart +++ b/lib/screens/settings/settings_tab.dart @@ -15,6 +15,7 @@ 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'; @@ -31,6 +32,7 @@ class _Destination { required this.subtitle, required this.pageBuilder, this.keywords = const [], + this.searchEntries = const [], }); final IconData icon; @@ -41,14 +43,20 @@ class _Destination { /// 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(String query) { - if (query.isEmpty) return true; - final haystack = [title, subtitle, ...keywords].join(' ').toLowerCase(); - return haystack.contains(query); + 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}); @@ -65,11 +73,15 @@ class SettingsTab extends ConsumerStatefulWidget { 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(); } @@ -77,7 +89,12 @@ class _SettingsTabState extends ConsumerState { /// without adding section labels that compete with the page title. List<_Group> _groups(BuildContext context) { final l10n = context.l10n; - return [ + if (identical(_cachedLocalizations, l10n) && _cachedGroups != null) { + return _cachedGroups!; + } + + final searchCatalog = SettingsSearchCatalog(l10n); + final groups = [ _Group( destinations: [ _Destination( @@ -96,6 +113,7 @@ class _SettingsTabState extends ConsumerState { title: l10n.settingsExtensions, subtitle: l10n.settingsExtensionsSubtitle, keywords: const ['plugin', 'provider', 'priority', 'store'], + searchEntries: searchCatalog.extensions, pageBuilder: () => const ExtensionsPage(), ), _Destination( @@ -113,6 +131,7 @@ class _SettingsTabState extends ConsumerState { 'layout', 'grid', ], + searchEntries: searchCatalog.appearance, pageBuilder: () => const AppearanceSettingsPage(), ), ], @@ -130,6 +149,7 @@ class _SettingsTabState extends ConsumerState { 'playback', 'duplicate', ], + searchEntries: searchCatalog.library, pageBuilder: () => const LibrarySettingsPage(), ), _Destination( @@ -137,6 +157,7 @@ class _SettingsTabState extends ConsumerState { title: l10n.settingsMetadata, subtitle: l10n.settingsMetadataSubtitle, keywords: const ['tag', 'cover', 'artwork', 'isrc', 'provider'], + searchEntries: searchCatalog.metadata, pageBuilder: () => const MetadataSettingsPage(), ), _Destination( @@ -144,6 +165,7 @@ class _SettingsTabState extends ConsumerState { title: l10n.settingsLyrics, subtitle: l10n.settingsLyricsSubtitle, keywords: const ['lrc', 'synced', 'provider'], + searchEntries: searchCatalog.lyrics, pageBuilder: () => const LyricsSettingsPage(), ), ], @@ -163,6 +185,7 @@ class _SettingsTabState extends ConsumerState { 'service', 'region', ], + searchEntries: searchCatalog.download, pageBuilder: () => const DownloadSettingsPage(), ), _Destination( @@ -177,6 +200,7 @@ class _SettingsTabState extends ConsumerState { 'path', 'permission', ], + searchEntries: searchCatalog.files, pageBuilder: () => const FilesSettingsPage(), ), ], @@ -188,6 +212,7 @@ class _SettingsTabState extends ConsumerState { title: l10n.settingsApp, subtitle: l10n.settingsAppSubtitle, keywords: const ['update', 'channel', 'debug', 'logging'], + searchEntries: searchCatalog.app, pageBuilder: () => const AppSettingsPage(), ), _Destination( @@ -195,6 +220,7 @@ class _SettingsTabState extends ConsumerState { title: l10n.settingsCache, subtitle: l10n.settingsCacheSubtitle, keywords: const ['clear', 'space', 'image', 'temp'], + searchEntries: searchCatalog.cache, pageBuilder: () => const CacheManagementPage(), ), _Destination( @@ -202,6 +228,7 @@ class _SettingsTabState extends ConsumerState { title: l10n.settingsBackup, subtitle: l10n.settingsBackupSubtitle, keywords: const ['export', 'import', 'restore', 'json'], + searchEntries: searchCatalog.backup, pageBuilder: () => const BackupRestorePage(), ), _Destination( @@ -209,6 +236,7 @@ class _SettingsTabState extends ConsumerState { title: l10n.logTitle, subtitle: l10n.settingsLogsSubtitle, keywords: const ['debug', 'error', 'report'], + searchEntries: searchCatalog.logs, pageBuilder: () => const LogScreen(), ), ], @@ -220,16 +248,33 @@ class _SettingsTabState extends ConsumerState { 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; } - void _navigateTo(BuildContext context, Widget page) { + Future _navigateTo(BuildContext context, Widget page) async { + _searchFocusNode.unfocus(); + _searchFocusNode.canRequestFocus = false; FocusManager.instance.primaryFocus?.unfocus(); - Navigator.of(context).push(slidePageRoute(page: page)); + 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}) { @@ -242,12 +287,30 @@ class _SettingsTabState extends ConsumerState { ); } + 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 = _query.trim().toLowerCase(); + final query = SettingsSearchQuery(_query); final groups = _groups(context); final margin = EdgeInsets.fromLTRB( @@ -275,9 +338,13 @@ class _SettingsTabState extends ConsumerState { ), ]; } else { - final matches = [ + final matches = <_SearchResult>[ for (final group in groups) - ...group.destinations.where((d) => d.matches(query)), + 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 ? [ @@ -300,7 +367,7 @@ class _SettingsTabState extends ConsumerState { margin: margin, children: [ for (var i = 0; i < matches.length; i++) - _itemFor( + _searchItemFor( matches[i], showDivider: i != matches.length - 1, ), @@ -323,6 +390,7 @@ class _SettingsTabState extends ConsumerState { ), child: AppSearchField( controller: _searchController, + focusNode: _searchFocusNode, hintText: context.l10n.settingsSearchHint, clearTooltip: context.l10n.dialogClear, onChanged: (value) => setState(() => _query = value), diff --git a/test/settings_search_test.dart b/test/settings_search_test.dart new file mode 100644 index 00000000..78bde6f7 --- /dev/null +++ b/test/settings_search_test.dart @@ -0,0 +1,70 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +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'; + +void main() { + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + Future pumpSettings(WidgetTester tester) async { + await tester.pumpWidget( + ProviderScope( + child: MaterialApp( + theme: AppTheme.light(), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: const Scaffold(body: SettingsTab()), + ), + ), + ); + await tester.pumpAndSettle(); + } + + testWidgets('finds controls nested inside a settings page', (tester) async { + await pumpSettings(tester); + + await tester.enterText(find.byType(TextField), 'parallel downloads'); + await tester.pump(); + + expect(find.text('Concurrent downloads'), findsOneWidget); + expect(find.text('Download'), findsOneWidget); + expect( + find.text('No settings found for "parallel downloads"'), + findsNothing, + ); + }); + + testWidgets( + 'returning from a search result does not restore keyboard focus', + (tester) async { + await pumpSettings(tester); + + final searchField = find.byType(TextField); + await tester.tap(searchField); + await tester.enterText(searchField, 'build number'); + await tester.pump(); + + final focusedField = tester.widget(searchField); + expect(focusedField.focusNode?.hasFocus, isTrue); + expect(find.text('Version'), findsOneWidget); + + await tester.tap(find.text('Version')); + await tester.pumpAndSettle(); + expect(find.byType(AboutPage), findsOneWidget); + + await tester.pageBack(); + await tester.pumpAndSettle(); + + final restoredField = tester.widget(find.byType(TextField)); + expect(restoredField.focusNode?.hasFocus, isFalse); + expect(tester.testTextInput.isVisible, isFalse); + expect(find.text('Version'), findsOneWidget); + }, + ); +}