fix(ui): center modals on large screens, modernize edit-metadata + convert sheets, themed badges, fix artist skeleton and format-editor crash

- app: clear displayFeatures so bottom sheets/dialogs center on large/foldable screens

- edit metadata sheet: card sections, modern label-above inputs, elegant collapsible headers, removed title icon

- convert + batch convert: modern card-based sheets; shared BatchConvertSheet widget

- queue: keep selection toolbar hidden until modal close animation finishes

- 24-bit and In Library badges now use primary dynamic color

- artist skeleton: remove duplicate name/listeners lines, keep cover placeholder

- files settings: own filename-format controller in a StatefulWidget to fix use-after-dispose crash
This commit is contained in:
zarzet
2026-06-13 02:08:06 +07:00
parent b8b670642c
commit ca413a16fa
26 changed files with 1564 additions and 1224 deletions
@@ -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<String, Map<String, DocumentFile>>,
@@ -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<Pair<DocumentFile, String>>()
val cueFiles = mutableListOf<Pair<DocumentFile, DocumentFile>>()
val visitedDirUris = mutableSetOf<String>()
@@ -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<Triple<DocumentFile, String, Long>>()
val cueFilesToScan = mutableListOf<Triple<DocumentFile, DocumentFile, Long>>()
val unchangedCueFiles = mutableListOf<Pair<DocumentFile, DocumentFile>>()
+13
View File
@@ -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) {
+1 -1
View File
@@ -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)
+1 -1
View File
@@ -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) {
+1 -1
View File
@@ -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) {
+1 -1
View File
@@ -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"
},
+1 -1
View File
@@ -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"
},
+3 -3
View File
@@ -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,
),
),
],
+3 -3
View File
@@ -1586,7 +1586,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
vertical: 2,
),
decoration: BoxDecoration(
color: colorScheme.tertiaryContainer,
color: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(4),
),
child: Row(
@@ -1595,7 +1595,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
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<ArtistScreen> {
style: TextStyle(
fontSize: 9,
fontWeight: FontWeight.w500,
color: colorScheme.onTertiaryContainer,
color: colorScheme.onPrimaryContainer,
),
),
],
+14 -152
View File
@@ -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<DownloadedAlbumScreen> {
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<void>(
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,
);
},
),
);
}
+2 -1
View File
@@ -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<HomeTab>
decoration: InputDecoration(
hintText: _getSearchHint(),
filled: true,
fillColor: colorScheme.surfaceContainerHighest,
fillColor: settingsGroupColor(context),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(28),
borderSide: BorderSide(color: colorScheme.outlineVariant),
+3 -3
View File
@@ -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,
),
),
],
@@ -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,
),
),
],
+14 -152
View File
@@ -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<LocalAlbumScreen> {
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<void>(
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,
);
},
),
);
}
+19 -14
View File
@@ -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<MainShell>
);
},
),
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,
),
),
),
);
+3 -3
View File
@@ -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,
),
),
],
+56 -161
View File
@@ -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<QueueTab> {
List<UnifiedLibraryItem> _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<String> _selectedPlaylistIds = {};
OverlayEntry? _playlistSelectionOverlayEntry;
@@ -809,7 +817,9 @@ class _QueueTabState extends ConsumerState<QueueTab> {
required double bottomPadding,
}) {
if (!mounted) return;
if (!_isSelectionMode || _isPlaylistSelectionMode) {
if (_suppressSelectionOverlay ||
!_isSelectionMode ||
_isPlaylistSelectionMode) {
_hideSelectionOverlay();
return;
}
@@ -857,7 +867,9 @@ class _QueueTabState extends ConsumerState<QueueTab> {
required double bottomPadding,
}) {
if (!mounted) return;
if (!_isPlaylistSelectionMode || _isSelectionMode) {
if (_suppressSelectionOverlay ||
!_isPlaylistSelectionMode ||
_isSelectionMode) {
_hidePlaylistSelectionOverlay();
return;
}
@@ -2775,7 +2787,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
)
: 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<QueueTab> {
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<QueueTab> {
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<void>.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<QueueTab> {
if (selectedItems.isEmpty) return;
_suppressSelectionOverlay = true;
_hideSelectionOverlay();
_hidePlaylistSelectionOverlay();
@@ -5422,8 +5308,16 @@ class _QueueTabState extends ConsumerState<QueueTab> {
),
);
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<void>.delayed(const Duration(milliseconds: 220));
_suppressSelectionOverlay = false;
if (!mounted) return;
if (_isSelectionMode) {
_syncSelectionOverlay(
items: allItems,
@@ -5432,6 +5326,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
}
return;
}
_suppressSelectionOverlay = false;
var cancelled = false;
int successCount = 0;
@@ -6360,7 +6255,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
),
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<QueueTab> {
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<QueueTab> {
),
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<QueueTab> {
style: Theme.of(context).textTheme.labelSmall
?.copyWith(
color: item.quality!.startsWith('24')
? colorScheme.onTertiary
? colorScheme.onPrimary
: colorScheme.onSurfaceVariant,
fontSize: 9,
fontWeight: FontWeight.w600,
+2
View File
@@ -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)),
);
}
}
+4 -1
View File
@@ -172,7 +172,7 @@ class _RepoTabState extends ConsumerState<RepoTab> {
),
),
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)),
);
}
}
+258 -216
View File
@@ -708,53 +708,14 @@ class _FilesSettingsPageState extends ConsumerState<FilesSettingsPage> {
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<void>(
context: context,
isScrollControlled: true,
@@ -763,178 +724,13 @@ class _FilesSettingsPageState extends ConsumerState<FilesSettingsPage> {
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),
],
),
),
),
),
);
}
}
+442 -360
View File
@@ -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<Widget> 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,
),
),
],
),
),
),
+188 -86
View File
@@ -1468,6 +1468,17 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
);
}
/// 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<TrackMetadataScreen> {
) {
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<TrackMetadataScreen> {
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<TrackMetadataScreen> {
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<TrackMetadataScreen> {
height: size,
fit: BoxFit.cover,
memCacheWidth: cacheWidth,
memCacheHeight: cacheWidth,
errorWidget: (_, _, _) => placeholder(),
);
}
@@ -3728,9 +3740,84 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
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<TrackMetadataScreen> {
),
),
),
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<TrackMetadataScreen> {
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),
],
),
),
+23 -30
View File
@@ -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),
],
),
),
+207 -18
View File
@@ -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<AudioAnalysisCard> {
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<AudioAnalysisCard> {
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<AudioAnalysisCard> {
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<Color> _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;
+269
View File
@@ -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<String> 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<BatchConvertSheet> createState() => _BatchConvertSheetState();
}
class _BatchConvertSheetState extends State<BatchConvertSheet> {
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,
),
),
),
),
);
}
}
+21 -11
View File
@@ -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<Widget> 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(