Files
SpotiFLAC-Mobile/lib/screens/settings/settings_tab.dart
T

359 lines
12 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/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 [],
});
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;
bool matches(String query) {
if (query.isEmpty) return true;
final haystack = [title, subtitle, ...keywords].join(' ').toLowerCase();
return haystack.contains(query);
}
}
/// A settings group. The optional label lets priority shortcuts sit directly
/// below search without a misleading section heading.
class _Group {
const _Group({this.label, required this.destinations});
final String? label;
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();
String _query = '';
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
/// Destinations grouped by intent.
///
/// They used to sit in three unlabelled groups whose logic was hard to infer
/// (the local library grouped with appearance, cache grouped with backup).
/// Labelling them and regrouping by what the user is trying to do makes the
/// list scannable.
List<_Group> _groups(BuildContext context) {
final l10n = context.l10n;
return [
_Group(
destinations: [
_Destination(
icon: Icons.favorite_outline,
title: l10n.settingsDonate,
subtitle: l10n.settingsDonateSubtitle,
keywords: const ['support', 'ko-fi', 'sponsor'],
pageBuilder: () => const DonatePage(),
),
],
),
_Group(
label: l10n.settingsGroupInterface,
destinations: [
_Destination(
icon: Icons.extension_outlined,
title: l10n.settingsExtensions,
subtitle: l10n.settingsExtensionsSubtitle,
keywords: const ['plugin', 'provider', 'priority', 'store'],
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',
],
pageBuilder: () => const AppearanceSettingsPage(),
),
],
),
_Group(
label: l10n.settingsGroupContent,
destinations: [
_Destination(
icon: Icons.library_music_outlined,
title: l10n.settingsLocalLibrary,
subtitle: l10n.settingsLocalLibrarySubtitle,
keywords: const [
'scan',
'local',
'player',
'playback',
'duplicate',
],
pageBuilder: () => const LibrarySettingsPage(),
),
_Destination(
icon: Icons.sell_outlined,
title: l10n.settingsMetadata,
subtitle: l10n.settingsMetadataSubtitle,
keywords: const ['tag', 'cover', 'artwork', 'isrc', 'provider'],
pageBuilder: () => const MetadataSettingsPage(),
),
_Destination(
icon: Icons.lyrics_outlined,
title: l10n.settingsLyrics,
subtitle: l10n.settingsLyricsSubtitle,
keywords: const ['lrc', 'synced', 'provider'],
pageBuilder: () => const LyricsSettingsPage(),
),
],
),
_Group(
label: l10n.settingsGroupDownloads,
destinations: [
_Destination(
icon: Icons.download_outlined,
title: l10n.settingsDownload,
subtitle: l10n.settingsDownloadSubtitle,
keywords: const [
'quality',
'flac',
'concurrent',
'network',
'wifi',
'service',
'region',
],
pageBuilder: () => const DownloadSettingsPage(),
),
_Destination(
icon: Icons.folder_outlined,
title: l10n.settingsFiles,
subtitle: l10n.settingsFilesSubtitle,
keywords: const [
'saf',
'storage',
'folder',
'filename',
'path',
'permission',
],
pageBuilder: () => const FilesSettingsPage(),
),
],
),
_Group(
label: l10n.settingsGroupSystem,
destinations: [
_Destination(
icon: Icons.tune_outlined,
title: l10n.settingsApp,
subtitle: l10n.settingsAppSubtitle,
keywords: const ['update', 'channel', 'debug', 'logging'],
pageBuilder: () => const AppSettingsPage(),
),
_Destination(
icon: Icons.storage_outlined,
title: l10n.settingsCache,
subtitle: l10n.settingsCacheSubtitle,
keywords: const ['clear', 'space', 'image', 'temp'],
pageBuilder: () => const CacheManagementPage(),
),
_Destination(
icon: Icons.settings_backup_restore,
title: l10n.settingsBackup,
subtitle: l10n.settingsBackupSubtitle,
keywords: const ['export', 'import', 'restore', 'json'],
pageBuilder: () => const BackupRestorePage(),
),
_Destination(
icon: Icons.article_outlined,
title: l10n.logTitle,
subtitle: l10n.settingsLogsSubtitle,
keywords: const ['debug', 'error', 'report'],
pageBuilder: () => const LogScreen(),
),
],
),
_Group(
label: l10n.settingsGroupHelp,
destinations: [
_Destination(
icon: Icons.info_outline,
title: l10n.settingsAbout,
subtitle: '${l10n.aboutVersion} ${AppInfo.displayVersion}',
keywords: const ['version', 'license', 'contributor'],
pageBuilder: () => const AboutPage(),
),
],
),
];
}
void _navigateTo(BuildContext context, Widget page) {
FocusManager.instance.primaryFocus?.unfocus();
Navigator.of(context).push(slidePageRoute<void>(page: page));
}
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()),
);
}
@override
Widget build(BuildContext context) {
final tokens = context.tokens;
final bottomInset = context.navBarBottomInset;
final wideInset = wideListInset(context);
final query = _query.trim().toLowerCase();
final groups = _groups(context);
final margin = EdgeInsets.fromLTRB(
tokens.gapLg + wideInset,
tokens.gapXs,
tokens.gapLg + wideInset,
tokens.gapXs,
);
final List<Widget> body;
if (query.isEmpty) {
body = [
for (final group in groups) ...[
if (group.label != null)
SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.only(left: wideInset, right: wideInset),
child: SettingsSectionHeader(title: group.label!),
),
),
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 = [
for (final group in groups)
...group.destinations.where((d) => d.matches(query)),
];
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++)
_itemFor(
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,
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()),
],
);
}
}