feat(ui): smooth download progress updates

This commit is contained in:
zarzet
2026-07-23 13:36:22 +07:00
parent cdbb0d6bd7
commit 5481f8e8cf
5 changed files with 544 additions and 242 deletions
+37 -9
View File
@@ -45,6 +45,7 @@ import 'package:spotiflac_android/widgets/download_service_picker.dart';
import 'package:spotiflac_android/widgets/animation_utils.dart';
import 'package:spotiflac_android/widgets/selection_action_button.dart';
import 'package:spotiflac_android/widgets/selection_bottom_bar.dart';
import 'package:spotiflac_android/widgets/smoothed_progress.dart';
part 'queue_tab_helpers.dart';
part 'queue_tab_widgets.dart';
@@ -57,14 +58,20 @@ part 'queue_tab_item_widgets.dart';
String _formatDownloadSizeMB(num bytes) => '${formatMegabytes(bytes)} MB';
String _formatDownloadProgressLabel(BuildContext context, DownloadItem item) {
final progress = item.progress.clamp(0.0, 1.0);
String _formatDownloadProgressLabel(
BuildContext context,
DownloadItem item, {
double? visualProgress,
}) {
final progress = (visualProgress ?? item.progress).clamp(0.0, 1.0);
final speedSuffix = item.speedMBps > 0
? '${item.speedMBps.toStringAsFixed(1)} MB/s'
: '';
if (item.bytesTotal > 0) {
final received = item.bytesReceived > 0
final received = visualProgress != null
? item.bytesTotal * progress
: item.bytesReceived > 0
? item.bytesReceived
: item.bytesTotal * progress;
final percent = (progress * 100).toStringAsFixed(0);
@@ -111,17 +118,28 @@ String _formatDownloadProgressLabel(BuildContext context, DownloadItem item) {
return context.l10n.queueDownloadStarting;
}
String _formatDownloadStatusLine(BuildContext context, DownloadItem item) {
final base = _formatDownloadProgressLabel(context, item);
final eta = _formatDownloadEta(item);
String _formatDownloadStatusLine(
BuildContext context,
DownloadItem item, {
double? visualProgress,
}) {
final base = _formatDownloadProgressLabel(
context,
item,
visualProgress: visualProgress,
);
final eta = _formatDownloadEta(item, visualProgress: visualProgress);
return eta == null ? base : '$base$eta';
}
String? _formatDownloadEta(DownloadItem item) {
String? _formatDownloadEta(DownloadItem item, {double? visualProgress}) {
if (item.speedMBps <= 0 || item.bytesTotal <= 0) return null;
final received = item.bytesReceived > 0
final progress = (visualProgress ?? item.progress).clamp(0.0, 1.0);
final received = visualProgress != null
? (item.bytesTotal * progress).round()
: item.bytesReceived > 0
? item.bytesReceived
: (item.bytesTotal * item.progress).round();
: (item.bytesTotal * progress).round();
final remaining = item.bytesTotal - received;
if (remaining <= 0) return null;
final seconds = remaining / (item.speedMBps * 1024 * 1024);
@@ -132,6 +150,16 @@ String? _formatDownloadEta(DownloadItem item) {
return '~${minutes}m${secs.toString().padLeft(2, '0')}s';
}
bool _shouldAnimateDownloadProgress(BuildContext context, DownloadItem item) {
final progress = item.progress.clamp(0.0, 1.0);
final animationsDisabled =
MediaQuery.maybeOf(context)?.disableAnimations ?? false;
return item.status == DownloadStatus.downloading &&
progress > 0 &&
progress < 1 &&
!animationsDisabled;
}
DownloadHistoryItem? _historyItemForCompletionBridge(
DownloadItem item,
List<DownloadHistoryItem> historyItems,
+101 -83
View File
@@ -12,7 +12,6 @@ extension _QueueTabCollectionItemWidgets on _QueueTabState {
final isQueued = item.status == DownloadStatus.queued;
final isFailed = item.status == DownloadStatus.failed;
final progress = item.progress.clamp(0.0, 1.0);
final pct = (progress * 100).round();
final cover = item.track.coverUrl != null
? CachedCoverImage(
@@ -34,95 +33,114 @@ extension _QueueTabCollectionItemWidgets on _QueueTabState {
? () => ref.read(downloadQueueProvider.notifier).removeItem(item.id)
: () => _confirmCancelDownload(context, item);
return GestureDetector(
onTap: onTap,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AspectRatio(
aspectRatio: 1,
child: Stack(
fit: StackFit.expand,
children: [
ClipRRect(borderRadius: radius, child: cover),
if (isDownloading || isFinalizing || isQueued)
ClipRRect(
borderRadius: radius,
child: ColoredBox(
color: Colors.black.withValues(alpha: 0.45),
return SmoothedProgressScope(
value: item.progress,
animate: _shouldAnimateDownloadProgress(context, item),
child: GestureDetector(
onTap: onTap,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AspectRatio(
aspectRatio: 1,
child: Stack(
fit: StackFit.expand,
children: [
ClipRRect(borderRadius: radius, child: cover),
if (isDownloading || isFinalizing || isQueued)
ClipRRect(
borderRadius: radius,
child: ColoredBox(
color: Colors.black.withValues(alpha: 0.45),
),
),
),
if (isDownloading || isFinalizing || isQueued)
Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 34,
height: 34,
child: CircularProgressIndicator(
value: (isFinalizing || isQueued || progress <= 0)
? null
: progress,
strokeWidth: 3,
color: Colors.white,
backgroundColor: Colors.white.withValues(
alpha: 0.25,
if (isDownloading || isFinalizing || isQueued)
Center(
child: isDownloading && progress > 0
? SmoothedProgressBuilder(
builder: (context, visualProgress, child) =>
Column(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 34,
height: 34,
child: CircularProgressIndicator(
value: visualProgress,
strokeWidth: 3,
color: Colors.white,
backgroundColor: Colors.white
.withValues(alpha: 0.25),
),
),
const SizedBox(height: 6),
Text(
'${(visualProgress * 100).round()}%',
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w700,
fontSize: 14,
),
),
],
),
)
: Column(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 34,
height: 34,
child: CircularProgressIndicator(
strokeWidth: 3,
color: Colors.white,
backgroundColor: Colors.white.withValues(
alpha: 0.25,
),
),
),
],
),
),
),
if (isFailed)
Positioned(
right: 4,
top: 4,
child: Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: colorScheme.errorContainer,
shape: BoxShape.circle,
),
child: Icon(
Icons.error_outline,
color: colorScheme.error,
size: 14,
),
if (isDownloading && progress > 0) ...[
const SizedBox(height: 6),
Text(
'$pct%',
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w700,
fontSize: 14,
),
),
],
],
),
),
if (isFailed)
Positioned(
right: 4,
top: 4,
child: Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: colorScheme.errorContainer,
shape: BoxShape.circle,
),
child: Icon(
Icons.error_outline,
color: colorScheme.error,
size: 14,
),
),
),
],
],
),
),
),
const SizedBox(height: 6),
Text(
item.track.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(fontWeight: FontWeight.w500),
),
Text(
item.track.artistName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: colorScheme.onSurfaceVariant,
const SizedBox(height: 6),
Text(
item.track.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(fontWeight: FontWeight.w500),
),
),
],
Text(
item.track.artistName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
);
}
+187 -150
View File
@@ -140,66 +140,76 @@ extension _QueueTabItemWidgets on _QueueTabState {
item.status == DownloadStatus.downloading ||
item.status == DownloadStatus.finalizing;
return Dismissible(
key: ValueKey('dismiss_${item.id}'),
direction: DismissDirection.endToStart,
confirmDismiss: isActive
? (_) async {
return await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(context.l10n.cancelDownloadTitle),
content: Text(
context.l10n.cancelDownloadContent(item.track.name),
return SmoothedProgressScope(
value: item.progress,
animate: _shouldAnimateDownloadProgress(context, item),
child: Dismissible(
key: ValueKey('dismiss_${item.id}'),
direction: DismissDirection.endToStart,
confirmDismiss: isActive
? (_) async {
return await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(context.l10n.cancelDownloadTitle),
content: Text(
context.l10n.cancelDownloadContent(item.track.name),
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: Text(context.l10n.cancelDownloadKeep),
),
TextButton(
onPressed: () => Navigator.of(ctx).pop(true),
child: Text(context.l10n.dialogCancel),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: Text(context.l10n.cancelDownloadKeep),
),
TextButton(
onPressed: () => Navigator.of(ctx).pop(true),
child: Text(context.l10n.dialogCancel),
),
],
),
) ??
false;
}
: null,
onDismissed: (_) {
ref.read(downloadQueueProvider.notifier).dismissItem(item.id);
},
background: Container(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
decoration: BoxDecoration(
color: colorScheme.errorContainer,
borderRadius: BorderRadius.circular(12),
),
alignment: Alignment.centerRight,
padding: const EdgeInsets.only(right: 20),
child: Icon(Icons.delete_outline, color: colorScheme.onErrorContainer),
),
child: DownloadSuccessOverlay(
showSuccess: isCompleted,
child: Card(
) ??
false;
}
: null,
onDismissed: (_) {
ref.read(downloadQueueProvider.notifier).dismissItem(item.id);
},
background: Container(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: isCompleted
? () => _navigateToMetadataScreen(item)
: item.status == DownloadStatus.failed
? () => _showDownloadErrorDialog(context, item)
: null,
decoration: BoxDecoration(
color: colorScheme.errorContainer,
borderRadius: BorderRadius.circular(12),
child: Stack(
children: [
if (item.status == DownloadStatus.downloading)
Positioned.fill(
child: Align(
alignment: Alignment.centerLeft,
child: FractionallySizedBox(
widthFactor: item.progress.clamp(0.0, 1.0),
),
alignment: Alignment.centerRight,
padding: const EdgeInsets.only(right: 20),
child: Icon(
Icons.delete_outline,
color: colorScheme.onErrorContainer,
),
),
child: DownloadSuccessOverlay(
showSuccess: isCompleted,
child: Card(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: isCompleted
? () => _navigateToMetadataScreen(item)
: item.status == DownloadStatus.failed
? () => _showDownloadErrorDialog(context, item)
: null,
borderRadius: BorderRadius.circular(12),
child: Stack(
children: [
if (item.status == DownloadStatus.downloading)
Positioned.fill(
child: SmoothedProgressBuilder(
builder: (context, progress, child) => Align(
alignment: Alignment.centerLeft,
child: FractionallySizedBox(
widthFactor: progress,
child: child,
),
),
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
@@ -214,86 +224,94 @@ extension _QueueTabItemWidgets on _QueueTabState {
),
),
),
),
Padding(
padding: const EdgeInsets.all(12),
child: Row(
children: [
isCompleted
? Hero(
tag: 'cover_${item.id}',
child: _buildCoverArt(item, colorScheme),
)
: _buildCoverArt(item, colorScheme),
const SizedBox(width: 12),
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),
ClickableArtistName(
artistName: item.track.artistName,
artistId: item.track.artistId,
coverUrl: item.track.coverUrl,
extensionId: item.track.source,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall
?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
if (item.status == DownloadStatus.downloading) ...[
const SizedBox(height: 5),
Row(
children: [
Icon(
Icons.download_rounded,
size: 12,
color: colorScheme.primary,
),
const SizedBox(width: 4),
Expanded(
child: Text(
_formatDownloadStatusLine(context, item),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context)
.textTheme
.labelSmall
?.copyWith(
color: colorScheme.primary,
fontWeight: FontWeight.w600,
),
Padding(
padding: const EdgeInsets.all(12),
child: Row(
children: [
isCompleted
? Hero(
tag: 'cover_${item.id}',
child: _buildCoverArt(item, colorScheme),
)
: _buildCoverArt(item, colorScheme),
const SizedBox(width: 12),
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),
ClickableArtistName(
artistName: item.track.artistName,
artistId: item.track.artistId,
coverUrl: item.track.coverUrl,
extensionId: item.track.source,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall
?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
if (item.status ==
DownloadStatus.downloading) ...[
const SizedBox(height: 5),
Row(
children: [
Icon(
Icons.download_rounded,
size: 12,
color: colorScheme.primary,
),
const SizedBox(width: 4),
Expanded(
child: SmoothedProgressBuilder(
builder: (context, progress, child) =>
Text(
_formatDownloadStatusLine(
context,
item,
visualProgress: progress,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context)
.textTheme
.labelSmall
?.copyWith(
color: colorScheme.primary,
fontWeight: FontWeight.w600,
),
),
),
),
],
),
],
if (item.status == DownloadStatus.failed) ...[
const SizedBox(height: 4),
_buildDownloadFailureMessage(
context,
item,
colorScheme,
),
],
],
if (item.status == DownloadStatus.failed) ...[
const SizedBox(height: 4),
_buildDownloadFailureMessage(
context,
item,
colorScheme,
),
],
],
),
),
),
const SizedBox(width: 8),
_buildActionButtons(context, item, colorScheme),
],
const SizedBox(width: 8),
_buildActionButtons(context, item, colorScheme),
],
),
),
),
],
],
),
),
),
),
@@ -414,28 +432,47 @@ extension _QueueTabItemWidgets on _QueueTabState {
borderRadius: radius,
child: ColoredBox(color: Colors.black.withValues(alpha: 0.45)),
),
Center(
child: SizedBox(
width: coverSize * 0.6,
height: coverSize * 0.6,
child: CircularProgressIndicator(
value: indeterminate ? null : progress,
strokeWidth: 3,
color: Colors.white,
backgroundColor: Colors.white.withValues(alpha: 0.25),
),
),
),
if (!indeterminate)
if (indeterminate)
Center(
child: Text(
'${(progress * 100).round()}',
style: const TextStyle(
child: SizedBox(
width: coverSize * 0.6,
height: coverSize * 0.6,
child: CircularProgressIndicator(
strokeWidth: 3,
color: Colors.white,
fontSize: 11,
fontWeight: FontWeight.w700,
backgroundColor: Colors.white.withValues(alpha: 0.25),
),
),
)
else
SmoothedProgressBuilder(
builder: (context, visualProgress, child) => Stack(
fit: StackFit.expand,
children: [
Center(
child: SizedBox(
width: coverSize * 0.6,
height: coverSize * 0.6,
child: CircularProgressIndicator(
value: visualProgress,
strokeWidth: 3,
color: Colors.white,
backgroundColor: Colors.white.withValues(alpha: 0.25),
),
),
),
Center(
child: Text(
'${(visualProgress * 100).round()}',
style: const TextStyle(
color: Colors.white,
fontSize: 11,
fontWeight: FontWeight.w700,
),
),
),
],
),
),
],
),