mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-11 02:08:44 +02:00
v1.5.0: UI rework, multi-progress tracking, performance optimizations
This commit is contained in:
1 parent
db1439e08f
commit
d227d57545
38 files changed
+2801
-833
No files matched your search
+283
-360
@@ -11,34 +11,49 @@ import 'package:spotiflac_android/screens/track_metadata_screen.dart';
|
||||
|
||||
class HomeTab extends ConsumerStatefulWidget {
|
||||
const HomeTab({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<HomeTab> createState() => _HomeTabState();
|
||||
}
|
||||
|
||||
class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClientMixin {
|
||||
final _urlController = TextEditingController();
|
||||
|
||||
final Map<String, bool> _fileExistsCache = {}; // Cache file existence
|
||||
|
||||
@override
|
||||
bool get wantKeepAlive => true;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_urlController.dispose();
|
||||
super.dispose();
|
||||
void dispose() { _urlController.dispose(); super.dispose(); }
|
||||
|
||||
/// Check if file exists with caching to avoid blocking main thread
|
||||
bool _checkFileExists(String filePath) {
|
||||
if (_fileExistsCache.containsKey(filePath)) {
|
||||
return _fileExistsCache[filePath]!;
|
||||
}
|
||||
// Schedule async check and return false for now
|
||||
Future.microtask(() async {
|
||||
final exists = await File(filePath).exists();
|
||||
if (mounted && _fileExistsCache[filePath] != exists) {
|
||||
setState(() => _fileExistsCache[filePath] = exists);
|
||||
}
|
||||
});
|
||||
_fileExistsCache[filePath] = false; // Assume false until checked
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<void> _pasteFromClipboard() async {
|
||||
final data = await Clipboard.getData(Clipboard.kTextPlain);
|
||||
if (data?.text != null) {
|
||||
_urlController.text = data!.text!;
|
||||
}
|
||||
if (data?.text != null) _urlController.text = data!.text!;
|
||||
}
|
||||
|
||||
Future<void> _clearAndRefresh() async {
|
||||
_urlController.clear();
|
||||
ref.read(trackProvider.notifier).clear();
|
||||
await Future.delayed(const Duration(milliseconds: 300));
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -52,35 +67,21 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
final track = trackState.tracks[index];
|
||||
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')),
|
||||
);
|
||||
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')),
|
||||
);
|
||||
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(trackState.tracks, settings.defaultService);
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added ${trackState.tracks.length} tracks to queue')));
|
||||
}
|
||||
|
||||
Future<void> _openFile(String filePath) async {
|
||||
try {
|
||||
await OpenFilex.open(filePath);
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Cannot open file: $e')),
|
||||
);
|
||||
}
|
||||
try { await OpenFilex.open(filePath); } catch (e) {
|
||||
if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Cannot open file: $e')));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,138 +92,173 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
final historyState = ref.watch(downloadHistoryProvider);
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return CustomScrollView(
|
||||
slivers: [
|
||||
// Search bar
|
||||
return RefreshIndicator(
|
||||
onRefresh: _clearAndRefresh,
|
||||
displacement: 100,
|
||||
child: CustomScrollView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
slivers: [
|
||||
// Collapsing App Bar - Simplified for performance
|
||||
SliverAppBar(
|
||||
expandedHeight: 100,
|
||||
collapsedHeight: kToolbarHeight,
|
||||
floating: false,
|
||||
pinned: true,
|
||||
backgroundColor: colorScheme.surface,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
automaticallyImplyLeading: false,
|
||||
flexibleSpace: FlexibleSpaceBar(
|
||||
expandedTitleScale: 1.4,
|
||||
titlePadding: const EdgeInsets.only(left: 24, bottom: 16),
|
||||
title: Text(
|
||||
'Home',
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Search bar - Simple TextField with border
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 4),
|
||||
child: TextField(
|
||||
controller: _urlController,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Paste Spotify URL or search...',
|
||||
filled: true,
|
||||
fillColor: colorScheme.surfaceContainerHighest,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
borderSide: BorderSide(color: colorScheme.outline.withOpacity(0.5)),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
borderSide: BorderSide(color: colorScheme.outline.withOpacity(0.5)),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
borderSide: BorderSide(color: colorScheme.primary, width: 2),
|
||||
),
|
||||
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),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.paste),
|
||||
onPressed: _pasteFromClipboard,
|
||||
tooltip: 'Paste',
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: IconButton(
|
||||
icon: Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.primary,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Icon(Icons.search, color: colorScheme.onPrimary, size: 20),
|
||||
),
|
||||
onPressed: _fetchMetadata,
|
||||
tooltip: 'Search',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
|
||||
),
|
||||
onSubmitted: (_) => _fetchMetadata(),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Helper text
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
||||
child: Text(
|
||||
'Supports: Track, Album, Playlist URLs',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Error message
|
||||
if (trackState.error != null)
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||
child: Text(
|
||||
trackState.error!,
|
||||
style: TextStyle(color: colorScheme.error),
|
||||
),
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Text(trackState.error!, style: TextStyle(color: colorScheme.error)),
|
||||
)),
|
||||
|
||||
// Loading indicator
|
||||
if (trackState.isLoading)
|
||||
const SliverToBoxAdapter(
|
||||
child: LinearProgressIndicator(),
|
||||
),
|
||||
const SliverToBoxAdapter(child: Padding(padding: EdgeInsets.symmetric(horizontal: 16), child: LinearProgressIndicator())),
|
||||
|
||||
// Album/Playlist header
|
||||
if (trackState.albumName != null || trackState.playlistName != null)
|
||||
SliverToBoxAdapter(child: _buildHeader(trackState, colorScheme)),
|
||||
|
||||
// Download All button (when no header)
|
||||
// Download All button
|
||||
if (trackState.tracks.length > 1 && trackState.albumName == null && trackState.playlistName == null)
|
||||
SliverToBoxAdapter(
|
||||
child: 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 (${trackState.tracks.length})'),
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(48),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: FilledButton.icon(onPressed: _downloadAll, icon: const Icon(Icons.download),
|
||||
label: Text('Download All (${trackState.tracks.length})'),
|
||||
style: FilledButton.styleFrom(minimumSize: const Size.fromHeight(48))),
|
||||
)),
|
||||
|
||||
// Track list
|
||||
if (trackState.tracks.isNotEmpty)
|
||||
SliverList(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) => _buildTrackTile(index, colorScheme),
|
||||
childCount: trackState.tracks.length,
|
||||
),
|
||||
),
|
||||
SliverList(delegate: SliverChildBuilderDelegate(
|
||||
(context, index) => _buildTrackTile(index, colorScheme),
|
||||
childCount: trackState.tracks.length,
|
||||
)),
|
||||
|
||||
// Divider between search results and history
|
||||
// Divider
|
||||
if (trackState.tracks.isNotEmpty && historyState.items.isNotEmpty)
|
||||
const SliverToBoxAdapter(
|
||||
child: Divider(height: 32),
|
||||
),
|
||||
const SliverToBoxAdapter(child: Divider(height: 32)),
|
||||
|
||||
// Recent Downloads section header
|
||||
// Recent Downloads header
|
||||
if (historyState.items.isNotEmpty)
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'Recent Downloads',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => _showClearHistoryDialog(colorScheme),
|
||||
child: const Text('Clear'),
|
||||
),
|
||||
],
|
||||
),
|
||||
SliverToBoxAdapter(child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text('Recent Downloads', style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
color: colorScheme.primary, fontWeight: FontWeight.w600)),
|
||||
TextButton(onPressed: () => _showClearHistoryDialog(colorScheme), child: const Text('Clear')),
|
||||
],
|
||||
),
|
||||
),
|
||||
)),
|
||||
|
||||
// Recent Downloads list
|
||||
if (historyState.items.isNotEmpty)
|
||||
SliverList(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) => _buildHistoryTile(historyState.items[index], colorScheme),
|
||||
childCount: historyState.items.length > 5 ? 5 : historyState.items.length,
|
||||
),
|
||||
),
|
||||
SliverList(delegate: SliverChildBuilderDelegate(
|
||||
(context, index) => _buildHistoryTile(historyState.items[index], colorScheme),
|
||||
childCount: historyState.items.length > 10 ? 10 : historyState.items.length,
|
||||
)),
|
||||
|
||||
// Show more history button
|
||||
if (historyState.items.length > 5)
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: OutlinedButton(
|
||||
onPressed: () => _showAllHistory(colorScheme),
|
||||
child: Text('Show all ${historyState.items.length} downloads'),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Show more button
|
||||
if (historyState.items.length > 10)
|
||||
SliverToBoxAdapter(child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: OutlinedButton(onPressed: () => _showAllHistory(colorScheme),
|
||||
child: Text('Show all ${historyState.items.length} downloads')),
|
||||
)),
|
||||
|
||||
// Empty state (when no tracks and no history)
|
||||
// Empty state or fill remaining for scroll
|
||||
if (trackState.tracks.isEmpty && historyState.items.isEmpty)
|
||||
SliverFillRemaining(
|
||||
child: _buildEmptyState(colorScheme),
|
||||
),
|
||||
|
||||
// Bottom padding
|
||||
const SliverToBoxAdapter(
|
||||
child: SizedBox(height: 16),
|
||||
),
|
||||
SliverFillRemaining(hasScrollBody: false, child: _buildEmptyState(colorScheme))
|
||||
else
|
||||
const SliverFillRemaining(hasScrollBody: false, child: SizedBox()),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -234,52 +270,21 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
child: Row(
|
||||
children: [
|
||||
if (state.coverUrl != null)
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: state.coverUrl!,
|
||||
width: 80,
|
||||
height: 80,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (_, __) => Container(
|
||||
width: 80,
|
||||
height: 80,
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
),
|
||||
),
|
||||
),
|
||||
ClipRRect(borderRadius: BorderRadius.circular(12),
|
||||
child: CachedNetworkImage(imageUrl: state.coverUrl!, width: 80, height: 80, fit: BoxFit.cover,
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Download all button
|
||||
FilledButton.tonal(
|
||||
onPressed: _downloadAll,
|
||||
style: FilledButton.styleFrom(
|
||||
shape: const CircleBorder(),
|
||||
padding: const EdgeInsets.all(16),
|
||||
),
|
||||
child: const Icon(Icons.download),
|
||||
),
|
||||
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)),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -290,231 +295,149 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
final track = ref.watch(trackProvider).tracks[index];
|
||||
return ListTile(
|
||||
leading: track.coverUrl != null
|
||||
? ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
? ClipRRect(borderRadius: BorderRadius.circular(8),
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: track.coverUrl!,
|
||||
width: 48,
|
||||
height: 48,
|
||||
imageUrl: track.coverUrl!,
|
||||
width: 48,
|
||||
height: 48,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
)
|
||||
: Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant),
|
||||
),
|
||||
memCacheWidth: 96,
|
||||
memCacheHeight: 96,
|
||||
))
|
||||
: Container(width: 48, height: 48,
|
||||
decoration: BoxDecoration(color: colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(8)),
|
||||
child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant)),
|
||||
title: Text(track.name, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
subtitle: Text(
|
||||
track.artistName,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: colorScheme.onSurfaceVariant),
|
||||
),
|
||||
trailing: IconButton(
|
||||
icon: Icon(Icons.download, color: colorScheme.primary),
|
||||
onPressed: () => _downloadTrack(index),
|
||||
),
|
||||
subtitle: Text(track.artistName, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(color: colorScheme.onSurfaceVariant)),
|
||||
trailing: IconButton(icon: Icon(Icons.download, color: colorScheme.primary), onPressed: () => _downloadTrack(index)),
|
||||
onTap: () => _downloadTrack(index),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHistoryTile(DownloadHistoryItem item, ColorScheme colorScheme) {
|
||||
final fileExists = File(item.filePath).existsSync();
|
||||
|
||||
final fileExists = _checkFileExists(item.filePath);
|
||||
return ListTile(
|
||||
leading: Hero(
|
||||
tag: 'cover_${item.id}',
|
||||
leading: Hero(tag: 'cover_${item.id}',
|
||||
child: item.coverUrl != null
|
||||
? ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
? ClipRRect(borderRadius: BorderRadius.circular(8),
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: item.coverUrl!,
|
||||
width: 48,
|
||||
height: 48,
|
||||
imageUrl: item.coverUrl!,
|
||||
width: 48,
|
||||
height: 48,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
)
|
||||
: Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant),
|
||||
),
|
||||
),
|
||||
memCacheWidth: 96,
|
||||
memCacheHeight: 96,
|
||||
))
|
||||
: 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.trackName, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
subtitle: Text(
|
||||
item.artistName,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: colorScheme.onSurfaceVariant),
|
||||
),
|
||||
subtitle: Text(item.artistName, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(color: colorScheme.onSurfaceVariant)),
|
||||
trailing: fileExists
|
||||
? IconButton(
|
||||
icon: Icon(Icons.play_arrow, color: colorScheme.primary),
|
||||
onPressed: () => _openFile(item.filePath),
|
||||
)
|
||||
? IconButton(icon: Icon(Icons.play_arrow, color: colorScheme.primary), onPressed: () => _openFile(item.filePath))
|
||||
: Icon(Icons.error_outline, color: colorScheme.error, size: 20),
|
||||
// Tap to show metadata details
|
||||
onTap: () => _navigateToMetadataScreen(item),
|
||||
);
|
||||
}
|
||||
|
||||
void _navigateToMetadataScreen(DownloadHistoryItem item) {
|
||||
Navigator.push(
|
||||
context,
|
||||
PageRouteBuilder(
|
||||
transitionDuration: const Duration(milliseconds: 300),
|
||||
reverseTransitionDuration: const Duration(milliseconds: 250),
|
||||
pageBuilder: (context, animation, secondaryAnimation) =>
|
||||
TrackMetadataScreen(item: item),
|
||||
transitionsBuilder: (context, animation, secondaryAnimation, child) {
|
||||
return FadeTransition(
|
||||
opacity: animation,
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEmptyState(ColorScheme colorScheme) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.music_note,
|
||||
size: 64,
|
||||
Widget _buildEmptyState(ColorScheme colorScheme) => Center(child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.primaryContainer.withOpacity(0.3),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(Icons.music_note, size: 48, color: colorScheme.primary),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
'Ready to Download',
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Paste a Spotify link in the search bar above to get started',
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
),
|
||||
],
|
||||
),
|
||||
));
|
||||
|
||||
void _navigateToMetadataScreen(DownloadHistoryItem item) {
|
||||
Navigator.push(context, PageRouteBuilder(
|
||||
transitionDuration: const Duration(milliseconds: 300),
|
||||
reverseTransitionDuration: const Duration(milliseconds: 250),
|
||||
pageBuilder: (context, animation, secondaryAnimation) => TrackMetadataScreen(item: item),
|
||||
transitionsBuilder: (context, animation, secondaryAnimation, child) => FadeTransition(opacity: animation, child: child),
|
||||
));
|
||||
}
|
||||
|
||||
void _showClearHistoryDialog(ColorScheme colorScheme) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Clear History'),
|
||||
content: const Text('Clear all download history?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
ref.read(downloadHistoryProvider.notifier).clearHistory();
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: Text('Clear', style: TextStyle(color: colorScheme.error)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
showDialog(context: context, builder: (context) => AlertDialog(
|
||||
title: const Text('Clear History'),
|
||||
content: const Text('Clear all download history?'),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')),
|
||||
TextButton(onPressed: () { ref.read(downloadHistoryProvider.notifier).clearHistory(); Navigator.pop(context); },
|
||||
child: Text('Clear', style: TextStyle(color: colorScheme.error))),
|
||||
],
|
||||
));
|
||||
}
|
||||
|
||||
void _showAllHistory(ColorScheme colorScheme) {
|
||||
final historyState = ref.read(downloadHistoryProvider);
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
showModalBottomSheet(context: context, isScrollControlled: true,
|
||||
builder: (context) => DraggableScrollableSheet(
|
||||
initialChildSize: 0.7,
|
||||
minChildSize: 0.5,
|
||||
maxChildSize: 0.95,
|
||||
expand: false,
|
||||
builder: (context, scrollController) => Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'All Downloads (${historyState.items.length})',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
controller: scrollController,
|
||||
itemCount: historyState.items.length,
|
||||
itemBuilder: (context, index) {
|
||||
final item = historyState.items[index];
|
||||
final fileExists = File(item.filePath).existsSync();
|
||||
|
||||
return ListTile(
|
||||
leading: item.coverUrl != null
|
||||
? ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: item.coverUrl!,
|
||||
width: 48,
|
||||
height: 48,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
)
|
||||
: 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.trackName, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
subtitle: Text(
|
||||
item.artistName,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: colorScheme.onSurfaceVariant),
|
||||
),
|
||||
trailing: fileExists
|
||||
? IconButton(
|
||||
icon: Icon(Icons.play_arrow, color: colorScheme.primary),
|
||||
onPressed: () => _openFile(item.filePath),
|
||||
)
|
||||
: Icon(Icons.error_outline, color: colorScheme.error, size: 20),
|
||||
onTap: () {
|
||||
Navigator.pop(context); // Close bottom sheet first
|
||||
Future.delayed(const Duration(milliseconds: 100), () {
|
||||
_navigateToMetadataScreen(item);
|
||||
});
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
initialChildSize: 0.7, minChildSize: 0.5, maxChildSize: 0.95, expand: false,
|
||||
builder: (context, scrollController) => Column(children: [
|
||||
Padding(padding: const EdgeInsets.all(16), child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text('All Downloads (${historyState.items.length})',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)),
|
||||
IconButton(icon: const Icon(Icons.close), onPressed: () => Navigator.pop(context)),
|
||||
],
|
||||
)),
|
||||
const Divider(height: 1),
|
||||
Expanded(child: ListView.builder(
|
||||
controller: scrollController,
|
||||
itemCount: historyState.items.length,
|
||||
itemBuilder: (context, index) {
|
||||
final item = historyState.items[index];
|
||||
final fileExists = _checkFileExists(item.filePath);
|
||||
return ListTile(
|
||||
leading: item.coverUrl != null
|
||||
? ClipRRect(borderRadius: BorderRadius.circular(8),
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: item.coverUrl!,
|
||||
width: 48,
|
||||
height: 48,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: 96,
|
||||
memCacheHeight: 96,
|
||||
))
|
||||
: 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.trackName, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
subtitle: Text(item.artistName, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(color: colorScheme.onSurfaceVariant)),
|
||||
trailing: fileExists
|
||||
? IconButton(icon: Icon(Icons.play_arrow, color: colorScheme.primary), onPressed: () => _openFile(item.filePath))
|
||||
: Icon(Icons.error_outline, color: colorScheme.error, size: 20),
|
||||
onTap: () { Navigator.pop(context); Future.delayed(const Duration(milliseconds: 100), () => _navigateToMetadataScreen(item)); },
|
||||
);
|
||||
},
|
||||
)),
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
+17
-36
@@ -4,7 +4,7 @@ import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/screens/home_tab.dart';
|
||||
import 'package:spotiflac_android/screens/queue_tab.dart';
|
||||
import 'package:spotiflac_android/screens/settings_tab.dart';
|
||||
import 'package:spotiflac_android/screens/settings/settings_tab.dart';
|
||||
import 'package:spotiflac_android/services/update_checker.dart';
|
||||
import 'package:spotiflac_android/widgets/update_dialog.dart';
|
||||
|
||||
@@ -19,14 +19,6 @@ class _MainShellState extends ConsumerState<MainShell> {
|
||||
int _currentIndex = 0;
|
||||
late PageController _pageController;
|
||||
bool _hasCheckedUpdate = false;
|
||||
bool _isAnimating = false;
|
||||
|
||||
// Cache tab widgets to prevent rebuilds
|
||||
final List<Widget> _tabs = const [
|
||||
HomeTab(),
|
||||
QueueTab(),
|
||||
SettingsTab(),
|
||||
];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -64,14 +56,13 @@ class _MainShellState extends ConsumerState<MainShell> {
|
||||
}
|
||||
|
||||
void _onNavTap(int index) {
|
||||
if (_currentIndex != index && !_isAnimating) {
|
||||
_isAnimating = true;
|
||||
if (_currentIndex != index) {
|
||||
setState(() => _currentIndex = index);
|
||||
_pageController.animateToPage(
|
||||
index,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.easeOut,
|
||||
).then((_) => _isAnimating = false);
|
||||
duration: const Duration(milliseconds: 250),
|
||||
curve: Curves.easeOutCubic,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,33 +74,23 @@ class _MainShellState extends ConsumerState<MainShell> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final queueState = ref.watch(downloadQueueProvider);
|
||||
final queueState = ref.watch(downloadQueueProvider.select((s) => s.queuedCount));
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
leading: Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: Image.asset(
|
||||
'assets/images/logo.png',
|
||||
width: 40,
|
||||
height: 40,
|
||||
),
|
||||
),
|
||||
),
|
||||
title: const Text('SpotiFLAC'),
|
||||
),
|
||||
body: PageView(
|
||||
controller: _pageController,
|
||||
onPageChanged: _onPageChanged,
|
||||
physics: const ClampingScrollPhysics(),
|
||||
children: _tabs,
|
||||
physics: const BouncingScrollPhysics(),
|
||||
children: const [
|
||||
HomeTab(),
|
||||
QueueTab(),
|
||||
SettingsTab(),
|
||||
],
|
||||
),
|
||||
bottomNavigationBar: NavigationBar(
|
||||
selectedIndex: _currentIndex,
|
||||
onDestinationSelected: _onNavTap,
|
||||
animationDuration: const Duration(milliseconds: 300),
|
||||
animationDuration: const Duration(milliseconds: 200),
|
||||
destinations: [
|
||||
const NavigationDestination(
|
||||
icon: Icon(Icons.home_outlined),
|
||||
@@ -118,13 +99,13 @@ class _MainShellState extends ConsumerState<MainShell> {
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Badge(
|
||||
isLabelVisible: queueState.queuedCount > 0,
|
||||
label: Text('${queueState.queuedCount}'),
|
||||
isLabelVisible: queueState > 0,
|
||||
label: Text('$queueState'),
|
||||
child: const Icon(Icons.download_outlined),
|
||||
),
|
||||
selectedIcon: Badge(
|
||||
isLabelVisible: queueState.queuedCount > 0,
|
||||
label: Text('${queueState.queuedCount}'),
|
||||
isLabelVisible: queueState > 0,
|
||||
label: Text('$queueState'),
|
||||
child: const Icon(Icons.download),
|
||||
),
|
||||
label: 'Downloads',
|
||||
|
||||
+316
-197
@@ -12,23 +12,108 @@ class QueueTab extends ConsumerWidget {
|
||||
final queueState = ref.watch(downloadQueueProvider);
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
// Header with actions
|
||||
if (queueState.items.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'${queueState.items.length} items',
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
return CustomScrollView(
|
||||
slivers: [
|
||||
// Collapsing App Bar - Simplified for performance
|
||||
SliverAppBar(
|
||||
expandedHeight: 100,
|
||||
collapsedHeight: kToolbarHeight,
|
||||
floating: false,
|
||||
pinned: true,
|
||||
backgroundColor: colorScheme.surface,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
automaticallyImplyLeading: false,
|
||||
flexibleSpace: FlexibleSpaceBar(
|
||||
expandedTitleScale: 1.4,
|
||||
titlePadding: const EdgeInsets.only(left: 24, bottom: 16),
|
||||
title: Text(
|
||||
'Downloads',
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Pause/Resume controls when downloading
|
||||
if (queueState.isProcessing || queueState.queuedCount > 0)
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
|
||||
child: Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Row(
|
||||
children: [
|
||||
// Status icon
|
||||
Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: queueState.isPaused
|
||||
? colorScheme.errorContainer
|
||||
: colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Icon(
|
||||
queueState.isPaused ? Icons.pause : Icons.downloading,
|
||||
color: queueState.isPaused
|
||||
? colorScheme.onErrorContainer
|
||||
: colorScheme.onPrimaryContainer,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
// Status text
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
queueState.isPaused ? 'Queue Paused' : 'Downloading...',
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${queueState.completedCount}/${queueState.items.length} completed',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Pause/Resume button
|
||||
FilledButton.tonal(
|
||||
onPressed: () => ref.read(downloadQueueProvider.notifier).togglePause(),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(queueState.isPaused ? Icons.play_arrow : Icons.pause, size: 20),
|
||||
const SizedBox(width: 4),
|
||||
Text(queueState.isPaused ? 'Resume' : 'Pause'),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Header with actions
|
||||
if (queueState.items.isNotEmpty)
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 8, 8),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text('${queueState.items.length} items',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)),
|
||||
Row(children: [
|
||||
TextButton.icon(
|
||||
onPressed: () => ref.read(downloadQueueProvider.notifier).clearCompleted(),
|
||||
icon: const Icon(Icons.done_all, size: 18),
|
||||
@@ -39,213 +124,247 @@ class QueueTab extends ConsumerWidget {
|
||||
icon: Icon(Icons.clear_all, size: 18, color: colorScheme.error),
|
||||
label: Text('Clear all', style: TextStyle(color: colorScheme.error)),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
]),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
|
||||
// Queue list
|
||||
Expanded(
|
||||
child: queueState.items.isEmpty
|
||||
? _buildEmptyState(context, colorScheme)
|
||||
: ListView.builder(
|
||||
itemCount: queueState.items.length,
|
||||
itemBuilder: (context, index) => _buildQueueItem(context, ref, queueState.items[index], colorScheme),
|
||||
),
|
||||
),
|
||||
if (queueState.items.isNotEmpty)
|
||||
SliverList(delegate: SliverChildBuilderDelegate(
|
||||
(context, index) => _buildQueueItem(context, ref, queueState.items[index], colorScheme),
|
||||
childCount: queueState.items.length,
|
||||
)),
|
||||
|
||||
// Empty state or fill remaining for scroll
|
||||
if (queueState.items.isEmpty)
|
||||
SliverFillRemaining(hasScrollBody: false, child: _buildEmptyState(context, colorScheme))
|
||||
else
|
||||
const SliverFillRemaining(hasScrollBody: false, child: SizedBox()),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEmptyState(BuildContext context, ColorScheme colorScheme) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.queue_music,
|
||||
size: 64,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'No downloads in queue',
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Add tracks from the Home tab',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
Widget _buildEmptyState(BuildContext context, ColorScheme colorScheme) => Center(
|
||||
child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
|
||||
Icon(Icons.queue_music, size: 64, color: colorScheme.onSurfaceVariant),
|
||||
const SizedBox(height: 16),
|
||||
Text('No downloads in queue', style: Theme.of(context).textTheme.bodyLarge?.copyWith(color: colorScheme.onSurfaceVariant)),
|
||||
const SizedBox(height: 8),
|
||||
Text('Add tracks from the Home tab', 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,
|
||||
),
|
||||
)
|
||||
: 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,
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Row(
|
||||
children: [
|
||||
// Cover art
|
||||
item.track.coverUrl != null
|
||||
? ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: item.track.coverUrl!,
|
||||
width: 56,
|
||||
height: 56,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: 112,
|
||||
memCacheHeight: 112,
|
||||
),
|
||||
)
|
||||
: Container(
|
||||
width: 56,
|
||||
height: 56,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'${(item.progress * 100).toStringAsFixed(0)}%',
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
fontWeight: FontWeight.bold,
|
||||
const SizedBox(width: 12),
|
||||
|
||||
// Track info
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
item.track.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
item.track.artistName,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
if (item.status == DownloadStatus.downloading) ...[
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: LinearProgressIndicator(
|
||||
value: item.progress > 0 ? item.progress : null,
|
||||
backgroundColor: colorScheme.surfaceContainerHighest,
|
||||
color: colorScheme.primary,
|
||||
minHeight: 6,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'${(item.progress * 100).toStringAsFixed(0)}%',
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: colorScheme.primary,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
if (item.status == DownloadStatus.failed) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
item.error ?? 'Download failed',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: colorScheme.error,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
|
||||
// Action buttons based on status
|
||||
_buildActionButtons(context, ref, item, colorScheme),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
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) {
|
||||
Widget _buildActionButtons(BuildContext context, WidgetRef ref, 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.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(
|
||||
// Queued: Show play (start) and cancel buttons
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.error, color: colorScheme.error),
|
||||
const SizedBox(width: 8),
|
||||
const Text('Download Failed'),
|
||||
],
|
||||
),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text('Track: ${item.track.name}', style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||
Text('Artist: ${item.track.artistName}'),
|
||||
const SizedBox(height: 16),
|
||||
const Text('Error:', style: 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 ?? 'Unknown error',
|
||||
style: TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 12,
|
||||
color: colorScheme.onErrorContainer,
|
||||
),
|
||||
),
|
||||
// Cancel button
|
||||
IconButton(
|
||||
onPressed: () => ref.read(downloadQueueProvider.notifier).cancelItem(item.id),
|
||||
icon: Icon(Icons.close, color: colorScheme.error),
|
||||
tooltip: 'Cancel',
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor: colorScheme.errorContainer.withValues(alpha: 0.3),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
case DownloadStatus.downloading:
|
||||
// Downloading: Show progress indicator and cancel button
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Cancel button (skip this download)
|
||||
IconButton(
|
||||
onPressed: () => ref.read(downloadQueueProvider.notifier).cancelItem(item.id),
|
||||
icon: Icon(Icons.stop, color: colorScheme.error),
|
||||
tooltip: 'Stop',
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor: colorScheme.errorContainer.withValues(alpha: 0.3),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
case DownloadStatus.completed:
|
||||
// Completed: Show check icon
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.primaryContainer,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Close'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
child: Icon(Icons.check, color: colorScheme.onPrimaryContainer, size: 20),
|
||||
);
|
||||
|
||||
case DownloadStatus.failed:
|
||||
// Failed: Show retry and remove buttons
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconButton(
|
||||
onPressed: () => ref.read(downloadQueueProvider.notifier).retryItem(item.id),
|
||||
icon: Icon(Icons.refresh, color: colorScheme.primary),
|
||||
tooltip: 'Retry',
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor: colorScheme.primaryContainer.withValues(alpha: 0.3),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
IconButton(
|
||||
onPressed: () => ref.read(downloadQueueProvider.notifier).removeItem(item.id),
|
||||
icon: Icon(Icons.close, color: colorScheme.error),
|
||||
tooltip: 'Remove',
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor: colorScheme.errorContainer.withValues(alpha: 0.3),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
case DownloadStatus.skipped:
|
||||
// Skipped: Show retry and remove buttons
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconButton(
|
||||
onPressed: () => ref.read(downloadQueueProvider.notifier).retryItem(item.id),
|
||||
icon: Icon(Icons.refresh, color: colorScheme.primary),
|
||||
tooltip: 'Retry',
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor: colorScheme.primaryContainer.withValues(alpha: 0.3),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
IconButton(
|
||||
onPressed: () => ref.read(downloadQueueProvider.notifier).removeItem(item.id),
|
||||
icon: Icon(Icons.close, color: colorScheme.onSurfaceVariant),
|
||||
tooltip: 'Remove',
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _showClearAllDialog(BuildContext context, WidgetRef ref) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Clear All'),
|
||||
content: const Text('Are you sure you want to clear all downloads?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
ref.read(downloadQueueProvider.notifier).clearAll();
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: Text('Clear', style: TextStyle(color: colorScheme.error)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
showDialog(context: context, builder: (context) => AlertDialog(
|
||||
title: const Text('Clear All'),
|
||||
content: const Text('Are you sure you want to clear all downloads?'),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')),
|
||||
TextButton(onPressed: () { ref.read(downloadQueueProvider.notifier).clearAll(); Navigator.pop(context); },
|
||||
child: Text('Clear', style: TextStyle(color: colorScheme.error))),
|
||||
],
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:spotiflac_android/constants/app_info.dart';
|
||||
|
||||
class AboutPage extends StatelessWidget {
|
||||
const AboutPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final topPadding = MediaQuery.of(context).padding.top;
|
||||
|
||||
return Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
// Collapsing App Bar with back button
|
||||
SliverAppBar(
|
||||
expandedHeight: 120 + topPadding,
|
||||
collapsedHeight: kToolbarHeight,
|
||||
floating: false,
|
||||
pinned: true,
|
||||
backgroundColor: colorScheme.surface,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => Navigator.pop(context)),
|
||||
flexibleSpace: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final maxHeight = 120 + topPadding;
|
||||
final minHeight = kToolbarHeight + topPadding;
|
||||
final expandRatio = ((constraints.maxHeight - minHeight) / (maxHeight - minHeight)).clamp(0.0, 1.0);
|
||||
final animation = AlwaysStoppedAnimation(expandRatio);
|
||||
return FlexibleSpaceBar(
|
||||
expandedTitleScale: 1.0,
|
||||
titlePadding: EdgeInsets.zero,
|
||||
title: SafeArea(
|
||||
child: Container(
|
||||
alignment: Alignment.bottomLeft,
|
||||
padding: EdgeInsets.only(
|
||||
left: Tween<double>(begin: 56, end: 24).evaluate(animation),
|
||||
bottom: Tween<double>(begin: 12, end: 16).evaluate(animation),
|
||||
),
|
||||
child: Text('About',
|
||||
style: TextStyle(
|
||||
fontSize: Tween<double>(begin: 20, end: 28).evaluate(animation),
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
// App info card
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||
child: Card(
|
||||
elevation: 0,
|
||||
color: colorScheme.surfaceContainerHigh,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(children: [
|
||||
Container(
|
||||
width: 56, height: 56,
|
||||
decoration: BoxDecoration(color: colorScheme.primaryContainer, borderRadius: BorderRadius.circular(16)),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Image.asset('assets/images/logo.png', fit: BoxFit.cover,
|
||||
errorBuilder: (_, __, ___) => Icon(Icons.music_note, size: 32, color: colorScheme.onPrimaryContainer)),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text(AppInfo.appName, style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 4),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(color: colorScheme.secondaryContainer, borderRadius: BorderRadius.circular(12)),
|
||||
child: Text('v${AppInfo.version}', style: Theme.of(context).textTheme.labelMedium?.copyWith(color: colorScheme.onSecondaryContainer)),
|
||||
),
|
||||
]),
|
||||
]),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// GitHub section
|
||||
SliverToBoxAdapter(child: _SectionHeader(title: 'GitHub')),
|
||||
SliverList(delegate: SliverChildListDelegate([
|
||||
ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
leading: Icon(Icons.phone_android, color: colorScheme.onSurfaceVariant),
|
||||
title: Text('${AppInfo.appName} Mobile'),
|
||||
subtitle: Text('github.com/${AppInfo.githubRepo}'),
|
||||
trailing: const Icon(Icons.open_in_new, size: 20),
|
||||
onTap: () => _launchUrl(AppInfo.githubUrl),
|
||||
),
|
||||
ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
leading: Icon(Icons.computer, color: colorScheme.onSurfaceVariant),
|
||||
title: Text('Original ${AppInfo.appName}'),
|
||||
subtitle: Text('github.com/${AppInfo.originalAuthor}/SpotiFLAC'),
|
||||
trailing: const Icon(Icons.open_in_new, size: 20),
|
||||
onTap: () => _launchUrl(AppInfo.originalGithubUrl),
|
||||
),
|
||||
])),
|
||||
|
||||
// Credits section
|
||||
SliverToBoxAdapter(child: _SectionHeader(title: 'Credits')),
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
child: Column(children: [
|
||||
_CreditRow(label: 'Mobile Version', value: AppInfo.mobileAuthor),
|
||||
const SizedBox(height: 12),
|
||||
_CreditRow(label: 'Original Project', value: AppInfo.originalAuthor),
|
||||
]),
|
||||
),
|
||||
),
|
||||
|
||||
// Copyright
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Center(child: Text(AppInfo.copyright,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant))),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _launchUrl(String url) async {
|
||||
final uri = Uri.parse(url);
|
||||
if (await canLaunchUrl(uri)) await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
}
|
||||
}
|
||||
|
||||
class _SectionHeader extends StatelessWidget {
|
||||
final String title;
|
||||
const _SectionHeader({required this.title});
|
||||
@override
|
||||
Widget build(BuildContext context) => Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
|
||||
child: Text(title, style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.primary, fontWeight: FontWeight.w600)),
|
||||
);
|
||||
}
|
||||
|
||||
class _CreditRow extends StatelessWidget {
|
||||
final String label;
|
||||
final String value;
|
||||
const _CreditRow({required this.label, required this.value});
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [
|
||||
Text(label, style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)),
|
||||
Text(value, style: Theme.of(context).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600)),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:spotiflac_android/providers/theme_provider.dart';
|
||||
|
||||
class AppearanceSettingsPage extends ConsumerWidget {
|
||||
const AppearanceSettingsPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final themeSettings = ref.watch(themeProvider);
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final topPadding = MediaQuery.of(context).padding.top;
|
||||
|
||||
return Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
// Collapsing App Bar with back button
|
||||
SliverAppBar(
|
||||
expandedHeight: 120 + topPadding,
|
||||
collapsedHeight: kToolbarHeight,
|
||||
floating: false,
|
||||
pinned: true,
|
||||
backgroundColor: colorScheme.surface,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => Navigator.pop(context)),
|
||||
flexibleSpace: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final maxHeight = 120 + topPadding;
|
||||
final minHeight = kToolbarHeight + topPadding;
|
||||
final expandRatio = ((constraints.maxHeight - minHeight) / (maxHeight - minHeight)).clamp(0.0, 1.0);
|
||||
final animation = AlwaysStoppedAnimation(expandRatio);
|
||||
return FlexibleSpaceBar(
|
||||
expandedTitleScale: 1.0,
|
||||
titlePadding: EdgeInsets.zero,
|
||||
title: SafeArea(
|
||||
child: Container(
|
||||
alignment: Alignment.bottomLeft,
|
||||
padding: EdgeInsets.only(
|
||||
left: Tween<double>(begin: 56, end: 24).evaluate(animation),
|
||||
bottom: Tween<double>(begin: 12, end: 16).evaluate(animation),
|
||||
),
|
||||
child: Text('Appearance',
|
||||
style: TextStyle(
|
||||
fontSize: Tween<double>(begin: 20, end: 28).evaluate(animation),
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
// Theme section
|
||||
SliverToBoxAdapter(child: _SectionHeader(title: 'Theme')),
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: _ThemeModeSelector(
|
||||
currentMode: themeSettings.themeMode,
|
||||
onChanged: (mode) => ref.read(themeProvider.notifier).setThemeMode(mode),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Color section
|
||||
SliverToBoxAdapter(child: _SectionHeader(title: 'Color')),
|
||||
SliverToBoxAdapter(
|
||||
child: SwitchListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
title: const Text('Dynamic Color'),
|
||||
subtitle: const Text('Use colors from your wallpaper'),
|
||||
value: themeSettings.useDynamicColor,
|
||||
onChanged: (value) => ref.read(themeProvider.notifier).setUseDynamicColor(value),
|
||||
),
|
||||
),
|
||||
|
||||
if (!themeSettings.useDynamicColor)
|
||||
SliverToBoxAdapter(
|
||||
child: _ColorPicker(
|
||||
currentColor: themeSettings.seedColorValue,
|
||||
onColorSelected: (color) => ref.read(themeProvider.notifier).setSeedColor(color),
|
||||
),
|
||||
),
|
||||
|
||||
// Fill remaining for scroll
|
||||
const SliverFillRemaining(hasScrollBody: false, child: SizedBox()),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SectionHeader extends StatelessWidget {
|
||||
final String title;
|
||||
const _SectionHeader({required this.title});
|
||||
@override
|
||||
Widget build(BuildContext context) => Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
|
||||
child: Text(title, style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.primary, fontWeight: FontWeight.w600)),
|
||||
);
|
||||
}
|
||||
|
||||
class _ThemeModeSelector extends StatelessWidget {
|
||||
final ThemeMode currentMode;
|
||||
final ValueChanged<ThemeMode> onChanged;
|
||||
const _ThemeModeSelector({required this.currentMode, required this.onChanged});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return Card(
|
||||
elevation: 0,
|
||||
color: colorScheme.surfaceContainerHigh,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
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)),
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ThemeModeChip extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final bool isSelected;
|
||||
final VoidCallback 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;
|
||||
return Expanded(
|
||||
child: Material(
|
||||
color: isSelected ? colorScheme.primaryContainer : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
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)),
|
||||
]),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ColorPicker extends StatelessWidget {
|
||||
final int currentColor;
|
||||
final ValueChanged<Color> onColorSelected;
|
||||
const _ColorPicker({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(0xFFFF6F00),
|
||||
];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8),
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text('Accent Color', style: Theme.of(context).textTheme.titleSmall),
|
||||
const SizedBox(height: 12),
|
||||
Wrap(spacing: 12, runSpacing: 12, children: _colors.map((color) {
|
||||
final isSelected = color.toARGB32() == currentColor;
|
||||
return GestureDetector(
|
||||
onTap: () => onColorSelected(color),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
width: 44, height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: color, shape: BoxShape.circle,
|
||||
border: isSelected ? Border.all(color: colorScheme.onSurface, width: 3) : null,
|
||||
boxShadow: isSelected ? [BoxShadow(color: color.withValues(alpha: 0.4), blurRadius: 8, spreadRadius: 2)] : null,
|
||||
),
|
||||
child: isSelected ? const Icon(Icons.check, color: Colors.white, size: 20) : null,
|
||||
),
|
||||
);
|
||||
}).toList()),
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
|
||||
class DownloadSettingsPage extends ConsumerWidget {
|
||||
const DownloadSettingsPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final settings = ref.watch(settingsProvider);
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final topPadding = MediaQuery.of(context).padding.top;
|
||||
|
||||
return Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
// Collapsing App Bar with back button
|
||||
SliverAppBar(
|
||||
expandedHeight: 120 + topPadding,
|
||||
collapsedHeight: kToolbarHeight,
|
||||
floating: false,
|
||||
pinned: true,
|
||||
backgroundColor: colorScheme.surface,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => Navigator.pop(context)),
|
||||
flexibleSpace: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final maxHeight = 120 + topPadding;
|
||||
final minHeight = kToolbarHeight + topPadding;
|
||||
final expandRatio = ((constraints.maxHeight - minHeight) / (maxHeight - minHeight)).clamp(0.0, 1.0);
|
||||
final animation = AlwaysStoppedAnimation(expandRatio);
|
||||
return FlexibleSpaceBar(
|
||||
expandedTitleScale: 1.0,
|
||||
titlePadding: EdgeInsets.zero,
|
||||
title: SafeArea(
|
||||
child: Container(
|
||||
alignment: Alignment.bottomLeft,
|
||||
padding: EdgeInsets.only(
|
||||
left: Tween<double>(begin: 56, end: 24).evaluate(animation),
|
||||
bottom: Tween<double>(begin: 12, end: 16).evaluate(animation),
|
||||
),
|
||||
child: Text('Download',
|
||||
style: TextStyle(
|
||||
fontSize: Tween<double>(begin: 20, end: 28).evaluate(animation),
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
// Service section
|
||||
SliverToBoxAdapter(child: _SectionHeader(title: 'Service')),
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: _ServiceSelector(
|
||||
currentService: settings.defaultService,
|
||||
onChanged: (service) => ref.read(settingsProvider.notifier).setDefaultService(service),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Quality section
|
||||
SliverToBoxAdapter(child: _SectionHeader(title: 'Audio Quality')),
|
||||
SliverList(delegate: SliverChildListDelegate([
|
||||
_QualityOption(title: 'FLAC Lossless', subtitle: '16-bit / 44.1kHz', value: 'LOSSLESS',
|
||||
isSelected: settings.audioQuality == 'LOSSLESS',
|
||||
onTap: () => ref.read(settingsProvider.notifier).setAudioQuality('LOSSLESS')),
|
||||
_QualityOption(title: 'Hi-Res FLAC', subtitle: '24-bit / up to 96kHz', value: 'HI_RES',
|
||||
isSelected: settings.audioQuality == 'HI_RES',
|
||||
onTap: () => ref.read(settingsProvider.notifier).setAudioQuality('HI_RES')),
|
||||
_QualityOption(title: 'Hi-Res FLAC Max', subtitle: '24-bit / up to 192kHz', value: 'HI_RES_LOSSLESS',
|
||||
isSelected: settings.audioQuality == 'HI_RES_LOSSLESS',
|
||||
onTap: () => ref.read(settingsProvider.notifier).setAudioQuality('HI_RES_LOSSLESS')),
|
||||
])),
|
||||
|
||||
// File settings section
|
||||
SliverToBoxAdapter(child: _SectionHeader(title: 'File Settings')),
|
||||
SliverList(delegate: SliverChildListDelegate([
|
||||
ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
leading: Icon(Icons.text_fields, color: colorScheme.onSurfaceVariant),
|
||||
title: const Text('Filename Format'),
|
||||
subtitle: Text(settings.filenameFormat),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => _showFormatEditor(context, ref, settings.filenameFormat),
|
||||
),
|
||||
ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
leading: Icon(Icons.folder_outlined, color: colorScheme.onSurfaceVariant),
|
||||
title: const Text('Download Directory'),
|
||||
subtitle: Text(settings.downloadDirectory.isEmpty ? 'Music/SpotiFLAC' : settings.downloadDirectory, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => _pickDirectory(ref),
|
||||
),
|
||||
])),
|
||||
|
||||
const SliverToBoxAdapter(child: SizedBox(height: 32)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showFormatEditor(BuildContext context, WidgetRef ref, String current) {
|
||||
final controller = TextEditingController(text: current);
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
showModalBottomSheet(
|
||||
context: context, isScrollControlled: true,
|
||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(28))),
|
||||
builder: (context) => Padding(
|
||||
padding: EdgeInsets.fromLTRB(24, 24, 24, MediaQuery.of(context).viewInsets.bottom + 24),
|
||||
child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text('Filename Format', style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 16),
|
||||
TextField(controller: controller, decoration: const InputDecoration(hintText: '{artist} - {title}'), autofocus: true),
|
||||
const SizedBox(height: 16),
|
||||
Text('Available: {title}, {artist}, {album}, {track}, {year}, {disc}',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant)),
|
||||
const SizedBox(height: 24),
|
||||
Row(mainAxisAlignment: MainAxisAlignment.end, children: [
|
||||
TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')),
|
||||
const SizedBox(width: 8),
|
||||
FilledButton(onPressed: () { ref.read(settingsProvider.notifier).setFilenameFormat(controller.text); Navigator.pop(context); }, child: const Text('Save')),
|
||||
]),
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _pickDirectory(WidgetRef ref) async {
|
||||
final result = await FilePicker.platform.getDirectoryPath();
|
||||
if (result != null) ref.read(settingsProvider.notifier).setDownloadDirectory(result);
|
||||
}
|
||||
}
|
||||
|
||||
class _SectionHeader extends StatelessWidget {
|
||||
final String title;
|
||||
const _SectionHeader({required this.title});
|
||||
@override
|
||||
Widget build(BuildContext context) => Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
|
||||
child: Text(title, style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.primary, fontWeight: FontWeight.w600)),
|
||||
);
|
||||
}
|
||||
|
||||
class _ServiceSelector extends StatelessWidget {
|
||||
final String currentService;
|
||||
final ValueChanged<String> onChanged;
|
||||
const _ServiceSelector({required this.currentService, required this.onChanged});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return Card(
|
||||
elevation: 0,
|
||||
color: colorScheme.surfaceContainerHigh,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Row(children: [
|
||||
_ServiceChip(icon: Icons.music_note, label: 'Tidal', isSelected: currentService == 'tidal', onTap: () => onChanged('tidal')),
|
||||
const SizedBox(width: 8),
|
||||
_ServiceChip(icon: Icons.album, label: 'Qobuz', isSelected: currentService == 'qobuz', onTap: () => onChanged('qobuz')),
|
||||
const SizedBox(width: 8),
|
||||
_ServiceChip(icon: Icons.shopping_bag, label: 'Amazon', isSelected: currentService == 'amazon', onTap: () => onChanged('amazon')),
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ServiceChip extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final bool isSelected;
|
||||
final VoidCallback onTap;
|
||||
const _ServiceChip({required this.icon, required this.label, required this.isSelected, required this.onTap});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return Expanded(
|
||||
child: Material(
|
||||
color: isSelected ? colorScheme.primaryContainer : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
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)),
|
||||
]),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _QualityOption extends StatelessWidget {
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final String value;
|
||||
final bool isSelected;
|
||||
final VoidCallback onTap;
|
||||
const _QualityOption({required this.title, required this.subtitle, required this.value, required this.isSelected, required this.onTap});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
title: Text(title),
|
||||
subtitle: Text(subtitle),
|
||||
trailing: isSelected ? Icon(Icons.check_circle, color: colorScheme.primary) : Icon(Icons.circle_outlined, color: colorScheme.outline),
|
||||
onTap: onTap,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
|
||||
class OptionsSettingsPage extends ConsumerWidget {
|
||||
const OptionsSettingsPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final settings = ref.watch(settingsProvider);
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final topPadding = MediaQuery.of(context).padding.top;
|
||||
|
||||
return Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
// Collapsing App Bar with back button
|
||||
SliverAppBar(
|
||||
expandedHeight: 120 + topPadding,
|
||||
collapsedHeight: kToolbarHeight,
|
||||
floating: false,
|
||||
pinned: true,
|
||||
backgroundColor: colorScheme.surface,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => Navigator.pop(context)),
|
||||
flexibleSpace: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final maxHeight = 120 + topPadding;
|
||||
final minHeight = kToolbarHeight + topPadding;
|
||||
final expandRatio = ((constraints.maxHeight - minHeight) / (maxHeight - minHeight)).clamp(0.0, 1.0);
|
||||
final animation = AlwaysStoppedAnimation(expandRatio);
|
||||
return FlexibleSpaceBar(
|
||||
expandedTitleScale: 1.0,
|
||||
titlePadding: EdgeInsets.zero,
|
||||
title: SafeArea(
|
||||
child: Container(
|
||||
alignment: Alignment.bottomLeft,
|
||||
padding: EdgeInsets.only(
|
||||
left: Tween<double>(begin: 56, end: 24).evaluate(animation),
|
||||
bottom: Tween<double>(begin: 12, end: 16).evaluate(animation),
|
||||
),
|
||||
child: Text('Options',
|
||||
style: TextStyle(
|
||||
fontSize: Tween<double>(begin: 20, end: 28).evaluate(animation),
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
// Download options section
|
||||
SliverToBoxAdapter(child: _SectionHeader(title: 'Download')),
|
||||
SliverList(delegate: SliverChildListDelegate([
|
||||
SwitchListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
secondary: Icon(Icons.sync, color: colorScheme.onSurfaceVariant),
|
||||
title: const Text('Auto Fallback'),
|
||||
subtitle: const Text('Try other services if download fails'),
|
||||
value: settings.autoFallback,
|
||||
onChanged: (v) => ref.read(settingsProvider.notifier).setAutoFallback(v),
|
||||
),
|
||||
SwitchListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
secondary: Icon(Icons.lyrics, color: colorScheme.onSurfaceVariant),
|
||||
title: const Text('Embed Lyrics'),
|
||||
subtitle: const Text('Embed synced lyrics into FLAC files'),
|
||||
value: settings.embedLyrics,
|
||||
onChanged: (v) => ref.read(settingsProvider.notifier).setEmbedLyrics(v),
|
||||
),
|
||||
SwitchListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
secondary: Icon(Icons.image, color: colorScheme.onSurfaceVariant),
|
||||
title: const Text('Max Quality Cover'),
|
||||
subtitle: const Text('Download highest resolution cover art'),
|
||||
value: settings.maxQualityCover,
|
||||
onChanged: (v) => ref.read(settingsProvider.notifier).setMaxQualityCover(v),
|
||||
),
|
||||
])),
|
||||
|
||||
// Performance section
|
||||
SliverToBoxAdapter(child: _SectionHeader(title: 'Performance')),
|
||||
SliverToBoxAdapter(
|
||||
child: _ConcurrentDownloadsSelector(
|
||||
currentValue: settings.concurrentDownloads,
|
||||
onChanged: (v) => ref.read(settingsProvider.notifier).setConcurrentDownloads(v),
|
||||
),
|
||||
),
|
||||
|
||||
// App section
|
||||
SliverToBoxAdapter(child: _SectionHeader(title: 'App')),
|
||||
SliverToBoxAdapter(
|
||||
child: SwitchListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
secondary: Icon(Icons.system_update, color: colorScheme.onSurfaceVariant),
|
||||
title: const Text('Check for Updates'),
|
||||
subtitle: const Text('Notify when new version is available'),
|
||||
value: settings.checkForUpdates,
|
||||
onChanged: (v) => ref.read(settingsProvider.notifier).setCheckForUpdates(v),
|
||||
),
|
||||
),
|
||||
|
||||
const SliverToBoxAdapter(child: SizedBox(height: 32)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SectionHeader extends StatelessWidget {
|
||||
final String title;
|
||||
const _SectionHeader({required this.title});
|
||||
@override
|
||||
Widget build(BuildContext context) => Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
|
||||
child: Text(title, style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.primary, fontWeight: FontWeight.w600)),
|
||||
);
|
||||
}
|
||||
|
||||
class _ConcurrentDownloadsSelector extends StatelessWidget {
|
||||
final int currentValue;
|
||||
final ValueChanged<int> onChanged;
|
||||
const _ConcurrentDownloadsSelector({required this.currentValue, required this.onChanged});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Row(children: [
|
||||
Icon(Icons.download_for_offline, color: colorScheme.onSurfaceVariant),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
const Text('Concurrent Downloads'),
|
||||
Text(currentValue == 1 ? 'Sequential (1 at a time)' : '$currentValue parallel downloads',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant)),
|
||||
])),
|
||||
]),
|
||||
const SizedBox(height: 16),
|
||||
Row(children: [
|
||||
_ConcurrentChip(label: '1', isSelected: currentValue == 1, onTap: () => onChanged(1)),
|
||||
const SizedBox(width: 8),
|
||||
_ConcurrentChip(label: '2', isSelected: currentValue == 2, onTap: () => onChanged(2)),
|
||||
const SizedBox(width: 8),
|
||||
_ConcurrentChip(label: '3', isSelected: currentValue == 3, onTap: () => onChanged(3)),
|
||||
]),
|
||||
const SizedBox(height: 12),
|
||||
Row(children: [
|
||||
Icon(Icons.warning_amber_rounded, size: 16, color: colorScheme.error),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(child: Text('Parallel downloads may trigger rate limiting',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.error))),
|
||||
]),
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ConcurrentChip extends StatelessWidget {
|
||||
final String label;
|
||||
final bool isSelected;
|
||||
final VoidCallback onTap;
|
||||
const _ConcurrentChip({required this.label, required this.isSelected, required this.onTap});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return Expanded(
|
||||
child: Material(
|
||||
color: isSelected ? colorScheme.primaryContainer : colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
child: Center(child: Text(label, style: TextStyle(
|
||||
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
|
||||
color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant))),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:spotiflac_android/constants/app_info.dart';
|
||||
import 'package:spotiflac_android/screens/settings/appearance_settings_page.dart';
|
||||
import 'package:spotiflac_android/screens/settings/download_settings_page.dart';
|
||||
import 'package:spotiflac_android/screens/settings/options_settings_page.dart';
|
||||
import 'package:spotiflac_android/screens/settings/about_page.dart';
|
||||
|
||||
class SettingsTab extends ConsumerWidget {
|
||||
const SettingsTab({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return CustomScrollView(
|
||||
slivers: [
|
||||
// Collapsing App Bar - Simplified for performance
|
||||
SliverAppBar(
|
||||
expandedHeight: 100,
|
||||
collapsedHeight: kToolbarHeight,
|
||||
floating: false,
|
||||
pinned: true,
|
||||
backgroundColor: colorScheme.surface,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
automaticallyImplyLeading: false,
|
||||
flexibleSpace: FlexibleSpaceBar(
|
||||
expandedTitleScale: 1.4,
|
||||
titlePadding: const EdgeInsets.only(left: 24, bottom: 16),
|
||||
title: Text(
|
||||
'Settings',
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Menu items
|
||||
SliverList(delegate: SliverChildListDelegate([
|
||||
_SettingsMenuItem(
|
||||
icon: Icons.palette_outlined,
|
||||
title: 'Appearance',
|
||||
subtitle: 'Theme, colors, display',
|
||||
onTap: () => _navigateTo(context, const AppearanceSettingsPage()),
|
||||
),
|
||||
_SettingsMenuItem(
|
||||
icon: Icons.download_outlined,
|
||||
title: 'Download',
|
||||
subtitle: 'Service, quality, filename format',
|
||||
onTap: () => _navigateTo(context, const DownloadSettingsPage()),
|
||||
),
|
||||
_SettingsMenuItem(
|
||||
icon: Icons.tune_outlined,
|
||||
title: 'Options',
|
||||
subtitle: 'Fallback, lyrics, cover art, updates',
|
||||
onTap: () => _navigateTo(context, const OptionsSettingsPage()),
|
||||
),
|
||||
_SettingsMenuItem(
|
||||
icon: Icons.info_outline,
|
||||
title: 'About',
|
||||
subtitle: 'Version ${AppInfo.version}, credits, GitHub',
|
||||
onTap: () => _navigateTo(context, const AboutPage()),
|
||||
),
|
||||
])),
|
||||
|
||||
// Fill remaining space to enable scroll
|
||||
const SliverFillRemaining(hasScrollBody: false, child: SizedBox()),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
void _navigateTo(BuildContext context, Widget page) {
|
||||
Navigator.of(context).push(MaterialPageRoute(builder: (_) => page));
|
||||
}
|
||||
}
|
||||
|
||||
class _SettingsMenuItem extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _SettingsMenuItem({required this.icon, required this.title, required this.subtitle, required this.onTap});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
|
||||
child: Row(children: [
|
||||
Container(
|
||||
width: 44, height: 44,
|
||||
decoration: BoxDecoration(color: colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(12)),
|
||||
child: Icon(icon, color: colorScheme.onSurfaceVariant, size: 22),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text(title, style: Theme.of(context).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500)),
|
||||
const SizedBox(height: 2),
|
||||
Text(subtitle, style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)),
|
||||
])),
|
||||
Icon(Icons.chevron_right, color: colorScheme.onSurfaceVariant, size: 24),
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+210
-171
@@ -1,4 +1,5 @@
|
||||
import 'dart:io';
|
||||
import 'dart:math' as math;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
@@ -17,11 +18,15 @@ class SetupScreen extends ConsumerStatefulWidget {
|
||||
|
||||
class _SetupScreenState extends ConsumerState<SetupScreen> {
|
||||
int _currentStep = 0;
|
||||
bool _permissionGranted = false;
|
||||
bool _storagePermissionGranted = false;
|
||||
bool _notificationPermissionGranted = false;
|
||||
String? _selectedDirectory;
|
||||
bool _isLoading = false;
|
||||
int _androidSdkVersion = 0;
|
||||
|
||||
// Total steps: Storage -> Notification (Android 13+) -> Folder
|
||||
int get _totalSteps => _androidSdkVersion >= 33 ? 3 : 2;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -35,47 +40,54 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
||||
_androidSdkVersion = androidInfo.version.sdkInt;
|
||||
debugPrint('Android SDK Version: $_androidSdkVersion');
|
||||
}
|
||||
await _checkInitialPermission();
|
||||
await _checkInitialPermissions();
|
||||
}
|
||||
|
||||
Future<void> _checkInitialPermission() async {
|
||||
Future<void> _checkInitialPermissions() async {
|
||||
if (Platform.isIOS) {
|
||||
// iOS doesn't need storage permission - app uses its own Documents directory
|
||||
if (mounted) {
|
||||
setState(() => _permissionGranted = true);
|
||||
setState(() {
|
||||
_storagePermissionGranted = true;
|
||||
_notificationPermissionGranted = true;
|
||||
});
|
||||
}
|
||||
} else if (Platform.isAndroid) {
|
||||
PermissionStatus status;
|
||||
|
||||
// Check storage permission
|
||||
PermissionStatus storageStatus;
|
||||
if (_androidSdkVersion >= 33) {
|
||||
status = await Permission.audio.status;
|
||||
storageStatus = await Permission.audio.status;
|
||||
} else if (_androidSdkVersion >= 30) {
|
||||
status = await Permission.manageExternalStorage.status;
|
||||
storageStatus = await Permission.manageExternalStorage.status;
|
||||
} else {
|
||||
status = await Permission.storage.status;
|
||||
storageStatus = await Permission.storage.status;
|
||||
}
|
||||
|
||||
if (status.isGranted && mounted) {
|
||||
setState(() => _permissionGranted = true);
|
||||
// Check notification permission (Android 13+)
|
||||
PermissionStatus notificationStatus = PermissionStatus.granted;
|
||||
if (_androidSdkVersion >= 33) {
|
||||
notificationStatus = await Permission.notification.status;
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_storagePermissionGranted = storageStatus.isGranted;
|
||||
_notificationPermissionGranted = notificationStatus.isGranted;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _requestPermission() async {
|
||||
Future<void> _requestStoragePermission() async {
|
||||
setState(() => _isLoading = true);
|
||||
|
||||
try {
|
||||
if (Platform.isIOS) {
|
||||
// iOS doesn't need storage permission - app uses its own Documents directory
|
||||
setState(() => _permissionGranted = true);
|
||||
setState(() => _storagePermissionGranted = true);
|
||||
} else if (Platform.isAndroid) {
|
||||
PermissionStatus status;
|
||||
|
||||
if (_androidSdkVersion >= 33) {
|
||||
status = await Permission.audio.request();
|
||||
if (!status.isGranted) {
|
||||
await Permission.notification.request();
|
||||
}
|
||||
} else if (_androidSdkVersion >= 30) {
|
||||
status = await Permission.manageExternalStorage.request();
|
||||
} else {
|
||||
@@ -83,15 +95,13 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
||||
}
|
||||
|
||||
if (status.isGranted) {
|
||||
setState(() => _permissionGranted = true);
|
||||
setState(() => _storagePermissionGranted = true);
|
||||
} else if (status.isPermanentlyDenied) {
|
||||
_showPermissionDeniedDialog();
|
||||
_showPermissionDeniedDialog('Storage');
|
||||
} else {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Permission denied. Please grant permission to continue.'),
|
||||
),
|
||||
const SnackBar(content: Text('Permission denied. Please grant permission to continue.')),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -99,22 +109,46 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
||||
} catch (e) {
|
||||
debugPrint('Permission error: $e');
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Error: $e')),
|
||||
);
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error: $e')));
|
||||
}
|
||||
} finally {
|
||||
setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
|
||||
void _showPermissionDeniedDialog() {
|
||||
Future<void> _requestNotificationPermission() async {
|
||||
setState(() => _isLoading = true);
|
||||
|
||||
try {
|
||||
if (_androidSdkVersion >= 33) {
|
||||
final status = await Permission.notification.request();
|
||||
if (status.isGranted) {
|
||||
setState(() => _notificationPermissionGranted = true);
|
||||
} else if (status.isPermanentlyDenied) {
|
||||
_showPermissionDeniedDialog('Notification');
|
||||
}
|
||||
} else {
|
||||
// Notification permission not needed for older Android
|
||||
setState(() => _notificationPermissionGranted = true);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Notification permission error: $e');
|
||||
} finally {
|
||||
setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
|
||||
void _skipNotificationPermission() {
|
||||
setState(() => _notificationPermissionGranted = true);
|
||||
}
|
||||
|
||||
void _showPermissionDeniedDialog(String permissionType) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Permission Required'),
|
||||
content: const Text(
|
||||
'Storage permission is required to save downloaded music files. '
|
||||
title: Text('$permissionType Permission Required'),
|
||||
content: Text(
|
||||
'$permissionType permission is required for the best experience. '
|
||||
'Please grant permission in app settings.',
|
||||
),
|
||||
actions: [
|
||||
@@ -151,18 +185,10 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Use Default Folder?'),
|
||||
content: Text(
|
||||
'No folder selected. Would you like to use the default Music folder?\n\n$defaultDir',
|
||||
),
|
||||
content: Text('No folder selected. Would you like to use the default Music folder?\n\n$defaultDir'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: const Text('Use Default'),
|
||||
),
|
||||
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Cancel')),
|
||||
TextButton(onPressed: () => Navigator.pop(context, true), child: const Text('Use Default')),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -179,7 +205,6 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
||||
|
||||
Future<String> _getDefaultDirectory() async {
|
||||
if (Platform.isIOS) {
|
||||
// iOS: Use Documents directory (accessible via Files app)
|
||||
final appDir = await getApplicationDocumentsDirectory();
|
||||
final musicDir = Directory('${appDir.path}/SpotiFLAC');
|
||||
try {
|
||||
@@ -225,9 +250,7 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Error: $e')),
|
||||
);
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error: $e')));
|
||||
}
|
||||
} finally {
|
||||
setState(() => _isLoading = false);
|
||||
@@ -244,9 +267,9 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
minHeight: MediaQuery.of(context).size.height -
|
||||
minHeight: math.max(0, MediaQuery.of(context).size.height -
|
||||
MediaQuery.of(context).padding.top -
|
||||
MediaQuery.of(context).padding.bottom - 48,
|
||||
MediaQuery.of(context).padding.bottom - 48),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
@@ -257,27 +280,16 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
||||
const SizedBox(height: 24),
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
child: Image.asset(
|
||||
'assets/images/logo.png',
|
||||
width: 96,
|
||||
height: 96,
|
||||
),
|
||||
child: Image.asset('assets/images/logo.png', width: 96, height: 96),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'SpotiFLAC',
|
||||
Text('SpotiFLAC',
|
||||
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
),
|
||||
fontWeight: FontWeight.bold, color: colorScheme.primary)),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Download Spotify tracks in FLAC',
|
||||
Text('Download Spotify tracks in FLAC',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
color: colorScheme.onSurfaceVariant)),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -287,9 +299,7 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
||||
const SizedBox(height: 24),
|
||||
_buildStepIndicator(colorScheme),
|
||||
const SizedBox(height: 24),
|
||||
_currentStep == 0
|
||||
? _buildPermissionStep(colorScheme)
|
||||
: _buildDirectoryStep(colorScheme),
|
||||
_buildCurrentStepContent(colorScheme),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -310,27 +320,32 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
||||
}
|
||||
|
||||
Widget _buildStepIndicator(ColorScheme colorScheme) {
|
||||
final steps = _androidSdkVersion >= 33
|
||||
? ['Storage', 'Notification', 'Folder']
|
||||
: ['Permission', 'Folder'];
|
||||
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
_buildStepDot(0, 'Permission', colorScheme),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 20), // Offset for label height
|
||||
child: Container(
|
||||
width: 40,
|
||||
height: 2,
|
||||
color: _currentStep >= 1 ? colorScheme.primary : colorScheme.surfaceContainerHighest,
|
||||
),
|
||||
),
|
||||
_buildStepDot(1, 'Folder', colorScheme),
|
||||
for (int i = 0; i < steps.length; i++) ...[
|
||||
if (i > 0)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 20),
|
||||
child: Container(
|
||||
width: 32,
|
||||
height: 2,
|
||||
color: _currentStep >= i ? colorScheme.primary : colorScheme.surfaceContainerHighest,
|
||||
),
|
||||
),
|
||||
_buildStepDot(i, steps[i], colorScheme),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStepDot(int step, String label, ColorScheme colorScheme) {
|
||||
final isActive = _currentStep >= step;
|
||||
final isCompleted = (step == 0 && _permissionGranted) ||
|
||||
(step == 1 && _selectedDirectory != null);
|
||||
final isCompleted = _isStepCompleted(step);
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
@@ -341,86 +356,143 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
||||
shape: BoxShape.circle,
|
||||
color: isCompleted
|
||||
? colorScheme.primary
|
||||
: isActive
|
||||
? colorScheme.primaryContainer
|
||||
: colorScheme.surfaceContainerHighest,
|
||||
: isActive ? colorScheme.primaryContainer : colorScheme.surfaceContainerHighest,
|
||||
),
|
||||
child: Center(
|
||||
child: isCompleted
|
||||
? Icon(Icons.check, size: 18, color: colorScheme.onPrimary)
|
||||
: Text(
|
||||
'${step + 1}',
|
||||
: Text('${step + 1}',
|
||||
style: TextStyle(
|
||||
color: isActive ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
fontWeight: FontWeight.bold)),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
label,
|
||||
Text(label,
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: isActive ? colorScheme.onSurface : colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
color: isActive ? colorScheme.onSurface : colorScheme.onSurfaceVariant)),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPermissionStep(ColorScheme colorScheme) {
|
||||
bool _isStepCompleted(int step) {
|
||||
if (_androidSdkVersion >= 33) {
|
||||
// 3 steps: Storage, Notification, Folder
|
||||
switch (step) {
|
||||
case 0: return _storagePermissionGranted;
|
||||
case 1: return _notificationPermissionGranted;
|
||||
case 2: return _selectedDirectory != null;
|
||||
}
|
||||
} else {
|
||||
// 2 steps: Permission, Folder
|
||||
switch (step) {
|
||||
case 0: return _storagePermissionGranted;
|
||||
case 1: return _selectedDirectory != null;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
Widget _buildCurrentStepContent(ColorScheme colorScheme) {
|
||||
if (_androidSdkVersion >= 33) {
|
||||
switch (_currentStep) {
|
||||
case 0: return _buildStoragePermissionStep(colorScheme);
|
||||
case 1: return _buildNotificationPermissionStep(colorScheme);
|
||||
case 2: return _buildDirectoryStep(colorScheme);
|
||||
}
|
||||
} else {
|
||||
switch (_currentStep) {
|
||||
case 0: return _buildStoragePermissionStep(colorScheme);
|
||||
case 1: return _buildDirectoryStep(colorScheme);
|
||||
}
|
||||
}
|
||||
return const SizedBox();
|
||||
}
|
||||
|
||||
Widget _buildStoragePermissionStep(ColorScheme colorScheme) {
|
||||
return Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
_permissionGranted ? Icons.check_circle : Icons.folder_open,
|
||||
_storagePermissionGranted ? Icons.check_circle : Icons.folder_open,
|
||||
size: 56,
|
||||
color: _permissionGranted ? colorScheme.primary : colorScheme.onSurfaceVariant,
|
||||
color: _storagePermissionGranted ? colorScheme.primary : colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
_permissionGranted
|
||||
? 'Storage Permission Granted!'
|
||||
: 'Storage Permission Required',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
_storagePermissionGranted ? 'Storage Permission Granted!' : 'Storage Permission Required',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
_permissionGranted
|
||||
? 'You can now select where to save your music files.'
|
||||
_storagePermissionGranted
|
||||
? 'You can now proceed to the next step.'
|
||||
: 'SpotiFLAC needs storage access to save downloaded music files to your device.',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
if (!_permissionGranted)
|
||||
if (!_storagePermissionGranted)
|
||||
FilledButton.icon(
|
||||
onPressed: _isLoading ? null : _requestPermission,
|
||||
onPressed: _isLoading ? null : _requestStoragePermission,
|
||||
icon: _isLoading
|
||||
? SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: colorScheme.onPrimary,
|
||||
),
|
||||
)
|
||||
? SizedBox(width: 20, height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2, color: colorScheme.onPrimary))
|
||||
: const Icon(Icons.security),
|
||||
label: const Text('Grant Permission'),
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
||||
),
|
||||
style: FilledButton.styleFrom(padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12)),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildNotificationPermissionStep(ColorScheme colorScheme) {
|
||||
return Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
_notificationPermissionGranted ? Icons.check_circle : Icons.notifications_outlined,
|
||||
size: 56,
|
||||
color: _notificationPermissionGranted ? colorScheme.primary : colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
_notificationPermissionGranted ? 'Notification Permission Granted!' : 'Enable Notifications',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
_notificationPermissionGranted
|
||||
? 'You will receive download progress notifications.'
|
||||
: 'Get notified about download progress and completion. This helps you track downloads when the app is in background.',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
if (!_notificationPermissionGranted) ...[
|
||||
FilledButton.icon(
|
||||
onPressed: _isLoading ? null : _requestNotificationPermission,
|
||||
icon: _isLoading
|
||||
? SizedBox(width: 20, height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2, color: colorScheme.onPrimary))
|
||||
: const Icon(Icons.notifications_active),
|
||||
label: const Text('Enable Notifications'),
|
||||
style: FilledButton.styleFrom(padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12)),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextButton(
|
||||
onPressed: _skipNotificationPermission,
|
||||
child: const Text('Skip for now'),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDirectoryStep(ColorScheme colorScheme) {
|
||||
return Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
@@ -433,12 +505,8 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
_selectedDirectory != null
|
||||
? 'Download Folder Selected!'
|
||||
: 'Choose Download Folder',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
_selectedDirectory != null ? 'Download Folder Selected!' : 'Choose Download Folder',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
@@ -455,46 +523,35 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
||||
Icon(Icons.folder, color: colorScheme.primary, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
Flexible(
|
||||
child: Text(
|
||||
_selectedDirectory!,
|
||||
child: Text(_selectedDirectory!,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
else
|
||||
Text(
|
||||
'Select a folder where your downloaded music will be saved.',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
Text('Select a folder where your downloaded music will be saved.',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||
textAlign: TextAlign.center),
|
||||
const SizedBox(height: 20),
|
||||
FilledButton.icon(
|
||||
onPressed: _isLoading ? null : _selectDirectory,
|
||||
icon: _isLoading
|
||||
? SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: colorScheme.onPrimary,
|
||||
),
|
||||
)
|
||||
? SizedBox(width: 20, height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2, color: colorScheme.onPrimary))
|
||||
: Icon(_selectedDirectory != null ? Icons.edit : Icons.folder_open),
|
||||
label: Text(_selectedDirectory != null ? 'Change Folder' : 'Select Folder'),
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
||||
),
|
||||
style: FilledButton.styleFrom(padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12)),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildNavigationButtons(ColorScheme colorScheme) {
|
||||
final isLastStep = _currentStep == _totalSteps - 1;
|
||||
final canProceed = _isStepCompleted(_currentStep);
|
||||
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
@@ -509,41 +566,23 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
||||
const SizedBox(width: 100),
|
||||
|
||||
// Next/Finish button
|
||||
if (_currentStep == 0)
|
||||
if (!isLastStep)
|
||||
FilledButton(
|
||||
onPressed: _permissionGranted
|
||||
? () => setState(() => _currentStep++)
|
||||
: null,
|
||||
onPressed: canProceed ? () => setState(() => _currentStep++) : null,
|
||||
child: const Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text('Next'),
|
||||
SizedBox(width: 8),
|
||||
Icon(Icons.arrow_forward, size: 18),
|
||||
],
|
||||
children: [Text('Next'), SizedBox(width: 8), Icon(Icons.arrow_forward, size: 18)],
|
||||
),
|
||||
)
|
||||
else
|
||||
FilledButton(
|
||||
onPressed: _selectedDirectory != null && !_isLoading
|
||||
? _completeSetup
|
||||
: null,
|
||||
onPressed: _selectedDirectory != null && !_isLoading ? _completeSetup : null,
|
||||
child: _isLoading
|
||||
? SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: colorScheme.onPrimary,
|
||||
),
|
||||
)
|
||||
? SizedBox(width: 20, height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2, color: colorScheme.onPrimary))
|
||||
: const Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text('Get Started'),
|
||||
SizedBox(width: 8),
|
||||
Icon(Icons.check, size: 18),
|
||||
],
|
||||
children: [Text('Get Started'), SizedBox(width: 8), Icon(Icons.check, size: 18)],
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -9,23 +9,47 @@ import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||
|
||||
/// Screen to display detailed metadata for a downloaded track
|
||||
/// Designed with Material Expressive 3 style
|
||||
class TrackMetadataScreen extends ConsumerWidget {
|
||||
class TrackMetadataScreen extends ConsumerStatefulWidget {
|
||||
final DownloadHistoryItem item;
|
||||
|
||||
const TrackMetadataScreen({super.key, required this.item});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final fileExists = File(item.filePath).existsSync();
|
||||
|
||||
// Get file info
|
||||
int? fileSize;
|
||||
if (fileExists) {
|
||||
ConsumerState<TrackMetadataScreen> createState() => _TrackMetadataScreenState();
|
||||
}
|
||||
|
||||
class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
bool _fileExists = false;
|
||||
int? _fileSize;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_checkFile();
|
||||
}
|
||||
|
||||
Future<void> _checkFile() async {
|
||||
final file = File(widget.item.filePath);
|
||||
final exists = await file.exists();
|
||||
int? size;
|
||||
if (exists) {
|
||||
try {
|
||||
fileSize = File(item.filePath).lengthSync();
|
||||
size = await file.length();
|
||||
} catch (_) {}
|
||||
}
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_fileExists = exists;
|
||||
_fileSize = size;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
DownloadHistoryItem get item => widget.item;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return Scaffold(
|
||||
body: CustomScrollView(
|
||||
@@ -77,22 +101,22 @@ class TrackMetadataScreen extends ConsumerWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Track info card
|
||||
_buildTrackInfoCard(context, colorScheme, fileExists),
|
||||
_buildTrackInfoCard(context, colorScheme, _fileExists),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Metadata card
|
||||
_buildMetadataCard(context, colorScheme, fileSize),
|
||||
_buildMetadataCard(context, colorScheme, _fileSize),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// File info card
|
||||
_buildFileInfoCard(context, colorScheme, fileExists, fileSize),
|
||||
_buildFileInfoCard(context, colorScheme, _fileExists, _fileSize),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Action buttons
|
||||
_buildActionButtons(context, ref, colorScheme, fileExists),
|
||||
_buildActionButtons(context, ref, colorScheme, _fileExists),
|
||||
|
||||
const SizedBox(height: 32),
|
||||
],
|
||||
|
||||
Reference in new issue
Block a user