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, ), ), ); }, ), ), 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, ), ], ), ), 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, ), ], ), ), 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( context, settings.albumFolderStructure, ), onTap: () => _showAlbumFolderStructurePicker( context, ref, settings.albumFolderStructure, ), ), if (!settings.separateSingles) SettingsItem( icon: Icons.create_new_folder_outlined, title: context.l10n.downloadFolderOrganization, subtitle: _getFolderOrganizationLabel( context, 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, ), ], ], ), ), 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)), ], ), ), ); } String _getAlbumFolderStructureLabel(BuildContext context, String structure) { switch (structure) { case 'album_only': return context.l10n.albumFolderAlbumOnlySubtitle; case 'artist_year_album': return context.l10n.albumFolderArtistYearAlbumSubtitle; case 'year_album': return context.l10n.albumFolderYearAlbumSubtitle; case 'artist_album_singles': return context.l10n.albumFolderArtistAlbumSinglesSubtitle; case 'artist_album_flat': return context.l10n.albumFolderArtistAlbumFlatSubtitle; default: return context.l10n.albumFolderArtistAlbumSubtitle; } } String _getFolderOrganizationLabel(BuildContext context, String value) { switch (value) { case 'playlist': return context.l10n.folderOrganizationByPlaylist; case 'artist': return context.l10n.folderOrganizationByArtist; case 'album': return context.l10n.folderOrganizationByAlbum; case 'artist_album': return context.l10n.folderOrganizationByArtistAlbum; default: return context.l10n.folderOrganizationNone; } } 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.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; } final bookmark = await PlatformBridge.createIosBookmarkFromPath(result); if (bookmark == null || bookmark.isEmpty) { if (ctx.mounted) { ScaffoldMessenger.of(ctx).showSnackBar( SnackBar( content: Text( ctx.l10n.snackbarFolderPickerFailed( ctx.l10n.errorCouldNotKeepFolderAccess, ), ), backgroundColor: Theme.of(ctx).colorScheme.error, duration: const Duration(seconds: 4), ), ); } return; } ref .read(settingsProvider.notifier) .setDownloadDirectory(result, iosBookmark: bookmark); 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 colorScheme = Theme.of(context).colorScheme; final save = onSave ?? ref.read(settingsProvider.notifier).setFilenameFormat; showModalBottomSheet( context: context, isScrollControlled: true, useSafeArea: true, backgroundColor: colorScheme.surface, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(28)), ), builder: (context) => _FilenameFormatEditorSheet( initialText: current, onSave: save, title: title, description: description, ), ); } 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, ); } } /// Bottom sheet for editing a filename format. Owns its controller and disposes /// it in [dispose] to avoid use-after-dispose during the close animation. class _FilenameFormatEditorSheet extends StatefulWidget { final String initialText; final void Function(String) onSave; final String? title; final String? description; const _FilenameFormatEditorSheet({ required this.initialText, required this.onSave, this.title, this.description, }); @override State<_FilenameFormatEditorSheet> createState() => _FilenameFormatEditorSheetState(); } class _FilenameFormatEditorSheetState extends State<_FilenameFormatEditorSheet> { static const _basicTags = [ '{artist}', '{title}', '{album}', '{track}', '{year}', '{date}', '{disc}', ]; static const _advancedTags = [ '{track_raw}', '{track:02}', '{track:1}', '{date:%Y}', '{date:%Y-%m-%d}', '{disc_raw}', '{disc:02}', ]; late final TextEditingController _controller; late bool _showAdvancedTags; @override void initState() { super.initState(); _controller = TextEditingController(text: widget.initialText); _showAdvancedTags = RegExp( r'\{(?:track_raw|disc_raw|track:\d+|disc:\d+|date:[^}]+)\}', caseSensitive: false, ).hasMatch(widget.initialText); } @override void dispose() { _controller.dispose(); super.dispose(); } 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), ); } Widget _tagChip(ColorScheme colorScheme, String 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, ), ); } @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; return 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( widget.title ?? context.l10n.filenameFormat, style: Theme.of( context, ).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold), textAlign: TextAlign.center, ), const SizedBox(height: 8), Text( widget.description ?? context.l10n.downloadFilenameDescription( '{album}', '{artist}', '{date}', '{disc}', '{title}', '{track}', '{year}', ), 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) => _tagChip(colorScheme, tag)) .toList(), ), const SizedBox(height: 12), SwitchListTile( value: _showAdvancedTags, onChanged: (value) => setState(() => _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) => _tagChip(colorScheme, tag)) .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: () { widget.onSave(_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), ], ), ), ), ), ); } }