diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt index 2e23f238..bb9e8384 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt @@ -1117,6 +1117,16 @@ class MainActivity: FlutterFragmentActivity() { ".flac", ".wav", ".ape", ".mp3", ".ogg", ".wv", ".m4a", ".mp4", ".aac" ) + // Audio file extensions that the local library scanner accepts. Must stay in + // sync with supportedAudioFormats in go_backend/library_scan.go so that every + // format the Go engine can read (FLAC, M4A/MP4/AAC, MP3, Opus/OGG, APE/WV/MPC, + // WAV, AIFF) is also enumerated here during the SAF folder walk. (.cue is + // handled separately.) + private val libraryScanAudioExtensions = setOf( + ".flac", ".m4a", ".mp4", ".aac", ".mp3", ".opus", ".ogg", + ".ape", ".wv", ".mpc", ".wav", ".aiff", ".aif" + ) + private fun getSafChildFileLookup( dir: DocumentFile, cache: MutableMap>, @@ -1186,7 +1196,7 @@ class MainActivity: FlutterFragmentActivity() { it.currentFile = "Scanning folders..." } - val supportedAudioExt = setOf(".flac", ".m4a", ".mp4", ".aac", ".mp3", ".opus", ".ogg") + val supportedAudioExt = libraryScanAudioExtensions val audioFiles = mutableListOf>() val cueFiles = mutableListOf>() val visitedDirUris = mutableSetOf() @@ -1486,7 +1496,7 @@ class MainActivity: FlutterFragmentActivity() { it.currentFile = "Scanning folders..." } - val supportedAudioExt = setOf(".flac", ".m4a", ".mp4", ".aac", ".mp3", ".opus", ".ogg") + val supportedAudioExt = libraryScanAudioExtensions val audioFiles = mutableListOf>() val cueFilesToScan = mutableListOf>() val unchangedCueFiles = mutableListOf>() diff --git a/lib/app.dart b/lib/app.dart index fd68d9e3..fde40a5a 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -114,6 +114,19 @@ class SpotiFLACApp extends ConsumerWidget { scrollBehavior: scrollBehavior, themeAnimationDuration: const Duration(milliseconds: 300), themeAnimationCurve: Curves.easeInOut, + // Treat the display as one continuous surface. Some large/foldable + // devices report a full-height display feature (hinge/cutout) which + // makes Flutter split modal routes into a sub-screen, leaving bottom + // sheets and dialogs visibly off-center instead of centered on the + // full screen. Clearing displayFeatures keeps them centered for every + // modal/dialog generically, without per-sheet workarounds. + builder: (context, child) { + final mediaQuery = MediaQuery.of(context); + return MediaQuery( + data: mediaQuery.copyWith(displayFeatures: const []), + child: child ?? const SizedBox.shrink(), + ); + }, routerConfig: router, locale: locale, localeResolutionCallback: (deviceLocale, supportedLocales) { diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 5533f423..97652455 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -3533,7 +3533,7 @@ abstract class AppLocalizations { /// Description of local library feature /// /// In en, this message translates to: - /// **'Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.'** + /// **'Scans your existing music collection to detect duplicates when downloading. Supports FLAC, ALAC, M4A, MP3, Opus, OGG, WAV, AIFF, and APE formats. Metadata is read from file tags when available.'** String get libraryAboutDescription; /// Unit label for tracks count (without the number itself) diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index d0b0b8e9..0dae1cfa 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -1935,7 +1935,7 @@ class AppLocalizationsEn extends AppLocalizations { @override String get libraryAboutDescription => - 'Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.'; + 'Scans your existing music collection to detect duplicates when downloading. Supports FLAC, ALAC, M4A, MP3, Opus, OGG, WAV, AIFF, and APE formats. Metadata is read from file tags when available.'; @override String libraryTracksUnit(int count) { diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index 791f9490..62f18ee9 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -1942,7 +1942,7 @@ class AppLocalizationsId extends AppLocalizations { @override String get libraryAboutDescription => - 'Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.'; + 'Memindai koleksi musik yang sudah ada untuk mendeteksi duplikat saat mengunduh. Mendukung format FLAC, ALAC, M4A, MP3, Opus, OGG, WAV, AIFF, dan APE. Metadata dibaca dari tag file jika tersedia.'; @override String libraryTracksUnit(int count) { diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index a89cf24a..823f696e 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -2556,7 +2556,7 @@ "@libraryAbout": { "description": "Section header for about info" }, - "libraryAboutDescription": "Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.", + "libraryAboutDescription": "Scans your existing music collection to detect duplicates when downloading. Supports FLAC, ALAC, M4A, MP3, Opus, OGG, WAV, AIFF, and APE formats. Metadata is read from file tags when available.", "@libraryAboutDescription": { "description": "Description of local library feature" }, diff --git a/lib/l10n/arb/app_id.arb b/lib/l10n/arb/app_id.arb index 1ddb8ab0..14418a9d 100644 --- a/lib/l10n/arb/app_id.arb +++ b/lib/l10n/arb/app_id.arb @@ -2245,7 +2245,7 @@ "@libraryAbout": { "description": "Section header for about info" }, - "libraryAboutDescription": "Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.", + "libraryAboutDescription": "Memindai koleksi musik yang sudah ada untuk mendeteksi duplikat saat mengunduh. Mendukung format FLAC, ALAC, M4A, MP3, Opus, OGG, WAV, AIFF, dan APE. Metadata dibaca dari tag file jika tersedia.", "@libraryAboutDescription": { "description": "Description of local library feature" }, diff --git a/lib/screens/album_screen.dart b/lib/screens/album_screen.dart index ab4c959d..347f1d16 100644 --- a/lib/screens/album_screen.dart +++ b/lib/screens/album_screen.dart @@ -1088,7 +1088,7 @@ class _AlbumTrackItem extends ConsumerWidget { vertical: 2, ), decoration: BoxDecoration( - color: colorScheme.tertiaryContainer, + color: colorScheme.primaryContainer, borderRadius: BorderRadius.circular(4), ), child: Row( @@ -1097,7 +1097,7 @@ class _AlbumTrackItem extends ConsumerWidget { Icon( Icons.folder_outlined, size: 10, - color: colorScheme.onTertiaryContainer, + color: colorScheme.onPrimaryContainer, ), const SizedBox(width: 3), Text( @@ -1105,7 +1105,7 @@ class _AlbumTrackItem extends ConsumerWidget { style: TextStyle( fontSize: 9, fontWeight: FontWeight.w500, - color: colorScheme.onTertiaryContainer, + color: colorScheme.onPrimaryContainer, ), ), ], diff --git a/lib/screens/artist_screen.dart b/lib/screens/artist_screen.dart index 37fb1756..032ccda3 100644 --- a/lib/screens/artist_screen.dart +++ b/lib/screens/artist_screen.dart @@ -1586,7 +1586,7 @@ class _ArtistScreenState extends ConsumerState { vertical: 2, ), decoration: BoxDecoration( - color: colorScheme.tertiaryContainer, + color: colorScheme.primaryContainer, borderRadius: BorderRadius.circular(4), ), child: Row( @@ -1595,7 +1595,7 @@ class _ArtistScreenState extends ConsumerState { Icon( Icons.folder_outlined, size: 10, - color: colorScheme.onTertiaryContainer, + color: colorScheme.onPrimaryContainer, ), const SizedBox(width: 3), Text( @@ -1603,7 +1603,7 @@ class _ArtistScreenState extends ConsumerState { style: TextStyle( fontSize: 9, fontWeight: FontWeight.w500, - color: colorScheme.onTertiaryContainer, + color: colorScheme.onPrimaryContainer, ), ), ], diff --git a/lib/screens/downloaded_album_screen.dart b/lib/screens/downloaded_album_screen.dart index c3516a5d..126f68fa 100644 --- a/lib/screens/downloaded_album_screen.dart +++ b/lib/screens/downloaded_album_screen.dart @@ -17,6 +17,7 @@ import 'package:spotiflac_android/utils/image_cache_utils.dart'; import 'package:spotiflac_android/utils/lyrics_metadata_helper.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart'; import 'package:spotiflac_android/widgets/batch_progress_dialog.dart'; +import 'package:spotiflac_android/widgets/batch_convert_sheet.dart'; import 'package:spotiflac_android/providers/playback_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/screens/track_metadata_screen.dart'; @@ -967,164 +968,25 @@ class _DownloadedAlbumScreenState extends ConsumerState { if (formats.isEmpty) return; - String selectedFormat = formats.first; - bool isLosslessTarget = isLosslessConversionTarget(selectedFormat); - String defaultBitrateForFormat(String format) { - if (format == 'Opus') return '128k'; - if (format == 'AAC') return '256k'; - return '320k'; - } - - String selectedBitrate = isLosslessTarget - ? '320k' - : defaultBitrateForFormat(selectedFormat); - showModalBottomSheet( context: context, useRootNavigator: true, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(20)), ), - builder: (sheetContext) { - return StatefulBuilder( - builder: (context, setSheetState) { - final colorScheme = Theme.of(context).colorScheme; - final bitrates = ['128k', '192k', '256k', '320k']; - - return SafeArea( - child: Padding( - padding: const EdgeInsets.all(24), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Center( - child: Container( - width: 40, - height: 4, - decoration: BoxDecoration( - color: colorScheme.onSurfaceVariant.withValues( - alpha: 0.4, - ), - borderRadius: BorderRadius.circular(2), - ), - ), - ), - const SizedBox(height: 16), - Text( - context.l10n.selectionBatchConvertConfirmTitle, - style: Theme.of(context).textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 20), - Text( - context.l10n.trackConvertTargetFormat, - style: Theme.of(context).textTheme.titleSmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: 8), - Wrap( - spacing: 8, - children: formats.map((format) { - final isSelected = format == selectedFormat; - return ChoiceChip( - label: Text(format), - selected: isSelected, - onSelected: (selected) { - if (selected) { - setSheetState(() { - selectedFormat = format; - isLosslessTarget = isLosslessConversionTarget( - format, - ); - if (!isLosslessTarget) { - selectedBitrate = defaultBitrateForFormat( - format, - ); - } - }); - } - }, - ); - }).toList(), - ), - if (!isLosslessTarget) ...[ - const SizedBox(height: 16), - Text( - context.l10n.trackConvertBitrate, - style: Theme.of(context).textTheme.titleSmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: 8), - Wrap( - spacing: 8, - children: bitrates.map((br) { - final isSelected = br == selectedBitrate; - return ChoiceChip( - label: Text(br), - selected: isSelected, - onSelected: (selected) { - if (selected) { - setSheetState(() => selectedBitrate = br); - } - }, - ); - }).toList(), - ), - ], - if (isLosslessTarget) ...[ - const SizedBox(height: 16), - Row( - children: [ - Icon( - Icons.verified, - size: 16, - color: colorScheme.primary, - ), - const SizedBox(width: 6), - Text( - context.l10n.trackConvertLosslessHint, - style: Theme.of(context).textTheme.bodySmall - ?.copyWith(color: colorScheme.primary), - ), - ], - ), - ], - const SizedBox(height: 24), - SizedBox( - width: double.infinity, - child: FilledButton( - onPressed: () { - Navigator.pop(context); - _performBatchConversion( - allTracks: allTracks, - targetFormat: selectedFormat, - bitrate: selectedBitrate, - ); - }, - style: FilledButton.styleFrom( - padding: const EdgeInsets.symmetric(vertical: 16), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), - ), - child: Text( - context.l10n.selectionConvertCount( - _selectedIds.length, - ), - ), - ), - ), - ], - ), - ), - ); - }, - ); - }, + builder: (sheetContext) => BatchConvertSheet( + formats: formats, + title: context.l10n.selectionBatchConvertConfirmTitle, + confirmLabel: context.l10n.selectionConvertCount(_selectedIds.length), + onConvert: (format, bitrate) { + Navigator.pop(sheetContext); + _performBatchConversion( + allTracks: allTracks, + targetFormat: format, + bitrate: bitrate, + ); + }, + ), ); } diff --git a/lib/screens/home_tab.dart b/lib/screens/home_tab.dart index 31bce767..9976d2d5 100644 --- a/lib/screens/home_tab.dart +++ b/lib/screens/home_tab.dart @@ -31,6 +31,7 @@ import 'package:spotiflac_android/widgets/animation_utils.dart'; import 'package:spotiflac_android/utils/clickable_metadata.dart'; import 'package:spotiflac_android/widgets/audio_quality_badges.dart'; import 'package:spotiflac_android/widgets/cached_cover_image.dart'; +import 'package:spotiflac_android/widgets/settings_group.dart'; part 'home_tab_helpers.dart'; part 'home_tab_widgets.dart'; @@ -3560,7 +3561,7 @@ class _HomeTabState extends ConsumerState decoration: InputDecoration( hintText: _getSearchHint(), filled: true, - fillColor: colorScheme.surfaceContainerHighest, + fillColor: settingsGroupColor(context), border: OutlineInputBorder( borderRadius: BorderRadius.circular(28), borderSide: BorderSide(color: colorScheme.outlineVariant), diff --git a/lib/screens/home_tab_widgets.dart b/lib/screens/home_tab_widgets.dart index ba0518d2..d7ecc974 100644 --- a/lib/screens/home_tab_widgets.dart +++ b/lib/screens/home_tab_widgets.dart @@ -347,7 +347,7 @@ class _TrackItemWithStatus extends ConsumerWidget { vertical: 2, ), decoration: BoxDecoration( - color: colorScheme.tertiaryContainer, + color: colorScheme.primaryContainer, borderRadius: BorderRadius.circular(4), ), child: Row( @@ -356,7 +356,7 @@ class _TrackItemWithStatus extends ConsumerWidget { Icon( Icons.folder_outlined, size: 10, - color: colorScheme.onTertiaryContainer, + color: colorScheme.onPrimaryContainer, ), const SizedBox(width: 3), Text( @@ -364,7 +364,7 @@ class _TrackItemWithStatus extends ConsumerWidget { style: TextStyle( fontSize: 9, fontWeight: FontWeight.w500, - color: colorScheme.onTertiaryContainer, + color: colorScheme.onPrimaryContainer, ), ), ], diff --git a/lib/screens/library_tracks_folder_screen.dart b/lib/screens/library_tracks_folder_screen.dart index bb1a3858..3c2009d6 100644 --- a/lib/screens/library_tracks_folder_screen.dart +++ b/lib/screens/library_tracks_folder_screen.dart @@ -1234,7 +1234,7 @@ class _CollectionTrackTile extends ConsumerWidget { vertical: 2, ), decoration: BoxDecoration( - color: colorScheme.tertiaryContainer, + color: colorScheme.primaryContainer, borderRadius: BorderRadius.circular(4), ), child: Row( @@ -1243,7 +1243,7 @@ class _CollectionTrackTile extends ConsumerWidget { Icon( Icons.folder_outlined, size: 10, - color: colorScheme.onTertiaryContainer, + color: colorScheme.onPrimaryContainer, ), const SizedBox(width: 3), Text( @@ -1251,7 +1251,7 @@ class _CollectionTrackTile extends ConsumerWidget { style: TextStyle( fontSize: 9, fontWeight: FontWeight.w500, - color: colorScheme.onTertiaryContainer, + color: colorScheme.onPrimaryContainer, ), ), ], diff --git a/lib/screens/local_album_screen.dart b/lib/screens/local_album_screen.dart index ed0c5908..2ac690da 100644 --- a/lib/screens/local_album_screen.dart +++ b/lib/screens/local_album_screen.dart @@ -17,6 +17,7 @@ import 'package:spotiflac_android/services/ffmpeg_service.dart'; import 'package:spotiflac_android/services/replaygain_service.dart'; import 'package:spotiflac_android/services/local_track_redownload_service.dart'; import 'package:spotiflac_android/widgets/batch_progress_dialog.dart'; +import 'package:spotiflac_android/widgets/batch_convert_sheet.dart'; import 'package:spotiflac_android/widgets/re_enrich_field_dialog.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/providers/local_library_provider.dart'; @@ -1215,164 +1216,25 @@ class _LocalAlbumScreenState extends ConsumerState { if (formats.isEmpty) return; - String selectedFormat = formats.first; - bool isLosslessTarget = isLosslessConversionTarget(selectedFormat); - String defaultBitrateForFormat(String format) { - if (format == 'Opus') return '128k'; - if (format == 'AAC') return '256k'; - return '320k'; - } - - String selectedBitrate = isLosslessTarget - ? '320k' - : defaultBitrateForFormat(selectedFormat); - showModalBottomSheet( context: context, useRootNavigator: true, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(20)), ), - builder: (sheetContext) { - return StatefulBuilder( - builder: (context, setSheetState) { - final colorScheme = Theme.of(context).colorScheme; - final bitrates = ['128k', '192k', '256k', '320k']; - - return SafeArea( - child: Padding( - padding: const EdgeInsets.all(24), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Center( - child: Container( - width: 40, - height: 4, - decoration: BoxDecoration( - color: colorScheme.onSurfaceVariant.withValues( - alpha: 0.4, - ), - borderRadius: BorderRadius.circular(2), - ), - ), - ), - const SizedBox(height: 16), - Text( - context.l10n.selectionBatchConvertConfirmTitle, - style: Theme.of(context).textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 20), - Text( - context.l10n.trackConvertTargetFormat, - style: Theme.of(context).textTheme.titleSmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: 8), - Wrap( - spacing: 8, - children: formats.map((format) { - final isSelected = format == selectedFormat; - return ChoiceChip( - label: Text(format), - selected: isSelected, - onSelected: (selected) { - if (selected) { - setSheetState(() { - selectedFormat = format; - isLosslessTarget = isLosslessConversionTarget( - format, - ); - if (!isLosslessTarget) { - selectedBitrate = defaultBitrateForFormat( - format, - ); - } - }); - } - }, - ); - }).toList(), - ), - if (!isLosslessTarget) ...[ - const SizedBox(height: 16), - Text( - context.l10n.trackConvertBitrate, - style: Theme.of(context).textTheme.titleSmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: 8), - Wrap( - spacing: 8, - children: bitrates.map((br) { - final isSelected = br == selectedBitrate; - return ChoiceChip( - label: Text(br), - selected: isSelected, - onSelected: (selected) { - if (selected) { - setSheetState(() => selectedBitrate = br); - } - }, - ); - }).toList(), - ), - ], - if (isLosslessTarget) ...[ - const SizedBox(height: 16), - Row( - children: [ - Icon( - Icons.verified, - size: 16, - color: colorScheme.primary, - ), - const SizedBox(width: 6), - Text( - context.l10n.trackConvertLosslessHint, - style: Theme.of(context).textTheme.bodySmall - ?.copyWith(color: colorScheme.primary), - ), - ], - ), - ], - const SizedBox(height: 24), - SizedBox( - width: double.infinity, - child: FilledButton( - onPressed: () { - Navigator.pop(context); - _performBatchConversion( - allTracks: allTracks, - targetFormat: selectedFormat, - bitrate: selectedBitrate, - ); - }, - style: FilledButton.styleFrom( - padding: const EdgeInsets.symmetric(vertical: 16), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), - ), - child: Text( - context.l10n.selectionConvertCount( - _selectedIds.length, - ), - ), - ), - ), - ], - ), - ), - ); - }, - ); - }, + builder: (sheetContext) => BatchConvertSheet( + formats: formats, + title: context.l10n.selectionBatchConvertConfirmTitle, + confirmLabel: context.l10n.selectionConvertCount(_selectedIds.length), + onConvert: (format, bitrate) { + Navigator.pop(sheetContext); + _performBatchConversion( + allTracks: allTracks, + targetFormat: format, + bitrate: bitrate, + ); + }, + ), ); } diff --git a/lib/screens/main_shell.dart b/lib/screens/main_shell.dart index 805637e0..5d0caeea 100644 --- a/lib/screens/main_shell.dart +++ b/lib/screens/main_shell.dart @@ -23,6 +23,7 @@ import 'package:spotiflac_android/services/update_checker.dart'; import 'package:spotiflac_android/widgets/app_announcement_dialog.dart'; import 'package:spotiflac_android/widgets/update_dialog.dart'; import 'package:spotiflac_android/widgets/animation_utils.dart'; +import 'package:spotiflac_android/widgets/settings_group.dart'; import 'package:spotiflac_android/utils/logger.dart'; final _log = AppLogger('MainShell'); @@ -570,20 +571,24 @@ class _MainShellState extends ConsumerState ); }, ), - bottomNavigationBar: NavigationBar( - selectedIndex: _currentIndex.clamp(0, maxIndex), - onDestinationSelected: _onNavTap, - animationDuration: const Duration(milliseconds: 500), - backgroundColor: Theme.of(context).brightness == Brightness.dark - ? Color.alphaBlend( - Colors.white.withValues(alpha: 0.05), - Theme.of(context).colorScheme.surface, - ) - : Color.alphaBlend( - Colors.black.withValues(alpha: 0.03), - Theme.of(context).colorScheme.surface, - ), - destinations: destinations, + bottomNavigationBar: DecoratedBox( + position: DecorationPosition.foreground, + decoration: BoxDecoration( + border: Border( + top: BorderSide( + color: Theme.of( + context, + ).colorScheme.outlineVariant.withValues(alpha: 0.5), + ), + ), + ), + child: NavigationBar( + selectedIndex: _currentIndex.clamp(0, maxIndex), + onDestinationSelected: _onNavTap, + animationDuration: const Duration(milliseconds: 500), + backgroundColor: settingsGroupColor(context), + destinations: destinations, + ), ), ), ); diff --git a/lib/screens/playlist_screen.dart b/lib/screens/playlist_screen.dart index 0c7fc5fc..b9f6c7da 100644 --- a/lib/screens/playlist_screen.dart +++ b/lib/screens/playlist_screen.dart @@ -900,7 +900,7 @@ class _PlaylistTrackItem extends ConsumerWidget { vertical: 2, ), decoration: BoxDecoration( - color: colorScheme.tertiaryContainer, + color: colorScheme.primaryContainer, borderRadius: BorderRadius.circular(4), ), child: Row( @@ -909,7 +909,7 @@ class _PlaylistTrackItem extends ConsumerWidget { Icon( Icons.folder_outlined, size: 10, - color: colorScheme.onTertiaryContainer, + color: colorScheme.onPrimaryContainer, ), const SizedBox(width: 3), Text( @@ -917,7 +917,7 @@ class _PlaylistTrackItem extends ConsumerWidget { style: TextStyle( fontSize: 9, fontWeight: FontWeight.w500, - color: colorScheme.onTertiaryContainer, + color: colorScheme.onPrimaryContainer, ), ), ], diff --git a/lib/screens/queue_tab.dart b/lib/screens/queue_tab.dart index a57bb496..ef2fc5eb 100644 --- a/lib/screens/queue_tab.dart +++ b/lib/screens/queue_tab.dart @@ -12,6 +12,7 @@ import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/utils/app_bar_layout.dart'; import 'package:spotiflac_android/utils/audio_conversion_utils.dart'; +import 'package:spotiflac_android/widgets/settings_group.dart'; import 'package:spotiflac_android/utils/file_access.dart'; import 'package:spotiflac_android/utils/lyrics_metadata_helper.dart'; import 'package:spotiflac_android/models/download_item.dart'; @@ -31,6 +32,7 @@ import 'package:spotiflac_android/screens/favorite_artists_screen.dart'; import 'package:spotiflac_android/screens/downloaded_album_screen.dart'; import 'package:spotiflac_android/widgets/re_enrich_field_dialog.dart'; import 'package:spotiflac_android/widgets/batch_progress_dialog.dart'; +import 'package:spotiflac_android/widgets/batch_convert_sheet.dart'; import 'package:spotiflac_android/widgets/cached_cover_image.dart'; import 'package:spotiflac_android/screens/library_tracks_folder_screen.dart'; import 'package:spotiflac_android/screens/local_album_screen.dart'; @@ -121,6 +123,12 @@ class _QueueTabState extends ConsumerState { List _selectionOverlayItems = const []; double _selectionOverlayBottomPadding = 0; + /// When true, the floating selection overlays are kept hidden even though + /// selection mode is still active. Used while a modal sheet/dialog launched + /// from the selection toolbar is open, so the overlay does not reappear on + /// top of (or behind) the modal's open/close animation. + bool _suppressSelectionOverlay = false; + bool _isPlaylistSelectionMode = false; final Set _selectedPlaylistIds = {}; OverlayEntry? _playlistSelectionOverlayEntry; @@ -809,7 +817,9 @@ class _QueueTabState extends ConsumerState { required double bottomPadding, }) { if (!mounted) return; - if (!_isSelectionMode || _isPlaylistSelectionMode) { + if (_suppressSelectionOverlay || + !_isSelectionMode || + _isPlaylistSelectionMode) { _hideSelectionOverlay(); return; } @@ -857,7 +867,9 @@ class _QueueTabState extends ConsumerState { required double bottomPadding, }) { if (!mounted) return; - if (!_isPlaylistSelectionMode || _isSelectionMode) { + if (_suppressSelectionOverlay || + !_isPlaylistSelectionMode || + _isSelectionMode) { _hidePlaylistSelectionOverlay(); return; } @@ -2775,7 +2787,7 @@ class _QueueTabState extends ConsumerState { ) : null, filled: true, - fillColor: colorScheme.surfaceContainerHighest, + fillColor: settingsGroupColor(context), border: OutlineInputBorder( borderRadius: BorderRadius.circular(28), borderSide: BorderSide( @@ -4835,19 +4847,9 @@ class _QueueTabState extends ConsumerState { if (formats.isEmpty) return; - String selectedFormat = formats.first; - bool isLosslessTarget = isLosslessConversionTarget(selectedFormat); - String defaultBitrateForFormat(String format) { - if (format == 'Opus') return '128k'; - if (format == 'AAC') return '256k'; - return '320k'; - } - - String selectedBitrate = isLosslessTarget - ? '320k' - : defaultBitrateForFormat(selectedFormat); var didStartConversion = false; + _suppressSelectionOverlay = true; _hideSelectionOverlay(); _hidePlaylistSelectionOverlay(); @@ -4857,150 +4859,33 @@ class _QueueTabState extends ConsumerState { shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(20)), ), - builder: (sheetContext) { - return StatefulBuilder( - builder: (context, setSheetState) { - final colorScheme = Theme.of(context).colorScheme; - final bitrates = ['128k', '192k', '256k', '320k']; - - return SafeArea( - child: Padding( - padding: const EdgeInsets.all(24), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Center( - child: Container( - width: 40, - height: 4, - decoration: BoxDecoration( - color: colorScheme.onSurfaceVariant.withValues( - alpha: 0.4, - ), - borderRadius: BorderRadius.circular(2), - ), - ), - ), - const SizedBox(height: 16), - Text( - context.l10n.selectionBatchConvertConfirmTitle, - style: Theme.of(context).textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 20), - Text( - context.l10n.trackConvertTargetFormat, - style: Theme.of(context).textTheme.titleSmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: 8), - Wrap( - spacing: 8, - children: formats.map((format) { - final isSelected = format == selectedFormat; - return ChoiceChip( - label: Text(format), - selected: isSelected, - onSelected: (selected) { - if (selected) { - setSheetState(() { - selectedFormat = format; - isLosslessTarget = isLosslessConversionTarget( - format, - ); - if (!isLosslessTarget) { - selectedBitrate = defaultBitrateForFormat( - format, - ); - } - }); - } - }, - ); - }).toList(), - ), - if (!isLosslessTarget) ...[ - const SizedBox(height: 16), - Text( - context.l10n.trackConvertBitrate, - style: Theme.of(context).textTheme.titleSmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: 8), - Wrap( - spacing: 8, - children: bitrates.map((br) { - final isSelected = br == selectedBitrate; - return ChoiceChip( - label: Text(br), - selected: isSelected, - onSelected: (selected) { - if (selected) { - setSheetState(() => selectedBitrate = br); - } - }, - ); - }).toList(), - ), - ], - if (isLosslessTarget) ...[ - const SizedBox(height: 16), - Row( - children: [ - Icon( - Icons.verified, - size: 16, - color: colorScheme.primary, - ), - const SizedBox(width: 6), - Text( - context.l10n.trackConvertLosslessHint, - style: Theme.of(context).textTheme.bodySmall - ?.copyWith(color: colorScheme.primary), - ), - ], - ), - ], - const SizedBox(height: 24), - SizedBox( - width: double.infinity, - child: FilledButton( - onPressed: () { - didStartConversion = true; - Navigator.pop(context); - _performBatchConversion( - allItems: allItems, - targetFormat: selectedFormat, - bitrate: selectedBitrate, - ); - }, - style: FilledButton.styleFrom( - padding: const EdgeInsets.symmetric(vertical: 16), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), - ), - child: Text( - context.l10n.selectionConvertCount( - _selectedIds.length, - ), - ), - ), - ), - ], - ), - ), - ); - }, - ); - }, + builder: (sheetContext) => BatchConvertSheet( + formats: formats, + title: context.l10n.selectionBatchConvertConfirmTitle, + confirmLabel: context.l10n.selectionConvertCount(_selectedIds.length), + onConvert: (format, bitrate) { + didStartConversion = true; + Navigator.pop(sheetContext); + _performBatchConversion( + allItems: allItems, + targetFormat: format, + bitrate: bitrate, + ); + }, + ), ); - if (!mounted || didStartConversion) return; + // The showModalBottomSheet future completes when the sheet begins closing, + // not when its exit animation finishes. Wait out the exit transition + // (~200ms) before restoring the selection toolbar so it does not pop in + // front of the still-animating sheet. + await Future.delayed(const Duration(milliseconds: 260)); + if (!mounted) { + _suppressSelectionOverlay = false; + return; + } + _suppressSelectionOverlay = false; + if (didStartConversion) return; if (_isSelectionMode) { _syncSelectionOverlay( items: allItems, @@ -5399,6 +5284,7 @@ class _QueueTabState extends ConsumerState { if (selectedItems.isEmpty) return; + _suppressSelectionOverlay = true; _hideSelectionOverlay(); _hidePlaylistSelectionOverlay(); @@ -5422,8 +5308,16 @@ class _QueueTabState extends ConsumerState { ), ); - if (!mounted) return; + if (!mounted) { + _suppressSelectionOverlay = false; + return; + } if (confirmed != true) { + // Restore after the dialog's exit animation so the toolbar does not + // appear in front of the closing dialog. + await Future.delayed(const Duration(milliseconds: 220)); + _suppressSelectionOverlay = false; + if (!mounted) return; if (_isSelectionMode) { _syncSelectionOverlay( items: allItems, @@ -5432,6 +5326,7 @@ class _QueueTabState extends ConsumerState { } return; } + _suppressSelectionOverlay = false; var cancelled = false; int successCount = 0; @@ -6360,7 +6255,7 @@ class _QueueTabState extends ConsumerState { ), decoration: BoxDecoration( color: item.quality!.startsWith('24') - ? colorScheme.tertiaryContainer + ? colorScheme.primaryContainer : colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(4), ), @@ -6369,7 +6264,7 @@ class _QueueTabState extends ConsumerState { style: Theme.of(context).textTheme.labelSmall ?.copyWith( color: item.quality!.startsWith('24') - ? colorScheme.onTertiaryContainer + ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant, fontSize: 10, fontWeight: FontWeight.w500, @@ -6513,7 +6408,7 @@ class _QueueTabState extends ConsumerState { ), decoration: BoxDecoration( color: item.quality!.startsWith('24') - ? colorScheme.tertiary + ? colorScheme.primary : colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(4), ), @@ -6522,7 +6417,7 @@ class _QueueTabState extends ConsumerState { style: Theme.of(context).textTheme.labelSmall ?.copyWith( color: item.quality!.startsWith('24') - ? colorScheme.onTertiary + ? colorScheme.onPrimary : colorScheme.onSurfaceVariant, fontSize: 9, fontWeight: FontWeight.w600, diff --git a/lib/screens/queue_tab_widgets.dart b/lib/screens/queue_tab_widgets.dart index 47169ef6..d4034276 100644 --- a/lib/screens/queue_tab_widgets.dart +++ b/lib/screens/queue_tab_widgets.dart @@ -89,6 +89,8 @@ class _FilterChip extends StatelessWidget { selected: isSelected, onSelected: (_) => onTap(), showCheckmark: false, + backgroundColor: settingsGroupColor(context), + side: BorderSide(color: colorScheme.outlineVariant.withValues(alpha: 0.6)), ); } } diff --git a/lib/screens/repo_tab.dart b/lib/screens/repo_tab.dart index 8ebb4e59..fd97f0d0 100644 --- a/lib/screens/repo_tab.dart +++ b/lib/screens/repo_tab.dart @@ -172,7 +172,7 @@ class _RepoTabState extends ConsumerState { ), ), filled: true, - fillColor: colorScheme.surfaceContainerHighest, + fillColor: settingsGroupColor(context), contentPadding: const EdgeInsets.symmetric( horizontal: 20, vertical: 16, @@ -651,6 +651,7 @@ class _CategoryChip extends StatelessWidget { @override Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; return FilterChip( label: Row( mainAxisSize: MainAxisSize.min, @@ -659,6 +660,8 @@ class _CategoryChip extends StatelessWidget { selected: isSelected, onSelected: (_) => onTap(), showCheckmark: false, + backgroundColor: settingsGroupColor(context), + side: BorderSide(color: colorScheme.outlineVariant.withValues(alpha: 0.6)), ); } } diff --git a/lib/screens/settings/files_settings_page.dart b/lib/screens/settings/files_settings_page.dart index 7ee9de43..84fa6111 100644 --- a/lib/screens/settings/files_settings_page.dart +++ b/lib/screens/settings/files_settings_page.dart @@ -708,53 +708,14 @@ class _FilesSettingsPageState extends ConsumerState { String? title, String? description, }) { - final controller = TextEditingController(text: current); final colorScheme = Theme.of(context).colorScheme; + final save = + onSave ?? ref.read(settingsProvider.notifier).setFilenameFormat; - 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), - ); - } - + // The controller is owned by a StatefulWidget so it is disposed in its + // State.dispose() (after the subtree is removed), instead of in + // whenComplete which fires while the closing/keyboard-hide animations can + // still rebuild the TextField and touch a disposed controller. showModalBottomSheet( context: context, isScrollControlled: true, @@ -763,178 +724,13 @@ class _FilesSettingsPageState extends ConsumerState { 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( - '{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) { - 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), - ], - ), - ), - ), - ), - ), + builder: (context) => _FilenameFormatEditorSheet( + initialText: current, + onSave: save, + title: title, + description: description, ), - ).whenComplete(controller.dispose); + ); } void _showAlbumFolderStructurePicker( @@ -1140,3 +936,249 @@ class _FolderOption extends StatelessWidget { ); } } + + +/// Bottom sheet body for editing a filename format. Owns its +/// [TextEditingController] and disposes it in [dispose], which runs only after +/// the sheet's subtree has been removed from the tree. This avoids the +/// "TextEditingController used after being disposed" crash that happens when +/// the controller is torn down in `whenComplete` while the closing and +/// keyboard-hide animations are still rebuilding the field. +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), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/screens/track_metadata_edit_sheet.dart b/lib/screens/track_metadata_edit_sheet.dart index 53f893c9..b9840864 100644 --- a/lib/screens/track_metadata_edit_sheet.dart +++ b/lib/screens/track_metadata_edit_sheet.dart @@ -1263,7 +1263,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { ), ), Padding( - padding: const EdgeInsets.symmetric(horizontal: 24), + padding: const EdgeInsets.fromLTRB(20, 4, 20, 12), child: Row( children: [ Expanded( @@ -1275,132 +1275,144 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { ), ), if (_saving) - const SizedBox( - width: 24, - height: 24, - child: CircularProgressIndicator(strokeWidth: 2), + const Padding( + padding: EdgeInsets.symmetric(horizontal: 12), + child: SizedBox( + width: 22, + height: 22, + child: CircularProgressIndicator(strokeWidth: 2), + ), ) else - FilledButton( + FilledButton.icon( onPressed: _save, - child: Text(context.l10n.dialogSave), + icon: const Icon(Icons.check, size: 18), + label: Text(context.l10n.dialogSave), + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 18, + vertical: 12, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + ), ), ], ), ), - const SizedBox(height: 12), + Divider( + height: 1, + color: cs.outlineVariant.withValues(alpha: 0.5), + ), Expanded( child: ListView( controller: scrollController, - padding: const EdgeInsets.symmetric(horizontal: 24), + padding: const EdgeInsets.fromLTRB(20, 6, 20, 24), children: [ - const SizedBox(height: 6), _buildCoverEditor(cs), _buildAutoFillSection(cs), - _field(context.l10n.editMetadataFieldTitle, _titleCtrl), - _field(context.l10n.editMetadataFieldArtist, _artistCtrl), - _field(context.l10n.editMetadataFieldAlbum, _albumCtrl), - _field( - context.l10n.editMetadataFieldAlbumArtist, - _albumArtistCtrl, - ), - _field( - context.l10n.editMetadataFieldDate, - _dateCtrl, - hint: context.l10n.editMetadataFieldDateHint, - ), - Row( + _sectionCard( + icon: Icons.info_outline, + title: context.l10n.trackMetadata, children: [ - Expanded( - child: _field( - context.l10n.editMetadataFieldTrackNum, - _trackNumCtrl, - keyboard: TextInputType.number, - ), + _field(context.l10n.editMetadataFieldTitle, _titleCtrl), + _field( + context.l10n.editMetadataFieldArtist, + _artistCtrl, ), - const SizedBox(width: 12), - Expanded( - child: _field( - context.l10n.editMetadataFieldTrackTotal, - _trackTotalCtrl, - keyboard: TextInputType.number, - ), + _field(context.l10n.editMetadataFieldAlbum, _albumCtrl), + _field( + context.l10n.editMetadataFieldAlbumArtist, + _albumArtistCtrl, + ), + _field( + context.l10n.editMetadataFieldDate, + _dateCtrl, + hint: context.l10n.editMetadataFieldDateHint, + ), + Row( + children: [ + Expanded( + child: _field( + context.l10n.editMetadataFieldTrackNum, + _trackNumCtrl, + keyboard: TextInputType.number, + ), + ), + const SizedBox(width: 12), + Expanded( + child: _field( + context.l10n.editMetadataFieldTrackTotal, + _trackTotalCtrl, + keyboard: TextInputType.number, + ), + ), + ], + ), + Row( + children: [ + Expanded( + child: _field( + context.l10n.editMetadataFieldDiscNum, + _discNumCtrl, + keyboard: TextInputType.number, + ), + ), + const SizedBox(width: 12), + Expanded( + child: _field( + context.l10n.editMetadataFieldDiscTotal, + _discTotalCtrl, + keyboard: TextInputType.number, + ), + ), + ], + ), + _field(context.l10n.editMetadataFieldGenre, _genreCtrl), + _field(context.l10n.editMetadataFieldIsrc, _isrcCtrl), + ], + ), + _sectionCard( + icon: Icons.lyrics_outlined, + title: context.l10n.trackLyrics, + children: [ + _field( + context.l10n.trackLyrics, + _lyricsCtrl, + maxLines: 8, + keyboard: TextInputType.multiline, ), ], ), - const SizedBox(height: 12), - Row( + _sectionCard( + icon: Icons.tune, + title: context.l10n.editMetadataAdvanced, + onHeaderTap: () => + setState(() => _showAdvanced = !_showAdvanced), + expanded: _showAdvanced, children: [ - Expanded( - child: _field( - context.l10n.editMetadataFieldDiscNum, - _discNumCtrl, - keyboard: TextInputType.number, + if (_showAdvanced) ...[ + _field( + context.l10n.editMetadataFieldLabel, + _labelCtrl, ), - ), - const SizedBox(width: 12), - Expanded( - child: _field( - context.l10n.editMetadataFieldDiscTotal, - _discTotalCtrl, - keyboard: TextInputType.number, + _field( + context.l10n.editMetadataFieldCopyright, + _copyrightCtrl, ), - ), + _field( + context.l10n.editMetadataFieldComposer, + _composerCtrl, + ), + _field( + context.l10n.editMetadataFieldComment, + _commentCtrl, + maxLines: 3, + ), + ], ], ), - _field(context.l10n.editMetadataFieldGenre, _genreCtrl), - _field(context.l10n.editMetadataFieldIsrc, _isrcCtrl), - _field( - context.l10n.trackLyrics, - _lyricsCtrl, - maxLines: 8, - keyboard: TextInputType.multiline, - ), - Padding( - padding: const EdgeInsets.only(top: 8, bottom: 4), - child: InkWell( - onTap: () => - setState(() => _showAdvanced = !_showAdvanced), - borderRadius: BorderRadius.circular(8), - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 8), - child: Row( - children: [ - Icon( - _showAdvanced - ? Icons.expand_less - : Icons.expand_more, - size: 20, - color: cs.onSurfaceVariant, - ), - const SizedBox(width: 8), - Text( - context.l10n.editMetadataAdvanced, - style: Theme.of(context).textTheme.labelLarge - ?.copyWith(color: cs.onSurfaceVariant), - ), - ], - ), - ), - ), - ), - if (_showAdvanced) ...[ - _field(context.l10n.editMetadataFieldLabel, _labelCtrl), - _field( - context.l10n.editMetadataFieldCopyright, - _copyrightCtrl, - ), - _field( - context.l10n.editMetadataFieldComposer, - _composerCtrl, - ), - _field( - context.l10n.editMetadataFieldComment, - _commentCtrl, - maxLines: 3, - ), - ], - const SizedBox(height: 24), ], ), ), @@ -1411,149 +1423,104 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { } Widget _buildAutoFillSection(ColorScheme cs) { - return Padding( - padding: const EdgeInsets.only(bottom: 12), - child: Container( - decoration: BoxDecoration( - color: cs.surfaceContainerHighest.withValues(alpha: 0.5), - borderRadius: BorderRadius.circular(12), - border: Border.all(color: cs.outlineVariant.withValues(alpha: 0.5)), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - InkWell( - onTap: () => setState(() => _showAutoFill = !_showAutoFill), - borderRadius: BorderRadius.circular(12), - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 10, - ), - child: Row( - children: [ - Icon(Icons.travel_explore, size: 20, color: cs.primary), - const SizedBox(width: 8), - Expanded( - child: Text( - context.l10n.editMetadataAutoFill, - style: Theme.of(context).textTheme.labelLarge?.copyWith( - color: cs.onSurface, - fontWeight: FontWeight.w600, - ), - ), - ), - Icon( - _showAutoFill ? Icons.expand_less : Icons.expand_more, - size: 20, - color: cs.onSurfaceVariant, - ), - ], - ), + return _sectionCard( + icon: Icons.travel_explore, + title: context.l10n.editMetadataAutoFill, + onHeaderTap: () => setState(() => _showAutoFill = !_showAutoFill), + expanded: _showAutoFill, + children: [ + if (_showAutoFill) ...[ + Text( + context.l10n.editMetadataAutoFillDesc, + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: cs.onSurfaceVariant), + ), + const SizedBox(height: 12), + Row( + children: [ + _quickSelectButton( + label: context.l10n.editMetadataSelectAll, + onTap: _selectAllFields, + cs: cs, ), - ), - if (_showAutoFill) ...[ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 12), - child: Text( - context.l10n.editMetadataAutoFillDesc, - style: Theme.of( - context, - ).textTheme.bodySmall?.copyWith(color: cs.onSurfaceVariant), - ), + const SizedBox(width: 8), + _quickSelectButton( + label: context.l10n.editMetadataSelectEmpty, + onTap: _selectEmptyFields, + cs: cs, ), - const SizedBox(height: 8), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 12), - child: Row( - children: [ - _quickSelectButton( - label: context.l10n.editMetadataSelectAll, - onTap: _selectAllFields, - cs: cs, - ), - const SizedBox(width: 8), - _quickSelectButton( - label: context.l10n.editMetadataSelectEmpty, - onTap: _selectEmptyFields, - cs: cs, - ), - const SizedBox(width: 8), - _quickSelectButton( - label: context.l10n.editMetadataSelectNone, - onTap: _selectNoFields, - cs: cs, - ), - ], - ), - ), - const SizedBox(height: 8), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 12), - child: Wrap( - spacing: 6, - runSpacing: 4, - children: _fieldDefs.keys.map((key) { - final selected = _autoFillFields.contains(key); - return FilterChip( - label: Text(_fieldLabel(key)), - selected: selected, - onSelected: _fetching - ? null - : (val) { - setState(() { - if (val) { - _autoFillFields.add(key); - } else { - _autoFillFields.remove(key); - } - }); - }, - selectedColor: cs.primaryContainer, - checkmarkColor: cs.onPrimaryContainer, - labelStyle: Theme.of(context).textTheme.labelSmall - ?.copyWith( - color: selected - ? cs.onPrimaryContainer - : cs.onSurfaceVariant, - ), - visualDensity: VisualDensity.compact, - materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, - ); - }).toList(), - ), - ), - const SizedBox(height: 10), - Padding( - padding: const EdgeInsets.only(left: 12, right: 12, bottom: 12), - child: SizedBox( - width: double.infinity, - child: FilledButton.icon( - onPressed: (_fetching || _saving || _autoFillFields.isEmpty) - ? null - : _fetchAndFill, - icon: _fetching - ? const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator( - strokeWidth: 2, - color: Colors.white, - ), - ) - : const Icon(Icons.auto_fix_high), - label: Text( - _fetching - ? context.l10n.editMetadataAutoFillSearching - : context.l10n.editMetadataAutoFillFetch, - ), - ), - ), + const SizedBox(width: 8), + _quickSelectButton( + label: context.l10n.editMetadataSelectNone, + onTap: _selectNoFields, + cs: cs, ), ], - ], - ), - ), + ), + const SizedBox(height: 12), + Wrap( + spacing: 6, + runSpacing: 4, + children: _fieldDefs.keys.map((key) { + final selected = _autoFillFields.contains(key); + return FilterChip( + label: Text(_fieldLabel(key)), + selected: selected, + onSelected: _fetching + ? null + : (val) { + setState(() { + if (val) { + _autoFillFields.add(key); + } else { + _autoFillFields.remove(key); + } + }); + }, + selectedColor: cs.primaryContainer, + checkmarkColor: cs.onPrimaryContainer, + labelStyle: Theme.of(context).textTheme.labelSmall?.copyWith( + color: selected ? cs.onPrimaryContainer : cs.onSurfaceVariant, + ), + visualDensity: VisualDensity.compact, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + ); + }).toList(), + ), + const SizedBox(height: 14), + SizedBox( + width: double.infinity, + child: FilledButton.icon( + onPressed: (_fetching || _saving || _autoFillFields.isEmpty) + ? null + : _fetchAndFill, + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + icon: _fetching + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : const Icon(Icons.auto_fix_high), + label: Text( + _fetching + ? context.l10n.editMetadataAutoFillSearching + : context.l10n.editMetadataAutoFillFetch, + ), + ), + ), + const SizedBox(height: 8), + ], + ], ); } @@ -1566,7 +1533,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { onTap: _fetching ? null : onTap, borderRadius: BorderRadius.circular(16), child: Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), decoration: BoxDecoration( borderRadius: BorderRadius.circular(16), border: Border.all(color: cs.outline.withValues(alpha: 0.5)), @@ -1584,103 +1551,97 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { Widget _buildCoverEditor(ColorScheme cs) { final hasSelectedCover = _hasValue(_selectedCoverPath); final hasCurrentCover = _hasValue(_currentCoverPath); - return Padding( - padding: const EdgeInsets.only(bottom: 12), - child: Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: cs.surfaceContainerHighest.withValues(alpha: 0.5), - borderRadius: BorderRadius.circular(12), - border: Border.all(color: cs.outlineVariant.withValues(alpha: 0.5)), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - context.l10n.editMetadataFieldCover, + return _sectionCard( + icon: Icons.image_outlined, + title: context.l10n.editMetadataFieldCover, + children: [ + if (_loadingCurrentCover) + const Padding( + padding: EdgeInsets.only(bottom: 8), + child: LinearProgressIndicator(minHeight: 2), + ) + else if (!hasCurrentCover && !hasSelectedCover) + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Text( + context.l10n.trackCoverNoEmbeddedArt, style: Theme.of( context, - ).textTheme.labelLarge?.copyWith(color: cs.onSurface), + ).textTheme.bodySmall?.copyWith(color: cs.onSurfaceVariant), ), - const SizedBox(height: 6), - if (_loadingCurrentCover) - const LinearProgressIndicator(minHeight: 2) - else if (!hasCurrentCover) - Text( - context.l10n.trackCoverNoEmbeddedArt, - style: Theme.of( - context, - ).textTheme.bodySmall?.copyWith(color: cs.onSurfaceVariant), - ), - const SizedBox(height: 8), - Row( - children: [ - Expanded( - child: OutlinedButton.icon( - onPressed: _saving ? null : _pickCoverImage, - icon: const Icon(Icons.image_outlined), - label: Text( - hasSelectedCover - ? context.l10n.trackCoverReplace - : context.l10n.trackCoverPick, - ), + ), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: _saving ? null : _pickCoverImage, + icon: const Icon(Icons.image_outlined), + label: Text( + hasSelectedCover + ? context.l10n.trackCoverReplace + : context.l10n.trackCoverPick, + ), + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), ), ), - if (hasSelectedCover) ...[ - const SizedBox(width: 8), - IconButton( - tooltip: context.l10n.trackCoverClearSelected, - onPressed: _saving - ? null - : () async { - await _cleanupSelectedCoverTemp(); - if (!mounted) return; - setState(() {}); - }, - icon: const Icon(Icons.close), - ), - ], - ], - ), - if (hasCurrentCover || hasSelectedCover) ...[ - const SizedBox(height: 12), - Row( - children: [ - if (hasCurrentCover) - Expanded( - child: _buildCoverPreviewTile( - cs: cs, - path: _currentCoverPath!, - label: context.l10n.trackCoverCurrent, - ), - ), - if (hasCurrentCover && hasSelectedCover) - const SizedBox(width: 12), - if (hasSelectedCover) - Expanded( - child: _buildCoverPreviewTile( - cs: cs, - path: _selectedCoverPath!, - label: - _selectedCoverName ?? - context.l10n.trackCoverSelected, - ), - ), - ], ), - if (hasSelectedCover) ...[ - const SizedBox(height: 8), - Text( - context.l10n.trackCoverReplaceNotice, - style: Theme.of( - context, - ).textTheme.bodySmall?.copyWith(color: cs.onSurfaceVariant), - ), - ], + ), + if (hasSelectedCover) ...[ + const SizedBox(width: 8), + IconButton( + tooltip: context.l10n.trackCoverClearSelected, + onPressed: _saving + ? null + : () async { + await _cleanupSelectedCoverTemp(); + if (!mounted) return; + setState(() {}); + }, + icon: const Icon(Icons.close), + ), ], ], ), - ), + if (hasCurrentCover || hasSelectedCover) ...[ + const SizedBox(height: 12), + Row( + children: [ + if (hasCurrentCover) + Expanded( + child: _buildCoverPreviewTile( + cs: cs, + path: _currentCoverPath!, + label: context.l10n.trackCoverCurrent, + ), + ), + if (hasCurrentCover && hasSelectedCover) + const SizedBox(width: 12), + if (hasSelectedCover) + Expanded( + child: _buildCoverPreviewTile( + cs: cs, + path: _selectedCoverPath!, + label: + _selectedCoverName ?? context.l10n.trackCoverSelected, + ), + ), + ], + ), + if (hasSelectedCover) ...[ + const SizedBox(height: 8), + Text( + context.l10n.trackCoverReplaceNotice, + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: cs.onSurfaceVariant), + ), + ], + ], + const SizedBox(height: 8), + ], ); } @@ -1738,6 +1699,16 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { ); } + /// Fill for the modern input fields. Sits one elevation step apart from the + /// section card so each field reads as a distinct, recessed surface in both + /// light and dark (including AMOLED) themes. + Color _fieldFill(ColorScheme cs) { + final isDark = Theme.of(context).brightness == Brightness.dark; + return isDark + ? Color.alphaBlend(Colors.white.withValues(alpha: 0.05), cs.surface) + : cs.surface; + } + Widget _field( String label, TextEditingController controller, { @@ -1746,36 +1717,147 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { int maxLines = 1, }) { final cs = widget.colorScheme; + final radius = BorderRadius.circular(14); + return Padding( + padding: const EdgeInsets.only(bottom: 14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(left: 4, bottom: 6), + child: Text( + label, + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: cs.onSurfaceVariant, + fontWeight: FontWeight.w600, + letterSpacing: 0.1, + ), + ), + ), + TextField( + controller: controller, + keyboardType: keyboard, + maxLines: maxLines, + cursorColor: cs.primary, + style: Theme.of(context).textTheme.bodyLarge, + decoration: InputDecoration( + hintText: hint, + filled: true, + fillColor: _fieldFill(cs), + isDense: true, + // Borderless by default; definition comes from the fill contrast. + // A soft primary ring appears only on focus for a clean look. + border: OutlineInputBorder( + borderRadius: radius, + borderSide: BorderSide.none, + ), + enabledBorder: OutlineInputBorder( + borderRadius: radius, + borderSide: BorderSide.none, + ), + focusedBorder: OutlineInputBorder( + borderRadius: radius, + borderSide: BorderSide(color: cs.primary, width: 1.5), + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 15, + ), + ), + ), + ], + ), + ); + } + + /// Shared shape for the edit sections, mirroring the bounded cards used on the + /// track metadata screen (rounded with a subtle outline). + RoundedRectangleBorder _sectionCardShape(ColorScheme cs) { + return RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + side: BorderSide(color: cs.outlineVariant.withValues(alpha: 0.5)), + ); + } + + /// A titled section card matching the track metadata screen layout. When + /// [onHeaderTap] is provided the header becomes a full-width tappable row so + /// the ink ripple follows the card's rounded shape (clipped to the card), + /// and a chevron is rendered automatically based on [expanded]. + Widget _sectionCard({ + required IconData icon, + required String title, + required List children, + VoidCallback? onHeaderTap, + bool expanded = true, + }) { + final cs = widget.colorScheme; + final collapsible = onHeaderTap != null; + + final headerRow = Row( + children: [ + Icon(icon, size: 20, color: cs.primary), + const SizedBox(width: 10), + Expanded( + child: Text( + title, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + color: cs.onSurface, + ), + ), + ), + if (collapsible) + AnimatedRotation( + turns: expanded ? 0.5 : 0, + duration: const Duration(milliseconds: 200), + curve: Curves.easeOutCubic, + child: Icon( + Icons.expand_more, + size: 22, + color: cs.onSurfaceVariant, + ), + ), + ], + ); + + final Widget header = collapsible + ? InkWell( + onTap: onHeaderTap, + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 16), + child: headerRow, + ), + ) + : Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), + child: headerRow, + ); + return Padding( padding: const EdgeInsets.only(bottom: 12), - child: TextField( - controller: controller, - keyboardType: keyboard, - maxLines: maxLines, - decoration: InputDecoration( - labelText: label, - hintText: hint, - filled: true, - fillColor: cs.surfaceContainerHighest.withValues(alpha: 0.5), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide( - color: cs.outlineVariant.withValues(alpha: 0.5), - ), - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide( - color: cs.outlineVariant.withValues(alpha: 0.5), - ), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide(color: cs.primary, width: 2), - ), - contentPadding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 14, + child: Card( + elevation: 0, + margin: EdgeInsets.zero, + color: settingsGroupColor(context), + shape: _sectionCardShape(cs), + clipBehavior: Clip.antiAlias, + child: AnimatedSize( + duration: const Duration(milliseconds: 220), + curve: Curves.easeOutCubic, + alignment: Alignment.topCenter, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + header, + if (children.isNotEmpty) + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: children, + ), + ), + ], ), ), ), diff --git a/lib/screens/track_metadata_screen.dart b/lib/screens/track_metadata_screen.dart index 992612bc..c270137a 100644 --- a/lib/screens/track_metadata_screen.dart +++ b/lib/screens/track_metadata_screen.dart @@ -1468,6 +1468,17 @@ class _TrackMetadataScreenState extends ConsumerState { ); } + /// Shared shape for the main section cards: rounded with a subtle outline so + /// each section (Metadata, File Info, Lyrics, Audio Analysis) is bounded. + RoundedRectangleBorder _sectionCardShape(ColorScheme colorScheme) { + return RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + side: BorderSide( + color: colorScheme.outlineVariant.withValues(alpha: 0.5), + ), + ); + } + Widget _buildMetadataCard( BuildContext context, ColorScheme colorScheme, @@ -1475,8 +1486,8 @@ class _TrackMetadataScreenState extends ConsumerState { ) { return Card( elevation: 0, - color: colorScheme.surfaceContainerLow, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + color: settingsGroupColor(context), + shape: _sectionCardShape(colorScheme), child: Padding( padding: const EdgeInsets.all(20), child: Column( @@ -1789,8 +1800,8 @@ class _TrackMetadataScreenState extends ConsumerState { return Card( elevation: 0, - color: colorScheme.surfaceContainerLow, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + color: settingsGroupColor(context), + shape: _sectionCardShape(colorScheme), child: Padding( padding: const EdgeInsets.all(20), child: Column( @@ -1997,8 +2008,8 @@ class _TrackMetadataScreenState extends ConsumerState { Widget _buildLyricsCard(BuildContext context, ColorScheme colorScheme) { return Card( elevation: 0, - color: colorScheme.surfaceContainerLow, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + color: settingsGroupColor(context), + shape: _sectionCardShape(colorScheme), child: Padding( padding: const EdgeInsets.all(20), child: Column( @@ -3451,6 +3462,7 @@ class _TrackMetadataScreenState extends ConsumerState { height: size, fit: BoxFit.cover, memCacheWidth: cacheWidth, + memCacheHeight: cacheWidth, errorWidget: (_, _, _) => placeholder(), ); } @@ -3728,9 +3740,84 @@ class _TrackMetadataScreenState extends ConsumerState { final colorScheme = Theme.of(context).colorScheme; final bitrates = ['128k', '192k', '256k', '320k']; + Widget card({required Widget child}) { + return Container( + width: double.infinity, + margin: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: settingsGroupColor(context), + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: colorScheme.outlineVariant.withValues(alpha: 0.5), + ), + ), + child: child, + ); + } + + Widget sectionLabel(String text) { + return Padding( + padding: const EdgeInsets.only(left: 2, bottom: 12), + child: Text( + text, + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w600, + letterSpacing: 0.1, + ), + ), + ); + } + + Widget choice({ + required String label, + required bool selected, + required VoidCallback onTap, + }) { + return Material( + color: selected + ? colorScheme.primaryContainer + : colorScheme.surface, + borderRadius: BorderRadius.circular(14), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(14), + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + padding: const EdgeInsets.symmetric( + horizontal: 18, + vertical: 11, + ), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(14), + border: Border.all( + color: selected + ? Colors.transparent + : colorScheme.outlineVariant.withValues( + alpha: 0.6, + ), + ), + ), + child: Text( + label, + style: Theme.of(context).textTheme.labelLarge?.copyWith( + color: selected + ? colorScheme.onPrimaryContainer + : colorScheme.onSurface, + fontWeight: selected + ? FontWeight.w600 + : FontWeight.w500, + ), + ), + ), + ), + ); + } + return SafeArea( - child: Padding( - padding: const EdgeInsets.all(24), + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(20, 12, 20, 20), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, @@ -3747,97 +3834,112 @@ class _TrackMetadataScreenState extends ConsumerState { ), ), ), - const SizedBox(height: 16), + const SizedBox(height: 18), Text( context.l10n.trackConvertTitle, style: Theme.of(context).textTheme.titleLarge?.copyWith( fontWeight: FontWeight.bold, ), ), - const SizedBox(height: 20), - + const SizedBox(height: 4), Text( - context.l10n.trackConvertTargetFormat, - style: Theme.of(context).textTheme.titleSmall?.copyWith( + currentFormat, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( color: colorScheme.onSurfaceVariant, ), ), - const SizedBox(height: 8), - Wrap( - spacing: 8, - children: formats.map((format) { - final isSelected = format == selectedFormat; - return ChoiceChip( - label: Text(format), - selected: isSelected, - onSelected: (selected) { - if (selected) { - setSheetState(() { - selectedFormat = format; - isLosslessTarget = isLosslessConversionTarget( - format, - ); - if (!isLosslessTarget) { - selectedBitrate = defaultBitrateForFormat( - format, - ); - } - }); - } - }, - ); - }).toList(), - ), + const SizedBox(height: 20), - if (!isLosslessTarget) ...[ - const SizedBox(height: 16), - Text( - context.l10n.trackConvertBitrate, - style: Theme.of(context).textTheme.titleSmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: 8), - Wrap( - spacing: 8, - children: bitrates.map((br) { - final isSelected = br == selectedBitrate; - return ChoiceChip( - label: Text(br), - selected: isSelected, - onSelected: (selected) { - if (selected) { - setSheetState(() => selectedBitrate = br); - } - }, - ); - }).toList(), - ), - ], - - if (isLosslessTarget && isLosslessSource) ...[ - const SizedBox(height: 16), - Row( + card( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Icon( - Icons.verified, - size: 16, - color: colorScheme.primary, - ), - const SizedBox(width: 6), - Text( - context.l10n.trackConvertLosslessHint, - style: Theme.of(context).textTheme.bodySmall - ?.copyWith(color: colorScheme.primary), + sectionLabel(context.l10n.trackConvertTargetFormat), + Wrap( + spacing: 8, + runSpacing: 8, + children: formats.map((format) { + return choice( + label: format, + selected: format == selectedFormat, + onTap: () { + setSheetState(() { + selectedFormat = format; + isLosslessTarget = + isLosslessConversionTarget(format); + if (!isLosslessTarget) { + selectedBitrate = + defaultBitrateForFormat(format); + } + }); + }, + ); + }).toList(), ), ], ), - ], - const SizedBox(height: 24), + ), + if (!isLosslessTarget) + card( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + sectionLabel(context.l10n.trackConvertBitrate), + Wrap( + spacing: 8, + runSpacing: 8, + children: bitrates.map((br) { + return choice( + label: br, + selected: br == selectedBitrate, + onTap: () => setSheetState( + () => selectedBitrate = br, + ), + ); + }).toList(), + ), + ], + ), + ), + + if (isLosslessTarget && isLosslessSource) + Container( + width: double.infinity, + margin: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.symmetric( + horizontal: 14, + vertical: 12, + ), + decoration: BoxDecoration( + color: colorScheme.primaryContainer.withValues( + alpha: 0.4, + ), + borderRadius: BorderRadius.circular(14), + ), + child: Row( + children: [ + Icon( + Icons.verified, + size: 18, + color: colorScheme.primary, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + context.l10n.trackConvertLosslessHint, + style: Theme.of(context).textTheme.bodySmall + ?.copyWith(color: colorScheme.primary), + ), + ), + ], + ), + ), + + const SizedBox(height: 8), SizedBox( width: double.infinity, - child: FilledButton( + child: FilledButton.icon( onPressed: () { Navigator.pop(context); _confirmAndConvert( @@ -3847,20 +3949,20 @@ class _TrackMetadataScreenState extends ConsumerState { bitrate: selectedBitrate, ); }, + icon: const Icon(Icons.swap_horiz), style: FilledButton.styleFrom( padding: const EdgeInsets.symmetric(vertical: 16), shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(14), ), ), - child: Text( + label: Text( isLosslessTarget - ? '$currentFormat -> $selectedFormat (Lossless)' - : '$currentFormat -> $selectedFormat @ $selectedBitrate', + ? '$currentFormat → $selectedFormat (Lossless)' + : '$currentFormat → $selectedFormat @ $selectedBitrate', ), ), ), - const SizedBox(height: 8), ], ), ), diff --git a/lib/widgets/animation_utils.dart b/lib/widgets/animation_utils.dart index cbe1e92b..ca8cd635 100644 --- a/lib/widgets/animation_utils.dart +++ b/lib/widgets/animation_utils.dart @@ -474,9 +474,12 @@ class GridSkeleton extends StatelessWidget { } } -/// Artist screen skeleton – mimics the artist page content below the header: -/// an optional "Popular" section (rank + cover 48x48 + title + trailing) then -/// a horizontal-scroll album section. +/// Artist screen skeleton – shown *below* the SliverAppBar header while the +/// discography loads. Renders a cover placeholder (only when the header image +/// isn't available yet), the "Popular" section (rank + cover 48x48 + title + +/// badge + trailing), and the horizontal album sections. The artist name and +/// listeners are intentionally omitted here since the header already shows them +/// overlaid on the cover. class ArtistScreenSkeleton extends StatelessWidget { final int popularCount; final int albumCount; @@ -500,25 +503,16 @@ class ArtistScreenSkeleton extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (showCoverHeader) ...[ + if (showCoverHeader) SkeletonBox( width: screenWidth, height: screenWidth * 0.75, borderRadius: 0, ), - ], - Padding( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 4), - child: SkeletonBox(width: 180, height: 24, borderRadius: 4), - ), - Padding( - padding: const EdgeInsets.fromLTRB(16, 4, 16, 16), - child: SkeletonBox(width: 120, height: 14, borderRadius: 4), - ), if (showPopularSection) ...[ - Padding( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 12), - child: SkeletonBox(width: 90, height: 20, borderRadius: 4), + const Padding( + padding: EdgeInsets.fromLTRB(16, 24, 16, 12), + child: SkeletonBox(width: 110, height: 22, borderRadius: 4), ), ...List.generate(popularCount, (index) { return Padding( @@ -528,7 +522,7 @@ class ArtistScreenSkeleton extends StatelessWidget { ), child: Row( children: [ - SizedBox( + const SizedBox( width: 24, child: Center( child: SkeletonBox( @@ -546,33 +540,31 @@ class ArtistScreenSkeleton extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ SkeletonBox( - width: 110 + (index % 4) * 30, + width: 120 + (index % 4) * 30, height: 14, borderRadius: 4, ), - const SizedBox(height: 6), - SkeletonBox( - width: 70 + (index % 3) * 15, - height: 11, + const SizedBox(height: 8), + // Mimics the small "In Library" badge pill. + const SkeletonBox( + width: 64, + height: 14, borderRadius: 4, ), ], ), ), - const SkeletonBox( - width: 20, - height: 20, - borderRadius: 10, - ), + const SizedBox(width: 8), + const SkeletonBox(width: 18, height: 18, borderRadius: 4), ], ), ); }), const SizedBox(height: 16), ], - Padding( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 12), - child: SkeletonBox(width: 80, height: 20, borderRadius: 4), + const Padding( + padding: EdgeInsets.fromLTRB(16, 8, 16, 12), + child: SkeletonBox(width: 120, height: 22, borderRadius: 4), ), SizedBox( height: 190, @@ -606,6 +598,7 @@ class ArtistScreenSkeleton extends StatelessWidget { }, ), ), + const SizedBox(height: 24), ], ), ), diff --git a/lib/widgets/audio_analysis_widget.dart b/lib/widgets/audio_analysis_widget.dart index 0b216acc..307b7ffa 100644 --- a/lib/widgets/audio_analysis_widget.dart +++ b/lib/widgets/audio_analysis_widget.dart @@ -10,6 +10,7 @@ import 'package:ffmpeg_kit_flutter_new_full/level.dart'; import 'package:ffmpeg_kit_flutter_new_full/return_code.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:spotiflac_android/widgets/settings_group.dart'; import 'package:path_provider/path_provider.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; @@ -887,7 +888,12 @@ class _AudioAnalysisCardState extends State { if (_analyzing) { final isRescan = _data != null || _spectrogramImage != null; return Card( - color: cs.surfaceContainerLow, + elevation: 0, + color: settingsGroupColor(context), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + side: BorderSide(color: cs.outlineVariant.withValues(alpha: 0.5)), + ), child: Padding( padding: const EdgeInsets.all(24), child: Center( @@ -945,10 +951,15 @@ class _AudioAnalysisCardState extends State { if (_data == null) { return Card( - color: cs.surfaceContainerLow, + elevation: 0, + color: settingsGroupColor(context), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + side: BorderSide(color: cs.outlineVariant.withValues(alpha: 0.5)), + ), child: InkWell( onTap: _analyze, - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(20), child: Padding( padding: const EdgeInsets.all(20), child: Row( @@ -1000,6 +1011,7 @@ class _AudioAnalysisCardState extends State { image: _spectrogramImage!, sampleRate: data.sampleRate, maxFreq: data.spectrum?.maxFreq ?? data.sampleRate / 2, + duration: data.spectrum?.duration ?? data.duration, ), ], ], @@ -1272,7 +1284,12 @@ class _AudioInfoCard extends StatelessWidget { final nyquist = data.sampleRate / 2; return Card( - color: cs.surfaceContainerLow, + elevation: 0, + color: settingsGroupColor(context), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + side: BorderSide(color: cs.outlineVariant.withValues(alpha: 0.5)), + ), child: Padding( padding: const EdgeInsets.all(16), child: Column( @@ -1583,16 +1600,19 @@ class _SpectrogramView extends StatelessWidget { final ui.Image image; final int sampleRate; final double maxFreq; + final double duration; const _SpectrogramView({ required this.image, required this.sampleRate, required this.maxFreq, + required this.duration, }); @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; + const labelColor = Color(0xFFB5B5B5); return Card( color: Colors.black, @@ -1600,13 +1620,60 @@ class _SpectrogramView extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - AspectRatio( - aspectRatio: 2.0, - child: CustomPaint( - painter: _ImagePainter(image), - size: Size.infinite, + Padding( + padding: const EdgeInsets.fromLTRB(6, 10, 10, 4), + child: LayoutBuilder( + builder: (context, constraints) { + const leftGutter = 34.0; + const bottomGutter = 18.0; + final plotWidth = constraints.maxWidth - leftGutter; + final plotHeight = plotWidth / 2.0; + final totalHeight = plotHeight + bottomGutter; + return SizedBox( + width: constraints.maxWidth, + height: totalHeight, + child: CustomPaint( + painter: _SpectrogramPainter( + image: image, + maxFreqHz: maxFreq, + durationSec: duration, + labelColor: labelColor, + gridColor: Colors.white.withValues(alpha: 0.10), + ), + size: Size(constraints.maxWidth, totalHeight), + ), + ); + }, ), ), + // Intensity color legend (matches the spectrogram colormap). + Padding( + padding: const EdgeInsets.fromLTRB(40, 0, 10, 8), + child: Row( + children: [ + const Text( + 'Quiet', + style: TextStyle(color: labelColor, fontSize: 10), + ), + const SizedBox(width: 8), + Expanded( + child: Container( + height: 8, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(4), + gradient: LinearGradient(colors: _legendColors()), + ), + ), + ), + const SizedBox(width: 8), + const Text( + 'Loud', + style: TextStyle(color: labelColor, fontSize: 10), + ), + ], + ), + ), + Divider(height: 1, color: cs.outlineVariant.withValues(alpha: 0.25)), Padding( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), child: Row( @@ -1627,27 +1694,149 @@ class _SpectrogramView extends StatelessWidget { ), ); } + + static List _legendColors() { + return List.generate(20, (i) { + final c = _spekColorRGB(i / 19.0); + return Color.fromARGB(255, c[0], c[1], c[2]); + }); + } } -class _ImagePainter extends CustomPainter { +class _SpectrogramPainter extends CustomPainter { final ui.Image image; - _ImagePainter(this.image); + final double maxFreqHz; + final double durationSec; + final Color labelColor; + final Color gridColor; + + static const double leftGutter = 34; + static const double bottomGutter = 18; + + _SpectrogramPainter({ + required this.image, + required this.maxFreqHz, + required this.durationSec, + required this.labelColor, + required this.gridColor, + }); @override void paint(Canvas canvas, Size size) { - paintImage( - canvas: canvas, - rect: Offset.zero & size, - image: image, - fit: BoxFit.contain, - filterQuality: FilterQuality.medium, + final plot = Rect.fromLTWH( + leftGutter, + 0, + size.width - leftGutter, + size.height - bottomGutter, ); + if (plot.width <= 0 || plot.height <= 0) return; + + // Spectrogram image. + canvas.drawImageRect( + image, + Rect.fromLTWH(0, 0, image.width.toDouble(), image.height.toDouble()), + plot, + Paint()..filterQuality = FilterQuality.medium, + ); + + final gridPaint = Paint() + ..color = gridColor + ..strokeWidth = 1; + + // Frequency axis (Y): 0 Hz at the bottom, maxFreq at the top. + final maxKHz = maxFreqHz / 1000.0; + if (maxKHz > 0) { + final stepKHz = _niceStepKHz(maxKHz); + for (double fk = 0; fk <= maxKHz + 0.001; fk += stepKHz) { + final ratio = (fk * 1000) / maxFreqHz; + final y = plot.bottom - ratio * plot.height; + canvas.drawLine(Offset(plot.left, y), Offset(plot.right, y), gridPaint); + _drawText( + canvas, + fk == 0 ? '0' : '${fk.toStringAsFixed(0)}k', + Offset(plot.left - 5, y), + align: _TextAlignV.rightCenter, + ); + } + } + + // Time axis (X): 0 at the left, duration at the right. + if (durationSec > 0) { + final stepSec = _niceStepSec(durationSec); + for (double ts = 0; ts <= durationSec + 0.001; ts += stepSec) { + final ratio = ts / durationSec; + final x = plot.left + ratio * plot.width; + canvas.drawLine(Offset(x, plot.top), Offset(x, plot.bottom), gridPaint); + _drawText( + canvas, + _fmtTime(ts), + Offset(x, plot.bottom + 3), + align: _TextAlignV.topCenter, + ); + } + } + } + + void _drawText( + Canvas canvas, + String text, + Offset anchor, { + required _TextAlignV align, + }) { + final tp = TextPainter( + text: TextSpan( + text: text, + style: TextStyle(color: labelColor, fontSize: 10), + ), + textDirection: TextDirection.ltr, + )..layout(); + double dx = anchor.dx; + double dy = anchor.dy; + switch (align) { + case _TextAlignV.rightCenter: + dx = anchor.dx - tp.width; + dy = anchor.dy - tp.height / 2; + break; + case _TextAlignV.topCenter: + dx = anchor.dx - tp.width / 2; + dy = anchor.dy; + break; + } + tp.paint(canvas, Offset(dx, dy)); + } + + static double _niceStepKHz(double maxKHz) { + const candidates = [1.0, 2.0, 5.0, 10.0, 20.0, 50.0]; + for (final c in candidates) { + if (maxKHz / c <= 6) return c; + } + return 100.0; + } + + static double _niceStepSec(double dur) { + const candidates = [5.0, 10.0, 15.0, 30.0, 60.0, 120.0, 300.0, 600.0]; + for (final c in candidates) { + if (dur / c <= 6) return c; + } + return 1200.0; + } + + static String _fmtTime(double sec) { + final s = sec.round(); + final m = s ~/ 60; + final r = s % 60; + return '$m:${r.toString().padLeft(2, '0')}'; } @override - bool shouldRepaint(covariant _ImagePainter old) => old.image != image; + bool shouldRepaint(covariant _SpectrogramPainter old) => + old.image != image || + old.maxFreqHz != maxFreqHz || + old.durationSec != durationSec; } +enum _TextAlignV { rightCenter, topCenter } + class _SpectrogramRenderParams { final SpectrogramData spectrum; final int width; diff --git a/lib/widgets/batch_convert_sheet.dart b/lib/widgets/batch_convert_sheet.dart new file mode 100644 index 00000000..31edb500 --- /dev/null +++ b/lib/widgets/batch_convert_sheet.dart @@ -0,0 +1,269 @@ +import 'package:flutter/material.dart'; +import 'package:spotiflac_android/l10n/l10n.dart'; +import 'package:spotiflac_android/utils/audio_conversion_utils.dart'; +import 'package:spotiflac_android/widgets/settings_group.dart'; + +/// Modern, card-based batch convert sheet shared by the queue and album +/// screens. It mirrors the single-track convert sheet styling so format and +/// bitrate selection look consistent across the app. +class BatchConvertSheet extends StatefulWidget { + /// Available target formats. + final List formats; + + /// Sheet title. + final String title; + + /// Optional subtitle shown under the title (e.g. number of tracks). + final String? subtitle; + + /// Label for the primary action button. + final String confirmLabel; + + /// Called with the selected format and bitrate when the user confirms. + final void Function(String format, String bitrate) onConvert; + + const BatchConvertSheet({ + super.key, + required this.formats, + required this.title, + required this.confirmLabel, + required this.onConvert, + this.subtitle, + }); + + @override + State createState() => _BatchConvertSheetState(); +} + +class _BatchConvertSheetState extends State { + static const _bitrates = ['128k', '192k', '256k', '320k']; + + late String _selectedFormat; + late bool _isLosslessTarget; + late String _selectedBitrate; + + String _defaultBitrateForFormat(String format) { + if (format == 'Opus') return '128k'; + if (format == 'AAC') return '256k'; + return '320k'; + } + + @override + void initState() { + super.initState(); + _selectedFormat = widget.formats.first; + _isLosslessTarget = isLosslessConversionTarget(_selectedFormat); + _selectedBitrate = _isLosslessTarget + ? '320k' + : _defaultBitrateForFormat(_selectedFormat); + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + + return SafeArea( + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(20, 12, 20, 20), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: cs.onSurfaceVariant.withValues(alpha: 0.4), + borderRadius: BorderRadius.circular(2), + ), + ), + ), + const SizedBox(height: 18), + Text( + widget.title, + style: Theme.of( + context, + ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), + ), + if (widget.subtitle != null) ...[ + const SizedBox(height: 4), + Text( + widget.subtitle!, + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(color: cs.onSurfaceVariant), + ), + ], + const SizedBox(height: 20), + + _card( + cs, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _sectionLabel(cs, context.l10n.trackConvertTargetFormat), + Wrap( + spacing: 8, + runSpacing: 8, + children: widget.formats.map((format) { + return _choice( + cs, + label: format, + selected: format == _selectedFormat, + onTap: () { + setState(() { + _selectedFormat = format; + _isLosslessTarget = isLosslessConversionTarget( + format, + ); + if (!_isLosslessTarget) { + _selectedBitrate = _defaultBitrateForFormat( + format, + ); + } + }); + }, + ); + }).toList(), + ), + ], + ), + ), + + if (!_isLosslessTarget) + _card( + cs, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _sectionLabel(cs, context.l10n.trackConvertBitrate), + Wrap( + spacing: 8, + runSpacing: 8, + children: _bitrates.map((br) { + return _choice( + cs, + label: br, + selected: br == _selectedBitrate, + onTap: () => setState(() => _selectedBitrate = br), + ); + }).toList(), + ), + ], + ), + ), + + if (_isLosslessTarget) + Container( + width: double.infinity, + margin: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.symmetric( + horizontal: 14, + vertical: 12, + ), + decoration: BoxDecoration( + color: cs.primaryContainer.withValues(alpha: 0.4), + borderRadius: BorderRadius.circular(14), + ), + child: Row( + children: [ + Icon(Icons.verified, size: 18, color: cs.primary), + const SizedBox(width: 8), + Expanded( + child: Text( + context.l10n.trackConvertLosslessHint, + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: cs.primary), + ), + ), + ], + ), + ), + + const SizedBox(height: 8), + SizedBox( + width: double.infinity, + child: FilledButton.icon( + onPressed: () => + widget.onConvert(_selectedFormat, _selectedBitrate), + icon: const Icon(Icons.swap_horiz), + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + ), + label: Text(widget.confirmLabel), + ), + ), + ], + ), + ), + ); + } + + Widget _card(ColorScheme cs, {required Widget child}) { + return Container( + width: double.infinity, + margin: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: settingsGroupColor(context), + borderRadius: BorderRadius.circular(20), + border: Border.all(color: cs.outlineVariant.withValues(alpha: 0.5)), + ), + child: child, + ); + } + + Widget _sectionLabel(ColorScheme cs, String text) { + return Padding( + padding: const EdgeInsets.only(left: 2, bottom: 12), + child: Text( + text, + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: cs.onSurfaceVariant, + fontWeight: FontWeight.w600, + letterSpacing: 0.1, + ), + ), + ); + } + + Widget _choice( + ColorScheme cs, { + required String label, + required bool selected, + required VoidCallback onTap, + }) { + return Material( + color: selected ? cs.primaryContainer : cs.surface, + borderRadius: BorderRadius.circular(14), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(14), + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 11), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(14), + border: Border.all( + color: selected + ? Colors.transparent + : cs.outlineVariant.withValues(alpha: 0.6), + ), + ), + child: Text( + label, + style: Theme.of(context).textTheme.labelLarge?.copyWith( + color: selected ? cs.onPrimaryContainer : cs.onSurface, + fontWeight: selected ? FontWeight.w600 : FontWeight.w500, + ), + ), + ), + ), + ); + } +} diff --git a/lib/widgets/settings_group.dart b/lib/widgets/settings_group.dart index ef839701..effa9ab3 100644 --- a/lib/widgets/settings_group.dart +++ b/lib/widgets/settings_group.dart @@ -1,5 +1,22 @@ import 'package:flutter/material.dart'; +/// Background fill for grouped cards, matching the Settings group look. Blends a +/// translucent overlay over the surface so it stays visible on AMOLED (pure +/// black) dark themes as well as normal light/dark themes. +Color settingsGroupColor(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final isDark = Theme.of(context).brightness == Brightness.dark; + return isDark + ? Color.alphaBlend( + Colors.white.withValues(alpha: 0.08), + colorScheme.surface, + ) + : Color.alphaBlend( + Colors.black.withValues(alpha: 0.04), + colorScheme.surface, + ); +} + class SettingsGroup extends StatelessWidget { final List children; final EdgeInsetsGeometry? margin; @@ -8,24 +25,17 @@ class SettingsGroup extends StatelessWidget { @override Widget build(BuildContext context) { + final cardColor = settingsGroupColor(context); final colorScheme = Theme.of(context).colorScheme; - final isDark = Theme.of(context).brightness == Brightness.dark; - - final cardColor = isDark - ? Color.alphaBlend( - Colors.white.withValues(alpha: 0.08), - colorScheme.surface, - ) - : Color.alphaBlend( - Colors.black.withValues(alpha: 0.04), - colorScheme.surface, - ); return Container( margin: margin ?? const EdgeInsets.symmetric(horizontal: 16, vertical: 4), decoration: BoxDecoration( color: cardColor, borderRadius: BorderRadius.circular(20), + border: Border.all( + color: colorScheme.outlineVariant.withValues(alpha: 0.5), + ), ), clipBehavior: Clip.antiAlias, child: Material(