mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-30 21:29:36 +02:00
Add a selectable Mornye theme with shared glass controls, dialogs, sheets, notifications, and collection headers while retaining Material components. Update player artwork, lyrics transitions and highlighting, synchronized rolling time labels, volume controls, and compact navigation. Unify metadata, Library, search, and extension surfaces; preserve restored artwork and report extension load failures. Validation: full Flutter analyzer clean; 713 tests passed and 4 skipped; pinned Dart formatting and diff checks passed; signed ARM64 and ARM32 release APKs built and verified.
419 lines
14 KiB
Dart
419 lines
14 KiB
Dart
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/theme/mornye_theme.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<String> keywords;
|
|
final List<SettingsSearchEntry> 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<SettingsTab> createState() => _SettingsTabState();
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
/// 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.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.favorite_outline,
|
|
title: l10n.settingsDonate,
|
|
subtitle: l10n.settingsDonateSubtitle,
|
|
keywords: const ['support', 'ko-fi', 'sponsor'],
|
|
pageBuilder: () => const DonatePage(),
|
|
),
|
|
_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<void> _navigateTo(
|
|
BuildContext context,
|
|
Widget page, {
|
|
String? targetLabel,
|
|
}) async {
|
|
_searchFocusNode.unfocus();
|
|
_searchFocusNode.canRequestFocus = false;
|
|
FocusManager.instance.primaryFocus?.unfocus();
|
|
final destination = targetLabel == null
|
|
? page
|
|
: SettingsSearchHighlightScope(targetLabel: targetLabel, child: page);
|
|
await Navigator.of(
|
|
context,
|
|
).push(slidePageRoute<void>(page: MornyeSettingsTheme(child: destination)));
|
|
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: context.isMornye ? null : 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(),
|
|
targetLabel: entry.targetLabel,
|
|
),
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final tokens = context.tokens;
|
|
final wideInset = wideListInset(context);
|
|
final query = SettingsSearchQuery(_query);
|
|
final groups = _groups(context);
|
|
|
|
final margin = EdgeInsets.fromLTRB(
|
|
tokens.gapLg + wideInset,
|
|
context.isMornye ? 12 : tokens.gapSm,
|
|
tokens.gapLg + wideInset,
|
|
context.isMornye ? 12 : tokens.gapSm,
|
|
);
|
|
|
|
final List<Widget> 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 MornyeSettingsTheme(
|
|
child: 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,
|
|
const NavBarSliverSpacer(),
|
|
const SliverFillRemaining(hasScrollBody: false, child: SizedBox()),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|