feat(settings): search nested options

This commit is contained in:
zarzet
2026-08-01 19:08:35 +07:00
parent 7972fd64a4
commit f7a0c8e993
3 changed files with 804 additions and 11 deletions
@@ -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<String> 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<String> terms;
bool get isEmpty => terms.isEmpty;
bool matches(Iterable<String> 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<SettingsSearchEntry> extensions;
final List<SettingsSearchEntry> appearance;
final List<SettingsSearchEntry> library;
final List<SettingsSearchEntry> metadata;
final List<SettingsSearchEntry> lyrics;
final List<SettingsSearchEntry> download;
final List<SettingsSearchEntry> files;
final List<SettingsSearchEntry> app;
final List<SettingsSearchEntry> cache;
final List<SettingsSearchEntry> backup;
final List<SettingsSearchEntry> logs;
final List<SettingsSearchEntry> about;
}
+79 -11
View File
@@ -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<String> keywords;
final List<SettingsSearchEntry> 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<SettingsTab> {
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<SettingsTab> {
/// 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<SettingsTab> {
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<SettingsTab> {
'layout',
'grid',
],
searchEntries: searchCatalog.appearance,
pageBuilder: () => const AppearanceSettingsPage(),
),
],
@@ -130,6 +149,7 @@ class _SettingsTabState extends ConsumerState<SettingsTab> {
'playback',
'duplicate',
],
searchEntries: searchCatalog.library,
pageBuilder: () => const LibrarySettingsPage(),
),
_Destination(
@@ -137,6 +157,7 @@ class _SettingsTabState extends ConsumerState<SettingsTab> {
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<SettingsTab> {
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<SettingsTab> {
'service',
'region',
],
searchEntries: searchCatalog.download,
pageBuilder: () => const DownloadSettingsPage(),
),
_Destination(
@@ -177,6 +200,7 @@ class _SettingsTabState extends ConsumerState<SettingsTab> {
'path',
'permission',
],
searchEntries: searchCatalog.files,
pageBuilder: () => const FilesSettingsPage(),
),
],
@@ -188,6 +212,7 @@ class _SettingsTabState extends ConsumerState<SettingsTab> {
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<SettingsTab> {
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<SettingsTab> {
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<SettingsTab> {
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<SettingsTab> {
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<void> _navigateTo(BuildContext context, Widget page) async {
_searchFocusNode.unfocus();
_searchFocusNode.canRequestFocus = false;
FocusManager.instance.primaryFocus?.unfocus();
Navigator.of(context).push(slidePageRoute<void>(page: page));
await Navigator.of(context).push(slidePageRoute<void>(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<SettingsTab> {
);
}
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<SettingsTab> {
),
];
} 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<SettingsTab> {
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<SettingsTab> {
),
child: AppSearchField(
controller: _searchController,
focusNode: _searchFocusNode,
hintText: context.l10n.settingsSearchHint,
clearTooltip: context.l10n.dialogClear,
onChanged: (value) => setState(() => _query = value),
+70
View File
@@ -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<void> 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<TextField>(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<TextField>(find.byType(TextField));
expect(restoredField.focusNode?.hasFocus, isFalse);
expect(tester.testTextInput.isVisible, isFalse);
expect(find.text('Version'), findsOneWidget);
},
);
}