v1.5.0: UI rework, multi-progress tracking, performance optimizations

This commit is contained in:
zarzet
2026-01-02 02:54:50 +07:00
parent db1439e08f
commit d227d57545
38 changed files with 2801 additions and 833 deletions
+316 -197
View File
@@ -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))),
],
));
}
}