Add CSV import and optimize Appearance settings

This commit is contained in:
zarzet
2026-01-11 05:56:30 +07:00
parent 9f47f2ce85
commit b73a3f8912
8 changed files with 1220 additions and 549 deletions
File diff suppressed because it is too large Load Diff
+106 -1
View File
@@ -11,6 +11,7 @@ import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/screens/track_metadata_screen.dart';
import 'package:spotiflac_android/screens/album_screen.dart';
import 'package:spotiflac_android/screens/artist_screen.dart';
import 'package:spotiflac_android/services/csv_import_service.dart';
import 'package:spotiflac_android/screens/playlist_screen.dart';
import 'package:spotiflac_android/models/download_item.dart';
@@ -266,6 +267,104 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
);
}
Future<void> _importCsv(BuildContext context, WidgetRef ref) async {
// Show loading dialog with progress
int currentProgress = 0;
int totalTracks = 0;
// Use StatefulBuilder to update dialog content
final dialogContext = context;
bool dialogShown = false;
StateSetter? setDialogState;
void showProgressDialog() {
if (dialogShown) return;
dialogShown = true;
showDialog(
context: dialogContext,
barrierDismissible: false,
builder: (context) => StatefulBuilder(
builder: (context, setState) {
setDialogState = setState;
return AlertDialog(
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
const CircularProgressIndicator(),
const SizedBox(height: 16),
Text(
totalTracks > 0
? 'Fetching metadata... $currentProgress/$totalTracks'
: 'Reading CSV...',
),
],
),
);
},
),
);
}
final tracks = await CsvImportService.pickAndParseCsv(
onProgress: (current, total) {
currentProgress = current;
totalTracks = total;
if (!dialogShown && total > 0) {
showProgressDialog();
}
setDialogState?.call(() {});
},
);
// Close progress dialog
if (dialogShown && mounted) {
Navigator.of(dialogContext).pop();
}
if (tracks.isNotEmpty) {
final settings = ref.read(settingsProvider);
// Optionally show confirmation dialog
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Import Playlist'),
content: Text('Found ${tracks.length} tracks in CSV. Add them to download queue?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Import'),
),
],
),
);
if (confirmed == true) {
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, settings.defaultService);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Added ${tracks.length} tracks to queue'),
action: SnackBarAction(
label: 'View Queue',
onPressed: () {
// Navigate to queue tab (handled by main_shell index)
// We don't have direct access to set index here easily without provider
},
),
),
);
}
}
} else {
// Only show error if pick was not cancelled (handled inside service logging usually, but maybe show snackbar if file empty)
}
}
@override
Widget build(BuildContext context) {
super.build(context);
@@ -770,12 +869,18 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
onPressed: _clearAndRefresh,
tooltip: 'Clear',
)
else
else ...[
IconButton(
icon: const Icon(Icons.file_upload_outlined),
onPressed: () => _importCsv(context, ref),
tooltip: 'Import CSV',
),
IconButton(
icon: const Icon(Icons.paste),
onPressed: _pasteFromClipboard,
tooltip: 'Paste',
),
],
],
),
contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
+3
View File
@@ -62,6 +62,9 @@ class _MainShellState extends ConsumerState<MainShell> {
}
void _handleSharedUrl(String url) {
// Pop any existing screens (Album, Artist, Settings sub-pages) to return to root
Navigator.of(context).popUntil((route) => route.isFirst);
// Navigate to Home tab
if (_currentIndex != 0) {
_onNavTap(0);
+369 -201
View File
@@ -27,20 +27,31 @@ class AppearanceSettingsPage extends ConsumerWidget {
pinned: true,
backgroundColor: colorScheme.surface,
surfaceTintColor: Colors.transparent,
leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => Navigator.pop(context)),
flexibleSpace: _AppBarTitle(title: 'Appearance', topPadding: topPadding),
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.pop(context),
),
flexibleSpace: _AppBarTitle(
title: 'Appearance',
topPadding: topPadding,
),
),
// Preview Section
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
child: _ThemePreviewCard(),
),
),
// Color section
const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'Color')),
const SliverToBoxAdapter(
child: SettingsSectionHeader(title: 'Color'),
),
SliverToBoxAdapter(
child: SettingsGroup(
@@ -50,7 +61,9 @@ class AppearanceSettingsPage extends ConsumerWidget {
title: 'Dynamic Color',
subtitle: 'Use colors from your wallpaper',
value: themeSettings.useDynamicColor,
onChanged: (value) => ref.read(themeProvider.notifier).setUseDynamicColor(value),
onChanged: (value) => ref
.read(themeProvider.notifier)
.setUseDynamicColor(value),
showDivider: false,
),
],
@@ -62,47 +75,60 @@ class AppearanceSettingsPage extends ConsumerWidget {
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
child: _ColorPalettePicker(
currentColor: themeSettings.seedColorValue,
onColorSelected: (color) => ref.read(themeProvider.notifier).setSeedColor(color),
onColorSelected: (color) =>
ref.read(themeProvider.notifier).setSeedColor(color),
),
),
),
// Theme section
const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'Theme')),
const SliverToBoxAdapter(
child: SettingsSectionHeader(title: 'Theme'),
),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
_ThemeModeSelector(
currentMode: themeSettings.themeMode,
onChanged: (mode) => ref.read(themeProvider.notifier).setThemeMode(mode),
),
SettingsSwitchItem(
icon: Icons.brightness_2,
title: 'AMOLED Dark',
subtitle: 'Pure black background',
value: themeSettings.useAmoled,
onChanged: (value) => ref.read(themeProvider.notifier).setUseAmoled(value),
showDivider: false,
onChanged: (mode) =>
ref.read(themeProvider.notifier).setThemeMode(mode),
),
if (Theme.of(context).brightness == Brightness.dark)
SettingsSwitchItem(
icon: Icons.brightness_2,
title: 'AMOLED Dark',
subtitle: 'Pure black background',
value: themeSettings.useAmoled,
onChanged: (value) =>
ref.read(themeProvider.notifier).setUseAmoled(value),
showDivider: false,
),
],
),
),
// Layout section
const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'Layout')),
const SliverToBoxAdapter(
child: SettingsSectionHeader(title: 'Layout'),
),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
_HistoryViewSelector(
currentMode: settings.historyViewMode,
onChanged: (mode) => ref.read(settingsProvider.notifier).setHistoryViewMode(mode),
onChanged: (mode) => ref
.read(settingsProvider.notifier)
.setHistoryViewMode(mode),
),
],
),
),
// Fill remaining for scroll
const SliverFillRemaining(hasScrollBody: false, child: SizedBox(height: 32)),
const SliverFillRemaining(
hasScrollBody: false,
child: SizedBox(height: 32),
),
],
),
),
@@ -116,143 +142,180 @@ class _ThemePreviewCard extends StatelessWidget {
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final isDark = Theme.of(context).brightness == Brightness.dark;
return Container(
height: 200,
width: double.infinity,
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest, // Background similar to reference
borderRadius: BorderRadius.circular(28),
),
clipBehavior: Clip.antiAlias,
child: Stack(
children: [
// Decorative background blobs
Positioned(
top: -50,
right: -50,
child: Container(
width: 200, height: 200,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: colorScheme.primaryContainer.withValues(alpha: 0.5),
return RepaintBoundary(
child: Container(
height: 200,
width: double.infinity,
decoration: BoxDecoration(
color: colorScheme
.surfaceContainerHighest, // Background similar to reference
borderRadius: BorderRadius.circular(28),
),
clipBehavior: Clip.antiAlias,
child: Stack(
children: [
// Decorative background blobs
Positioned(
top: -50,
right: -50,
child: Container(
width: 200,
height: 200,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: colorScheme.primaryContainer.withValues(alpha: 0.5),
),
),
),
),
Positioned(
bottom: -30,
left: -30,
child: Container(
width: 150, height: 150,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: colorScheme.tertiaryContainer.withValues(alpha: 0.5),
Positioned(
bottom: -30,
left: -30,
child: Container(
width: 150,
height: 150,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: colorScheme.tertiaryContainer.withValues(alpha: 0.5),
),
),
),
),
// Foreground "fake UI"
Center(
child: Container(
width: 260,
height: 140,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: colorScheme.surface,
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.1),
blurRadius: 20,
offset: const Offset(0, 10),
),
],
),
child: Row(
children: [
// Fake Album Art
Container(
width: 108,
height: 108,
decoration: BoxDecoration(
color: colorScheme.primary,
borderRadius: BorderRadius.circular(16),
// Foreground "fake UI"
Center(
child: Container(
width: 260,
height: 140,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: colorScheme.surface,
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.1),
blurRadius: 12, // Reduced from 20 for performance
offset: const Offset(0, 8),
),
child: Icon(Icons.music_note, color: colorScheme.onPrimary, size: 48),
),
const SizedBox(width: 16),
// Fake Text Info
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: double.infinity, height: 14,
decoration: BoxDecoration(
color: colorScheme.onSurface,
borderRadius: BorderRadius.circular(4),
),
),
const SizedBox(height: 8),
Container(
width: 80, height: 10,
decoration: BoxDecoration(
color: colorScheme.primary,
borderRadius: BorderRadius.circular(4),
),
),
const SizedBox(height: 24),
Row(
children: [
Icon(Icons.skip_previous, size: 24, color: colorScheme.onSurfaceVariant),
const SizedBox(width: 12),
Icon(Icons.play_circle_fill, size: 32, color: colorScheme.primary),
const SizedBox(width: 12),
Icon(Icons.skip_next, size: 24, color: colorScheme.onSurfaceVariant),
],
),
],
],
),
child: Row(
children: [
// Fake Album Art
Container(
width: 108,
height: 108,
decoration: BoxDecoration(
color: colorScheme.primary,
borderRadius: BorderRadius.circular(16),
),
child: Icon(
Icons.music_note,
color: colorScheme.onPrimary,
size: 48,
),
),
const SizedBox(width: 16),
// Fake Text Info
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: double.infinity,
height: 14,
decoration: BoxDecoration(
color: colorScheme.onSurface,
borderRadius: BorderRadius.circular(4),
),
),
const SizedBox(height: 8),
Container(
width: 80,
height: 10,
decoration: BoxDecoration(
color: colorScheme.primary,
borderRadius: BorderRadius.circular(4),
),
),
const SizedBox(height: 24),
Row(
children: [
Icon(
Icons.skip_previous,
size: 24,
color: colorScheme.onSurfaceVariant,
),
const SizedBox(width: 12),
Icon(
Icons.play_circle_fill,
size: 32,
color: colorScheme.primary,
),
const SizedBox(width: 12),
Icon(
Icons.skip_next,
size: 24,
color: colorScheme.onSurfaceVariant,
),
],
),
],
),
),
],
),
),
),
// Label badge
Positioned(
bottom: 12,
right: 12,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 4,
),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.6),
borderRadius: BorderRadius.circular(20),
),
child: Text(
isDark ? 'Dark Mode' : 'Light Mode',
style: const TextStyle(
color: Colors.white,
fontSize: 10,
fontWeight: FontWeight.bold,
),
],
),
),
),
),
// Label badge
Positioned(
bottom: 12,
right: 12,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.6),
borderRadius: BorderRadius.circular(20),
),
child: Text(
isDark ? 'Dark Mode' : 'Light Mode',
style: const TextStyle(color: Colors.white, fontSize: 10, fontWeight: FontWeight.bold),
),
),
)
],
],
),
),
);
}
}
class _ColorPalettePicker extends StatelessWidget {
final int currentColor;
final ValueChanged<Color> onColorSelected;
const _ColorPalettePicker({required this.currentColor, required this.onColorSelected});
const _ColorPalettePicker({
required this.currentColor,
required this.onColorSelected,
});
static const _colors = [
Color(0xFF1DB954), Color(0xFF6750A4), Color(0xFF0061A4), Color(0xFF006E1C),
Color(0xFFBA1A1A), Color(0xFF984061), Color(0xFF7D5260), Color(0xFF006874),
Color(0xFF1DB954),
Color(0xFF6750A4),
Color(0xFF0061A4),
Color(0xFF006E1C),
Color(0xFFBA1A1A),
Color(0xFF984061),
Color(0xFF7D5260),
Color(0xFF006874),
];
@override
@@ -278,22 +341,23 @@ class _ColorPalettePicker extends StatelessWidget {
class _ColorPaletteItem extends StatelessWidget {
final Color color;
final bool isSelected;
const _ColorPaletteItem({required this.color, required this.isSelected});
@override
Widget build(BuildContext context) {
final scheme = ColorScheme.fromSeed(seedColor: color, brightness: Theme.of(context).brightness);
final scheme = ColorScheme.fromSeed(
seedColor: color,
brightness: Theme.of(context).brightness,
);
final size = 64.0;
return Stack(
children: [
Container(
width: size,
height: size,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
),
decoration: BoxDecoration(borderRadius: BorderRadius.circular(20)),
clipBehavior: Clip.antiAlias,
child: Column(
children: [
@@ -308,7 +372,9 @@ class _ColorPaletteItem extends StatelessWidget {
Expanded(
child: Row(
children: [
Expanded(child: Container(color: scheme.secondaryContainer)),
Expanded(
child: Container(color: scheme.secondaryContainer),
),
Expanded(child: Container(color: scheme.surfaceContainer)),
],
),
@@ -318,16 +384,16 @@ class _ColorPaletteItem extends StatelessWidget {
),
if (isSelected)
Positioned.fill(
child: Center(
child: Container(
padding: const EdgeInsets.all(4),
decoration: const BoxDecoration(
color: Colors.white,
shape: BoxShape.circle,
),
child: Icon(Icons.check, size: 16, color: scheme.primary),
),
),
child: Center(
child: Container(
padding: const EdgeInsets.all(4),
decoration: const BoxDecoration(
color: Colors.white,
shape: BoxShape.circle,
),
child: Icon(Icons.check, size: 16, color: scheme.primary),
),
),
),
],
);
@@ -338,7 +404,7 @@ class _ColorPaletteItem extends StatelessWidget {
class _AppBarTitle extends StatelessWidget {
final String title;
final double topPadding;
const _AppBarTitle({required this.title, required this.topPadding});
@override
@@ -348,7 +414,9 @@ class _AppBarTitle extends StatelessWidget {
builder: (context, constraints) {
final maxHeight = 120 + topPadding;
final minHeight = kToolbarHeight + topPadding;
final expandRatio = ((constraints.maxHeight - minHeight) / (maxHeight - minHeight)).clamp(0.0, 1.0);
final expandRatio =
((constraints.maxHeight - minHeight) / (maxHeight - minHeight))
.clamp(0.0, 1.0);
final leftPadding = 56 - (32 * expandRatio); // 56 -> 24
return FlexibleSpaceBar(
expandedTitleScale: 1.0,
@@ -370,19 +438,39 @@ class _AppBarTitle extends StatelessWidget {
class _ThemeModeSelector extends StatelessWidget {
final ThemeMode currentMode;
final ValueChanged<ThemeMode> onChanged;
const _ThemeModeSelector({required this.currentMode, required this.onChanged});
const _ThemeModeSelector({
required this.currentMode,
required this.onChanged,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(12),
child: Row(children: [
_ThemeModeChip(icon: Icons.brightness_auto, label: 'System', isSelected: currentMode == ThemeMode.system, onTap: () => onChanged(ThemeMode.system)),
const SizedBox(width: 8),
_ThemeModeChip(icon: Icons.light_mode, label: 'Light', isSelected: currentMode == ThemeMode.light, onTap: () => onChanged(ThemeMode.light)),
const SizedBox(width: 8),
_ThemeModeChip(icon: Icons.dark_mode, label: 'Dark', isSelected: currentMode == ThemeMode.dark, onTap: () => onChanged(ThemeMode.dark)),
]),
child: Row(
children: [
_ThemeModeChip(
icon: Icons.brightness_auto,
label: 'System',
isSelected: currentMode == ThemeMode.system,
onTap: () => onChanged(ThemeMode.system),
),
const SizedBox(width: 8),
_ThemeModeChip(
icon: Icons.light_mode,
label: 'Light',
isSelected: currentMode == ThemeMode.light,
onTap: () => onChanged(ThemeMode.light),
),
const SizedBox(width: 8),
_ThemeModeChip(
icon: Icons.dark_mode,
label: 'Dark',
isSelected: currentMode == ThemeMode.dark,
onTap: () => onChanged(ThemeMode.dark),
),
],
),
);
}
}
@@ -392,27 +480,41 @@ class _ThemeModeChip extends StatelessWidget {
final String label;
final bool isSelected;
final VoidCallback onTap;
const _ThemeModeChip({required this.icon, required this.label, required this.isSelected, required this.onTap});
const _ThemeModeChip({
required this.icon,
required this.label,
required this.isSelected,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final isDark = Theme.of(context).brightness == Brightness.dark;
// Unselected chips need contrast with card background
// Card uses: dark = white 8% overlay, light = surfaceContainerHighest
// So chips use: dark = white 5% overlay (darker), light = black 5% overlay (darker than card)
final unselectedColor = isDark
? Color.alphaBlend(Colors.white.withValues(alpha: 0.05), colorScheme.surface)
: Color.alphaBlend(Colors.black.withValues(alpha: 0.05), colorScheme.surfaceContainerHighest);
final unselectedColor = isDark
? Color.alphaBlend(
Colors.white.withValues(alpha: 0.05),
colorScheme.surface,
)
: Color.alphaBlend(
Colors.black.withValues(alpha: 0.05),
colorScheme.surfaceContainerHighest,
);
return Expanded(
child: Container(
decoration: BoxDecoration(
color: isSelected ? colorScheme.primaryContainer : unselectedColor,
borderRadius: BorderRadius.circular(12),
border: !isDark && !isSelected
? Border.all(color: colorScheme.outlineVariant.withValues(alpha: 0.5), width: 1)
border: !isDark && !isSelected
? Border.all(
color: colorScheme.outlineVariant.withValues(alpha: 0.5),
width: 1,
)
: null,
),
child: Material(
@@ -423,13 +525,29 @@ class _ThemeModeChip extends StatelessWidget {
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 14),
child: Column(children: [
Icon(icon, color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant),
const SizedBox(height: 6),
Text(label, style: TextStyle(fontSize: 12,
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant)),
]),
child: Column(
children: [
Icon(
icon,
color: isSelected
? colorScheme.onPrimaryContainer
: colorScheme.onSurfaceVariant,
),
const SizedBox(height: 6),
Text(
label,
style: TextStyle(
fontSize: 12,
fontWeight: isSelected
? FontWeight.w600
: FontWeight.normal,
color: isSelected
? colorScheme.onPrimaryContainer
: colorScheme.onSurfaceVariant,
),
),
],
),
),
),
),
@@ -441,7 +559,10 @@ class _ThemeModeChip extends StatelessWidget {
class _HistoryViewSelector extends StatelessWidget {
final String currentMode;
final ValueChanged<String> onChanged;
const _HistoryViewSelector({required this.currentMode, required this.onChanged});
const _HistoryViewSelector({
required this.currentMode,
required this.onChanged,
});
@override
Widget build(BuildContext context) {
@@ -453,13 +574,30 @@ class _HistoryViewSelector extends StatelessWidget {
children: [
Padding(
padding: const EdgeInsets.only(left: 8, bottom: 8),
child: Text('History View', style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)),
child: Text(
'History View',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
Row(
children: [
_ViewModeChip(
icon: Icons.view_list,
label: 'List',
isSelected: currentMode == 'list',
onTap: () => onChanged('list'),
),
const SizedBox(width: 8),
_ViewModeChip(
icon: Icons.grid_view,
label: 'Grid',
isSelected: currentMode == 'grid',
onTap: () => onChanged('grid'),
),
],
),
Row(children: [
_ViewModeChip(icon: Icons.view_list, label: 'List', isSelected: currentMode == 'list', onTap: () => onChanged('list')),
const SizedBox(width: 8),
_ViewModeChip(icon: Icons.grid_view, label: 'Grid', isSelected: currentMode == 'grid', onTap: () => onChanged('grid')),
]),
],
),
);
@@ -471,25 +609,39 @@ class _ViewModeChip extends StatelessWidget {
final String label;
final bool isSelected;
final VoidCallback onTap;
const _ViewModeChip({required this.icon, required this.label, required this.isSelected, required this.onTap});
const _ViewModeChip({
required this.icon,
required this.label,
required this.isSelected,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final isDark = Theme.of(context).brightness == Brightness.dark;
// Unselected chips need contrast with card background
final unselectedColor = isDark
? Color.alphaBlend(Colors.white.withValues(alpha: 0.05), colorScheme.surface)
: Color.alphaBlend(Colors.black.withValues(alpha: 0.05), colorScheme.surfaceContainerHighest);
final unselectedColor = isDark
? Color.alphaBlend(
Colors.white.withValues(alpha: 0.05),
colorScheme.surface,
)
: Color.alphaBlend(
Colors.black.withValues(alpha: 0.05),
colorScheme.surfaceContainerHighest,
);
return Expanded(
child: Container(
decoration: BoxDecoration(
color: isSelected ? colorScheme.primaryContainer : unselectedColor,
borderRadius: BorderRadius.circular(12),
border: !isDark && !isSelected
? Border.all(color: colorScheme.outlineVariant.withValues(alpha: 0.5), width: 1)
border: !isDark && !isSelected
? Border.all(
color: colorScheme.outlineVariant.withValues(alpha: 0.5),
width: 1,
)
: null,
),
child: Material(
@@ -500,13 +652,29 @@ class _ViewModeChip extends StatelessWidget {
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 14),
child: Column(children: [
Icon(icon, color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant),
const SizedBox(height: 6),
Text(label, style: TextStyle(fontSize: 12,
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant)),
]),
child: Column(
children: [
Icon(
icon,
color: isSelected
? colorScheme.onPrimaryContainer
: colorScheme.onSurfaceVariant,
),
const SizedBox(height: 6),
Text(
label,
style: TextStyle(
fontSize: 12,
fontWeight: isSelected
? FontWeight.w600
: FontWeight.normal,
color: isSelected
? colorScheme.onPrimaryContainer
: colorScheme.onSurfaceVariant,
),
),
],
),
),
),
),
+1 -1
View File
@@ -442,7 +442,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
_MetadataItem('Album', albumName),
if (trackNumber != null && trackNumber! > 0)
_MetadataItem('Track number', trackNumber.toString()),
if (discNumber != null && discNumber! > 1)
if (discNumber != null && discNumber! > 0)
_MetadataItem('Disc number', discNumber.toString()),
if (item.duration != null)
_MetadataItem('Duration', _formatDuration(item.duration!)),
+216
View File
@@ -0,0 +1,216 @@
import 'dart:io';
import 'package:file_picker/file_picker.dart';
import 'package:spotiflac_android/models/track.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/logger.dart';
class CsvImportService {
static final _log = AppLogger('CsvImportService');
/// Pick and parse CSV file, then enrich metadata from Deezer
/// [onProgress] callback receives (current, total) for progress updates
static Future<List<Track>> pickAndParseCsv({
void Function(int current, int total)? onProgress,
}) async {
try {
final FilePickerResult? result = await FilePicker.platform.pickFiles(
type: FileType.custom,
allowedExtensions: ['csv'],
);
if (result != null && result.files.single.path != null) {
final file = File(result.files.single.path!);
final content = await file.readAsString();
final tracks = _parseCsv(content);
// Enrich tracks with metadata from Deezer (cover URL, duration, etc.)
if (tracks.isNotEmpty) {
return await _enrichTracksMetadata(tracks, onProgress: onProgress);
}
return tracks;
}
} catch (e) {
_log.e('Error picking/parsing CSV: $e');
}
return [];
}
/// Enrich tracks with metadata from Deezer using ISRC
/// This fetches cover URL, duration, and other metadata that CSV doesn't have
static Future<List<Track>> _enrichTracksMetadata(
List<Track> tracks, {
void Function(int current, int total)? onProgress,
}) async {
_log.i('Enriching metadata for ${tracks.length} tracks from Deezer...');
final enrichedTracks = <Track>[];
for (int i = 0; i < tracks.length; i++) {
final track = tracks[i];
onProgress?.call(i + 1, tracks.length);
// Only enrich if we have ISRC and missing cover/duration
if (track.isrc != null &&
track.isrc!.isNotEmpty &&
(track.coverUrl == null || track.duration == 0)) {
try {
// searchDeezerByISRC returns TrackMetadata directly (not wrapped in "track" key)
final trackData = await PlatformBridge.searchDeezerByISRC(track.isrc!);
// Extract enriched data from TrackMetadata
final coverUrl = trackData['images'] as String?;
final durationMs = trackData['duration_ms'] as int? ?? 0;
final deezerIdRaw = trackData['spotify_id'] as String?; // Format: "deezer:123456"
enrichedTracks.add(Track(
id: deezerIdRaw ?? track.id, // Use Deezer ID if available
name: trackData['name'] as String? ?? track.name,
artistName: trackData['artists'] as String? ?? track.artistName,
albumName: trackData['album_name'] as String? ?? track.albumName,
albumArtist: trackData['album_artist'] as String?,
coverUrl: coverUrl ?? track.coverUrl,
isrc: trackData['isrc'] as String? ?? track.isrc,
duration: durationMs > 0 ? durationMs ~/ 1000 : track.duration,
trackNumber: trackData['track_number'] as int? ?? track.trackNumber,
discNumber: trackData['disc_number'] as int? ?? track.discNumber,
releaseDate: trackData['release_date'] as String? ?? track.releaseDate,
));
_log.d('Enriched: ${track.name} - cover: ${coverUrl != null}, duration: ${durationMs ~/ 1000}s');
// Small delay to avoid rate limiting (50ms between requests)
if (i < tracks.length - 1) {
await Future.delayed(const Duration(milliseconds: 50));
}
continue;
} catch (e) {
_log.w('Failed to enrich ${track.name}: $e');
}
}
// Keep original track if enrichment failed or not needed
enrichedTracks.add(track);
}
_log.i('Enrichment complete: ${enrichedTracks.length} tracks');
return enrichedTracks;
}
static List<Track> _parseCsv(String content) {
final List<Track> tracks = [];
final lines = content.split(RegExp(r'\r\n|\r|\n')); // Handle various newline formats
if (lines.isEmpty) return tracks;
// Detect headers line (assume first non-empty line)
int startIdx = 0;
while (startIdx < lines.length && lines[startIdx].trim().isEmpty) {
startIdx++;
}
if (startIdx >= lines.length) return tracks;
final headers = _parseLine(lines[startIdx]);
final colMap = <String, int>{};
for (int i = 0; i < headers.length; i++) {
// Normalize header: lowercase, trim, remove quotes
String h = _cleanValue(headers[i]).toLowerCase();
colMap[h] = i;
}
_log.d('CSV Headers: ${colMap.keys.toList()}');
// Parse rows
for (int i = startIdx + 1; i < lines.length; i++) {
final line = lines[i].trim();
if (line.isEmpty) continue;
final values = _parseLine(line);
// Helper to get value securely
String? getVal(List<String> keys) {
return _getValue(values, colMap, keys);
}
String? trackName = getVal(['track name', 'track', 'name', 'title']);
String? artistName = getVal(['artist name', 'artist']);
String? albumName = getVal(['album name', 'album']);
String? isrc = getVal(['isrc']); // Often formatted with leading/trailing quotes
String? spotifyId = getVal(['spotify - id', 'spotify id', 'id', 'uri']); // Uri might need parsing
// If 'spotify uri' contains the id: 'spotify:track:ID'
if (spotifyId != null && spotifyId.startsWith('spotify:track:')) {
spotifyId = spotifyId.replaceAll('spotify:track:', '');
}
// Basic validation: Need at least name and artist, OR a spotify ID
if ((trackName != null && trackName.isNotEmpty && artistName != null) || (spotifyId != null && spotifyId.isNotEmpty)) {
tracks.add(Track(
id: spotifyId ?? 'csv_${DateTime.now().millisecondsSinceEpoch}_$i',
name: trackName ?? 'Unknown Track',
artistName: artistName ?? 'Unknown Artist',
albumName: albumName ?? 'Unknown Album',
isrc: isrc,
duration: 0, // Will be updated by enrichment later
coverUrl: null, // Will be fetched by enrichment
));
}
}
_log.i('Parsed ${tracks.length} tracks from CSV');
return tracks;
}
static String? _getValue(List<String> values, Map<String, int> colMap, List<String> possibleKeys) {
for (final key in possibleKeys) {
if (colMap.containsKey(key)) {
final index = colMap[key]!;
if (index < values.length) {
return _cleanValue(values[index]);
}
}
}
return null;
}
static String _cleanValue(String val) {
val = val.trim();
if (val.startsWith('"') && val.endsWith('"') && val.length >= 2) {
val = val.substring(1, val.length - 1);
}
// Handle double quotes escape in CSV ("" -> ")
val = val.replaceAll('""', '"');
return val;
}
// Robust CSV Line Parser
static List<String> _parseLine(String line) {
final List<String> result = [];
bool inQuote = false;
StringBuffer buffer = StringBuffer();
for (int i=0; i<line.length; i++) {
String char = line[i];
if (char == '"') {
// Look ahead to check for escaped quote
if (i + 1 < line.length && line[i+1] == '"') {
buffer.write('"'); // Keep format for now, _cleanValue handles unescaping logic differently...
// Wait, standard CSV: "Thumb ""Up""" -> Thumb "Up"
// My _cleanValue handles it, so I should just preserve raw content here mostly,
// BUT I need to know if " toggles inQuote.
// Escaped "" does NOT toggle inQuote mode effectively (it counts as literal char inside quote).
buffer.write('"'); // Write 1st quote
i++; // Skip next quote char loop
buffer.write('"'); // Write 2nd quote
} else {
inQuote = !inQuote;
buffer.write(char);
}
} else if (char == ',' && !inQuote) {
result.add(buffer.toString());
buffer.clear();
} else {
buffer.write(char);
}
}
result.add(buffer.toString());
return result;
}
}