perf: optimize providers, throttle polling, queued settings save, remove dead screens

This commit is contained in:
zarzet
2026-02-07 19:57:44 +07:00
parent 35f412dbd2
commit 3d366d21b7
13 changed files with 1184 additions and 2637 deletions
-413
View File
@@ -1,413 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:spotiflac_android/services/cover_cache_manager.dart';
import 'package:spotiflac_android/providers/track_provider.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/models/track.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
class HomeScreen extends ConsumerStatefulWidget {
const HomeScreen({super.key});
@override
ConsumerState<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends ConsumerState<HomeScreen> {
final _urlController = TextEditingController();
int _currentIndex = 0;
@override
void dispose() {
_urlController.dispose();
super.dispose();
}
Future<void> _pasteFromClipboard() async {
final data = await Clipboard.getData(Clipboard.kTextPlain);
if (data?.text != null) {
_urlController.text = data!.text!;
}
}
Future<void> _fetchMetadata() async {
final url = _urlController.text.trim();
if (url.isEmpty) return;
if (url.startsWith('http') || url.startsWith('spotify:')) {
await ref.read(trackProvider.notifier).fetchFromUrl(url);
} else {
final settings = ref.read(settingsProvider);
await ref.read(trackProvider.notifier).search(url, metadataSource: settings.metadataSource);
}
}
void _downloadTrack(Track track) {
final settings = ref.read(settingsProvider);
ref.read(downloadQueueProvider.notifier).addToQueue(
track,
settings.defaultService,
);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Added "${track.name}" to queue')),
);
}
void _downloadAll() {
final trackState = ref.read(trackProvider);
if (trackState.tracks.isEmpty) return;
final settings = ref.read(settingsProvider);
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(
trackState.tracks,
settings.defaultService,
);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Added ${trackState.tracks.length} tracks to queue')),
);
}
void _onNavTap(int index) {
setState(() => _currentIndex = index);
switch (index) {
case 0:
break;
case 1:
context.push('/queue');
break;
case 2:
context.push('/history');
break;
}
}
@override
Widget build(BuildContext context) {
final trackState = ref.watch(trackProvider);
final queuedCount =
ref.watch(downloadQueueProvider.select((s) => s.queuedCount));
final colorScheme = Theme.of(context).colorScheme;
final tracks = trackState.tracks;
return Scaffold(
appBar: AppBar(
leading: Padding(
padding: const EdgeInsets.all(8.0),
child: CircleAvatar(
backgroundColor: colorScheme.primaryContainer,
child: Icon(Icons.music_note, color: colorScheme.onPrimaryContainer, size: 20),
),
),
title: const Text('SpotiFLAC'),
actions: [
IconButton(
icon: const Icon(Icons.settings_outlined),
onPressed: () => context.push('/settings'),
),
],
),
body: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: TextField(
controller: _urlController,
decoration: InputDecoration(
hintText: 'Paste Spotify URL or search...',
prefixIcon: const Icon(Icons.link),
suffixIcon: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(icon: const Icon(Icons.paste), onPressed: _pasteFromClipboard),
IconButton(icon: const Icon(Icons.search), onPressed: _fetchMetadata),
],
),
),
onSubmitted: (_) => _fetchMetadata(),
),
),
if (trackState.error != null)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Text(
trackState.error!,
style: TextStyle(color: colorScheme.error),
),
),
if (trackState.isLoading)
LinearProgressIndicator(color: colorScheme.primary),
if (trackState.albumName != null || trackState.playlistName != null)
_buildHeader(trackState, colorScheme),
if (tracks.length > 1)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
child: FilledButton.icon(
onPressed: _downloadAll,
icon: const Icon(Icons.download),
label: Text('Download All (${tracks.length})'),
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(48),
),
),
),
Expanded(
child: tracks.isEmpty
? _buildEmptyState(colorScheme)
: ListView.builder(
itemCount: tracks.length,
itemBuilder: (context, index) =>
_buildTrackTile(tracks[index], colorScheme),
),
),
],
),
bottomNavigationBar: NavigationBar(
selectedIndex: _currentIndex,
onDestinationSelected: _onNavTap,
destinations: [
const NavigationDestination(
icon: Icon(Icons.search_outlined),
selectedIcon: Icon(Icons.search),
label: 'Search',
),
NavigationDestination(
icon: Badge(
isLabelVisible: queuedCount > 0,
label: Text('$queuedCount'),
child: const Icon(Icons.queue_music_outlined),
),
selectedIcon: Badge(
isLabelVisible: queuedCount > 0,
label: Text('$queuedCount'),
child: const Icon(Icons.queue_music),
),
label: 'Queue',
),
const NavigationDestination(
icon: Icon(Icons.history_outlined),
selectedIcon: Icon(Icons.history),
label: 'History',
),
],
),
);
}
Widget _buildHeader(TrackState state, ColorScheme colorScheme) {
return Card(
margin: const EdgeInsets.all(16),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
if (state.coverUrl != null)
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: CachedNetworkImage(
imageUrl: state.coverUrl!,
width: 80,
height: 80,
fit: BoxFit.cover,
cacheManager: CoverCacheManager.instance,
placeholder: (_, _) => Container(
width: 80,
height: 80,
color: colorScheme.surfaceContainerHighest,
),
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
state.albumName ?? state.playlistName ?? '',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Text(
'${state.tracks.length} tracks',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
FilledButton.tonal(
onPressed: _downloadAll,
style: FilledButton.styleFrom(
shape: const CircleBorder(),
padding: const EdgeInsets.all(16),
),
child: const Icon(Icons.download),
),
],
),
),
);
}
Widget _buildTrackTile(Track track, ColorScheme colorScheme) {
final isCollection = track.isCollection;
String subtitleText;
if (isCollection) {
final typeLabel = track.albumType ?? (track.isPlaylistItem ? 'Playlist' : 'Album');
final capitalizedType = typeLabel.isNotEmpty
? '${typeLabel[0].toUpperCase()}${typeLabel.substring(1)}'
: 'Album';
final year = track.releaseDate != null && track.releaseDate!.length >= 4
? track.releaseDate!.substring(0, 4)
: '';
subtitleText = '$capitalizedType${track.artistName}${year.isNotEmpty ? '$year' : ''}';
} else {
subtitleText = track.artistName;
}
return ListTile(
leading: track.coverUrl != null
? ClipRRect(
borderRadius: BorderRadius.circular(8),
child: CachedNetworkImage(
imageUrl: track.coverUrl!,
width: 48,
height: 48,
fit: BoxFit.cover,
cacheManager: CoverCacheManager.instance,
),
)
: Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
),
child: Icon(
isCollection ? Icons.album : Icons.music_note,
color: colorScheme.onSurfaceVariant,
),
),
title: Text(track.name, maxLines: 1, overflow: TextOverflow.ellipsis),
subtitle: Text(
subtitleText,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: colorScheme.onSurfaceVariant),
),
trailing: isCollection
? Icon(Icons.chevron_right, color: colorScheme.onSurfaceVariant)
: Text(
_formatDuration(track.duration),
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
onTap: () => isCollection ? _openCollection(track) : _downloadTrack(track),
);
}
Future<void> _openCollection(Track track) async {
final extensionId = track.source;
if (extensionId == null) return;
try {
if (track.isAlbumItem) {
final albumData = await PlatformBridge.getAlbumWithExtension(extensionId, track.id);
if (albumData != null && mounted) {
final trackList = albumData['tracks'] as List<dynamic>? ?? [];
final tracks = trackList.map((t) => _parseExtensionTrack(t as Map<String, dynamic>, extensionId)).toList();
ref.read(trackProvider.notifier).setTracksFromCollection(
tracks: tracks,
albumName: albumData['name'] as String? ?? track.name,
coverUrl: albumData['cover_url'] as String? ?? track.coverUrl,
);
}
} else if (track.isPlaylistItem) {
final playlistData = await PlatformBridge.getPlaylistWithExtension(extensionId, track.id);
if (playlistData != null && mounted) {
final trackList = playlistData['tracks'] as List<dynamic>? ?? [];
final tracks = trackList.map((t) => _parseExtensionTrack(t as Map<String, dynamic>, extensionId)).toList();
ref.read(trackProvider.notifier).setTracksFromCollection(
tracks: tracks,
playlistName: playlistData['name'] as String? ?? track.name,
coverUrl: playlistData['cover_url'] as String? ?? track.coverUrl,
);
}
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to load: $e')),
);
}
}
}
Track _parseExtensionTrack(Map<String, dynamic> data, String source) {
int durationMs = 0;
final durationValue = data['duration_ms'];
if (durationValue is int) {
durationMs = durationValue;
} else if (durationValue is double) {
durationMs = durationValue.toInt();
}
return Track(
id: (data['id'] ?? '').toString(),
name: (data['name'] ?? '').toString(),
artistName: (data['artists'] ?? '').toString(),
albumName: (data['album_name'] ?? '').toString(),
coverUrl: (data['cover_url'] ?? data['images'])?.toString(),
duration: (durationMs / 1000).round(),
releaseDate: data['release_date']?.toString(),
source: source,
);
}
String _formatDuration(int ms) {
if (ms == 0) return '';
final duration = Duration(milliseconds: ms);
final minutes = duration.inMinutes;
final seconds = duration.inSeconds % 60;
return '$minutes:${seconds.toString().padLeft(2, '0')}';
}
Widget _buildEmptyState(ColorScheme colorScheme) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.music_note,
size: 64,
color: colorScheme.onSurfaceVariant,
),
const SizedBox(height: 16),
Text(
'Paste a Spotify URL to get started',
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
);
}
}
+24 -11
View File
@@ -82,9 +82,9 @@ class _MainShellState extends ConsumerState<MainShell> {
}
}
}
if (!mounted) return;
Navigator.of(context).popUntil((route) => route.isFirst);
if (_currentIndex != 0) {
@@ -181,10 +181,14 @@ class _MainShellState extends ConsumerState<MainShell> {
final treeUri = result['tree_uri'] as String? ?? '';
final displayName = result['display_name'] as String? ?? '';
if (treeUri.isNotEmpty) {
ref.read(settingsProvider.notifier).setDownloadTreeUri(
treeUri,
displayName: displayName.isNotEmpty ? displayName : treeUri,
);
ref
.read(settingsProvider.notifier)
.setDownloadTreeUri(
treeUri,
displayName: displayName.isNotEmpty
? displayName
: treeUri,
);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
@@ -280,7 +284,16 @@ class _MainShellState extends ConsumerState<MainShell> {
final queueState = ref.watch(
downloadQueueProvider.select((s) => s.queuedCount),
);
final trackState = ref.watch(trackProvider);
final trackHasSearchText = ref.watch(
trackProvider.select((s) => s.hasSearchText),
);
final trackHasContent = ref.watch(
trackProvider.select((s) => s.hasContent),
);
final trackIsLoading = ref.watch(trackProvider.select((s) => s.isLoading));
final trackIsShowingRecentAccess = ref.watch(
trackProvider.select((s) => s.isShowingRecentAccess),
);
final showStore = ref.watch(
settingsProvider.select((s) => s.showExtensionStore),
);
@@ -292,10 +305,10 @@ class _MainShellState extends ConsumerState<MainShell> {
final canPop =
_currentIndex == 0 &&
!trackState.hasSearchText &&
!trackState.hasContent &&
!trackState.isLoading &&
!trackState.isShowingRecentAccess &&
!trackHasSearchText &&
!trackHasContent &&
!trackIsLoading &&
!trackIsShowingRecentAccess &&
!isKeyboardVisible;
final tabs = <Widget>[
-248
View File
@@ -1,248 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:spotiflac_android/services/cover_cache_manager.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/models/download_item.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
class QueueScreen extends ConsumerWidget {
const QueueScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final items = ref.watch(downloadQueueProvider.select((s) => s.items));
final colorScheme = Theme.of(context).colorScheme;
return Scaffold(
appBar: AppBar(
title: Text(context.l10n.queueTitle),
actions: [
if (items.isNotEmpty)
IconButton(
icon: const Icon(Icons.delete_sweep),
onPressed: () => ref.read(downloadQueueProvider.notifier).clearCompleted(),
tooltip: context.l10n.queueClearCompleted,
),
if (items.isNotEmpty)
IconButton(
icon: const Icon(Icons.clear_all),
onPressed: () => _showClearAllDialog(context, ref),
tooltip: context.l10n.queueClearAll,
),
],
),
body: items.isEmpty
? _buildEmptyState(context, colorScheme)
: ListView.builder(
itemCount: items.length,
itemBuilder: (context, index) =>
_buildQueueItem(context, ref, items[index], colorScheme),
),
);
}
Widget _buildEmptyState(BuildContext context, ColorScheme colorScheme) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.queue,
size: 64,
color: colorScheme.onSurfaceVariant,
),
const SizedBox(height: 16),
Text(
context.l10n.queueEmpty,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 8),
Text(
context.l10n.queueEmptySubtitle,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7),
),
),
],
),
);
}
Widget _buildQueueItem(BuildContext context, WidgetRef ref, DownloadItem item, ColorScheme colorScheme) {
return ListTile(
leading: item.track.coverUrl != null
? ClipRRect(
borderRadius: BorderRadius.circular(8),
child: CachedNetworkImage(
imageUrl: item.track.coverUrl!,
width: 48,
height: 48,
fit: BoxFit.cover,
cacheManager: CoverCacheManager.instance,
),
)
: Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
),
child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant),
),
title: Text(item.track.name, maxLines: 1, overflow: TextOverflow.ellipsis),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.track.artistName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: colorScheme.onSurfaceVariant),
),
if (item.status == DownloadStatus.downloading) ...[
const SizedBox(height: 4),
Row(
children: [
Expanded(
child: LinearProgressIndicator(
value: item.progress > 0 ? item.progress : null,
backgroundColor: colorScheme.surfaceContainerHighest,
color: colorScheme.primary,
),
),
const SizedBox(width: 8),
Text(
'${(item.progress * 100).toStringAsFixed(0)}%',
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: colorScheme.onSurfaceVariant,
fontWeight: FontWeight.bold,
),
),
],
),
],
],
),
trailing: _buildStatusIcon(context, item, colorScheme),
onTap: item.status == DownloadStatus.queued
? () => ref.read(downloadQueueProvider.notifier).cancelItem(item.id)
: null,
);
}
Widget _buildStatusIcon(BuildContext context, DownloadItem item, ColorScheme colorScheme) {
switch (item.status) {
case DownloadStatus.queued:
return Icon(Icons.hourglass_empty, color: colorScheme.onSurfaceVariant);
case DownloadStatus.downloading:
return SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(
value: item.progress,
strokeWidth: 2,
color: colorScheme.primary,
),
);
case DownloadStatus.finalizing:
return SizedBox(
width: 24,
height: 24,
child: Stack(
alignment: Alignment.center,
children: [
CircularProgressIndicator(strokeWidth: 2, color: colorScheme.tertiary),
Icon(Icons.edit_note, color: colorScheme.tertiary, size: 12),
],
),
);
case DownloadStatus.completed:
return Icon(Icons.check_circle, color: colorScheme.primary);
case DownloadStatus.failed:
return IconButton(
icon: Icon(Icons.error, color: colorScheme.error),
onPressed: () => _showErrorDialog(context, item, colorScheme),
tooltip: 'Tap to see error details',
);
case DownloadStatus.skipped:
return Icon(Icons.skip_next, color: colorScheme.primary);
}
}
void _showErrorDialog(BuildContext context, DownloadItem item, ColorScheme colorScheme) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Row(
children: [
Icon(Icons.error, color: colorScheme.error),
const SizedBox(width: 8),
Text(context.l10n.queueDownloadFailed),
],
),
content: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text('${context.l10n.queueTrackLabel} ${item.track.name}', style: const TextStyle(fontWeight: FontWeight.bold)),
Text('${context.l10n.queueArtistLabel} ${item.track.artistName}'),
const SizedBox(height: 16),
Text(context.l10n.queueErrorLabel, style: const TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(height: 4),
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: colorScheme.errorContainer,
borderRadius: BorderRadius.circular(8),
),
child: Text(
item.error ?? context.l10n.queueUnknownError,
style: TextStyle(
fontFamily: 'monospace',
fontSize: 12,
color: colorScheme.onErrorContainer,
),
),
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(context.l10n.dialogClose),
),
],
),
);
}
void _showClearAllDialog(BuildContext context, WidgetRef ref) {
final colorScheme = Theme.of(context).colorScheme;
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(context.l10n.queueClearAll),
content: Text(context.l10n.queueClearAllMessage),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(context.l10n.dialogCancel),
),
TextButton(
onPressed: () {
ref.read(downloadQueueProvider.notifier).clearAll();
Navigator.pop(context);
},
child: Text(context.l10n.dialogClear, style: TextStyle(color: colorScheme.error)),
),
],
),
);
}
}
+595 -613
View File
File diff suppressed because it is too large Load Diff
-540
View File
@@ -1,540 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:file_picker/file_picker.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:spotiflac_android/constants/app_info.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/providers/theme_provider.dart';
class SettingsScreen extends ConsumerWidget {
const SettingsScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final settings = ref.watch(settingsProvider);
final themeSettings = ref.watch(themeProvider);
final colorScheme = Theme.of(context).colorScheme;
return Scaffold(
appBar: AppBar(title: const Text('Settings')),
body: ListView(
children: [
// Theme Section
_buildSectionHeader(context, 'Appearance', colorScheme),
// Theme Mode
ListTile(
leading: Icon(Icons.brightness_6, color: colorScheme.primary),
title: const Text('Theme Mode'),
subtitle: Text(_getThemeModeName(themeSettings.themeMode)),
onTap: () => _showThemeModePicker(context, ref, themeSettings.themeMode),
),
// Dynamic Color Toggle
SwitchListTile(
secondary: Icon(Icons.palette, color: colorScheme.primary),
title: const Text('Dynamic Color'),
subtitle: const Text('Use colors from your wallpaper'),
value: themeSettings.useDynamicColor,
onChanged: (value) => ref.read(themeProvider.notifier).setUseDynamicColor(value),
),
// Seed Color Picker (only when dynamic color is disabled)
if (!themeSettings.useDynamicColor)
ListTile(
leading: Container(
width: 24,
height: 24,
decoration: BoxDecoration(
color: Color(themeSettings.seedColorValue),
shape: BoxShape.circle,
border: Border.all(color: colorScheme.outline),
),
),
title: const Text('Accent Color'),
subtitle: const Text('Choose your preferred color'),
onTap: () => _showColorPicker(context, ref, themeSettings.seedColorValue),
),
const Divider(),
// Download Section
_buildSectionHeader(context, 'Download', colorScheme),
// Download Service
ListTile(
leading: Icon(Icons.cloud_download, color: colorScheme.primary),
title: const Text('Default Service'),
subtitle: Text(_getServiceName(settings.defaultService)),
onTap: () => _showServicePicker(context, ref, settings.defaultService),
),
// Audio Quality
ListTile(
leading: Icon(Icons.high_quality, color: colorScheme.primary),
title: const Text('Audio Quality'),
subtitle: Text(_getQualityName(settings.audioQuality)),
onTap: () => _showQualityPicker(context, ref, settings.audioQuality),
),
// Filename Format
ListTile(
leading: Icon(Icons.text_fields, color: colorScheme.primary),
title: const Text('Filename Format'),
subtitle: Text(settings.filenameFormat),
onTap: () => _showFormatEditor(context, ref, settings.filenameFormat),
),
// Download Directory
ListTile(
leading: Icon(Icons.folder, color: colorScheme.primary),
title: const Text('Download Directory'),
subtitle: Text(settings.downloadDirectory.isEmpty ? 'Music/SpotiFLAC' : settings.downloadDirectory),
onTap: () => _pickDirectory(context, ref),
),
const Divider(),
// Options Section
_buildSectionHeader(context, 'Options', colorScheme),
// Auto Fallback
SwitchListTile(
secondary: Icon(Icons.sync, color: colorScheme.primary),
title: const Text('Auto Fallback'),
subtitle: const Text('Try other services if download fails'),
value: settings.autoFallback,
onChanged: (value) => ref.read(settingsProvider.notifier).setAutoFallback(value),
),
// Embed Lyrics
SwitchListTile(
secondary: Icon(Icons.lyrics, color: colorScheme.primary),
title: const Text('Embed Lyrics'),
subtitle: const Text('Embed synced lyrics into FLAC files'),
value: settings.embedLyrics,
onChanged: (value) => ref.read(settingsProvider.notifier).setEmbedLyrics(value),
),
// Max Quality Cover
SwitchListTile(
secondary: Icon(Icons.image, color: colorScheme.primary),
title: const Text('Max Quality Cover'),
subtitle: const Text('Download highest resolution cover art'),
value: settings.maxQualityCover,
onChanged: (value) => ref.read(settingsProvider.notifier).setMaxQualityCover(value),
),
// Concurrent Downloads
ListTile(
leading: Icon(Icons.download_for_offline, color: colorScheme.primary),
title: const Text('Concurrent Downloads'),
subtitle: Text(settings.concurrentDownloads == 1
? 'Sequential (1 at a time)'
: '${settings.concurrentDownloads} parallel downloads'),
onTap: () => _showConcurrentDownloadsPicker(context, ref, settings.concurrentDownloads),
),
// Check for Updates
SwitchListTile(
secondary: Icon(Icons.system_update, color: colorScheme.primary),
title: const Text('Check for Updates'),
subtitle: const Text('Notify when new version is available'),
value: settings.checkForUpdates,
onChanged: (value) => ref.read(settingsProvider.notifier).setCheckForUpdates(value),
),
const Divider(),
// GitHub & Credits Section
_buildSectionHeader(context, 'GitHub & Credits', colorScheme),
ListTile(
leading: Icon(Icons.code, color: colorScheme.primary),
title: Text('${AppInfo.appName} Mobile'),
subtitle: Text('github.com/${AppInfo.githubRepo}'),
onTap: () => _launchUrl(AppInfo.githubUrl),
),
ListTile(
leading: Icon(Icons.computer, color: colorScheme.primary),
title: Text('Original ${AppInfo.appName} (Desktop)'),
subtitle: Text('github.com/${AppInfo.originalAuthor}/SpotiFLAC'),
onTap: () => _launchUrl(AppInfo.originalGithubUrl),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Text(
'Mobile version maintained by ${AppInfo.mobileAuthor}\nOriginal project by ${AppInfo.originalAuthor}',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
const Divider(),
// About
ListTile(
leading: Icon(Icons.info, color: colorScheme.primary),
title: const Text('About'),
subtitle: Text('${AppInfo.appName} v${AppInfo.version}'),
onTap: () => _showAboutDialog(context),
),
],
),
);
}
void _showAboutDialog(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Row(
children: [
Image.asset('assets/images/logo.png', width: 40, height: 40, errorBuilder: (_, _, _) => Icon(Icons.music_note, size: 40, color: colorScheme.primary)),
const SizedBox(width: 12),
Text(AppInfo.appName),
],
),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildAboutRow('Version', AppInfo.version, colorScheme),
const SizedBox(height: 8),
_buildAboutRow('Mobile', AppInfo.mobileAuthor, colorScheme),
const SizedBox(height: 8),
_buildAboutRow('Original', AppInfo.originalAuthor, colorScheme),
const SizedBox(height: 16),
Text(
AppInfo.copyright,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Close'),
),
],
),
);
}
Widget _buildAboutRow(String label, String value, ColorScheme colorScheme) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(label, style: TextStyle(color: colorScheme.onSurfaceVariant)),
Text(value, style: const TextStyle(fontWeight: FontWeight.w500)),
],
);
}
Widget _buildSectionHeader(BuildContext context, String title, ColorScheme colorScheme) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Text(
title,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: colorScheme.primary,
fontWeight: FontWeight.bold,
),
),
);
}
String _getThemeModeName(ThemeMode mode) {
switch (mode) {
case ThemeMode.light: return 'Light';
case ThemeMode.dark: return 'Dark';
case ThemeMode.system: return 'System';
}
}
String _getServiceName(String service) {
switch (service) {
case 'tidal': return 'Tidal';
case 'qobuz': return 'Qobuz';
case 'amazon': return 'Amazon Music';
default: return service;
}
}
String _getQualityName(String quality) {
switch (quality) {
case 'LOSSLESS': return 'FLAC (Lossless)';
case 'HI_RES': return 'Hi-Res FLAC (24-bit)';
default: return quality;
}
}
void _showThemeModePicker(BuildContext context, WidgetRef ref, ThemeMode current) {
final colorScheme = Theme.of(context).colorScheme;
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Theme Mode'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
_buildThemeModeOption(context, ref, ThemeMode.system, 'System', Icons.brightness_auto, current, colorScheme),
_buildThemeModeOption(context, ref, ThemeMode.light, 'Light', Icons.light_mode, current, colorScheme),
_buildThemeModeOption(context, ref, ThemeMode.dark, 'Dark', Icons.dark_mode, current, colorScheme),
],
),
),
);
}
Widget _buildThemeModeOption(BuildContext context, WidgetRef ref, ThemeMode mode, String label, IconData icon, ThemeMode current, ColorScheme colorScheme) {
final isSelected = mode == current;
return ListTile(
leading: Icon(icon, color: isSelected ? colorScheme.primary : null),
title: Text(label),
trailing: isSelected ? Icon(Icons.check, color: colorScheme.primary) : null,
onTap: () {
ref.read(themeProvider.notifier).setThemeMode(mode);
Navigator.pop(context);
},
);
}
void _showColorPicker(BuildContext context, WidgetRef ref, int currentColor) {
final colors = [
const Color(0xFF1DB954), // Spotify Green
const Color(0xFF6750A4), // Purple
const Color(0xFF0061A4), // Blue
const Color(0xFF006E1C), // Green
const Color(0xFFBA1A1A), // Red
const Color(0xFF984061), // Pink
const Color(0xFF7D5260), // Brown
const Color(0xFF006874), // Teal
const Color(0xFFFF6F00), // Orange
];
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Choose Accent Color'),
content: Wrap(
spacing: 12,
runSpacing: 12,
children: colors.map((color) {
final isSelected = color.toARGB32() == currentColor;
return GestureDetector(
onTap: () {
ref.read(themeProvider.notifier).setSeedColor(color);
Navigator.pop(context);
},
child: Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
border: isSelected
? Border.all(color: Theme.of(context).colorScheme.onSurface, width: 3)
: null,
),
child: isSelected
? const Icon(Icons.check, color: Colors.white)
: null,
),
);
}).toList(),
),
),
);
}
void _showServicePicker(BuildContext context, WidgetRef ref, String current) {
final colorScheme = Theme.of(context).colorScheme;
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Select Service'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
_buildServiceOption(context, ref, 'tidal', 'Tidal', current, colorScheme),
_buildServiceOption(context, ref, 'qobuz', 'Qobuz', current, colorScheme),
_buildServiceOption(context, ref, 'amazon', 'Amazon Music', current, colorScheme),
],
),
),
);
}
Widget _buildServiceOption(BuildContext context, WidgetRef ref, String value, String label, String current, ColorScheme colorScheme) {
final isSelected = value == current;
return ListTile(
title: Text(label),
trailing: isSelected ? Icon(Icons.check, color: colorScheme.primary) : null,
onTap: () {
ref.read(settingsProvider.notifier).setDefaultService(value);
Navigator.pop(context);
},
);
}
void _showQualityPicker(BuildContext context, WidgetRef ref, String current) {
final colorScheme = Theme.of(context).colorScheme;
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Select Quality'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Disclaimer
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Text(
'Actual quality depends on track availability. Hi-Res may not be available for all tracks.',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
fontStyle: FontStyle.italic,
),
),
),
_buildQualityOption(context, ref, 'LOSSLESS', 'FLAC (Lossless)', '16-bit / 44.1kHz', current, colorScheme),
_buildQualityOption(context, ref, 'HI_RES', 'Hi-Res FLAC', '24-bit / up to 192kHz', current, colorScheme),
],
),
),
);
}
Widget _buildQualityOption(BuildContext context, WidgetRef ref, String value, String title, String subtitle, String current, ColorScheme colorScheme) {
final isSelected = value == current;
return ListTile(
title: Text(title),
subtitle: Text(subtitle),
trailing: isSelected ? Icon(Icons.check, color: colorScheme.primary) : null,
onTap: () {
ref.read(settingsProvider.notifier).setAudioQuality(value);
Navigator.pop(context);
},
);
}
void _showFormatEditor(BuildContext context, WidgetRef ref, String current) {
final controller = TextEditingController(text: current);
final colorScheme = Theme.of(context).colorScheme;
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Filename Format'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(
controller: controller,
decoration: const InputDecoration(
hintText: '{artist} - {title}',
),
),
const SizedBox(height: 16),
Text(
'Available placeholders:',
style: Theme.of(context).textTheme.labelMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 4),
Text(
'{title}, {artist}, {album}, {track}, {year}, {disc}',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () {
ref.read(settingsProvider.notifier).setFilenameFormat(controller.text);
Navigator.pop(context);
},
child: const Text('Save'),
),
],
),
);
}
Future<void> _pickDirectory(BuildContext context, WidgetRef ref) async {
final result = await FilePicker.platform.getDirectoryPath();
if (result != null) {
ref.read(settingsProvider.notifier).setDownloadDirectory(result);
}
}
void _showConcurrentDownloadsPicker(BuildContext context, WidgetRef ref, int current) {
final colorScheme = Theme.of(context).colorScheme;
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Concurrent Downloads'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildConcurrentOption(context, ref, 1, 'Sequential', 'Download one at a time (recommended)', current, colorScheme),
_buildConcurrentOption(context, ref, 2, '2 Parallel', 'Download 2 tracks simultaneously', current, colorScheme),
_buildConcurrentOption(context, ref, 3, '3 Parallel', 'Download 3 tracks simultaneously', current, colorScheme),
const SizedBox(height: 12),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Icons.warning_amber_rounded, size: 16, color: colorScheme.error),
const SizedBox(width: 8),
Expanded(
child: Text(
'Parallel downloads may trigger rate limiting from streaming services.',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.error,
),
),
),
],
),
],
),
),
);
}
Widget _buildConcurrentOption(BuildContext context, WidgetRef ref, int value, String title, String subtitle, int current, ColorScheme colorScheme) {
final isSelected = value == current;
return ListTile(
title: Text(title),
subtitle: Text(subtitle),
trailing: isSelected ? Icon(Icons.check, color: colorScheme.primary) : null,
onTap: () {
ref.read(settingsProvider.notifier).setConcurrentDownloads(value);
Navigator.pop(context);
},
);
}
Future<void> _launchUrl(String url) async {
final uri = Uri.parse(url);
if (await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
}
}
}
-520
View File
@@ -1,520 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:file_picker/file_picker.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:spotiflac_android/constants/app_info.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/providers/theme_provider.dart';
class SettingsTab extends ConsumerStatefulWidget {
const SettingsTab({super.key});
@override
ConsumerState<SettingsTab> createState() => _SettingsTabState();
}
class _SettingsTabState extends ConsumerState<SettingsTab> with AutomaticKeepAliveClientMixin {
@override
bool get wantKeepAlive => true;
@override
Widget build(BuildContext context) {
super.build(context);
final settings = ref.watch(settingsProvider);
final themeSettings = ref.watch(themeProvider);
final colorScheme = Theme.of(context).colorScheme;
return ListView(
children: [
// Theme Section
_buildSectionHeader(context, 'Appearance', colorScheme),
// Theme Mode
ListTile(
leading: Icon(Icons.brightness_6, color: colorScheme.primary),
title: const Text('Theme Mode'),
subtitle: Text(_getThemeModeName(themeSettings.themeMode)),
onTap: () => _showThemeModePicker(context, ref, themeSettings.themeMode),
),
// Dynamic Color Toggle
SwitchListTile(
secondary: Icon(Icons.palette, color: colorScheme.primary),
title: const Text('Dynamic Color'),
subtitle: const Text('Use colors from your wallpaper'),
value: themeSettings.useDynamicColor,
onChanged: (value) => ref.read(themeProvider.notifier).setUseDynamicColor(value),
),
// Seed Color Picker (only when dynamic color is disabled)
if (!themeSettings.useDynamicColor)
ListTile(
leading: Container(
width: 24,
height: 24,
decoration: BoxDecoration(
color: Color(themeSettings.seedColorValue),
shape: BoxShape.circle,
border: Border.all(color: colorScheme.outline),
),
),
title: const Text('Accent Color'),
subtitle: const Text('Choose your preferred color'),
onTap: () => _showColorPicker(context, ref, themeSettings.seedColorValue),
),
const Divider(),
// Download Section
_buildSectionHeader(context, 'Download', colorScheme),
// Download Service
ListTile(
leading: Icon(Icons.cloud_download, color: colorScheme.primary),
title: const Text('Default Service'),
subtitle: Text(_getServiceName(settings.defaultService)),
onTap: () => _showServicePicker(context, ref, settings.defaultService),
),
// Audio Quality
ListTile(
leading: Icon(Icons.high_quality, color: colorScheme.primary),
title: const Text('Audio Quality'),
subtitle: Text(_getQualityName(settings.audioQuality)),
onTap: () => _showQualityPicker(context, ref, settings.audioQuality),
),
// Filename Format
ListTile(
leading: Icon(Icons.text_fields, color: colorScheme.primary),
title: const Text('Filename Format'),
subtitle: Text(settings.filenameFormat),
onTap: () => _showFormatEditor(context, ref, settings.filenameFormat),
),
// Download Directory
ListTile(
leading: Icon(Icons.folder, color: colorScheme.primary),
title: const Text('Download Directory'),
subtitle: Text(settings.downloadDirectory.isEmpty ? 'Music/SpotiFLAC' : settings.downloadDirectory),
onTap: () => _pickDirectory(context, ref),
),
const Divider(),
// Options Section
_buildSectionHeader(context, 'Options', colorScheme),
// Auto Fallback
SwitchListTile(
secondary: Icon(Icons.sync, color: colorScheme.primary),
title: const Text('Auto Fallback'),
subtitle: const Text('Try other services if download fails'),
value: settings.autoFallback,
onChanged: (value) => ref.read(settingsProvider.notifier).setAutoFallback(value),
),
// Embed Lyrics
SwitchListTile(
secondary: Icon(Icons.lyrics, color: colorScheme.primary),
title: const Text('Embed Lyrics'),
subtitle: const Text('Embed synced lyrics into FLAC files'),
value: settings.embedLyrics,
onChanged: (value) => ref.read(settingsProvider.notifier).setEmbedLyrics(value),
),
// Max Quality Cover
SwitchListTile(
secondary: Icon(Icons.image, color: colorScheme.primary),
title: const Text('Max Quality Cover'),
subtitle: const Text('Download highest resolution cover art'),
value: settings.maxQualityCover,
onChanged: (value) => ref.read(settingsProvider.notifier).setMaxQualityCover(value),
),
// Concurrent Downloads
ListTile(
leading: Icon(Icons.download_for_offline, color: colorScheme.primary),
title: const Text('Concurrent Downloads'),
subtitle: Text(settings.concurrentDownloads == 1
? 'Sequential (1 at a time)'
: '${settings.concurrentDownloads} parallel downloads'),
onTap: () => _showConcurrentDownloadsPicker(context, ref, settings.concurrentDownloads),
),
// Check for Updates
SwitchListTile(
secondary: Icon(Icons.system_update, color: colorScheme.primary),
title: const Text('Check for Updates'),
subtitle: const Text('Notify when new version is available'),
value: settings.checkForUpdates,
onChanged: (value) => ref.read(settingsProvider.notifier).setCheckForUpdates(value),
),
const Divider(),
// GitHub & Credits Section
_buildSectionHeader(context, 'GitHub & Credits', colorScheme),
ListTile(
leading: Icon(Icons.code, color: colorScheme.primary),
title: Text('${AppInfo.appName} Mobile'),
subtitle: Text('github.com/${AppInfo.githubRepo}'),
onTap: () => _launchUrl(AppInfo.githubUrl),
),
ListTile(
leading: Icon(Icons.computer, color: colorScheme.primary),
title: Text('Original ${AppInfo.appName} (Desktop)'),
subtitle: Text('github.com/${AppInfo.originalAuthor}/SpotiFLAC'),
onTap: () => _launchUrl(AppInfo.originalGithubUrl),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Text(
'Mobile version maintained by ${AppInfo.mobileAuthor}\nOriginal project by ${AppInfo.originalAuthor}',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
const Divider(),
// About
ListTile(
leading: Icon(Icons.info, color: colorScheme.primary),
title: const Text('About'),
subtitle: Text('${AppInfo.appName} v${AppInfo.version}'),
onTap: () => _showAboutDialog(context),
),
// Bottom padding for navigation bar
const SizedBox(height: 16),
],
);
}
void _showAboutDialog(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Row(
children: [
Image.asset('assets/images/logo.png', width: 40, height: 40, errorBuilder: (_, _, _) => Icon(Icons.music_note, size: 40, color: colorScheme.primary)),
const SizedBox(width: 12),
Text(AppInfo.appName),
],
),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildAboutRow('Version', AppInfo.version, colorScheme),
const SizedBox(height: 8),
_buildAboutRow('Mobile', AppInfo.mobileAuthor, colorScheme),
const SizedBox(height: 8),
_buildAboutRow('Original', AppInfo.originalAuthor, colorScheme),
const SizedBox(height: 16),
Text(
AppInfo.copyright,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Close'),
),
],
),
);
}
Widget _buildAboutRow(String label, String value, ColorScheme colorScheme) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(label, style: TextStyle(color: colorScheme.onSurfaceVariant)),
Text(value, style: const TextStyle(fontWeight: FontWeight.w500)),
],
);
}
Widget _buildSectionHeader(BuildContext context, String title, ColorScheme colorScheme) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Text(
title,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: colorScheme.primary,
fontWeight: FontWeight.bold,
),
),
);
}
String _getThemeModeName(ThemeMode mode) {
switch (mode) {
case ThemeMode.light: return 'Light';
case ThemeMode.dark: return 'Dark';
case ThemeMode.system: return 'System';
}
}
String _getServiceName(String service) {
switch (service) {
case 'tidal': return 'Tidal';
case 'qobuz': return 'Qobuz';
case 'amazon': return 'Amazon Music';
default: return service;
}
}
String _getQualityName(String quality) {
switch (quality) {
case 'LOSSLESS': return 'FLAC (16-bit / 44.1kHz)';
case 'HI_RES': return 'Hi-Res FLAC (24-bit / 96kHz)';
case 'HI_RES_LOSSLESS': return 'Hi-Res FLAC (24-bit / 192kHz)';
default: return quality;
}
}
void _showThemeModePicker(BuildContext context, WidgetRef ref, ThemeMode current) {
final colorScheme = Theme.of(context).colorScheme;
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Theme Mode'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
_buildThemeModeOption(context, ref, ThemeMode.system, 'System', Icons.brightness_auto, current, colorScheme),
_buildThemeModeOption(context, ref, ThemeMode.light, 'Light', Icons.light_mode, current, colorScheme),
_buildThemeModeOption(context, ref, ThemeMode.dark, 'Dark', Icons.dark_mode, current, colorScheme),
],
),
),
);
}
Widget _buildThemeModeOption(BuildContext context, WidgetRef ref, ThemeMode mode, String label, IconData icon, ThemeMode current, ColorScheme colorScheme) {
final isSelected = mode == current;
return ListTile(
leading: Icon(icon, color: isSelected ? colorScheme.primary : null),
title: Text(label),
trailing: isSelected ? Icon(Icons.check, color: colorScheme.primary) : null,
onTap: () {
ref.read(themeProvider.notifier).setThemeMode(mode);
Navigator.pop(context);
},
);
}
void _showColorPicker(BuildContext context, WidgetRef ref, int currentColor) {
final colors = [
const Color(0xFF1DB954), const Color(0xFF6750A4), const Color(0xFF0061A4),
const Color(0xFF006E1C), const Color(0xFFBA1A1A), const Color(0xFF984061),
const Color(0xFF7D5260), const Color(0xFF006874), const Color(0xFFFF6F00),
];
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Choose Accent Color'),
content: Wrap(
spacing: 12,
runSpacing: 12,
children: colors.map((color) {
final isSelected = color.toARGB32() == currentColor;
return GestureDetector(
onTap: () {
ref.read(themeProvider.notifier).setSeedColor(color);
Navigator.pop(context);
},
child: Container(
width: 48, height: 48,
decoration: BoxDecoration(
color: color, shape: BoxShape.circle,
border: isSelected ? Border.all(color: Theme.of(context).colorScheme.onSurface, width: 3) : null,
),
child: isSelected ? const Icon(Icons.check, color: Colors.white) : null,
),
);
}).toList(),
),
),
);
}
void _showServicePicker(BuildContext context, WidgetRef ref, String current) {
final colorScheme = Theme.of(context).colorScheme;
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Select Service'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
_buildServiceOption(context, ref, 'tidal', 'Tidal', current, colorScheme),
_buildServiceOption(context, ref, 'qobuz', 'Qobuz', current, colorScheme),
_buildServiceOption(context, ref, 'amazon', 'Amazon Music', current, colorScheme),
],
),
),
);
}
Widget _buildServiceOption(BuildContext context, WidgetRef ref, String value, String label, String current, ColorScheme colorScheme) {
final isSelected = value == current;
return ListTile(
title: Text(label),
trailing: isSelected ? Icon(Icons.check, color: colorScheme.primary) : null,
onTap: () {
ref.read(settingsProvider.notifier).setDefaultService(value);
Navigator.pop(context);
},
);
}
void _showQualityPicker(BuildContext context, WidgetRef ref, String current) {
final colorScheme = Theme.of(context).colorScheme;
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Select Quality'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Disclaimer
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Text(
'Actual quality depends on track availability. Hi-Res may not be available for all tracks.',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
fontStyle: FontStyle.italic,
),
),
),
_buildQualityOption(context, ref, 'LOSSLESS', 'FLAC (Lossless)', '16-bit / 44.1kHz', current, colorScheme),
_buildQualityOption(context, ref, 'HI_RES', 'Hi-Res FLAC', '24-bit / up to 96kHz', current, colorScheme),
_buildQualityOption(context, ref, 'HI_RES_LOSSLESS', 'Hi-Res FLAC Max', '24-bit / up to 192kHz', current, colorScheme),
],
),
),
);
}
Widget _buildQualityOption(BuildContext context, WidgetRef ref, String value, String title, String subtitle, String current, ColorScheme colorScheme) {
final isSelected = value == current;
return ListTile(
title: Text(title),
subtitle: Text(subtitle),
trailing: isSelected ? Icon(Icons.check, color: colorScheme.primary) : null,
onTap: () {
ref.read(settingsProvider.notifier).setAudioQuality(value);
Navigator.pop(context);
},
);
}
void _showFormatEditor(BuildContext context, WidgetRef ref, String current) {
final controller = TextEditingController(text: current);
final colorScheme = Theme.of(context).colorScheme;
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Filename Format'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(controller: controller, decoration: const InputDecoration(hintText: '{artist} - {title}')),
const SizedBox(height: 16),
Text('Available placeholders:', style: Theme.of(context).textTheme.labelMedium?.copyWith(color: colorScheme.onSurfaceVariant)),
const SizedBox(height: 4),
Text('{title}, {artist}, {album}, {track}, {year}, {disc}', style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant)),
],
),
actions: [
TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')),
FilledButton(
onPressed: () {
ref.read(settingsProvider.notifier).setFilenameFormat(controller.text);
Navigator.pop(context);
},
child: const Text('Save'),
),
],
),
);
}
Future<void> _pickDirectory(BuildContext context, WidgetRef ref) async {
final result = await FilePicker.platform.getDirectoryPath();
if (result != null) {
ref.read(settingsProvider.notifier).setDownloadDirectory(result);
}
}
void _showConcurrentDownloadsPicker(BuildContext context, WidgetRef ref, int current) {
final colorScheme = Theme.of(context).colorScheme;
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Concurrent Downloads'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildConcurrentOption(context, ref, 1, 'Sequential', 'Download one at a time (recommended)', current, colorScheme),
_buildConcurrentOption(context, ref, 2, '2 Parallel', 'Download 2 tracks simultaneously', current, colorScheme),
_buildConcurrentOption(context, ref, 3, '3 Parallel', 'Download 3 tracks simultaneously', current, colorScheme),
const SizedBox(height: 12),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Icons.warning_amber_rounded, size: 16, color: colorScheme.error),
const SizedBox(width: 8),
Expanded(
child: Text(
'Parallel downloads may trigger rate limiting from streaming services.',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.error,
),
),
),
],
),
],
),
),
);
}
Widget _buildConcurrentOption(BuildContext context, WidgetRef ref, int value, String title, String subtitle, int current, ColorScheme colorScheme) {
final isSelected = value == current;
return ListTile(
title: Text(title),
subtitle: Text(subtitle),
trailing: isSelected ? Icon(Icons.check, color: colorScheme.primary) : null,
onTap: () {
ref.read(settingsProvider.notifier).setConcurrentDownloads(value);
Navigator.pop(context);
},
);
}
Future<void> _launchUrl(String url) async {
final uri = Uri.parse(url);
if (await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
}
}
}