From 7dafbc1063711a74a8a482536cfc5cfa1f99fa0d Mon Sep 17 00:00:00 2001 From: Amonoman Date: Mon, 27 Apr 2026 20:43:12 +0200 Subject: [PATCH] refactor(settings): split download/options into focused pages - Extract files, metadata, lyrics into dedicated pages - Move search source + fallback into download page - Move app/update/debug settings into new app_settings_page - Replace options_settings_page with app_settings_page - Reorganize settings_tab into 3 logical groups --- lib/screens/settings/app_settings_page.dart | 372 +++ .../settings/download_settings_page.dart | 2046 ++++------------- lib/screens/settings/files_settings_page.dart | 1055 +++++++++ .../settings/lyrics_settings_page.dart | 373 +++ .../settings/metadata_settings_page.dart | 253 ++ .../settings/options_settings_page.dart | 1012 -------- lib/screens/settings/settings_tab.dart | 106 +- 7 files changed, 2547 insertions(+), 2670 deletions(-) create mode 100644 lib/screens/settings/app_settings_page.dart create mode 100644 lib/screens/settings/files_settings_page.dart create mode 100644 lib/screens/settings/lyrics_settings_page.dart create mode 100644 lib/screens/settings/metadata_settings_page.dart delete mode 100644 lib/screens/settings/options_settings_page.dart diff --git a/lib/screens/settings/app_settings_page.dart b/lib/screens/settings/app_settings_page.dart new file mode 100644 index 00000000..8b7d14bf --- /dev/null +++ b/lib/screens/settings/app_settings_page.dart @@ -0,0 +1,372 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:spotiflac_android/l10n/l10n.dart'; +import 'package:spotiflac_android/providers/download_queue_provider.dart'; +import 'package:spotiflac_android/providers/settings_provider.dart'; +import 'package:spotiflac_android/utils/app_bar_layout.dart'; +import 'package:spotiflac_android/widgets/settings_group.dart'; + +class AppSettingsPage extends ConsumerWidget { + const AppSettingsPage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final settings = ref.watch(settingsProvider); + final colorScheme = Theme.of(context).colorScheme; + final topPadding = normalizedHeaderTopPadding(context); + + return PopScope( + canPop: true, + child: Scaffold( + body: CustomScrollView( + slivers: [ + SliverAppBar( + expandedHeight: 120 + topPadding, + collapsedHeight: kToolbarHeight, + floating: false, + pinned: true, + backgroundColor: colorScheme.surface, + surfaceTintColor: Colors.transparent, + leading: IconButton( + tooltip: MaterialLocalizations.of(context).backButtonTooltip, + icon: const Icon(Icons.arrow_back), + onPressed: () => Navigator.pop(context), + ), + flexibleSpace: LayoutBuilder( + builder: (context, constraints) { + final maxHeight = 120 + topPadding; + final minHeight = kToolbarHeight + topPadding; + final expandRatio = + ((constraints.maxHeight - minHeight) / + (maxHeight - minHeight)) + .clamp(0.0, 1.0); + final leftPadding = 56 - (32 * expandRatio); + return FlexibleSpaceBar( + expandedTitleScale: 1.0, + titlePadding: EdgeInsets.only( + left: leftPadding, + bottom: 16, + ), + title: Text( + context.l10n.settingsApp, + style: TextStyle( + fontSize: 20 + (8 * expandRatio), + fontWeight: FontWeight.bold, + color: colorScheme.onSurface, + ), + ), + ); + }, + ), + ), + + // ── Updates ──────────────────────────────────────────────── + SliverToBoxAdapter( + child: SettingsSectionHeader(title: context.l10n.sectionApp), + ), + SliverToBoxAdapter( + child: SettingsGroup( + children: [ + SettingsSwitchItem( + icon: Icons.extension, + title: context.l10n.optionsExtensionStore, + subtitle: context.l10n.optionsExtensionStoreSubtitle, + value: settings.showExtensionStore, + onChanged: (v) => ref + .read(settingsProvider.notifier) + .setShowExtensionStore(v), + ), + SettingsSwitchItem( + icon: Icons.system_update, + title: context.l10n.optionsCheckUpdates, + subtitle: context.l10n.optionsCheckUpdatesSubtitle, + value: settings.checkForUpdates, + onChanged: (v) => ref + .read(settingsProvider.notifier) + .setCheckForUpdates(v), + showDivider: settings.checkForUpdates, + ), + if (settings.checkForUpdates) + _UpdateChannelSelector( + currentChannel: settings.updateChannel, + onChanged: (v) => ref + .read(settingsProvider.notifier) + .setUpdateChannel(v), + ), + ], + ), + ), + + // ── Data ─────────────────────────────────────────────────── + SliverToBoxAdapter( + child: SettingsSectionHeader(title: context.l10n.sectionData), + ), + SliverToBoxAdapter( + child: SettingsGroup( + children: [ + SettingsItem( + icon: Icons.cleaning_services_outlined, + title: context.l10n.cleanupOrphanedDownloads, + subtitle: context.l10n.cleanupOrphanedDownloadsSubtitle, + onTap: () => _cleanupOrphanedDownloads(context, ref), + ), + SettingsItem( + icon: Icons.delete_forever, + title: context.l10n.optionsClearHistory, + subtitle: context.l10n.optionsClearHistorySubtitle, + onTap: () => + _showClearHistoryDialog(context, ref, colorScheme), + showDivider: false, + ), + ], + ), + ), + + // ── Debug ────────────────────────────────────────────────── + SliverToBoxAdapter( + child: SettingsSectionHeader(title: context.l10n.sectionDebug), + ), + SliverToBoxAdapter( + child: SettingsGroup( + children: [ + SettingsSwitchItem( + icon: Icons.bug_report, + title: context.l10n.optionsDetailedLogging, + subtitle: settings.enableLogging + ? context.l10n.optionsDetailedLoggingOn + : context.l10n.optionsDetailedLoggingOff, + value: settings.enableLogging, + onChanged: (v) => ref + .read(settingsProvider.notifier) + .setEnableLogging(v), + showDivider: false, + ), + ], + ), + ), + + const SliverToBoxAdapter(child: SizedBox(height: 32)), + ], + ), + ), + ); + } + + void _showClearHistoryDialog( + BuildContext context, + WidgetRef ref, + ColorScheme colorScheme, + ) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(context.l10n.dialogClearHistoryTitle), + content: Text(context.l10n.dialogClearHistoryMessage), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(context.l10n.dialogCancel), + ), + TextButton( + onPressed: () { + ref.read(downloadHistoryProvider.notifier).clearHistory(); + Navigator.pop(context); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.snackbarHistoryCleared)), + ); + }, + child: Text( + context.l10n.dialogClear, + style: TextStyle(color: colorScheme.error), + ), + ), + ], + ), + ); + } + + Future _cleanupOrphanedDownloads( + BuildContext context, + WidgetRef ref, + ) async { + showDialog( + context: context, + barrierDismissible: false, + builder: (context) => AlertDialog( + content: Row( + children: [ + const CircularProgressIndicator(), + const SizedBox(width: 16), + Text(context.l10n.cleanupOrphanedDownloads), + ], + ), + ), + ); + try { + final removed = await ref + .read(downloadHistoryProvider.notifier) + .cleanupOrphanedDownloads(); + if (context.mounted) { + Navigator.pop(context); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + removed > 0 + ? context.l10n.cleanupOrphanedDownloadsResult(removed) + : context.l10n.cleanupOrphanedDownloadsNone, + ), + ), + ); + } + } catch (e) { + if (context.mounted) { + Navigator.pop(context); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.snackbarError(e.toString()))), + ); + } + } + } +} + +class _UpdateChannelSelector extends StatelessWidget { + final String currentChannel; + final ValueChanged onChanged; + const _UpdateChannelSelector({ + required this.currentChannel, + required this.onChanged, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final isDark = Theme.of(context).brightness == Brightness.dark; + final unselectedColor = isDark + ? Color.alphaBlend( + Colors.white.withValues(alpha: 0.05), + colorScheme.surface, + ) + : colorScheme.surfaceContainerHigh; + + return Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.new_releases, + color: colorScheme.onSurfaceVariant, + size: 24, + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + context.l10n.optionsUpdateChannel, + style: Theme.of(context).textTheme.bodyLarge, + ), + const SizedBox(height: 2), + Text( + currentChannel == 'preview' + ? context.l10n.optionsUpdateChannelPreview + : context.l10n.optionsUpdateChannelStable, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 16), + Row( + children: [ + _ChannelChip( + label: context.l10n.channelStable, + isSelected: currentChannel == 'stable', + onTap: () => onChanged('stable'), + ), + const SizedBox(width: 8), + _ChannelChip( + label: context.l10n.channelPreview, + isSelected: currentChannel == 'preview', + onTap: () => onChanged('preview'), + ), + ], + ), + const SizedBox(height: 12), + Row( + children: [ + Icon( + Icons.info_outline, + size: 16, + color: colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + context.l10n.optionsUpdateChannelWarning, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ), + ], + ), + ], + ), + ); + } +} + +class _ChannelChip extends StatelessWidget { + final String label; + final bool isSelected; + final VoidCallback onTap; + const _ChannelChip({ + required this.label, + required this.isSelected, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final isDark = Theme.of(context).brightness == Brightness.dark; + final unselectedColor = isDark + ? Color.alphaBlend( + Colors.white.withValues(alpha: 0.05), + colorScheme.surface, + ) + : colorScheme.surfaceContainerHigh; + return Expanded( + child: Material( + color: isSelected ? colorScheme.primaryContainer : unselectedColor, + borderRadius: BorderRadius.circular(12), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: Center( + child: Text( + label, + style: TextStyle( + fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal, + color: isSelected + ? colorScheme.onPrimaryContainer + : colorScheme.onSurfaceVariant, + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/screens/settings/download_settings_page.dart b/lib/screens/settings/download_settings_page.dart index 543dbc1f..2a2de59f 100644 --- a/lib/screens/settings/download_settings_page.dart +++ b/lib/screens/settings/download_settings_page.dart @@ -1,18 +1,13 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:file_picker/file_picker.dart'; -import 'package:path_provider/path_provider.dart'; -import 'package:permission_handler/permission_handler.dart'; import 'package:device_info_plus/device_info_plus.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/models/settings.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/providers/extension_provider.dart'; -import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/utils/app_bar_layout.dart'; -import 'package:spotiflac_android/utils/file_access.dart'; -import 'package:spotiflac_android/screens/settings/lyrics_provider_priority_page.dart'; +import 'package:spotiflac_android/screens/settings/download_fallback_extensions_page.dart'; import 'package:spotiflac_android/widgets/settings_group.dart'; class DownloadSettingsPage extends ConsumerStatefulWidget { @@ -25,278 +20,12 @@ class DownloadSettingsPage extends ConsumerStatefulWidget { class _DownloadSettingsPageState extends ConsumerState { static const _builtInServices = ['tidal', 'qobuz']; - static const _songLinkRegions = [ - 'AD', - 'AE', - 'AG', - 'AL', - 'AM', - 'AO', - 'AR', - 'AT', - 'AU', - 'AZ', - 'BA', - 'BB', - 'BD', - 'BE', - 'BF', - 'BG', - 'BH', - 'BI', - 'BJ', - 'BN', - 'BO', - 'BR', - 'BS', - 'BT', - 'BW', - 'BZ', - 'CA', - 'CD', - 'CG', - 'CH', - 'CI', - 'CL', - 'CM', - 'CO', - 'CR', - 'CV', - 'CW', - 'CY', - 'CZ', - 'DE', - 'DJ', - 'DK', - 'DM', - 'DO', - 'DZ', - 'EC', - 'EE', - 'EG', - 'ES', - 'ET', - 'FI', - 'FJ', - 'FM', - 'FR', - 'GA', - 'GB', - 'GD', - 'GE', - 'GH', - 'GM', - 'GN', - 'GQ', - 'GR', - 'GT', - 'GW', - 'GY', - 'HK', - 'HN', - 'HR', - 'HT', - 'HU', - 'ID', - 'IE', - 'IL', - 'IN', - 'IQ', - 'IS', - 'IT', - 'JM', - 'JO', - 'JP', - 'KE', - 'KG', - 'KH', - 'KI', - 'KM', - 'KN', - 'KR', - 'KW', - 'KZ', - 'LA', - 'LB', - 'LC', - 'LI', - 'LK', - 'LR', - 'LS', - 'LT', - 'LU', - 'LV', - 'LY', - 'MA', - 'MC', - 'MD', - 'ME', - 'MG', - 'MH', - 'MK', - 'ML', - 'MN', - 'MO', - 'MR', - 'MT', - 'MU', - 'MV', - 'MW', - 'MX', - 'MY', - 'MZ', - 'NA', - 'NE', - 'NG', - 'NI', - 'NL', - 'NO', - 'NP', - 'NR', - 'NZ', - 'OM', - 'PA', - 'PE', - 'PG', - 'PH', - 'PK', - 'PL', - 'PS', - 'PT', - 'PW', - 'PY', - 'QA', - 'RO', - 'RS', - 'RW', - 'SA', - 'SB', - 'SC', - 'SE', - 'SG', - 'SI', - 'SK', - 'SL', - 'SM', - 'SN', - 'SR', - 'ST', - 'SV', - 'SZ', - 'TD', - 'TG', - 'TH', - 'TJ', - 'TL', - 'TN', - 'TO', - 'TR', - 'TT', - 'TV', - 'TW', - 'TZ', - 'UA', - 'UG', - 'US', - 'UY', - 'UZ', - 'VC', - 'VE', - 'VN', - 'VU', - 'WS', - 'XK', - 'ZA', - 'ZM', - 'ZW', - ]; - static const _songLinkRegionNames = { - 'US': 'United States', - 'GB': 'United Kingdom', - 'FR': 'France', - 'DE': 'Germany', - 'JP': 'Japan', - 'KR': 'South Korea', - 'IN': 'India', - 'ID': 'Indonesia', - 'BR': 'Brazil', - 'MX': 'Mexico', - 'AU': 'Australia', - 'CA': 'Canada', - 'XK': 'Kosovo', - }; - int _androidSdkVersion = 0; - bool _hasAllFilesAccess = false; - bool _artistFolderFiltersExpanded = false; - - @override - void initState() { - super.initState(); - _initDeviceInfo(); - } - - Future _initDeviceInfo() async { - if (Platform.isAndroid) { - final deviceInfo = DeviceInfoPlugin(); - final androidInfo = await deviceInfo.androidInfo; - final sdkVersion = androidInfo.version.sdkInt; - final hasAccess = await Permission.manageExternalStorage.isGranted; - if (mounted) { - setState(() { - _androidSdkVersion = sdkVersion; - _hasAllFilesAccess = hasAccess; - }); - } - } - } - - Future _requestAllFilesAccess() async { - final status = await Permission.manageExternalStorage.request(); - if (status.isGranted) { - ref.read(settingsProvider.notifier).setUseAllFilesAccess(true); - if (mounted) { - setState(() => _hasAllFilesAccess = true); - } - } else if (status.isPermanentlyDenied) { - if (mounted) { - final shouldOpen = await showDialog( - context: context, - builder: (context) => AlertDialog( - title: Text(context.l10n.setupStorageAccessRequired), - content: Text(context.l10n.allFilesAccessDeniedMessage), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context, false), - child: Text(context.l10n.dialogCancel), - ), - FilledButton( - onPressed: () => Navigator.pop(context, true), - child: Text(context.l10n.setupOpenSettings), - ), - ], - ), - ); - if (shouldOpen == true) { - await openAppSettings(); - } - } - } - } - - Future _disableAllFilesAccess() async { - ref.read(settingsProvider.notifier).setUseAllFilesAccess(false); - // Note: We can't revoke the permission programmatically, - // but we can stop using it in the app - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(context.l10n.allFilesAccessDisabledMessage)), - ); - } - } @override Widget build(BuildContext context) { final settings = ref.watch(settingsProvider); + final extensionState = ref.watch(extensionProvider); + final hasExtensions = extensionState.extensions.isNotEmpty; final colorScheme = Theme.of(context).colorScheme; final topPadding = normalizedHeaderTopPadding(context); @@ -336,7 +65,7 @@ class _DownloadSettingsPageState extends ConsumerState { bottom: 16, ), title: Text( - context.l10n.downloadTitle, + context.l10n.settingsDownload, style: TextStyle( fontSize: 20 + (8 * expandRatio), fontWeight: FontWeight.bold, @@ -348,6 +77,7 @@ class _DownloadSettingsPageState extends ConsumerState { ), ), + // ── Service ──────────────────────────────────────────────── SliverToBoxAdapter( child: SettingsSectionHeader(title: context.l10n.sectionService), ), @@ -364,6 +94,7 @@ class _DownloadSettingsPageState extends ConsumerState { ), ), + // ── Audio Quality ────────────────────────────────────────── SliverToBoxAdapter( child: SettingsSectionHeader( title: context.l10n.sectionAudioQuality, @@ -411,7 +142,6 @@ class _DownloadSettingsPageState extends ConsumerState { .setAudioQuality('HI_RES_LOSSLESS'), showDivider: isTidalService, ), - // Lossy 320kbps option (Tidal only) - downloads M4A AAC from server, converts to MP3/Opus if (isTidalService) _QualityOption( title: context.l10n.downloadLossy320, @@ -441,7 +171,7 @@ class _DownloadSettingsPageState extends ConsumerState { showDivider: false, ), ], - if (!isBuiltInService) ...[ + if (!isBuiltInService) Padding( padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), child: Row( @@ -464,278 +194,25 @@ class _DownloadSettingsPageState extends ConsumerState { ], ), ), - ], - ], - ), - ), - - SliverToBoxAdapter( - child: SettingsSectionHeader(title: context.l10n.sectionLyrics), - ), - SliverToBoxAdapter( - child: SettingsGroup( - children: [ - SettingsSwitchItem( - icon: Icons.subtitles_outlined, - title: context.l10n.optionsEmbedLyrics, - subtitle: settings.embedMetadata - ? context.l10n.optionsEmbedLyricsSubtitle - : context.l10n.downloadEmbedLyricsDisabled, - value: settings.embedLyrics, - enabled: settings.embedMetadata, - onChanged: (value) => ref - .read(settingsProvider.notifier) - .setEmbedLyrics(value), - showDivider: settings.embedMetadata && settings.embedLyrics, - ), - if (settings.embedMetadata && settings.embedLyrics) ...[ - SettingsItem( - icon: Icons.lyrics_outlined, - title: context.l10n.lyricsMode, - subtitle: _getLyricsModeLabel( - context, - settings.lyricsMode, - ), - onTap: () => _showLyricsModePicker( - context, - ref, - settings.lyricsMode, - ), - ), - SettingsItem( - icon: Icons.source_outlined, - title: context.l10n.lyricsProvidersTitle, - subtitle: _getLyricsProvidersSubtitle( - settings.lyricsProviders, - ), - onTap: () => Navigator.push( - context, - MaterialPageRoute( - builder: (_) => const LyricsProviderPriorityPage(), - ), - ), - ), - SettingsSwitchItem( - icon: Icons.translate_outlined, - title: context.l10n.downloadNeteaseIncludeTranslation, - subtitle: settings.lyricsIncludeTranslationNetease - ? context - .l10n - .downloadNeteaseIncludeTranslationEnabled - : context - .l10n - .downloadNeteaseIncludeTranslationDisabled, - value: settings.lyricsIncludeTranslationNetease, - onChanged: (value) => ref - .read(settingsProvider.notifier) - .setLyricsIncludeTranslationNetease(value), - ), - SettingsSwitchItem( - icon: Icons.text_fields_outlined, - title: context.l10n.downloadNeteaseIncludeRomanization, - subtitle: settings.lyricsIncludeRomanizationNetease - ? context - .l10n - .downloadNeteaseIncludeRomanizationEnabled - : context - .l10n - .downloadNeteaseIncludeRomanizationDisabled, - value: settings.lyricsIncludeRomanizationNetease, - onChanged: (value) => ref - .read(settingsProvider.notifier) - .setLyricsIncludeRomanizationNetease(value), - ), - SettingsSwitchItem( - icon: Icons.record_voice_over_outlined, - title: context.l10n.downloadAppleQqMultiPerson, - subtitle: settings.lyricsMultiPersonWordByWord - ? context.l10n.downloadAppleQqMultiPersonEnabled - : context.l10n.downloadAppleQqMultiPersonDisabled, - value: settings.lyricsMultiPersonWordByWord, - onChanged: (value) => ref - .read(settingsProvider.notifier) - .setLyricsMultiPersonWordByWord(value), - ), - SettingsItem( - icon: Icons.language_outlined, - title: context.l10n.downloadMusixmatchLanguage, - subtitle: settings.musixmatchLanguage.isEmpty - ? context.l10n.downloadMusixmatchLanguageAuto - : settings.musixmatchLanguage.toUpperCase(), - onTap: () => _showMusixmatchLanguagePicker( - context, - ref, - settings.musixmatchLanguage, - ), - showDivider: false, - ), - ], ], ), ), + // ── Network & Performance ────────────────────────────────── SliverToBoxAdapter( child: SettingsSectionHeader( - title: context.l10n.sectionFileSettings, + title: context.l10n.sectionPerformance, ), ), SliverToBoxAdapter( child: SettingsGroup( children: [ - SettingsItem( - icon: Icons.text_fields, - title: context.l10n.downloadFilenameFormat, - subtitle: settings.filenameFormat, - onTap: () => _showFormatEditor( - context, - ref, - settings.filenameFormat, - ), - ), - SettingsItem( - icon: Icons.music_note_outlined, - title: context.l10n.downloadSingleFilenameFormat, - subtitle: settings.singleFilenameFormat, - onTap: () => _showFormatEditor( - context, - ref, - settings.singleFilenameFormat, - onSave: ref - .read(settingsProvider.notifier) - .setSingleFilenameFormat, - title: context.l10n.downloadSingleFilenameFormat, - description: - context.l10n.downloadSingleFilenameFormatDescription, - ), - ), - SettingsItem( - icon: Icons.folder_outlined, - title: context.l10n.downloadDirectory, - subtitle: settings.downloadDirectory.isEmpty - ? (Platform.isIOS - ? context.l10n.setupAppDocumentsFolder - : 'Music/SpotiFLAC') - : settings.downloadDirectory, - onTap: () => _pickDirectory(context, ref), - ), - SettingsSwitchItem( - icon: Icons.library_music_outlined, - title: context.l10n.downloadSeparateSinglesFolder, - subtitle: settings.separateSingles - ? context.l10n.downloadSeparateSinglesEnabled - : context.l10n.downloadSeparateSinglesDisabled, - value: settings.separateSingles, - onChanged: (value) => ref + _ConcurrentDownloadsItem( + currentValue: settings.concurrentDownloads, + onChanged: (v) => ref .read(settingsProvider.notifier) - .setSeparateSingles(value), + .setConcurrentDownloads(v), ), - if (settings.separateSingles) - SettingsItem( - icon: Icons.folder_outlined, - title: context.l10n.downloadAlbumFolderStructure, - subtitle: _getAlbumFolderStructureLabel( - settings.albumFolderStructure, - ), - onTap: () => _showAlbumFolderStructurePicker( - context, - ref, - settings.albumFolderStructure, - ), - ), - if (!settings.separateSingles) - SettingsItem( - icon: Icons.create_new_folder_outlined, - title: context.l10n.downloadFolderOrganization, - subtitle: _getFolderOrganizationLabel( - settings.folderOrganization, - ), - onTap: () => _showFolderOrganizationPicker( - context, - ref, - settings.folderOrganization, - ), - ), - SettingsSwitchItem( - icon: Icons.playlist_play_outlined, - title: context.l10n.downloadCreatePlaylistSourceFolder, - subtitle: _getPlaylistFolderSubtitle(settings), - value: settings.createPlaylistFolder, - onChanged: (value) => ref - .read(settingsProvider.notifier) - .setCreatePlaylistFolder(value), - ), - SettingsSwitchItem( - icon: Icons.person_search_outlined, - title: context.l10n.downloadUseAlbumArtistForFolders, - subtitle: settings.useAlbumArtistForFolders - ? context - .l10n - .downloadUseAlbumArtistForFoldersAlbumSubtitle - : context - .l10n - .downloadUseAlbumArtistForFoldersTrackSubtitle, - value: settings.useAlbumArtistForFolders, - onChanged: (value) => ref - .read(settingsProvider.notifier) - .setUseAlbumArtistForFolders(value), - ), - SettingsItem( - icon: Icons.filter_alt_outlined, - title: context.l10n.downloadArtistNameFilters, - subtitle: _getArtistFolderFilterSubtitle( - context, - usePrimaryArtistOnly: settings.usePrimaryArtistOnly, - filterAlbumArtistContributors: - settings.filterContributingArtistsInAlbumArtist, - ), - trailing: Icon( - _artistFolderFiltersExpanded - ? Icons.expand_less - : Icons.expand_more, - ), - onTap: () { - setState(() { - _artistFolderFiltersExpanded = - !_artistFolderFiltersExpanded; - }); - }, - showDivider: !_artistFolderFiltersExpanded, - ), - if (_artistFolderFiltersExpanded) - SettingsSwitchItem( - icon: Icons.person_outline, - title: context.l10n.downloadUsePrimaryArtistOnly, - subtitle: settings.usePrimaryArtistOnly - ? context.l10n.downloadUsePrimaryArtistOnlyEnabled - : context.l10n.downloadUsePrimaryArtistOnlyDisabled, - value: settings.usePrimaryArtistOnly, - onChanged: (value) => ref - .read(settingsProvider.notifier) - .setUsePrimaryArtistOnly(value), - ), - if (_artistFolderFiltersExpanded) - SettingsSwitchItem( - icon: Icons.group_remove_outlined, - title: context.l10n.downloadFilterContributing, - subtitle: settings.filterContributingArtistsInAlbumArtist - ? context.l10n.downloadFilterContributingEnabled - : context.l10n.downloadFilterContributingDisabled, - value: settings.filterContributingArtistsInAlbumArtist, - onChanged: (value) => ref - .read(settingsProvider.notifier) - .setFilterContributingArtistsInAlbumArtist(value), - showDivider: false, - ), - ], - ), - ), - - SliverToBoxAdapter( - child: SettingsSectionHeader(title: context.l10n.sectionDownload), - ), - SliverToBoxAdapter( - child: SettingsGroup( - children: [ SettingsItem( icon: Icons.wifi, title: context.l10n.settingsDownloadNetwork, @@ -748,6 +225,77 @@ class _DownloadSettingsPageState extends ConsumerState { settings.downloadNetworkMode, ), ), + SettingsSwitchItem( + icon: Icons.security_outlined, + title: context.l10n.downloadNetworkCompatibilityMode, + subtitle: settings.networkCompatibilityMode + ? context.l10n.downloadNetworkCompatibilityModeEnabled + : context.l10n.downloadNetworkCompatibilityModeDisabled, + value: settings.networkCompatibilityMode, + onChanged: (value) => ref + .read(settingsProvider.notifier) + .setNetworkCompatibilityMode(value), + showDivider: false, + ), + ], + ), + ), + + // ── Fallback & Search ────────────────────────────────────── + SliverToBoxAdapter( + child: SettingsSectionHeader( + title: context.l10n.sectionSearchSource, + ), + ), + SliverToBoxAdapter( + child: SettingsGroup( + children: [ + const _MetadataSourceSelector(), + const _DefaultSearchTabSelector(), + SettingsSwitchItem( + icon: Icons.sync, + title: context.l10n.optionsAutoFallback, + subtitle: context.l10n.optionsAutoFallbackSubtitle, + value: settings.autoFallback, + onChanged: (v) => + ref.read(settingsProvider.notifier).setAutoFallback(v), + ), + if (hasExtensions) + SettingsSwitchItem( + icon: Icons.extension, + title: context.l10n.optionsUseExtensionProviders, + subtitle: settings.useExtensionProviders + ? context.l10n.optionsUseExtensionProvidersOn + : context.l10n.optionsUseExtensionProvidersOff, + value: settings.useExtensionProviders, + onChanged: (v) => ref + .read(settingsProvider.notifier) + .setUseExtensionProviders(v), + ), + SettingsItem( + icon: Icons.extension_outlined, + title: context.l10n.downloadFallbackExtensions, + subtitle: context.l10n.downloadFallbackExtensionsSubtitle, + onTap: () => Navigator.push( + context, + MaterialPageRoute( + builder: (_) => + const DownloadFallbackExtensionsPage(), + ), + ), + showDivider: false, + ), + ], + ), + ), + + // ── Misc ─────────────────────────────────────────────────── + SliverToBoxAdapter( + child: SettingsSectionHeader(title: context.l10n.sectionDownload), + ), + SliverToBoxAdapter( + child: SettingsGroup( + children: [ SettingsItem( icon: Icons.public, title: context.l10n.downloadSongLinkRegion, @@ -758,88 +306,20 @@ class _DownloadSettingsPageState extends ConsumerState { settings.songLinkRegion, ), ), - SettingsSwitchItem( - icon: Icons.security_outlined, - title: context.l10n.downloadNetworkCompatibilityMode, - subtitle: settings.networkCompatibilityMode - ? context.l10n.downloadNetworkCompatibilityModeEnabled - : context.l10n.downloadNetworkCompatibilityModeDisabled, - value: settings.networkCompatibilityMode, - onChanged: (value) { - ref - .read(settingsProvider.notifier) - .setNetworkCompatibilityMode(value); - }, - ), SettingsSwitchItem( icon: Icons.file_download_outlined, title: context.l10n.settingsAutoExportFailed, subtitle: context.l10n.settingsAutoExportFailedSubtitle, value: settings.autoExportFailedDownloads, - onChanged: (value) { - ref - .read(settingsProvider.notifier) - .setAutoExportFailedDownloads(value); - }, + onChanged: (value) => ref + .read(settingsProvider.notifier) + .setAutoExportFailedDownloads(value), showDivider: false, ), ], ), ), - if (Platform.isAndroid && _androidSdkVersion >= 33) ...[ - SliverToBoxAdapter( - child: SettingsSectionHeader( - title: context.l10n.sectionStorageAccess, - ), - ), - SliverToBoxAdapter( - child: SettingsGroup( - children: [ - SettingsSwitchItem( - icon: Icons.folder_special_outlined, - title: context.l10n.allFilesAccess, - subtitle: _hasAllFilesAccess - ? context.l10n.allFilesAccessEnabledSubtitle - : context.l10n.allFilesAccessDisabledSubtitle, - value: _hasAllFilesAccess && settings.useAllFilesAccess, - onChanged: (value) { - if (value) { - _requestAllFilesAccess(); - } else { - _disableAllFilesAccess(); - } - }, - showDivider: false, - ), - ], - ), - ), - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Icon( - Icons.info_outline, - size: 16, - color: colorScheme.onSurfaceVariant, - ), - const SizedBox(width: 8), - Expanded( - child: Text( - context.l10n.allFilesAccessDescription, - style: Theme.of(context).textTheme.bodySmall - ?.copyWith(color: colorScheme.onSurfaceVariant), - ), - ), - ], - ), - ), - ), - ], - const SliverToBoxAdapter(child: SizedBox(height: 32)), ], ), @@ -847,765 +327,6 @@ class _DownloadSettingsPageState extends ConsumerState { ); } - String _getAlbumFolderStructureLabel(String structure) { - switch (structure) { - case 'album_only': - return 'Albums/Album Name/'; - case 'artist_year_album': - return 'Albums/Artist/[Year] Album/'; - case 'year_album': - return 'Albums/[Year] Album/'; - case 'artist_album_singles': - return 'Artist/Album/ + Artist/Singles/'; - case 'artist_album_flat': - return 'Artist/Album/ + Artist/song.flac'; - default: - return 'Albums/Artist/Album Name/'; - } - } - - void _showAlbumFolderStructurePicker( - BuildContext context, - WidgetRef ref, - String current, - ) { - showModalBottomSheet( - context: context, - useRootNavigator: true, - builder: (context) => SafeArea( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - ListTile( - leading: const Icon(Icons.folder_outlined), - title: Text(context.l10n.albumFolderArtistAlbum), - subtitle: Text(context.l10n.albumFolderArtistAlbumSubtitle), - trailing: current == 'artist_album' - ? const Icon(Icons.check) - : null, - onTap: () { - ref - .read(settingsProvider.notifier) - .setAlbumFolderStructure('artist_album'); - Navigator.pop(context); - }, - ), - ListTile( - leading: const Icon(Icons.calendar_today_outlined), - title: Text(context.l10n.albumFolderArtistYearAlbum), - subtitle: Text(context.l10n.albumFolderArtistYearAlbumSubtitle), - trailing: current == 'artist_year_album' - ? const Icon(Icons.check) - : null, - onTap: () { - ref - .read(settingsProvider.notifier) - .setAlbumFolderStructure('artist_year_album'); - Navigator.pop(context); - }, - ), - ListTile( - leading: const Icon(Icons.album_outlined), - title: Text(context.l10n.albumFolderAlbumOnly), - subtitle: Text(context.l10n.albumFolderAlbumOnlySubtitle), - trailing: current == 'album_only' - ? const Icon(Icons.check) - : null, - onTap: () { - ref - .read(settingsProvider.notifier) - .setAlbumFolderStructure('album_only'); - Navigator.pop(context); - }, - ), - ListTile( - leading: const Icon(Icons.event_outlined), - title: Text(context.l10n.albumFolderYearAlbum), - subtitle: Text(context.l10n.albumFolderYearAlbumSubtitle), - trailing: current == 'year_album' - ? const Icon(Icons.check) - : null, - onTap: () { - ref - .read(settingsProvider.notifier) - .setAlbumFolderStructure('year_album'); - Navigator.pop(context); - }, - ), - ListTile( - leading: const Icon(Icons.person_outlined), - title: Text(context.l10n.albumFolderArtistAlbumSingles), - subtitle: Text( - context.l10n.albumFolderArtistAlbumSinglesSubtitle, - ), - trailing: current == 'artist_album_singles' - ? const Icon(Icons.check) - : null, - onTap: () { - ref - .read(settingsProvider.notifier) - .setAlbumFolderStructure('artist_album_singles'); - Navigator.pop(context); - }, - ), - ListTile( - leading: const Icon(Icons.person_outline_outlined), - title: Text(context.l10n.albumFolderArtistAlbumFlat), - subtitle: Text(context.l10n.albumFolderArtistAlbumFlatSubtitle), - trailing: current == 'artist_album_flat' - ? const Icon(Icons.check) - : null, - onTap: () { - ref - .read(settingsProvider.notifier) - .setAlbumFolderStructure('artist_album_flat'); - Navigator.pop(context); - }, - ), - ], - ), - ), - ); - } - - void _showFormatEditor( - BuildContext context, - WidgetRef ref, - String current, { - void Function(String)? onSave, - String? title, - String? description, - }) { - final controller = TextEditingController(text: current); - final colorScheme = Theme.of(context).colorScheme; - - final basicTags = [ - '{artist}', - '{title}', - '{album}', - '{track}', - '{year}', - '{date}', - '{disc}', - ]; - final advancedTags = [ - '{track_raw}', - '{track:02}', - '{track:1}', - '{date:%Y}', - '{date:%Y-%m-%d}', - '{disc_raw}', - '{disc:02}', - ]; - var showAdvancedTags = RegExp( - r'\{(?:track_raw|disc_raw|track:\d+|disc:\d+|date:[^}]+)\}', - caseSensitive: false, - ).hasMatch(current); - - void insertTag(String tag) { - final text = controller.text; - final selection = controller.selection; - final start = selection.start >= 0 ? selection.start : text.length; - final end = selection.end >= 0 ? selection.end : text.length; - - String insertion = tag; - if (start > 0) { - final before = text.substring(0, start); - if (!before.trim().endsWith('-')) { - insertion = ' - $tag'; - } else if (before.trim().endsWith('-') && !before.endsWith(' ')) { - insertion = ' $tag'; - } - } - - final newText = text.replaceRange(start, end, insertion); - controller.value = TextEditingValue( - text: newText, - selection: TextSelection.collapsed(offset: start + insertion.length), - ); - } - - showModalBottomSheet( - context: context, - useRootNavigator: true, - isScrollControlled: true, - backgroundColor: colorScheme.surface, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(28)), - ), - builder: (context) => StatefulBuilder( - builder: (context, setModalState) => Padding( - padding: EdgeInsets.only( - bottom: MediaQuery.of(context).viewInsets.bottom, - ), - child: SingleChildScrollView( - child: SafeArea( - child: Padding( - padding: const EdgeInsets.all(24), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Center( - child: Container( - width: 32, - height: 4, - margin: const EdgeInsets.only(bottom: 24), - decoration: BoxDecoration( - color: colorScheme.outlineVariant, - borderRadius: BorderRadius.circular(2), - ), - ), - ), - Text( - title ?? context.l10n.filenameFormat, - style: Theme.of(context).textTheme.headlineSmall - ?.copyWith(fontWeight: FontWeight.bold), - textAlign: TextAlign.center, - ), - const SizedBox(height: 8), - Text( - description ?? context.l10n.downloadFilenameDescription, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - textAlign: TextAlign.center, - ), - const SizedBox(height: 24), - - TextField( - controller: controller, - decoration: InputDecoration( - hintText: '{artist} - {title}', - filled: true, - fillColor: colorScheme.surfaceContainerHighest - .withValues(alpha: 0.3), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(16), - borderSide: BorderSide.none, - ), - ), - autofocus: true, - ), - const SizedBox(height: 24), - - Text( - context.l10n.downloadFilenameInsertTag, - style: Theme.of(context).textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 12), - Wrap( - spacing: 8, - runSpacing: 8, - children: basicTags.map((tag) { - return ActionChip( - label: Text(tag), - onPressed: () => insertTag(tag), - backgroundColor: colorScheme.surfaceContainerHighest - .withValues(alpha: 0.5), - side: BorderSide.none, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - labelStyle: TextStyle( - color: colorScheme.onSurface, - fontWeight: FontWeight.w500, - ), - ); - }).toList(), - ), - const SizedBox(height: 12), - SwitchListTile( - value: showAdvancedTags, - onChanged: (value) => - setModalState(() => showAdvancedTags = value), - contentPadding: EdgeInsets.zero, - title: Text(context.l10n.filenameShowAdvancedTags), - subtitle: Text( - context.l10n.filenameShowAdvancedTagsDescription, - ), - ), - if (showAdvancedTags) ...[ - const SizedBox(height: 8), - Wrap( - spacing: 8, - runSpacing: 8, - children: advancedTags.map((tag) { - return ActionChip( - label: Text(tag), - onPressed: () => insertTag(tag), - backgroundColor: colorScheme.surfaceContainerHighest - .withValues(alpha: 0.5), - side: BorderSide.none, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - labelStyle: TextStyle( - color: colorScheme.onSurface, - fontWeight: FontWeight.w500, - ), - ); - }).toList(), - ), - ], - - const SizedBox(height: 32), - - Row( - children: [ - Expanded( - child: TextButton( - onPressed: () => Navigator.pop(context), - style: TextButton.styleFrom( - padding: const EdgeInsets.symmetric(vertical: 16), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), - ), - child: Text(context.l10n.dialogCancel), - ), - ), - const SizedBox(width: 12), - Expanded( - flex: 2, - child: FilledButton( - onPressed: () { - final save = - onSave ?? - ref - .read(settingsProvider.notifier) - .setFilenameFormat; - save(controller.text); - Navigator.pop(context); - }, - style: FilledButton.styleFrom( - padding: const EdgeInsets.symmetric(vertical: 16), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), - ), - child: Text(context.l10n.dialogSave), - ), - ), - ], - ), - const SizedBox(height: 8), - ], - ), - ), - ), - ), - ), - ), - ); - } - - Future _pickDirectory(BuildContext context, WidgetRef ref) async { - if (Platform.isIOS) { - _showIOSDirectoryOptions(context, ref); - } else if (Platform.isAndroid) { - _showAndroidDirectoryOptions(context, ref); - } - } - - Future _getDefaultAndroidDirectory() async { - final directMusicPath = '/storage/emulated/0/Music/SpotiFLAC'; - try { - final musicDir = Directory(directMusicPath); - if (!await musicDir.exists()) { - await musicDir.create(recursive: true); - } - return musicDir.path; - } catch (_) {} - - try { - final externalDir = await getExternalStorageDirectory(); - if (externalDir != null) { - final musicDir = Directory( - '${externalDir.parent.parent.parent.parent.path}/Music/SpotiFLAC', - ); - if (!await musicDir.exists()) { - await musicDir.create(recursive: true); - } - return musicDir.path; - } - } catch (_) {} - - final appDir = await getApplicationDocumentsDirectory(); - final fallbackDir = Directory('${appDir.path}/SpotiFLAC'); - if (!await fallbackDir.exists()) { - await fallbackDir.create(recursive: true); - } - return fallbackDir.path; - } - - void _showAndroidDirectoryOptions(BuildContext context, WidgetRef ref) { - final colorScheme = Theme.of(context).colorScheme; - final settings = ref.read(settingsProvider); - final isSafMode = - settings.storageMode == 'saf' && settings.downloadTreeUri.isNotEmpty; - showModalBottomSheet( - context: context, - useRootNavigator: true, - backgroundColor: colorScheme.surfaceContainerHigh, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(28)), - ), - builder: (ctx) => SafeArea( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.fromLTRB(24, 24, 24, 8), - child: Text( - context.l10n.setupDownloadLocationTitle, - style: Theme.of( - ctx, - ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), - ), - ), - Padding( - padding: const EdgeInsets.fromLTRB(24, 0, 24, 16), - child: Text( - context.l10n.downloadLocationSubtitle, - style: Theme.of(ctx).textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - ), - ListTile( - leading: Icon(Icons.folder_special, color: colorScheme.primary), - title: Text(context.l10n.storageModeAppFolder), - subtitle: Text(context.l10n.storageModeAppFolderSubtitle), - trailing: !isSafMode ? const Icon(Icons.check) : null, - onTap: () async { - Navigator.pop(ctx); - final defaultDir = await _getDefaultAndroidDirectory(); - final notifier = ref.read(settingsProvider.notifier); - notifier.setStorageMode('app'); - notifier.setDownloadDirectory(defaultDir); - notifier.setDownloadTreeUri(''); - }, - ), - ListTile( - leading: Icon(Icons.folder_open, color: colorScheme.primary), - title: Text(context.l10n.storageModeSaf), - subtitle: Text(context.l10n.storageModeSafSubtitle), - trailing: isSafMode ? const Icon(Icons.check) : null, - onTap: () async { - Navigator.pop(ctx); - final result = await PlatformBridge.pickSafTree(); - if (result != null) { - final treeUri = result['tree_uri'] as String? ?? ''; - final displayName = result['display_name'] as String? ?? ''; - if (treeUri.isNotEmpty) { - ref.read(settingsProvider.notifier).setStorageMode('saf'); - ref - .read(settingsProvider.notifier) - .setDownloadTreeUri( - treeUri, - displayName: displayName.isNotEmpty - ? displayName - : treeUri, - ); - } - } - }, - ), - const SizedBox(height: 8), - ], - ), - ), - ); - } - - void _showIOSDirectoryOptions(BuildContext context, WidgetRef ref) { - final colorScheme = Theme.of(context).colorScheme; - showModalBottomSheet( - context: context, - useRootNavigator: true, - backgroundColor: colorScheme.surfaceContainerHigh, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(28)), - ), - builder: (ctx) => SafeArea( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.fromLTRB(24, 24, 24, 8), - child: Text( - context.l10n.setupDownloadLocationTitle, - style: Theme.of( - context, - ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), - ), - ), - Padding( - padding: const EdgeInsets.fromLTRB(24, 0, 24, 16), - child: Text( - context.l10n.setupDownloadLocationIosMessage, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - ), - ListTile( - leading: Icon(Icons.folder_special, color: colorScheme.primary), - title: Text(context.l10n.setupAppDocumentsFolder), - subtitle: Text(context.l10n.setupAppDocumentsFolderSubtitle), - trailing: Icon(Icons.check_circle, color: colorScheme.primary), - onTap: () async { - final dir = await getApplicationDocumentsDirectory(); - ref - .read(settingsProvider.notifier) - .setDownloadDirectory(dir.path); - if (ctx.mounted) Navigator.pop(ctx); - }, - ), - ListTile( - leading: Icon(Icons.cloud, color: colorScheme.onSurfaceVariant), - title: Text(context.l10n.setupChooseFromFiles), - subtitle: Text(context.l10n.setupChooseFromFilesSubtitle), - onTap: () async { - Navigator.pop(ctx); - if (Platform.isIOS) { - await Future.delayed(const Duration(milliseconds: 250)); - } - - // Note: iOS requires folder to have at least one file to be selectable - String? result; - try { - result = await FilePicker.platform.getDirectoryPath(); - } catch (e) { - if (ctx.mounted) { - ScaffoldMessenger.of(ctx).showSnackBar( - SnackBar( - content: Text( - ctx.l10n.snackbarFolderPickerFailed(e.toString()), - ), - backgroundColor: Theme.of(ctx).colorScheme.error, - duration: const Duration(seconds: 4), - ), - ); - } - return; - } - - if (result != null) { - // iOS: Validate the selected path is writable (not iCloud or container root) - if (Platform.isIOS) { - final validation = validateIosPath(result); - if (!validation.isValid) { - if (ctx.mounted) { - ScaffoldMessenger.of(ctx).showSnackBar( - SnackBar( - content: Text( - validation.errorReason ?? - context.l10n.setupIcloudNotSupported, - ), - backgroundColor: Theme.of(ctx).colorScheme.error, - duration: const Duration(seconds: 4), - ), - ); - } - return; - } - } - ref - .read(settingsProvider.notifier) - .setDownloadDirectory(result); - } - }, - ), - Padding( - padding: const EdgeInsets.fromLTRB(24, 8, 24, 16), - child: Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: colorScheme.tertiaryContainer.withValues(alpha: 0.3), - borderRadius: BorderRadius.circular(12), - ), - child: Row( - children: [ - Icon( - Icons.info_outline, - size: 20, - color: colorScheme.tertiary, - ), - const SizedBox(width: 12), - Expanded( - child: Text( - context.l10n.setupIosEmptyFolderWarning, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.onTertiaryContainer, - ), - ), - ), - ], - ), - ), - ), - const SizedBox(height: 8), - ], - ), - ), - ); - } - - String _getFolderOrganizationLabel(String value) { - switch (value) { - case 'playlist': - return 'By Playlist'; - case 'artist': - return 'By Artist'; - case 'album': - return 'By Album'; - case 'artist_album': - return 'Artist/Album'; - default: - return 'None'; - } - } - - String _getPlaylistFolderSubtitle(AppSettings settings) { - if (settings.folderOrganization == 'playlist') { - return context.l10n.downloadCreatePlaylistSourceFolderRedundant; - } - if (settings.createPlaylistFolder) { - return context.l10n.downloadCreatePlaylistSourceFolderEnabled; - } - return context.l10n.downloadCreatePlaylistSourceFolderDisabled; - } - - String _getArtistFolderFilterSubtitle( - BuildContext context, { - required bool usePrimaryArtistOnly, - required bool filterAlbumArtistContributors, - }) { - final statuses = [ - usePrimaryArtistOnly ? 'Primary only: On' : 'Primary only: Off', - filterAlbumArtistContributors - ? 'Album Artist metadata: Primary only' - : 'Album Artist metadata: Full', - ]; - return statuses.join(' | '); - } - - String _getLyricsModeLabel(BuildContext context, String mode) { - switch (mode) { - case 'external': - return context.l10n.lyricsModeExternal; - case 'both': - return context.l10n.lyricsModeBoth; - default: - return context.l10n.lyricsModeEmbed; - } - } - - String _getSongLinkRegionLabel(String code) { - final normalized = code.trim().toUpperCase(); - final effective = normalized.isEmpty ? 'US' : normalized; - final name = _songLinkRegionNames[effective]; - if (name == null) return effective; - return '$effective - $name'; - } - - void _showLyricsModePicker( - BuildContext context, - WidgetRef ref, - String current, - ) { - final colorScheme = Theme.of(context).colorScheme; - showModalBottomSheet( - context: context, - useRootNavigator: true, - backgroundColor: colorScheme.surfaceContainerHigh, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(28)), - ), - builder: (context) => SafeArea( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.fromLTRB(24, 24, 24, 8), - child: Text( - context.l10n.lyricsMode, - style: Theme.of( - context, - ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), - ), - ), - Padding( - padding: const EdgeInsets.fromLTRB(24, 0, 24, 16), - child: Text( - context.l10n.lyricsModeDescription, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - ), - ListTile( - leading: const Icon(Icons.audiotrack), - title: Text(context.l10n.lyricsModeEmbed), - subtitle: Text(context.l10n.lyricsModeEmbedSubtitle), - trailing: current == 'embed' ? const Icon(Icons.check) : null, - onTap: () { - ref.read(settingsProvider.notifier).setLyricsMode('embed'); - Navigator.pop(context); - }, - ), - ListTile( - leading: const Icon(Icons.insert_drive_file_outlined), - title: Text(context.l10n.lyricsModeExternal), - subtitle: Text(context.l10n.lyricsModeExternalSubtitle), - trailing: current == 'external' ? const Icon(Icons.check) : null, - onTap: () { - ref.read(settingsProvider.notifier).setLyricsMode('external'); - Navigator.pop(context); - }, - ), - ListTile( - leading: const Icon(Icons.library_music_outlined), - title: Text(context.l10n.lyricsModeBoth), - subtitle: Text(context.l10n.lyricsModeBothSubtitle), - trailing: current == 'both' ? const Icon(Icons.check) : null, - onTap: () { - ref.read(settingsProvider.notifier).setLyricsMode('both'); - Navigator.pop(context); - }, - ), - const SizedBox(height: 16), - ], - ), - ), - ); - } - - static const _providerDisplayNames = { - 'lrclib': 'LRCLIB', - 'netease': 'Netease', - 'musixmatch': 'Musixmatch', - 'apple_music': 'Apple Music', - 'qqmusic': 'QQ Music', - }; - - String _getLyricsProvidersSubtitle(List providers) { - if (providers.isEmpty) return context.l10n.downloadProvidersNoneEnabled; - return providers.map((p) => _providerDisplayNames[p] ?? p).join(' > '); - } - - String _normalizeMusixmatchLanguage(String value) { - final normalized = value.trim().toLowerCase(); - return normalized.replaceAll(RegExp(r'[^a-z0-9\-_]'), ''); - } - String _getTidalHighFormatLabel(BuildContext context, String format) { switch (format) { case 'mp3_320': @@ -1619,6 +340,19 @@ class _DownloadSettingsPageState extends ConsumerState { } } + String _getSongLinkRegionLabel(String code) { + const names = { + 'US': 'United States', 'GB': 'United Kingdom', 'FR': 'France', + 'DE': 'Germany', 'JP': 'Japan', 'KR': 'South Korea', + 'IN': 'India', 'ID': 'Indonesia', 'BR': 'Brazil', + 'MX': 'Mexico', 'AU': 'Australia', 'CA': 'Canada', 'XK': 'Kosovo', + }; + final normalized = code.trim().toUpperCase(); + final effective = normalized.isEmpty ? 'US' : normalized; + final name = names[effective]; + return name == null ? effective : '$effective - $name'; + } + void _showTidalHighFormatPicker( BuildContext context, WidgetRef ref, @@ -1641,18 +375,16 @@ class _DownloadSettingsPageState extends ConsumerState { padding: const EdgeInsets.fromLTRB(24, 24, 24, 8), child: Text( context.l10n.downloadLossy320Format, - style: Theme.of( - context, - ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), + style: Theme.of(context).textTheme.titleLarge + ?.copyWith(fontWeight: FontWeight.bold), ), ), Padding( padding: const EdgeInsets.fromLTRB(24, 0, 24, 16), child: Text( context.l10n.downloadLossy320FormatDesc, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurfaceVariant, - ), + style: Theme.of(context).textTheme.bodyMedium + ?.copyWith(color: colorScheme.onSurfaceVariant), ), ), ListTile( @@ -1663,9 +395,7 @@ class _DownloadSettingsPageState extends ConsumerState { ? Icon(Icons.check, color: colorScheme.primary) : null, onTap: () { - ref - .read(settingsProvider.notifier) - .setTidalHighFormat('mp3_320'); + ref.read(settingsProvider.notifier).setTidalHighFormat('mp3_320'); Navigator.pop(context); }, ), @@ -1677,9 +407,7 @@ class _DownloadSettingsPageState extends ConsumerState { ? Icon(Icons.check, color: colorScheme.primary) : null, onTap: () { - ref - .read(settingsProvider.notifier) - .setTidalHighFormat('opus_256'); + ref.read(settingsProvider.notifier).setTidalHighFormat('opus_256'); Navigator.pop(context); }, ), @@ -1691,9 +419,7 @@ class _DownloadSettingsPageState extends ConsumerState { ? Icon(Icons.check, color: colorScheme.primary) : null, onTap: () { - ref - .read(settingsProvider.notifier) - .setTidalHighFormat('opus_128'); + ref.read(settingsProvider.notifier).setTidalHighFormat('opus_128'); Navigator.pop(context); }, ), @@ -1704,94 +430,6 @@ class _DownloadSettingsPageState extends ConsumerState { ); } - void _showMusixmatchLanguagePicker( - BuildContext context, - WidgetRef ref, - String currentLanguage, - ) { - final colorScheme = Theme.of(context).colorScheme; - final controller = TextEditingController(text: currentLanguage); - - showModalBottomSheet( - context: context, - useRootNavigator: true, - backgroundColor: colorScheme.surfaceContainerHigh, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(28)), - ), - isScrollControlled: true, - builder: (context) => Padding( - padding: EdgeInsets.only( - left: 24, - right: 24, - top: 24, - bottom: 24 + MediaQuery.of(context).viewInsets.bottom, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - context.l10n.downloadMusixmatchLanguage, - style: Theme.of( - context, - ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), - ), - const SizedBox(height: 8), - Text( - context.l10n.downloadMusixmatchLanguageDesc, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: 16), - TextField( - controller: controller, - textInputAction: TextInputAction.done, - decoration: InputDecoration( - labelText: context.l10n.downloadMusixmatchLanguageCode, - hintText: context.l10n.downloadMusixmatchLanguageHint, - ), - ), - const SizedBox(height: 16), - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: Text(context.l10n.dialogCancel), - ), - const SizedBox(width: 8), - TextButton( - onPressed: () { - ref - .read(settingsProvider.notifier) - .setMusixmatchLanguage(''); - Navigator.pop(context); - }, - child: Text(context.l10n.downloadMusixmatchAuto), - ), - const SizedBox(width: 8), - FilledButton( - onPressed: () { - final normalized = _normalizeMusixmatchLanguage( - controller.text, - ); - ref - .read(settingsProvider.notifier) - .setMusixmatchLanguage(normalized); - Navigator.pop(context); - }, - child: Text(context.l10n.dialogSave), - ), - ], - ), - ], - ), - ), - ); - } - void _showNetworkModePicker( BuildContext context, WidgetRef ref, @@ -1814,18 +452,16 @@ class _DownloadSettingsPageState extends ConsumerState { padding: const EdgeInsets.fromLTRB(24, 24, 24, 8), child: Text( context.l10n.settingsDownloadNetwork, - style: Theme.of( - context, - ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), + style: Theme.of(context).textTheme.titleLarge + ?.copyWith(fontWeight: FontWeight.bold), ), ), Padding( padding: const EdgeInsets.fromLTRB(24, 0, 24, 16), child: Text( context.l10n.settingsDownloadNetworkSubtitle, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurfaceVariant, - ), + style: Theme.of(context).textTheme.bodyMedium + ?.copyWith(color: colorScheme.onSurfaceVariant), ), ), ListTile( @@ -1836,9 +472,7 @@ class _DownloadSettingsPageState extends ConsumerState { ? Icon(Icons.check, color: colorScheme.primary) : null, onTap: () { - ref - .read(settingsProvider.notifier) - .setDownloadNetworkMode('any'); + ref.read(settingsProvider.notifier).setDownloadNetworkMode('any'); Navigator.pop(context); }, ), @@ -1868,6 +502,27 @@ class _DownloadSettingsPageState extends ConsumerState { WidgetRef ref, String current, ) { + const regions = [ + 'AD','AE','AG','AL','AM','AO','AR','AT','AU','AZ','BA','BB','BD','BE', + 'BF','BG','BH','BI','BJ','BN','BO','BR','BS','BT','BW','BZ','CA','CD', + 'CG','CH','CI','CL','CM','CO','CR','CV','CW','CY','CZ','DE','DJ','DK', + 'DM','DO','DZ','EC','EE','EG','ES','ET','FI','FJ','FM','FR','GA','GB', + 'GD','GE','GH','GM','GN','GQ','GR','GT','GW','GY','HK','HN','HR','HT', + 'HU','ID','IE','IL','IN','IQ','IS','IT','JM','JO','JP','KE','KG','KH', + 'KI','KM','KN','KR','KW','KZ','LA','LB','LC','LI','LK','LR','LS','LT', + 'LU','LV','LY','MA','MC','MD','ME','MG','MH','MK','ML','MN','MO','MR', + 'MT','MU','MV','MW','MX','MY','MZ','NA','NE','NG','NI','NL','NO','NP', + 'NR','NZ','OM','PA','PE','PG','PH','PK','PL','PS','PT','PW','PY','QA', + 'RO','RS','RW','SA','SB','SC','SE','SG','SI','SK','SL','SM','SN','SR', + 'ST','SV','SZ','TD','TG','TH','TJ','TL','TN','TO','TR','TT','TV','TW', + 'TZ','UA','UG','US','UY','UZ','VC','VE','VN','VU','WS','XK','ZA','ZM','ZW', + ]; + const names = { + 'US': 'United States', 'GB': 'United Kingdom', 'FR': 'France', + 'DE': 'Germany', 'JP': 'Japan', 'KR': 'South Korea', + 'IN': 'India', 'ID': 'Indonesia', 'BR': 'Brazil', + 'MX': 'Mexico', 'AU': 'Australia', 'CA': 'Canada', 'XK': 'Kosovo', + }; final colorScheme = Theme.of(context).colorScheme; final normalizedCurrent = current.trim().toUpperCase(); showModalBottomSheet( @@ -1888,30 +543,27 @@ class _DownloadSettingsPageState extends ConsumerState { padding: const EdgeInsets.fromLTRB(24, 24, 24, 8), child: Text( context.l10n.downloadSongLinkRegion, - style: Theme.of( - context, - ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), + style: Theme.of(context).textTheme.titleLarge + ?.copyWith(fontWeight: FontWeight.bold), ), ), Padding( padding: const EdgeInsets.fromLTRB(24, 0, 24, 16), child: Text( context.l10n.downloadSongLinkRegionDesc, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurfaceVariant, - ), + style: Theme.of(context).textTheme.bodyMedium + ?.copyWith(color: colorScheme.onSurfaceVariant), ), ), Expanded( child: ListView.builder( - itemCount: _songLinkRegions.length, + itemCount: regions.length, itemBuilder: (context, index) { - final code = _songLinkRegions[index]; + final code = regions[index]; final isSelected = code == normalizedCurrent; - final displayName = _songLinkRegionNames[code]; return ListTile( title: Text(code), - subtitle: displayName != null ? Text(displayName) : null, + subtitle: names[code] != null ? Text(names[code]!) : null, trailing: isSelected ? Icon(Icons.check, color: colorScheme.primary) : null, @@ -1931,117 +583,10 @@ class _DownloadSettingsPageState extends ConsumerState { ), ); } - - void _showFolderOrganizationPicker( - BuildContext context, - WidgetRef ref, - String current, - ) { - final colorScheme = Theme.of(context).colorScheme; - showModalBottomSheet( - context: context, - useRootNavigator: true, - backgroundColor: colorScheme.surfaceContainerHigh, - isScrollControlled: true, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(28)), - ), - constraints: BoxConstraints( - maxHeight: MediaQuery.of(context).size.height * 0.7, - ), - builder: (context) => SafeArea( - child: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.fromLTRB(24, 24, 24, 8), - child: Text( - context.l10n.downloadFolderOrganization, - style: Theme.of( - context, - ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), - ), - ), - Padding( - padding: const EdgeInsets.fromLTRB(24, 0, 24, 16), - child: Text( - context.l10n.folderOrganizationDescription, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - ), - _FolderOption( - title: context.l10n.folderOrganizationNone, - subtitle: context.l10n.folderOrganizationNoneSubtitle, - example: 'SpotiFLAC/Track.flac', - isSelected: current == 'none', - onTap: () { - ref - .read(settingsProvider.notifier) - .setFolderOrganization('none'); - Navigator.pop(context); - }, - ), - _FolderOption( - title: context.l10n.folderOrganizationByPlaylist, - subtitle: context.l10n.folderOrganizationByPlaylistSubtitle, - example: 'SpotiFLAC/Playlist Name/Track.flac', - isSelected: current == 'playlist', - onTap: () { - ref - .read(settingsProvider.notifier) - .setFolderOrganization('playlist'); - Navigator.pop(context); - }, - ), - _FolderOption( - title: context.l10n.folderOrganizationByArtist, - subtitle: context.l10n.folderOrganizationByArtistSubtitle, - example: 'SpotiFLAC/Artist Name/Track.flac', - isSelected: current == 'artist', - onTap: () { - ref - .read(settingsProvider.notifier) - .setFolderOrganization('artist'); - Navigator.pop(context); - }, - ), - _FolderOption( - title: context.l10n.folderOrganizationByAlbum, - subtitle: context.l10n.folderOrganizationByAlbumSubtitle, - example: 'SpotiFLAC/Album Name/Track.flac', - isSelected: current == 'album', - onTap: () { - ref - .read(settingsProvider.notifier) - .setFolderOrganization('album'); - Navigator.pop(context); - }, - ), - _FolderOption( - title: context.l10n.folderOrganizationByArtistAlbum, - subtitle: context.l10n.folderOrganizationByArtistAlbumSubtitle, - example: 'SpotiFLAC/Artist/Album/Track.flac', - isSelected: current == 'artist_album', - onTap: () { - ref - .read(settingsProvider.notifier) - .setFolderOrganization('artist_album'); - Navigator.pop(context); - }, - ), - const SizedBox(height: 16), - ], - ), - ), - ), - ); - } } +// ── Private widgets (reused from original) ───────────────────────────────── + class _ServiceSelector extends ConsumerWidget { final String currentService; final ValueChanged onChanged; @@ -2129,14 +674,12 @@ class _ServiceChip extends StatelessWidget { Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; final isDark = Theme.of(context).brightness == Brightness.dark; - final unselectedColor = isDark ? Color.alphaBlend( Colors.white.withValues(alpha: 0.05), colorScheme.surface, ) : colorScheme.surfaceContainerHigh; - return Material( color: isSelected ? colorScheme.primaryContainer : unselectedColor, borderRadius: BorderRadius.circular(12), @@ -2234,16 +777,91 @@ class _QualityOption extends StatelessWidget { } } -class _FolderOption extends StatelessWidget { - final String title; - final String subtitle; - final String example; +class _ConcurrentDownloadsItem extends StatelessWidget { + final int currentValue; + final ValueChanged onChanged; + const _ConcurrentDownloadsItem({ + required this.currentValue, + required this.onChanged, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.download_for_offline, + color: colorScheme.onSurfaceVariant, + size: 24, + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + context.l10n.optionsConcurrentDownloads, + style: Theme.of(context).textTheme.bodyLarge, + ), + const SizedBox(height: 2), + Text( + currentValue == 1 + ? context.l10n.optionsConcurrentSequential + : context.l10n.optionsConcurrentParallel(currentValue), + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 16), + Row( + children: [ + for (final n in [1, 2, 3, 4, 5]) ...[ + if (n > 1) const SizedBox(width: 8), + _ConcurrentChip( + label: '$n', + isSelected: currentValue == n, + onTap: () => onChanged(n), + ), + ], + ], + ), + const SizedBox(height: 12), + Row( + children: [ + Icon(Icons.warning_amber_rounded, size: 16, color: colorScheme.error), + const SizedBox(width: 8), + Expanded( + child: Text( + context.l10n.optionsConcurrentWarning, + style: Theme.of(context).textTheme.bodySmall + ?.copyWith(color: colorScheme.error), + ), + ), + ], + ), + ], + ), + ); + } +} + +class _ConcurrentChip extends StatelessWidget { + final String label; final bool isSelected; final VoidCallback onTap; - const _FolderOption({ - required this.title, - required this.subtitle, - required this.example, + const _ConcurrentChip({ + required this.label, required this.isSelected, required this.onTap, }); @@ -2251,28 +869,204 @@ class _FolderOption extends StatelessWidget { @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; - return ListTile( - contentPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 4), - title: Text(title), - subtitle: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(subtitle), - const SizedBox(height: 4), - Text( - example, - style: TextStyle( - fontFamily: 'monospace', - fontSize: 11, - color: colorScheme.primary, + final isDark = Theme.of(context).brightness == Brightness.dark; + final unselectedColor = isDark + ? Color.alphaBlend( + Colors.white.withValues(alpha: 0.05), + colorScheme.surface, + ) + : colorScheme.surfaceContainerHigh; + return Expanded( + child: Material( + color: isSelected ? colorScheme.primaryContainer : unselectedColor, + borderRadius: BorderRadius.circular(12), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: Center( + child: Text( + label, + style: TextStyle( + fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal, + color: isSelected + ? colorScheme.onPrimaryContainer + : colorScheme.onSurfaceVariant, + ), + ), ), ), - ], + ), ), - trailing: isSelected - ? Icon(Icons.check_circle, color: colorScheme.primary) - : Icon(Icons.circle_outlined, color: colorScheme.outline), - onTap: onTap, + ); + } +} + +// Imported from options_settings_page — search source selectors +class _MetadataSourceSelector extends ConsumerWidget { + const _MetadataSourceSelector(); + + static const _builtInProviders = {'tidal': 'Tidal', 'qobuz': 'Qobuz'}; + + Extension? _defaultSearchExtension(List extensions) { + return extensions + .where( + (ext) => + ext.enabled && + ext.hasCustomSearch && + ext.searchBehavior?.primary == true, + ) + .firstOrNull ?? + extensions + .where((ext) => ext.enabled && ext.hasCustomSearch) + .firstOrNull; + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final colorScheme = Theme.of(context).colorScheme; + final settings = ref.watch(settingsProvider); + final extState = ref.watch(extensionProvider); + + final rawSearchProvider = settings.searchProvider?.trim() ?? ''; + final isValidBuiltIn = _builtInProviders.containsKey(rawSearchProvider); + final primarySearchExtension = _defaultSearchExtension(extState.extensions); + final defaultProviderTarget = + primarySearchExtension?.displayName ?? 'Tidal'; + final defaultProviderLabel = + '${context.l10n.extensionsHomeFeedAuto} ($defaultProviderTarget)'; + final searchProvider = + isValidBuiltIn || + extState.extensions.any( + (e) => + e.enabled && e.hasCustomSearch && e.id == rawSearchProvider, + ) + ? rawSearchProvider + : ''; + final isBuiltIn = _builtInProviders.containsKey(searchProvider); + + Extension? activeExtension; + if (searchProvider.isNotEmpty && !isBuiltIn) { + activeExtension = extState.extensions + .where((e) => e.id == searchProvider && e.enabled) + .firstOrNull; + } + final hasNonDefaultProvider = isBuiltIn || activeExtension != null; + + String subtitle; + if (isBuiltIn) { + subtitle = 'Using ${_builtInProviders[searchProvider]}'; + } else if (activeExtension != null) { + subtitle = context.l10n.optionsUsingExtension(activeExtension.displayName); + } else { + subtitle = context.l10n.optionsPrimaryProviderSubtitle; + } + + return Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + context.l10n.optionsPrimaryProvider, + style: Theme.of(context).textTheme.titleMedium + ?.copyWith(fontWeight: FontWeight.w500), + ), + const SizedBox(height: 4), + Text( + subtitle, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: hasNonDefaultProvider + ? colorScheme.primary + : colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 12), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + _SearchProviderChip( + label: defaultProviderLabel, + isSelected: searchProvider.isEmpty, + onTap: () => ref + .read(settingsProvider.notifier) + .setSearchProvider(''), + ), + for (final entry in _builtInProviders.entries) + _SearchProviderChip( + label: entry.value, + isSelected: searchProvider == entry.key, + onTap: () => ref + .read(settingsProvider.notifier) + .setSearchProvider(entry.key), + ), + for (final ext in extState.extensions.where( + (e) => e.enabled && e.hasCustomSearch, + )) + _SearchProviderChip( + label: ext.displayName, + isSelected: searchProvider == ext.id, + onTap: () => ref + .read(settingsProvider.notifier) + .setSearchProvider(ext.id), + ), + ], + ), + ], + ), + ); + } +} + +class _SearchProviderChip extends StatelessWidget { + final String label; + final bool isSelected; + final VoidCallback onTap; + const _SearchProviderChip({ + required this.label, + required this.isSelected, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return FilterChip( + label: Text(label), + selected: isSelected, + onSelected: (_) => onTap(), + selectedColor: colorScheme.primaryContainer, + checkmarkColor: colorScheme.onPrimaryContainer, + labelStyle: TextStyle( + color: isSelected + ? colorScheme.onPrimaryContainer + : colorScheme.onSurface, + ), + ); + } +} + +class _DefaultSearchTabSelector extends ConsumerWidget { + const _DefaultSearchTabSelector(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final settings = ref.watch(settingsProvider); + final colorScheme = Theme.of(context).colorScheme; + return SettingsItem( + icon: Icons.tab_outlined, + title: context.l10n.optionsDefaultSearchTab, + subtitle: settings.defaultSearchTab == 'albums' + ? context.l10n.optionsDefaultSearchTabAlbums + : context.l10n.optionsDefaultSearchTabTracks, + onTap: () { + final current = settings.defaultSearchTab; + ref.read(settingsProvider.notifier).setDefaultSearchTab( + current == 'albums' ? 'tracks' : 'albums', + ); + }, ); } } diff --git a/lib/screens/settings/files_settings_page.dart b/lib/screens/settings/files_settings_page.dart new file mode 100644 index 00000000..0382abc4 --- /dev/null +++ b/lib/screens/settings/files_settings_page.dart @@ -0,0 +1,1055 @@ +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:file_picker/file_picker.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:permission_handler/permission_handler.dart'; +import 'package:device_info_plus/device_info_plus.dart'; +import 'package:spotiflac_android/l10n/l10n.dart'; +import 'package:spotiflac_android/models/settings.dart'; +import 'package:spotiflac_android/providers/settings_provider.dart'; +import 'package:spotiflac_android/services/platform_bridge.dart'; +import 'package:spotiflac_android/utils/app_bar_layout.dart'; +import 'package:spotiflac_android/utils/file_access.dart'; +import 'package:spotiflac_android/widgets/settings_group.dart'; + +class FilesSettingsPage extends ConsumerStatefulWidget { + const FilesSettingsPage({super.key}); + + @override + ConsumerState createState() => _FilesSettingsPageState(); +} + +class _FilesSettingsPageState extends ConsumerState { + int _androidSdkVersion = 0; + bool _hasAllFilesAccess = false; + bool _artistFolderFiltersExpanded = false; + + @override + void initState() { + super.initState(); + _initDeviceInfo(); + } + + Future _initDeviceInfo() async { + if (Platform.isAndroid) { + final deviceInfo = DeviceInfoPlugin(); + final androidInfo = await deviceInfo.androidInfo; + final sdkVersion = androidInfo.version.sdkInt; + final hasAccess = await Permission.manageExternalStorage.isGranted; + if (mounted) { + setState(() { + _androidSdkVersion = sdkVersion; + _hasAllFilesAccess = hasAccess; + }); + } + } + } + + Future _requestAllFilesAccess() async { + final status = await Permission.manageExternalStorage.request(); + if (status.isGranted) { + ref.read(settingsProvider.notifier).setUseAllFilesAccess(true); + if (mounted) setState(() => _hasAllFilesAccess = true); + } else if (status.isPermanentlyDenied) { + if (mounted) { + final shouldOpen = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(context.l10n.setupStorageAccessRequired), + content: Text(context.l10n.allFilesAccessDeniedMessage), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: Text(context.l10n.dialogCancel), + ), + FilledButton( + onPressed: () => Navigator.pop(context, true), + child: Text(context.l10n.setupOpenSettings), + ), + ], + ), + ); + if (shouldOpen == true) await openAppSettings(); + } + } + } + + Future _disableAllFilesAccess() async { + ref.read(settingsProvider.notifier).setUseAllFilesAccess(false); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.allFilesAccessDisabledMessage)), + ); + } + } + + @override + Widget build(BuildContext context) { + final settings = ref.watch(settingsProvider); + final colorScheme = Theme.of(context).colorScheme; + final topPadding = normalizedHeaderTopPadding(context); + + return PopScope( + canPop: true, + child: Scaffold( + body: CustomScrollView( + slivers: [ + SliverAppBar( + expandedHeight: 120 + topPadding, + collapsedHeight: kToolbarHeight, + floating: false, + pinned: true, + backgroundColor: colorScheme.surface, + surfaceTintColor: Colors.transparent, + leading: IconButton( + tooltip: MaterialLocalizations.of(context).backButtonTooltip, + icon: const Icon(Icons.arrow_back), + onPressed: () => Navigator.pop(context), + ), + flexibleSpace: LayoutBuilder( + builder: (context, constraints) { + final maxHeight = 120 + topPadding; + final minHeight = kToolbarHeight + topPadding; + final expandRatio = + ((constraints.maxHeight - minHeight) / + (maxHeight - minHeight)) + .clamp(0.0, 1.0); + final leftPadding = 56 - (32 * expandRatio); + return FlexibleSpaceBar( + expandedTitleScale: 1.0, + titlePadding: EdgeInsets.only( + left: leftPadding, + bottom: 16, + ), + title: Text( + context.l10n.settingsFiles, + style: TextStyle( + fontSize: 20 + (8 * expandRatio), + fontWeight: FontWeight.bold, + color: colorScheme.onSurface, + ), + ), + ); + }, + ), + ), + + // ── Download Location ────────────────────────────────────── + SliverToBoxAdapter( + child: SettingsSectionHeader( + title: context.l10n.setupDownloadLocationTitle, + ), + ), + SliverToBoxAdapter( + child: SettingsGroup( + children: [ + SettingsItem( + icon: Icons.folder_outlined, + title: context.l10n.downloadDirectory, + subtitle: settings.downloadDirectory.isEmpty + ? (Platform.isIOS + ? context.l10n.setupAppDocumentsFolder + : 'Music/SpotiFLAC') + : settings.downloadDirectory, + onTap: () => _pickDirectory(context, ref), + showDivider: false, + ), + ], + ), + ), + + // ── Filename Formats ─────────────────────────────────────── + SliverToBoxAdapter( + child: SettingsSectionHeader( + title: context.l10n.sectionFileSettings, + ), + ), + SliverToBoxAdapter( + child: SettingsGroup( + children: [ + SettingsItem( + icon: Icons.text_fields, + title: context.l10n.downloadFilenameFormat, + subtitle: settings.filenameFormat, + onTap: () => _showFormatEditor( + context, + ref, + settings.filenameFormat, + ), + ), + SettingsItem( + icon: Icons.music_note_outlined, + title: context.l10n.downloadSingleFilenameFormat, + subtitle: settings.singleFilenameFormat, + onTap: () => _showFormatEditor( + context, + ref, + settings.singleFilenameFormat, + onSave: ref + .read(settingsProvider.notifier) + .setSingleFilenameFormat, + title: context.l10n.downloadSingleFilenameFormat, + description: + context.l10n.downloadSingleFilenameFormatDescription, + ), + showDivider: false, + ), + ], + ), + ), + + // ── Folder Structure ─────────────────────────────────────── + SliverToBoxAdapter( + child: SettingsSectionHeader( + title: context.l10n.downloadFolderOrganization, + ), + ), + SliverToBoxAdapter( + child: SettingsGroup( + children: [ + SettingsSwitchItem( + icon: Icons.library_music_outlined, + title: context.l10n.downloadSeparateSinglesFolder, + subtitle: settings.separateSingles + ? context.l10n.downloadSeparateSinglesEnabled + : context.l10n.downloadSeparateSinglesDisabled, + value: settings.separateSingles, + onChanged: (value) => ref + .read(settingsProvider.notifier) + .setSeparateSingles(value), + ), + if (settings.separateSingles) + SettingsItem( + icon: Icons.folder_outlined, + title: context.l10n.downloadAlbumFolderStructure, + subtitle: _getAlbumFolderStructureLabel( + settings.albumFolderStructure, + ), + onTap: () => _showAlbumFolderStructurePicker( + context, + ref, + settings.albumFolderStructure, + ), + ), + if (!settings.separateSingles) + SettingsItem( + icon: Icons.create_new_folder_outlined, + title: context.l10n.downloadFolderOrganization, + subtitle: _getFolderOrganizationLabel( + settings.folderOrganization, + ), + onTap: () => _showFolderOrganizationPicker( + context, + ref, + settings.folderOrganization, + ), + ), + SettingsSwitchItem( + icon: Icons.playlist_play_outlined, + title: context.l10n.downloadCreatePlaylistSourceFolder, + subtitle: _getPlaylistFolderSubtitle(settings), + value: settings.createPlaylistFolder, + onChanged: (value) => ref + .read(settingsProvider.notifier) + .setCreatePlaylistFolder(value), + ), + SettingsSwitchItem( + icon: Icons.person_search_outlined, + title: context.l10n.downloadUseAlbumArtistForFolders, + subtitle: settings.useAlbumArtistForFolders + ? context + .l10n + .downloadUseAlbumArtistForFoldersAlbumSubtitle + : context + .l10n + .downloadUseAlbumArtistForFoldersTrackSubtitle, + value: settings.useAlbumArtistForFolders, + onChanged: (value) => ref + .read(settingsProvider.notifier) + .setUseAlbumArtistForFolders(value), + ), + SettingsItem( + icon: Icons.filter_alt_outlined, + title: context.l10n.downloadArtistNameFilters, + subtitle: _getArtistFolderFilterSubtitle( + context, + usePrimaryArtistOnly: settings.usePrimaryArtistOnly, + filterAlbumArtistContributors: + settings.filterContributingArtistsInAlbumArtist, + ), + trailing: Icon( + _artistFolderFiltersExpanded + ? Icons.expand_less + : Icons.expand_more, + ), + onTap: () => setState(() { + _artistFolderFiltersExpanded = + !_artistFolderFiltersExpanded; + }), + showDivider: !_artistFolderFiltersExpanded, + ), + if (_artistFolderFiltersExpanded) ...[ + SettingsSwitchItem( + icon: Icons.person_outline, + title: context.l10n.downloadUsePrimaryArtistOnly, + subtitle: settings.usePrimaryArtistOnly + ? context.l10n.downloadUsePrimaryArtistOnlyEnabled + : context.l10n.downloadUsePrimaryArtistOnlyDisabled, + value: settings.usePrimaryArtistOnly, + onChanged: (value) => ref + .read(settingsProvider.notifier) + .setUsePrimaryArtistOnly(value), + ), + SettingsSwitchItem( + icon: Icons.group_remove_outlined, + title: context.l10n.downloadFilterContributing, + subtitle: settings.filterContributingArtistsInAlbumArtist + ? context.l10n.downloadFilterContributingEnabled + : context.l10n.downloadFilterContributingDisabled, + value: settings.filterContributingArtistsInAlbumArtist, + onChanged: (value) => ref + .read(settingsProvider.notifier) + .setFilterContributingArtistsInAlbumArtist(value), + showDivider: false, + ), + ], + ], + ), + ), + + // ── Storage Access (Android 13+) ─────────────────────────── + if (Platform.isAndroid && _androidSdkVersion >= 33) ...[ + SliverToBoxAdapter( + child: SettingsSectionHeader( + title: context.l10n.sectionStorageAccess, + ), + ), + SliverToBoxAdapter( + child: SettingsGroup( + children: [ + SettingsSwitchItem( + icon: Icons.folder_special_outlined, + title: context.l10n.allFilesAccess, + subtitle: _hasAllFilesAccess + ? context.l10n.allFilesAccessEnabledSubtitle + : context.l10n.allFilesAccessDisabledSubtitle, + value: _hasAllFilesAccess && + settings.useAllFilesAccess, + onChanged: (value) { + if (value) { + _requestAllFilesAccess(); + } else { + _disableAllFilesAccess(); + } + }, + showDivider: false, + ), + ], + ), + ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + Icons.info_outline, + size: 16, + color: colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + context.l10n.allFilesAccessDescription, + style: Theme.of(context).textTheme.bodySmall + ?.copyWith(color: colorScheme.onSurfaceVariant), + ), + ), + ], + ), + ), + ), + ], + + const SliverToBoxAdapter(child: SizedBox(height: 32)), + ], + ), + ), + ); + } + + // ── Helpers ────────────────────────────────────────────────────────────── + + String _getAlbumFolderStructureLabel(String structure) { + switch (structure) { + case 'album_only': + return 'Albums/Album Name/'; + case 'artist_year_album': + return 'Albums/Artist/[Year] Album/'; + case 'year_album': + return 'Albums/[Year] Album/'; + case 'artist_album_singles': + return 'Artist/Album/ + Artist/Singles/'; + case 'artist_album_flat': + return 'Artist/Album/ + Artist/song.flac'; + default: + return 'Albums/Artist/Album Name/'; + } + } + + String _getFolderOrganizationLabel(String value) { + switch (value) { + case 'playlist': + return 'By Playlist'; + case 'artist': + return 'By Artist'; + case 'album': + return 'By Album'; + case 'artist_album': + return 'Artist/Album'; + default: + return 'None'; + } + } + + String _getPlaylistFolderSubtitle(AppSettings settings) { + if (settings.folderOrganization == 'playlist') { + return context.l10n.downloadCreatePlaylistSourceFolderRedundant; + } + if (settings.createPlaylistFolder) { + return context.l10n.downloadCreatePlaylistSourceFolderEnabled; + } + return context.l10n.downloadCreatePlaylistSourceFolderDisabled; + } + + String _getArtistFolderFilterSubtitle( + BuildContext context, { + required bool usePrimaryArtistOnly, + required bool filterAlbumArtistContributors, + }) { + final statuses = [ + usePrimaryArtistOnly ? 'Primary only: On' : 'Primary only: Off', + filterAlbumArtistContributors + ? 'Album Artist metadata: Primary only' + : 'Album Artist metadata: Full', + ]; + return statuses.join(' | '); + } + + Future _pickDirectory(BuildContext context, WidgetRef ref) async { + if (Platform.isIOS) { + _showIOSDirectoryOptions(context, ref); + } else if (Platform.isAndroid) { + _showAndroidDirectoryOptions(context, ref); + } + } + + Future _getDefaultAndroidDirectory() async { + const directMusicPath = '/storage/emulated/0/Music/SpotiFLAC'; + try { + final musicDir = Directory(directMusicPath); + if (!await musicDir.exists()) await musicDir.create(recursive: true); + return musicDir.path; + } catch (_) {} + try { + final externalDir = await getExternalStorageDirectory(); + if (externalDir != null) { + final musicDir = Directory( + '${externalDir.parent.parent.parent.parent.path}/Music/SpotiFLAC', + ); + if (!await musicDir.exists()) await musicDir.create(recursive: true); + return musicDir.path; + } + } catch (_) {} + final appDir = await getApplicationDocumentsDirectory(); + final fallbackDir = Directory('${appDir.path}/SpotiFLAC'); + if (!await fallbackDir.exists()) await fallbackDir.create(recursive: true); + return fallbackDir.path; + } + + void _showAndroidDirectoryOptions(BuildContext context, WidgetRef ref) { + final colorScheme = Theme.of(context).colorScheme; + final settings = ref.read(settingsProvider); + final isSafMode = + settings.storageMode == 'saf' && settings.downloadTreeUri.isNotEmpty; + showModalBottomSheet( + context: context, + useRootNavigator: true, + backgroundColor: colorScheme.surfaceContainerHigh, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(28)), + ), + builder: (ctx) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(24, 24, 24, 8), + child: Text( + context.l10n.setupDownloadLocationTitle, + style: Theme.of(ctx).textTheme.titleLarge + ?.copyWith(fontWeight: FontWeight.bold), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(24, 0, 24, 16), + child: Text( + context.l10n.downloadLocationSubtitle, + style: Theme.of(ctx).textTheme.bodyMedium + ?.copyWith(color: colorScheme.onSurfaceVariant), + ), + ), + ListTile( + leading: Icon(Icons.folder_special, color: colorScheme.primary), + title: Text(context.l10n.storageModeAppFolder), + subtitle: Text(context.l10n.storageModeAppFolderSubtitle), + trailing: !isSafMode ? const Icon(Icons.check) : null, + onTap: () async { + Navigator.pop(ctx); + final defaultDir = await _getDefaultAndroidDirectory(); + final notifier = ref.read(settingsProvider.notifier); + notifier.setStorageMode('app'); + notifier.setDownloadDirectory(defaultDir); + notifier.setDownloadTreeUri(''); + }, + ), + ListTile( + leading: Icon(Icons.folder_open, color: colorScheme.primary), + title: Text(context.l10n.storageModeSaf), + subtitle: Text(context.l10n.storageModeSafSubtitle), + trailing: isSafMode ? const Icon(Icons.check) : null, + onTap: () async { + Navigator.pop(ctx); + final result = await PlatformBridge.pickSafTree(); + if (result != null) { + final treeUri = result['tree_uri'] as String? ?? ''; + final displayName = result['display_name'] as String? ?? ''; + if (treeUri.isNotEmpty) { + ref.read(settingsProvider.notifier).setStorageMode('saf'); + ref.read(settingsProvider.notifier).setDownloadTreeUri( + treeUri, + displayName: displayName.isNotEmpty ? displayName : treeUri, + ); + } + } + }, + ), + const SizedBox(height: 8), + ], + ), + ), + ); + } + + void _showIOSDirectoryOptions(BuildContext context, WidgetRef ref) { + final colorScheme = Theme.of(context).colorScheme; + showModalBottomSheet( + context: context, + useRootNavigator: true, + backgroundColor: colorScheme.surfaceContainerHigh, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(28)), + ), + builder: (ctx) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(24, 24, 24, 8), + child: Text( + context.l10n.setupDownloadLocationTitle, + style: Theme.of(context).textTheme.titleLarge + ?.copyWith(fontWeight: FontWeight.bold), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(24, 0, 24, 16), + child: Text( + context.l10n.setupDownloadLocationIosMessage, + style: Theme.of(context).textTheme.bodyMedium + ?.copyWith(color: colorScheme.onSurfaceVariant), + ), + ), + ListTile( + leading: Icon(Icons.folder_special, color: colorScheme.primary), + title: Text(context.l10n.setupAppDocumentsFolder), + subtitle: Text(context.l10n.setupAppDocumentsFolderSubtitle), + trailing: Icon(Icons.check_circle, color: colorScheme.primary), + onTap: () async { + final dir = await getApplicationDocumentsDirectory(); + ref + .read(settingsProvider.notifier) + .setDownloadDirectory(dir.path); + if (ctx.mounted) Navigator.pop(ctx); + }, + ), + ListTile( + leading: Icon(Icons.cloud, color: colorScheme.onSurfaceVariant), + title: Text(context.l10n.setupChooseFromFiles), + subtitle: Text(context.l10n.setupChooseFromFilesSubtitle), + onTap: () async { + Navigator.pop(ctx); + if (Platform.isIOS) { + await Future.delayed(const Duration(milliseconds: 250)); + } + String? result; + try { + result = await FilePicker.platform.getDirectoryPath(); + } catch (e) { + if (ctx.mounted) { + ScaffoldMessenger.of(ctx).showSnackBar( + SnackBar( + content: Text( + ctx.l10n.snackbarFolderPickerFailed(e.toString()), + ), + backgroundColor: Theme.of(ctx).colorScheme.error, + duration: const Duration(seconds: 4), + ), + ); + } + return; + } + if (result != null) { + if (Platform.isIOS) { + final validation = validateIosPath(result); + if (!validation.isValid) { + if (ctx.mounted) { + ScaffoldMessenger.of(ctx).showSnackBar( + SnackBar( + content: Text( + validation.errorReason ?? + context.l10n.setupIcloudNotSupported, + ), + backgroundColor: Theme.of(ctx).colorScheme.error, + duration: const Duration(seconds: 4), + ), + ); + } + return; + } + } + ref + .read(settingsProvider.notifier) + .setDownloadDirectory(result); + } + }, + ), + Padding( + padding: const EdgeInsets.fromLTRB(24, 8, 24, 16), + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: colorScheme.tertiaryContainer.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + Icon(Icons.info_outline, size: 20, color: colorScheme.tertiary), + const SizedBox(width: 12), + Expanded( + child: Text( + context.l10n.setupIosEmptyFolderWarning, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onTertiaryContainer, + ), + ), + ), + ], + ), + ), + ), + const SizedBox(height: 8), + ], + ), + ), + ); + } + + void _showFormatEditor( + BuildContext context, + WidgetRef ref, + String current, { + void Function(String)? onSave, + String? title, + String? description, + }) { + final controller = TextEditingController(text: current); + final colorScheme = Theme.of(context).colorScheme; + + final basicTags = [ + '{artist}', '{title}', '{album}', '{track}', '{year}', '{date}', '{disc}', + ]; + final advancedTags = [ + '{track_raw}', '{track:02}', '{track:1}', + '{date:%Y}', '{date:%Y-%m-%d}', '{disc_raw}', '{disc:02}', + ]; + var showAdvancedTags = RegExp( + r'\{(?:track_raw|disc_raw|track:\d+|disc:\d+|date:[^}]+)\}', + caseSensitive: false, + ).hasMatch(current); + + void insertTag(String tag) { + final text = controller.text; + final selection = controller.selection; + final start = selection.start >= 0 ? selection.start : text.length; + final end = selection.end >= 0 ? selection.end : text.length; + String insertion = tag; + if (start > 0) { + final before = text.substring(0, start); + if (!before.trim().endsWith('-')) { + insertion = ' - $tag'; + } else if (before.trim().endsWith('-') && !before.endsWith(' ')) { + insertion = ' $tag'; + } + } + final newText = text.replaceRange(start, end, insertion); + controller.value = TextEditingValue( + text: newText, + selection: TextSelection.collapsed(offset: start + insertion.length), + ); + } + + showModalBottomSheet( + context: context, + useRootNavigator: true, + isScrollControlled: true, + backgroundColor: colorScheme.surface, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(28)), + ), + builder: (context) => StatefulBuilder( + builder: (context, setModalState) => Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).viewInsets.bottom, + ), + child: SingleChildScrollView( + child: SafeArea( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Center( + child: Container( + width: 32, + height: 4, + margin: const EdgeInsets.only(bottom: 24), + decoration: BoxDecoration( + color: colorScheme.outlineVariant, + borderRadius: BorderRadius.circular(2), + ), + ), + ), + Text( + title ?? context.l10n.filenameFormat, + style: Theme.of(context).textTheme.headlineSmall + ?.copyWith(fontWeight: FontWeight.bold), + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + description ?? context.l10n.downloadFilenameDescription, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 24), + TextField( + controller: controller, + decoration: InputDecoration( + hintText: '{artist} - {title}', + filled: true, + fillColor: colorScheme.surfaceContainerHighest + .withValues(alpha: 0.3), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: BorderSide.none, + ), + ), + autofocus: true, + ), + const SizedBox(height: 24), + Text( + context.l10n.downloadFilenameInsertTag, + style: Theme.of(context).textTheme.titleSmall + ?.copyWith(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 12), + Wrap( + spacing: 8, + runSpacing: 8, + children: basicTags.map((tag) { + return ActionChip( + label: Text(tag), + onPressed: () => insertTag(tag), + backgroundColor: colorScheme.surfaceContainerHighest + .withValues(alpha: 0.5), + side: BorderSide.none, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + labelStyle: TextStyle( + color: colorScheme.onSurface, + fontWeight: FontWeight.w500, + ), + ); + }).toList(), + ), + const SizedBox(height: 12), + SwitchListTile( + value: showAdvancedTags, + onChanged: (value) => + setModalState(() => showAdvancedTags = value), + contentPadding: EdgeInsets.zero, + title: Text(context.l10n.filenameShowAdvancedTags), + subtitle: Text( + context.l10n.filenameShowAdvancedTagsDescription, + ), + ), + if (showAdvancedTags) ...[ + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + children: advancedTags.map((tag) { + return ActionChip( + label: Text(tag), + onPressed: () => insertTag(tag), + backgroundColor: colorScheme.surfaceContainerHighest + .withValues(alpha: 0.5), + side: BorderSide.none, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + labelStyle: TextStyle( + color: colorScheme.onSurface, + fontWeight: FontWeight.w500, + ), + ); + }).toList(), + ), + ], + const SizedBox(height: 32), + Row( + children: [ + Expanded( + child: TextButton( + onPressed: () => Navigator.pop(context), + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + ), + child: Text(context.l10n.dialogCancel), + ), + ), + const SizedBox(width: 12), + Expanded( + flex: 2, + child: FilledButton( + onPressed: () { + final save = onSave ?? + ref + .read(settingsProvider.notifier) + .setFilenameFormat; + save(controller.text); + Navigator.pop(context); + }, + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + ), + child: Text(context.l10n.dialogSave), + ), + ), + ], + ), + const SizedBox(height: 8), + ], + ), + ), + ), + ), + ), + ), + ); + } + + void _showAlbumFolderStructurePicker( + BuildContext context, + WidgetRef ref, + String current, + ) { + showModalBottomSheet( + context: context, + useRootNavigator: true, + builder: (context) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final option in [ + ('artist_album', context.l10n.albumFolderArtistAlbum, + context.l10n.albumFolderArtistAlbumSubtitle, + Icons.folder_outlined), + ('artist_year_album', context.l10n.albumFolderArtistYearAlbum, + context.l10n.albumFolderArtistYearAlbumSubtitle, + Icons.calendar_today_outlined), + ('album_only', context.l10n.albumFolderAlbumOnly, + context.l10n.albumFolderAlbumOnlySubtitle, Icons.album_outlined), + ('year_album', context.l10n.albumFolderYearAlbum, + context.l10n.albumFolderYearAlbumSubtitle, + Icons.event_outlined), + ('artist_album_singles', context.l10n.albumFolderArtistAlbumSingles, + context.l10n.albumFolderArtistAlbumSinglesSubtitle, + Icons.person_outlined), + ('artist_album_flat', context.l10n.albumFolderArtistAlbumFlat, + context.l10n.albumFolderArtistAlbumFlatSubtitle, + Icons.person_outline_outlined), + ]) + ListTile( + leading: Icon(option.$4), + title: Text(option.$2), + subtitle: Text(option.$3), + trailing: current == option.$1 + ? const Icon(Icons.check) + : null, + onTap: () { + ref + .read(settingsProvider.notifier) + .setAlbumFolderStructure(option.$1); + Navigator.pop(context); + }, + ), + ], + ), + ), + ); + } + + void _showFolderOrganizationPicker( + BuildContext context, + WidgetRef ref, + String current, + ) { + final colorScheme = Theme.of(context).colorScheme; + showModalBottomSheet( + context: context, + useRootNavigator: true, + backgroundColor: colorScheme.surfaceContainerHigh, + isScrollControlled: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(28)), + ), + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.7, + ), + builder: (context) => SafeArea( + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(24, 24, 24, 8), + child: Text( + context.l10n.downloadFolderOrganization, + style: Theme.of(context).textTheme.titleLarge + ?.copyWith(fontWeight: FontWeight.bold), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(24, 0, 24, 16), + child: Text( + context.l10n.folderOrganizationDescription, + style: Theme.of(context).textTheme.bodyMedium + ?.copyWith(color: colorScheme.onSurfaceVariant), + ), + ), + for (final option in [ + ('none', context.l10n.folderOrganizationNone, + context.l10n.folderOrganizationNoneSubtitle, + 'SpotiFLAC/Track.flac'), + ('playlist', context.l10n.folderOrganizationByPlaylist, + context.l10n.folderOrganizationByPlaylistSubtitle, + 'SpotiFLAC/Playlist Name/Track.flac'), + ('artist', context.l10n.folderOrganizationByArtist, + context.l10n.folderOrganizationByArtistSubtitle, + 'SpotiFLAC/Artist Name/Track.flac'), + ('album', context.l10n.folderOrganizationByAlbum, + context.l10n.folderOrganizationByAlbumSubtitle, + 'SpotiFLAC/Album Name/Track.flac'), + ('artist_album', context.l10n.folderOrganizationByArtistAlbum, + context.l10n.folderOrganizationByArtistAlbumSubtitle, + 'SpotiFLAC/Artist/Album/Track.flac'), + ]) + _FolderOption( + title: option.$2, + subtitle: option.$3, + example: option.$4, + isSelected: current == option.$1, + onTap: () { + ref + .read(settingsProvider.notifier) + .setFolderOrganization(option.$1); + Navigator.pop(context); + }, + ), + ], + ), + ), + ), + ); + } +} + +class _FolderOption extends StatelessWidget { + final String title; + final String subtitle; + final String example; + final bool isSelected; + final VoidCallback onTap; + const _FolderOption({ + required this.title, + required this.subtitle, + required this.example, + required this.isSelected, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 4), + title: Text(title), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(subtitle), + const SizedBox(height: 4), + Text( + example, + style: TextStyle( + fontFamily: 'monospace', + fontSize: 11, + color: colorScheme.primary, + ), + ), + ], + ), + trailing: isSelected + ? Icon(Icons.check_circle, color: colorScheme.primary) + : Icon(Icons.circle_outlined, color: colorScheme.outline), + onTap: onTap, + ); + } +} diff --git a/lib/screens/settings/lyrics_settings_page.dart b/lib/screens/settings/lyrics_settings_page.dart new file mode 100644 index 00000000..862da242 --- /dev/null +++ b/lib/screens/settings/lyrics_settings_page.dart @@ -0,0 +1,373 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:spotiflac_android/l10n/l10n.dart'; +import 'package:spotiflac_android/providers/settings_provider.dart'; +import 'package:spotiflac_android/utils/app_bar_layout.dart'; +import 'package:spotiflac_android/screens/settings/lyrics_provider_priority_page.dart'; +import 'package:spotiflac_android/widgets/settings_group.dart'; + +class LyricsSettingsPage extends ConsumerWidget { + const LyricsSettingsPage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final settings = ref.watch(settingsProvider); + final colorScheme = Theme.of(context).colorScheme; + final topPadding = normalizedHeaderTopPadding(context); + + return PopScope( + canPop: true, + child: Scaffold( + body: CustomScrollView( + slivers: [ + SliverAppBar( + expandedHeight: 120 + topPadding, + collapsedHeight: kToolbarHeight, + floating: false, + pinned: true, + backgroundColor: colorScheme.surface, + surfaceTintColor: Colors.transparent, + leading: IconButton( + tooltip: MaterialLocalizations.of(context).backButtonTooltip, + icon: const Icon(Icons.arrow_back), + onPressed: () => Navigator.pop(context), + ), + flexibleSpace: LayoutBuilder( + builder: (context, constraints) { + final maxHeight = 120 + topPadding; + final minHeight = kToolbarHeight + topPadding; + final expandRatio = + ((constraints.maxHeight - minHeight) / + (maxHeight - minHeight)) + .clamp(0.0, 1.0); + final leftPadding = 56 - (32 * expandRatio); + return FlexibleSpaceBar( + expandedTitleScale: 1.0, + titlePadding: EdgeInsets.only( + left: leftPadding, + bottom: 16, + ), + title: Text( + context.l10n.settingsLyrics, + style: TextStyle( + fontSize: 20 + (8 * expandRatio), + fontWeight: FontWeight.bold, + color: colorScheme.onSurface, + ), + ), + ); + }, + ), + ), + + // ── Lyrics Embedding ─────────────────────────────────────── + SliverToBoxAdapter( + child: SettingsSectionHeader(title: context.l10n.sectionLyrics), + ), + SliverToBoxAdapter( + child: SettingsGroup( + children: [ + SettingsSwitchItem( + icon: Icons.subtitles_outlined, + title: context.l10n.optionsEmbedLyrics, + subtitle: settings.embedMetadata + ? context.l10n.optionsEmbedLyricsSubtitle + : context.l10n.downloadEmbedLyricsDisabled, + value: settings.embedLyrics, + enabled: settings.embedMetadata, + onChanged: (value) => ref + .read(settingsProvider.notifier) + .setEmbedLyrics(value), + showDivider: + settings.embedMetadata && settings.embedLyrics, + ), + if (settings.embedMetadata && settings.embedLyrics) ...[ + SettingsItem( + icon: Icons.lyrics_outlined, + title: context.l10n.lyricsMode, + subtitle: _getLyricsModeLabel( + context, + settings.lyricsMode, + ), + onTap: () => + _showLyricsModePicker(context, ref, settings.lyricsMode), + ), + SettingsItem( + icon: Icons.source_outlined, + title: context.l10n.lyricsProvidersTitle, + subtitle: _getLyricsProvidersSubtitle( + context, + settings.lyricsProviders, + ), + onTap: () => Navigator.push( + context, + MaterialPageRoute( + builder: (_) => const LyricsProviderPriorityPage(), + ), + ), + showDivider: false, + ), + ], + ], + ), + ), + + // ── Provider Options ─────────────────────────────────────── + if (settings.embedMetadata && settings.embedLyrics) ...[ + SliverToBoxAdapter( + child: SettingsSectionHeader( + title: context.l10n.sectionLyricsProviderOptions, + ), + ), + SliverToBoxAdapter( + child: SettingsGroup( + children: [ + SettingsSwitchItem( + icon: Icons.translate_outlined, + title: context.l10n.downloadNeteaseIncludeTranslation, + subtitle: settings.lyricsIncludeTranslationNetease + ? context.l10n.downloadNeteaseIncludeTranslationEnabled + : context.l10n.downloadNeteaseIncludeTranslationDisabled, + value: settings.lyricsIncludeTranslationNetease, + onChanged: (value) => ref + .read(settingsProvider.notifier) + .setLyricsIncludeTranslationNetease(value), + ), + SettingsSwitchItem( + icon: Icons.text_fields_outlined, + title: context.l10n.downloadNeteaseIncludeRomanization, + subtitle: settings.lyricsIncludeRomanizationNetease + ? context + .l10n + .downloadNeteaseIncludeRomanizationEnabled + : context + .l10n + .downloadNeteaseIncludeRomanizationDisabled, + value: settings.lyricsIncludeRomanizationNetease, + onChanged: (value) => ref + .read(settingsProvider.notifier) + .setLyricsIncludeRomanizationNetease(value), + ), + SettingsSwitchItem( + icon: Icons.record_voice_over_outlined, + title: context.l10n.downloadAppleQqMultiPerson, + subtitle: settings.lyricsMultiPersonWordByWord + ? context.l10n.downloadAppleQqMultiPersonEnabled + : context.l10n.downloadAppleQqMultiPersonDisabled, + value: settings.lyricsMultiPersonWordByWord, + onChanged: (value) => ref + .read(settingsProvider.notifier) + .setLyricsMultiPersonWordByWord(value), + ), + SettingsItem( + icon: Icons.language_outlined, + title: context.l10n.downloadMusixmatchLanguage, + subtitle: settings.musixmatchLanguage.isEmpty + ? context.l10n.downloadMusixmatchLanguageAuto + : settings.musixmatchLanguage.toUpperCase(), + onTap: () => _showMusixmatchLanguagePicker( + context, + ref, + settings.musixmatchLanguage, + ), + showDivider: false, + ), + ], + ), + ), + ], + + const SliverToBoxAdapter(child: SizedBox(height: 32)), + ], + ), + ), + ); + } + + String _getLyricsModeLabel(BuildContext context, String mode) { + switch (mode) { + case 'external': + return context.l10n.lyricsModeExternal; + case 'both': + return context.l10n.lyricsModeBoth; + default: + return context.l10n.lyricsModeEmbed; + } + } + + static const _providerDisplayNames = { + 'lrclib': 'LRCLIB', + 'netease': 'Netease', + 'musixmatch': 'Musixmatch', + 'apple_music': 'Apple Music', + 'qqmusic': 'QQ Music', + }; + + String _getLyricsProvidersSubtitle( + BuildContext context, + List providers, + ) { + if (providers.isEmpty) return context.l10n.downloadProvidersNoneEnabled; + return providers + .map((p) => _providerDisplayNames[p] ?? p) + .join(' > '); + } + + void _showLyricsModePicker( + BuildContext context, + WidgetRef ref, + String current, + ) { + final colorScheme = Theme.of(context).colorScheme; + showModalBottomSheet( + context: context, + useRootNavigator: true, + backgroundColor: colorScheme.surfaceContainerHigh, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(28)), + ), + builder: (context) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(24, 24, 24, 8), + child: Text( + context.l10n.lyricsMode, + style: Theme.of(context).textTheme.titleLarge + ?.copyWith(fontWeight: FontWeight.bold), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(24, 0, 24, 16), + child: Text( + context.l10n.lyricsModeDescription, + style: Theme.of(context).textTheme.bodyMedium + ?.copyWith(color: colorScheme.onSurfaceVariant), + ), + ), + ListTile( + leading: const Icon(Icons.audiotrack), + title: Text(context.l10n.lyricsModeEmbed), + subtitle: Text(context.l10n.lyricsModeEmbedSubtitle), + trailing: current == 'embed' ? const Icon(Icons.check) : null, + onTap: () { + ref.read(settingsProvider.notifier).setLyricsMode('embed'); + Navigator.pop(context); + }, + ), + ListTile( + leading: const Icon(Icons.insert_drive_file_outlined), + title: Text(context.l10n.lyricsModeExternal), + subtitle: Text(context.l10n.lyricsModeExternalSubtitle), + trailing: current == 'external' ? const Icon(Icons.check) : null, + onTap: () { + ref.read(settingsProvider.notifier).setLyricsMode('external'); + Navigator.pop(context); + }, + ), + ListTile( + leading: const Icon(Icons.library_music_outlined), + title: Text(context.l10n.lyricsModeBoth), + subtitle: Text(context.l10n.lyricsModeBothSubtitle), + trailing: current == 'both' ? const Icon(Icons.check) : null, + onTap: () { + ref.read(settingsProvider.notifier).setLyricsMode('both'); + Navigator.pop(context); + }, + ), + const SizedBox(height: 16), + ], + ), + ), + ); + } + + void _showMusixmatchLanguagePicker( + BuildContext context, + WidgetRef ref, + String currentLanguage, + ) { + final colorScheme = Theme.of(context).colorScheme; + final controller = TextEditingController(text: currentLanguage); + + showModalBottomSheet( + context: context, + useRootNavigator: true, + backgroundColor: colorScheme.surfaceContainerHigh, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(28)), + ), + isScrollControlled: true, + builder: (context) => Padding( + padding: EdgeInsets.only( + left: 24, + right: 24, + top: 24, + bottom: 24 + MediaQuery.of(context).viewInsets.bottom, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + context.l10n.downloadMusixmatchLanguage, + style: Theme.of(context).textTheme.titleLarge + ?.copyWith(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 8), + Text( + context.l10n.downloadMusixmatchLanguageDesc, + style: Theme.of(context).textTheme.bodyMedium + ?.copyWith(color: colorScheme.onSurfaceVariant), + ), + const SizedBox(height: 16), + TextField( + controller: controller, + textInputAction: TextInputAction.done, + decoration: InputDecoration( + labelText: context.l10n.downloadMusixmatchLanguageCode, + hintText: context.l10n.downloadMusixmatchLanguageHint, + ), + ), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(context.l10n.dialogCancel), + ), + const SizedBox(width: 8), + TextButton( + onPressed: () { + ref + .read(settingsProvider.notifier) + .setMusixmatchLanguage(''); + Navigator.pop(context); + }, + child: Text(context.l10n.downloadMusixmatchAuto), + ), + const SizedBox(width: 8), + FilledButton( + onPressed: () { + final normalized = controller.text + .trim() + .toLowerCase() + .replaceAll(RegExp(r'[^a-z0-9\-_]'), ''); + ref + .read(settingsProvider.notifier) + .setMusixmatchLanguage(normalized); + Navigator.pop(context); + }, + child: Text(context.l10n.dialogSave), + ), + ], + ), + ], + ), + ), + ); + } +} diff --git a/lib/screens/settings/metadata_settings_page.dart b/lib/screens/settings/metadata_settings_page.dart new file mode 100644 index 00000000..83f1afb1 --- /dev/null +++ b/lib/screens/settings/metadata_settings_page.dart @@ -0,0 +1,253 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:spotiflac_android/l10n/l10n.dart'; +import 'package:spotiflac_android/providers/settings_provider.dart'; +import 'package:spotiflac_android/utils/app_bar_layout.dart'; +import 'package:spotiflac_android/utils/artist_utils.dart'; +import 'package:spotiflac_android/screens/settings/metadata_provider_priority_page.dart'; +import 'package:spotiflac_android/widgets/settings_group.dart'; + +class MetadataSettingsPage extends ConsumerWidget { + const MetadataSettingsPage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final settings = ref.watch(settingsProvider); + final colorScheme = Theme.of(context).colorScheme; + final topPadding = normalizedHeaderTopPadding(context); + + return PopScope( + canPop: true, + child: Scaffold( + body: CustomScrollView( + slivers: [ + SliverAppBar( + expandedHeight: 120 + topPadding, + collapsedHeight: kToolbarHeight, + floating: false, + pinned: true, + backgroundColor: colorScheme.surface, + surfaceTintColor: Colors.transparent, + leading: IconButton( + tooltip: MaterialLocalizations.of(context).backButtonTooltip, + icon: const Icon(Icons.arrow_back), + onPressed: () => Navigator.pop(context), + ), + flexibleSpace: LayoutBuilder( + builder: (context, constraints) { + final maxHeight = 120 + topPadding; + final minHeight = kToolbarHeight + topPadding; + final expandRatio = + ((constraints.maxHeight - minHeight) / + (maxHeight - minHeight)) + .clamp(0.0, 1.0); + final leftPadding = 56 - (32 * expandRatio); + return FlexibleSpaceBar( + expandedTitleScale: 1.0, + titlePadding: EdgeInsets.only( + left: leftPadding, + bottom: 16, + ), + title: Text( + context.l10n.settingsMetadata, + style: TextStyle( + fontSize: 20 + (8 * expandRatio), + fontWeight: FontWeight.bold, + color: colorScheme.onSurface, + ), + ), + ); + }, + ), + ), + + // ── Embedding ────────────────────────────────────────────── + SliverToBoxAdapter( + child: SettingsSectionHeader(title: context.l10n.sectionDownload), + ), + SliverToBoxAdapter( + child: SettingsGroup( + children: [ + SettingsSwitchItem( + icon: Icons.sell_outlined, + title: 'Embed Metadata', + subtitle: settings.embedMetadata + ? 'Write metadata, cover art, and lyrics to files' + : 'Disabled (advanced): skip all metadata embedding', + value: settings.embedMetadata, + onChanged: (v) => + ref.read(settingsProvider.notifier).setEmbedMetadata(v), + showDivider: settings.embedMetadata, + ), + if (settings.embedMetadata) ...[ + SettingsItem( + icon: Icons.people_alt_outlined, + title: context.l10n.optionsArtistTagMode, + subtitle: _getArtistTagModeLabel( + context, + settings.artistTagMode, + ), + onTap: () => + _showArtistTagModePicker(context, ref, settings.artistTagMode), + ), + SettingsSwitchItem( + icon: Icons.image, + title: context.l10n.optionsMaxQualityCover, + subtitle: context.l10n.optionsMaxQualityCoverSubtitle, + value: settings.maxQualityCover, + onChanged: (v) => ref + .read(settingsProvider.notifier) + .setMaxQualityCover(v), + ), + SettingsSwitchItem( + icon: Icons.graphic_eq, + title: context.l10n.optionsReplayGain, + subtitle: settings.embedReplayGain + ? context.l10n.optionsReplayGainSubtitleOn + : context.l10n.optionsReplayGainSubtitleOff, + value: settings.embedReplayGain, + onChanged: (v) => ref + .read(settingsProvider.notifier) + .setEmbedReplayGain(v), + showDivider: false, + ), + ], + ], + ), + ), + + // ── Providers ───────────────────────────────────────────── + SliverToBoxAdapter( + child: SettingsSectionHeader( + title: context.l10n.sectionMetadataProviders, + ), + ), + SliverToBoxAdapter( + child: SettingsGroup( + children: [ + SettingsItem( + icon: Icons.source_outlined, + title: context.l10n.metadataProvidersTitle, + subtitle: context.l10n.metadataProvidersSubtitle, + onTap: () => Navigator.push( + context, + MaterialPageRoute( + builder: (_) => const MetadataProviderPriorityPage(), + ), + ), + showDivider: false, + ), + ], + ), + ), + + // ── Deduplication ────────────────────────────────────────── + SliverToBoxAdapter( + child: SettingsSectionHeader( + title: context.l10n.sectionDuplicates, + ), + ), + SliverToBoxAdapter( + child: SettingsGroup( + children: [ + SettingsSwitchItem( + icon: Icons.filter_list_outlined, + title: context.l10n.downloadDeduplication, + subtitle: settings.deduplicateDownloads + ? context.l10n.downloadDeduplicationEnabled + : context.l10n.downloadDeduplicationDisabled, + value: settings.deduplicateDownloads, + onChanged: (value) => ref + .read(settingsProvider.notifier) + .setDeduplicateDownloads(value), + showDivider: false, + ), + ], + ), + ), + + const SliverToBoxAdapter(child: SizedBox(height: 32)), + ], + ), + ), + ); + } + + String _getArtistTagModeLabel(BuildContext context, String mode) { + switch (mode) { + case artistTagModeSplitVorbis: + return context.l10n.optionsArtistTagModeSplitVorbis; + default: + return context.l10n.optionsArtistTagModeJoined; + } + } + + void _showArtistTagModePicker( + BuildContext context, + WidgetRef ref, + String currentMode, + ) { + final colorScheme = Theme.of(context).colorScheme; + showModalBottomSheet( + context: context, + useRootNavigator: true, + backgroundColor: colorScheme.surfaceContainerHigh, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(28)), + ), + builder: (context) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(24, 24, 24, 8), + child: Text( + context.l10n.optionsArtistTagMode, + style: Theme.of(context).textTheme.titleLarge + ?.copyWith(fontWeight: FontWeight.bold), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(24, 0, 24, 16), + child: Text( + context.l10n.optionsArtistTagModeDescription, + style: Theme.of(context).textTheme.bodyMedium + ?.copyWith(color: colorScheme.onSurfaceVariant), + ), + ), + ListTile( + leading: const Icon(Icons.segment_outlined), + title: Text(context.l10n.optionsArtistTagModeJoined), + subtitle: Text(context.l10n.optionsArtistTagModeJoinedSubtitle), + trailing: currentMode == artistTagModeJoined + ? const Icon(Icons.check) + : null, + onTap: () { + ref + .read(settingsProvider.notifier) + .setArtistTagMode(artistTagModeJoined); + Navigator.pop(context); + }, + ), + ListTile( + leading: const Icon(Icons.library_music_outlined), + title: Text(context.l10n.optionsArtistTagModeSplitVorbis), + subtitle: Text(context.l10n.optionsArtistTagModeSplitVorbisSubtitle), + trailing: currentMode == artistTagModeSplitVorbis + ? const Icon(Icons.check) + : null, + onTap: () { + ref + .read(settingsProvider.notifier) + .setArtistTagMode(artistTagModeSplitVorbis); + Navigator.pop(context); + }, + ), + const SizedBox(height: 16), + ], + ), + ), + ); + } +} diff --git a/lib/screens/settings/options_settings_page.dart b/lib/screens/settings/options_settings_page.dart deleted file mode 100644 index 147394aa..00000000 --- a/lib/screens/settings/options_settings_page.dart +++ /dev/null @@ -1,1012 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:spotiflac_android/l10n/l10n.dart'; -import 'package:spotiflac_android/providers/download_queue_provider.dart'; -import 'package:spotiflac_android/providers/extension_provider.dart'; -import 'package:spotiflac_android/providers/settings_provider.dart'; -import 'package:spotiflac_android/utils/app_bar_layout.dart'; -import 'package:spotiflac_android/utils/artist_utils.dart'; -import 'package:spotiflac_android/widgets/settings_group.dart'; - -class OptionsSettingsPage extends ConsumerWidget { - const OptionsSettingsPage({super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final settings = ref.watch(settingsProvider); - final extensionState = ref.watch(extensionProvider); - final hasExtensions = extensionState.extensions.isNotEmpty; - final colorScheme = Theme.of(context).colorScheme; - final topPadding = normalizedHeaderTopPadding(context); - - return PopScope( - canPop: true, - child: Scaffold( - body: CustomScrollView( - slivers: [ - SliverAppBar( - expandedHeight: 120 + topPadding, - collapsedHeight: kToolbarHeight, - floating: false, - pinned: true, - backgroundColor: colorScheme.surface, - surfaceTintColor: Colors.transparent, - leading: IconButton( - tooltip: MaterialLocalizations.of(context).backButtonTooltip, - icon: const Icon(Icons.arrow_back), - onPressed: () => Navigator.pop(context), - ), - flexibleSpace: LayoutBuilder( - builder: (context, constraints) { - final maxHeight = 120 + topPadding; - final minHeight = kToolbarHeight + topPadding; - final expandRatio = - ((constraints.maxHeight - minHeight) / - (maxHeight - minHeight)) - .clamp(0.0, 1.0); - final leftPadding = 56 - (32 * expandRatio); - return FlexibleSpaceBar( - expandedTitleScale: 1.0, - titlePadding: EdgeInsets.only( - left: leftPadding, - bottom: 16, - ), - title: Text( - context.l10n.optionsTitle, - style: TextStyle( - fontSize: 20 + (8 * expandRatio), - fontWeight: FontWeight.bold, - color: colorScheme.onSurface, - ), - ), - ); - }, - ), - ), - - SliverToBoxAdapter( - child: SettingsSectionHeader( - title: context.l10n.sectionSearchSource, - ), - ), - SliverToBoxAdapter( - child: SettingsGroup( - children: const [ - _MetadataSourceSelector(), - _DefaultSearchTabSelector(), - ], - ), - ), - - SliverToBoxAdapter( - child: SettingsSectionHeader(title: context.l10n.sectionDownload), - ), - SliverToBoxAdapter( - child: SettingsGroup( - children: [ - SettingsSwitchItem( - icon: Icons.sync, - title: context.l10n.optionsAutoFallback, - subtitle: context.l10n.optionsAutoFallbackSubtitle, - value: settings.autoFallback, - onChanged: (v) => - ref.read(settingsProvider.notifier).setAutoFallback(v), - ), - if (hasExtensions) - SettingsSwitchItem( - icon: Icons.extension, - title: context.l10n.optionsUseExtensionProviders, - subtitle: settings.useExtensionProviders - ? context.l10n.optionsUseExtensionProvidersOn - : context.l10n.optionsUseExtensionProvidersOff, - value: settings.useExtensionProviders, - onChanged: (v) => ref - .read(settingsProvider.notifier) - .setUseExtensionProviders(v), - ), - SettingsSwitchItem( - icon: Icons.sell_outlined, - title: 'Embed Metadata', - subtitle: settings.embedMetadata - ? 'Write metadata, cover art, and embedded lyrics to files' - : 'Disabled (advanced): skip all metadata embedding', - value: settings.embedMetadata, - onChanged: (v) => - ref.read(settingsProvider.notifier).setEmbedMetadata(v), - showDivider: settings.embedMetadata, - ), - if (settings.embedMetadata) - SettingsItem( - icon: Icons.people_alt_outlined, - title: context.l10n.optionsArtistTagMode, - subtitle: _getArtistTagModeLabel( - context, - settings.artistTagMode, - ), - onTap: () => _showArtistTagModePicker( - context, - ref, - settings.artistTagMode, - ), - ), - SettingsSwitchItem( - icon: Icons.image, - title: context.l10n.optionsMaxQualityCover, - subtitle: settings.embedMetadata - ? context.l10n.optionsMaxQualityCoverSubtitle - : 'Disabled when metadata embedding is off', - value: settings.maxQualityCover, - enabled: settings.embedMetadata, - onChanged: (v) => ref - .read(settingsProvider.notifier) - .setMaxQualityCover(v), - ), - SettingsSwitchItem( - icon: Icons.graphic_eq, - title: context.l10n.optionsReplayGain, - subtitle: settings.embedReplayGain - ? context.l10n.optionsReplayGainSubtitleOn - : context.l10n.optionsReplayGainSubtitleOff, - value: settings.embedReplayGain, - enabled: settings.embedMetadata, - onChanged: (v) => ref - .read(settingsProvider.notifier) - .setEmbedReplayGain(v), - showDivider: false, - ), - ], - ), - ), - - SliverToBoxAdapter( - child: SettingsSectionHeader( - title: context.l10n.sectionPerformance, - ), - ), - SliverToBoxAdapter( - child: SettingsGroup( - children: [ - _ConcurrentDownloadsItem( - currentValue: settings.concurrentDownloads, - onChanged: (v) => ref - .read(settingsProvider.notifier) - .setConcurrentDownloads(v), - ), - ], - ), - ), - - SliverToBoxAdapter( - child: SettingsSectionHeader(title: context.l10n.sectionApp), - ), - SliverToBoxAdapter( - child: SettingsGroup( - children: [ - SettingsSwitchItem( - icon: Icons.extension, - title: context.l10n.optionsExtensionStore, - subtitle: context.l10n.optionsExtensionStoreSubtitle, - value: settings.showExtensionStore, - onChanged: (v) => ref - .read(settingsProvider.notifier) - .setShowExtensionStore(v), - ), - SettingsSwitchItem( - icon: Icons.system_update, - title: context.l10n.optionsCheckUpdates, - subtitle: context.l10n.optionsCheckUpdatesSubtitle, - value: settings.checkForUpdates, - onChanged: (v) => ref - .read(settingsProvider.notifier) - .setCheckForUpdates(v), - ), - _UpdateChannelSelector( - currentChannel: settings.updateChannel, - onChanged: (v) => - ref.read(settingsProvider.notifier).setUpdateChannel(v), - ), - ], - ), - ), - - SliverToBoxAdapter( - child: SettingsSectionHeader(title: context.l10n.sectionData), - ), - SliverToBoxAdapter( - child: SettingsGroup( - children: [ - SettingsItem( - icon: Icons.cleaning_services_outlined, - title: context.l10n.cleanupOrphanedDownloads, - subtitle: context.l10n.cleanupOrphanedDownloadsSubtitle, - onTap: () => _cleanupOrphanedDownloads(context, ref), - ), - SettingsItem( - icon: Icons.delete_forever, - title: context.l10n.optionsClearHistory, - subtitle: context.l10n.optionsClearHistorySubtitle, - onTap: () => - _showClearHistoryDialog(context, ref, colorScheme), - showDivider: false, - ), - ], - ), - ), - - SliverToBoxAdapter( - child: SettingsSectionHeader(title: context.l10n.sectionDebug), - ), - SliverToBoxAdapter( - child: SettingsGroup( - children: [ - SettingsSwitchItem( - icon: Icons.bug_report, - title: context.l10n.optionsDetailedLogging, - subtitle: settings.enableLogging - ? context.l10n.optionsDetailedLoggingOn - : context.l10n.optionsDetailedLoggingOff, - value: settings.enableLogging, - onChanged: (v) => - ref.read(settingsProvider.notifier).setEnableLogging(v), - showDivider: false, - ), - ], - ), - ), - - const SliverToBoxAdapter(child: SizedBox(height: 32)), - ], - ), - ), - ); - } - - String _getArtistTagModeLabel(BuildContext context, String mode) { - switch (mode) { - case artistTagModeSplitVorbis: - return context.l10n.optionsArtistTagModeSplitVorbis; - default: - return context.l10n.optionsArtistTagModeJoined; - } - } - - void _showArtistTagModePicker( - BuildContext context, - WidgetRef ref, - String currentMode, - ) { - final colorScheme = Theme.of(context).colorScheme; - showModalBottomSheet( - context: context, - useRootNavigator: true, - backgroundColor: colorScheme.surfaceContainerHigh, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(28)), - ), - builder: (context) => SafeArea( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.fromLTRB(24, 24, 24, 8), - child: Text( - context.l10n.optionsArtistTagMode, - style: Theme.of( - context, - ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), - ), - ), - Padding( - padding: const EdgeInsets.fromLTRB(24, 0, 24, 16), - child: Text( - context.l10n.optionsArtistTagModeDescription, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - ), - ListTile( - leading: const Icon(Icons.segment_outlined), - title: Text(context.l10n.optionsArtistTagModeJoined), - subtitle: Text(context.l10n.optionsArtistTagModeJoinedSubtitle), - trailing: currentMode == artistTagModeJoined - ? const Icon(Icons.check) - : null, - onTap: () { - ref - .read(settingsProvider.notifier) - .setArtistTagMode(artistTagModeJoined); - Navigator.pop(context); - }, - ), - ListTile( - leading: const Icon(Icons.library_music_outlined), - title: Text(context.l10n.optionsArtistTagModeSplitVorbis), - subtitle: Text( - context.l10n.optionsArtistTagModeSplitVorbisSubtitle, - ), - trailing: currentMode == artistTagModeSplitVorbis - ? const Icon(Icons.check) - : null, - onTap: () { - ref - .read(settingsProvider.notifier) - .setArtistTagMode(artistTagModeSplitVorbis); - Navigator.pop(context); - }, - ), - const SizedBox(height: 16), - ], - ), - ), - ); - } - - void _showClearHistoryDialog( - BuildContext context, - WidgetRef ref, - ColorScheme colorScheme, - ) { - showDialog( - context: context, - builder: (context) => AlertDialog( - title: Text(context.l10n.dialogClearHistoryTitle), - content: Text(context.l10n.dialogClearHistoryMessage), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: Text(context.l10n.dialogCancel), - ), - TextButton( - onPressed: () { - ref.read(downloadHistoryProvider.notifier).clearHistory(); - Navigator.pop(context); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(context.l10n.snackbarHistoryCleared)), - ); - }, - child: Text( - context.l10n.dialogClear, - style: TextStyle(color: colorScheme.error), - ), - ), - ], - ), - ); - } - - Future _cleanupOrphanedDownloads( - BuildContext context, - WidgetRef ref, - ) async { - showDialog( - context: context, - barrierDismissible: false, - builder: (context) => AlertDialog( - content: Row( - children: [ - const CircularProgressIndicator(), - const SizedBox(width: 16), - Text(context.l10n.cleanupOrphanedDownloads), - ], - ), - ), - ); - - try { - final removed = await ref - .read(downloadHistoryProvider.notifier) - .cleanupOrphanedDownloads(); - - if (context.mounted) { - Navigator.pop(context); // Close loading dialog - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - removed > 0 - ? context.l10n.cleanupOrphanedDownloadsResult(removed) - : context.l10n.cleanupOrphanedDownloadsNone, - ), - ), - ); - } - } catch (e) { - if (context.mounted) { - Navigator.pop(context); // Close loading dialog - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(context.l10n.snackbarError(e.toString()))), - ); - } - } - } -} - -class _ConcurrentDownloadsItem extends StatelessWidget { - final int currentValue; - final ValueChanged onChanged; - const _ConcurrentDownloadsItem({ - required this.currentValue, - required this.onChanged, - }); - - @override - Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - return Padding( - padding: const EdgeInsets.all(20), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon( - Icons.download_for_offline, - color: colorScheme.onSurfaceVariant, - size: 24, - ), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - context.l10n.optionsConcurrentDownloads, - style: Theme.of(context).textTheme.bodyLarge, - ), - const SizedBox(height: 2), - Text( - currentValue == 1 - ? context.l10n.optionsConcurrentSequential - : context.l10n.optionsConcurrentParallel( - currentValue, - ), - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - ], - ), - ), - ], - ), - const SizedBox(height: 16), - Row( - children: [ - _ConcurrentChip( - label: '1', - isSelected: currentValue == 1, - onTap: () => onChanged(1), - ), - const SizedBox(width: 8), - _ConcurrentChip( - label: '2', - isSelected: currentValue == 2, - onTap: () => onChanged(2), - ), - const SizedBox(width: 8), - _ConcurrentChip( - label: '3', - isSelected: currentValue == 3, - onTap: () => onChanged(3), - ), - const SizedBox(width: 8), - _ConcurrentChip( - label: '4', - isSelected: currentValue == 4, - onTap: () => onChanged(4), - ), - const SizedBox(width: 8), - _ConcurrentChip( - label: '5', - isSelected: currentValue == 5, - onTap: () => onChanged(5), - ), - ], - ), - const SizedBox(height: 12), - Row( - children: [ - Icon( - Icons.warning_amber_rounded, - size: 16, - color: colorScheme.error, - ), - const SizedBox(width: 8), - Expanded( - child: Text( - context.l10n.optionsConcurrentWarning, - style: Theme.of( - context, - ).textTheme.bodySmall?.copyWith(color: colorScheme.error), - ), - ), - ], - ), - ], - ), - ); - } -} - -class _ConcurrentChip extends StatelessWidget { - final String label; - final bool isSelected; - final VoidCallback onTap; - const _ConcurrentChip({ - required this.label, - required this.isSelected, - required this.onTap, - }); - - @override - Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - final isDark = Theme.of(context).brightness == Brightness.dark; - - final unselectedColor = isDark - ? Color.alphaBlend( - Colors.white.withValues(alpha: 0.05), - colorScheme.surface, - ) - : colorScheme.surfaceContainerHigh; - - return Expanded( - child: Material( - color: isSelected ? colorScheme.primaryContainer : unselectedColor, - borderRadius: BorderRadius.circular(12), - child: InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(12), - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 12), - child: Center( - child: Text( - label, - style: TextStyle( - fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal, - color: isSelected - ? colorScheme.onPrimaryContainer - : colorScheme.onSurfaceVariant, - ), - ), - ), - ), - ), - ), - ); - } -} - -class _UpdateChannelSelector extends StatelessWidget { - final String currentChannel; - final ValueChanged onChanged; - const _UpdateChannelSelector({ - required this.currentChannel, - required this.onChanged, - }); - - @override - Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - return Padding( - padding: const EdgeInsets.all(20), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon( - Icons.new_releases, - color: colorScheme.onSurfaceVariant, - size: 24, - ), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - context.l10n.optionsUpdateChannel, - style: Theme.of(context).textTheme.bodyLarge, - ), - const SizedBox(height: 2), - Text( - currentChannel == 'preview' - ? context.l10n.optionsUpdateChannelPreview - : context.l10n.optionsUpdateChannelStable, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - ], - ), - ), - ], - ), - const SizedBox(height: 16), - Row( - children: [ - _ChannelChip( - label: context.l10n.channelStable, - isSelected: currentChannel == 'stable', - onTap: () => onChanged('stable'), - ), - const SizedBox(width: 8), - _ChannelChip( - label: context.l10n.channelPreview, - isSelected: currentChannel == 'preview', - onTap: () => onChanged('preview'), - ), - ], - ), - const SizedBox(height: 12), - Row( - children: [ - Icon( - Icons.info_outline, - size: 16, - color: colorScheme.onSurfaceVariant, - ), - const SizedBox(width: 8), - Expanded( - child: Text( - context.l10n.optionsUpdateChannelWarning, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - ), - ], - ), - ], - ), - ); - } -} - -class _ChannelChip extends StatelessWidget { - final String label; - final bool isSelected; - final VoidCallback onTap; - const _ChannelChip({ - required this.label, - required this.isSelected, - required this.onTap, - }); - - @override - Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - final isDark = Theme.of(context).brightness == Brightness.dark; - - final unselectedColor = isDark - ? Color.alphaBlend( - Colors.white.withValues(alpha: 0.05), - colorScheme.surface, - ) - : colorScheme.surfaceContainerHigh; - - return Expanded( - child: Material( - color: isSelected ? colorScheme.primaryContainer : unselectedColor, - borderRadius: BorderRadius.circular(12), - child: InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(12), - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 12), - child: Center( - child: Text( - label, - style: TextStyle( - fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal, - color: isSelected - ? colorScheme.onPrimaryContainer - : colorScheme.onSurfaceVariant, - ), - ), - ), - ), - ), - ), - ); - } -} - -class _MetadataSourceSelector extends ConsumerWidget { - const _MetadataSourceSelector(); - - static const _builtInProviders = {'tidal': 'Tidal', 'qobuz': 'Qobuz'}; - - Extension? _defaultSearchExtension(List extensions) { - return extensions - .where( - (ext) => - ext.enabled && - ext.hasCustomSearch && - ext.searchBehavior?.primary == true, - ) - .firstOrNull ?? - extensions - .where((ext) => ext.enabled && ext.hasCustomSearch) - .firstOrNull; - } - - @override - Widget build(BuildContext context, WidgetRef ref) { - final colorScheme = Theme.of(context).colorScheme; - final settings = ref.watch(settingsProvider); - final extState = ref.watch(extensionProvider); - - final rawSearchProvider = settings.searchProvider?.trim() ?? ''; - final isValidBuiltIn = _builtInProviders.containsKey(rawSearchProvider); - final primarySearchExtension = _defaultSearchExtension(extState.extensions); - final defaultProviderTarget = - primarySearchExtension?.displayName ?? 'Tidal'; - final defaultProviderLabel = - '${context.l10n.extensionsHomeFeedAuto} ($defaultProviderTarget)'; - final searchProvider = - isValidBuiltIn || - extState.extensions.any( - (e) => - e.enabled && e.hasCustomSearch && e.id == rawSearchProvider, - ) - ? rawSearchProvider - : ''; - final isBuiltIn = _builtInProviders.containsKey(searchProvider); - - Extension? activeExtension; - if (searchProvider.isNotEmpty && !isBuiltIn) { - activeExtension = extState.extensions - .where((e) => e.id == searchProvider && e.enabled) - .firstOrNull; - } - final hasNonDefaultProvider = isBuiltIn || activeExtension != null; - - String subtitle; - if (isBuiltIn) { - subtitle = 'Using ${_builtInProviders[searchProvider]}'; - } else if (activeExtension != null) { - subtitle = context.l10n.optionsUsingExtension( - activeExtension.displayName, - ); - } else { - subtitle = context.l10n.optionsPrimaryProviderSubtitle; - } - - return Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - context.l10n.optionsPrimaryProvider, - style: Theme.of( - context, - ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w500), - ), - const SizedBox(height: 4), - Text( - subtitle, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: hasNonDefaultProvider - ? colorScheme.primary - : colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: 16), - Row( - children: [ - Expanded( - child: _SourceChip( - icon: Icons.graphic_eq, - label: defaultProviderLabel, - isSelected: searchProvider.isEmpty, - onTap: () { - if (hasNonDefaultProvider) { - ref - .read(settingsProvider.notifier) - .setSearchProvider(null); - } - }, - ), - ), - const SizedBox(width: 8), - Expanded( - child: _SourceChip( - icon: Icons.waves, - label: 'Tidal', - isSelected: searchProvider == 'tidal', - onTap: () { - ref - .read(settingsProvider.notifier) - .setSearchProvider('tidal'); - }, - ), - ), - const SizedBox(width: 8), - Expanded( - child: _SourceChip( - icon: Icons.album, - label: 'Qobuz', - isSelected: searchProvider == 'qobuz', - onTap: () { - ref - .read(settingsProvider.notifier) - .setSearchProvider('qobuz'); - }, - ), - ), - ], - ), - if (activeExtension != null) ...[ - const SizedBox(height: 12), - Row( - children: [ - Icon( - Icons.info_outline, - size: 16, - color: colorScheme.onSurfaceVariant, - ), - const SizedBox(width: 8), - Expanded( - child: Text( - 'Tap $defaultProviderLabel to switch back from extension', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - ), - ], - ), - ], - ], - ), - ); - } -} - -class _DefaultSearchTabSelector extends ConsumerWidget { - const _DefaultSearchTabSelector(); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final colorScheme = Theme.of(context).colorScheme; - final selectedTab = ref.watch( - settingsProvider.select((s) => s.defaultSearchTab), - ); - - return Padding( - padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - context.l10n.optionsDefaultSearchTab, - style: Theme.of( - context, - ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w500), - ), - const SizedBox(height: 4), - Text( - context.l10n.optionsDefaultSearchTabSubtitle, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: 16), - Row( - children: [ - Expanded( - child: _SourceChip( - icon: Icons.dashboard_outlined, - label: context.l10n.historyFilterAll, - isSelected: selectedTab == 'all', - onTap: () => ref - .read(settingsProvider.notifier) - .setDefaultSearchTab('all'), - ), - ), - const SizedBox(width: 8), - Expanded( - child: _SourceChip( - icon: Icons.music_note, - label: context.l10n.searchSongs, - isSelected: selectedTab == 'track', - onTap: () => ref - .read(settingsProvider.notifier) - .setDefaultSearchTab('track'), - ), - ), - const SizedBox(width: 8), - Expanded( - child: _SourceChip( - icon: Icons.person, - label: context.l10n.searchArtists, - isSelected: selectedTab == 'artist', - onTap: () => ref - .read(settingsProvider.notifier) - .setDefaultSearchTab('artist'), - ), - ), - const SizedBox(width: 8), - Expanded( - child: _SourceChip( - icon: Icons.album, - label: context.l10n.searchAlbums, - isSelected: selectedTab == 'album', - onTap: () => ref - .read(settingsProvider.notifier) - .setDefaultSearchTab('album'), - ), - ), - ], - ), - ], - ), - ); - } -} - -class _SourceChip extends StatelessWidget { - final IconData icon; - final String label; - final bool isSelected; - final VoidCallback? onTap; - - const _SourceChip({ - required this.icon, - required this.label, - required this.isSelected, - this.onTap, - }); - - @override - Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - final isDark = Theme.of(context).brightness == Brightness.dark; - - final unselectedColor = isDark - ? Color.alphaBlend( - Colors.white.withValues(alpha: 0.05), - colorScheme.surface, - ) - : colorScheme.surfaceContainerHigh; - - return Material( - color: isSelected ? colorScheme.primaryContainer : unselectedColor, - borderRadius: BorderRadius.circular(12), - child: InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(12), - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 18), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - icon, - size: 28, - color: isSelected - ? colorScheme.onPrimaryContainer - : colorScheme.onSurfaceVariant, - ), - const SizedBox(height: 6), - Text( - label, - style: TextStyle( - fontSize: 12, - fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal, - color: isSelected - ? colorScheme.onPrimaryContainer - : colorScheme.onSurfaceVariant, - ), - ), - ], - ), - ), - ), - ); - } -} diff --git a/lib/screens/settings/settings_tab.dart b/lib/screens/settings/settings_tab.dart index f08d967a..97b51288 100644 --- a/lib/screens/settings/settings_tab.dart +++ b/lib/screens/settings/settings_tab.dart @@ -4,9 +4,12 @@ import 'package:spotiflac_android/constants/app_info.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/screens/settings/appearance_settings_page.dart'; import 'package:spotiflac_android/screens/settings/download_settings_page.dart'; +import 'package:spotiflac_android/screens/settings/files_settings_page.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/extensions_page.dart'; import 'package:spotiflac_android/screens/settings/library_settings_page.dart'; -import 'package:spotiflac_android/screens/settings/options_settings_page.dart'; +import 'package:spotiflac_android/screens/settings/app_settings_page.dart'; import 'package:spotiflac_android/screens/settings/about_page.dart'; import 'package:spotiflac_android/screens/settings/cache_management_page.dart'; import 'package:spotiflac_android/screens/settings/donate_page.dart'; @@ -48,7 +51,7 @@ class SettingsTab extends ConsumerWidget { title: Text( context.l10n.settingsTitle, style: TextStyle( - fontSize: 20 + (14 * expandRatio), // 20 -> 34 + fontSize: 20 + (14 * expandRatio), fontWeight: FontWeight.bold, color: colorScheme.onSurface, ), @@ -58,6 +61,7 @@ class SettingsTab extends ConsumerWidget { ), ), + // ── Group 1: Appearance & Content ────────────────────────────── SliverToBoxAdapter( child: Builder( builder: (context) { @@ -72,6 +76,34 @@ class SettingsTab extends ConsumerWidget { onTap: () => _navigateTo(context, const AppearanceSettingsPage()), ), + SettingsItem( + icon: Icons.library_music_outlined, + title: l10n.settingsLocalLibrary, + subtitle: l10n.settingsLocalLibrarySubtitle, + onTap: () => + _navigateTo(context, const LibrarySettingsPage()), + ), + SettingsItem( + icon: Icons.extension_outlined, + title: l10n.settingsExtensions, + subtitle: l10n.settingsExtensionsSubtitle, + onTap: () => _navigateTo(context, const ExtensionsPage()), + showDivider: false, + ), + ], + ); + }, + ), + ), + + // ── Group 2: Download ────────────────────────────────────────── + SliverToBoxAdapter( + child: Builder( + builder: (context) { + final l10n = context.l10n; + return SettingsGroup( + margin: const EdgeInsets.fromLTRB(16, 4, 16, 4), + children: [ SettingsItem( icon: Icons.download_outlined, title: l10n.settingsDownload, @@ -80,12 +112,41 @@ class SettingsTab extends ConsumerWidget { _navigateTo(context, const DownloadSettingsPage()), ), SettingsItem( - icon: Icons.library_music_outlined, - title: l10n.settingsLocalLibrary, - subtitle: l10n.settingsLocalLibrarySubtitle, + icon: Icons.folder_outlined, + title: l10n.settingsFiles, + subtitle: l10n.settingsFilesSubtitle, onTap: () => - _navigateTo(context, const LibrarySettingsPage()), + _navigateTo(context, const FilesSettingsPage()), ), + SettingsItem( + icon: Icons.sell_outlined, + title: l10n.settingsMetadata, + subtitle: l10n.settingsMetadataSubtitle, + onTap: () => + _navigateTo(context, const MetadataSettingsPage()), + ), + SettingsItem( + icon: Icons.lyrics_outlined, + title: l10n.settingsLyrics, + subtitle: l10n.settingsLyricsSubtitle, + onTap: () => + _navigateTo(context, const LyricsSettingsPage()), + showDivider: false, + ), + ], + ); + }, + ), + ), + + // ── Group 3: App ─────────────────────────────────────────────── + SliverToBoxAdapter( + child: Builder( + builder: (context) { + final l10n = context.l10n; + return SettingsGroup( + margin: const EdgeInsets.fromLTRB(16, 4, 16, 4), + children: [ SettingsItem( icon: Icons.storage_outlined, title: l10n.settingsCache, @@ -95,41 +156,22 @@ class SettingsTab extends ConsumerWidget { ), SettingsItem( icon: Icons.tune_outlined, - title: l10n.settingsOptions, - subtitle: l10n.settingsOptionsSubtitle, + title: l10n.settingsApp, + subtitle: l10n.settingsAppSubtitle, onTap: () => - _navigateTo(context, const OptionsSettingsPage()), + _navigateTo(context, const AppSettingsPage()), ), SettingsItem( - icon: Icons.extension_outlined, - title: l10n.settingsExtensions, - subtitle: l10n.settingsExtensionsSubtitle, - onTap: () => _navigateTo(context, const ExtensionsPage()), + icon: Icons.article_outlined, + title: l10n.logTitle, + subtitle: l10n.settingsLogsSubtitle, + onTap: () => _navigateTo(context, const LogScreen()), ), SettingsItem( icon: Icons.favorite_outline, title: l10n.settingsDonate, subtitle: l10n.settingsDonateSubtitle, onTap: () => _navigateTo(context, const DonatePage()), - showDivider: false, - ), - ], - ); - }, - ), - ), - - SliverToBoxAdapter( - child: Builder( - builder: (context) { - final l10n = context.l10n; - return SettingsGroup( - children: [ - SettingsItem( - icon: Icons.article_outlined, - title: l10n.logTitle, - subtitle: l10n.settingsLogsSubtitle, - onTap: () => _navigateTo(context, const LogScreen()), ), SettingsItem( icon: Icons.info_outline,