mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-27 03:41:47 +02:00
feat(ui): smooth download progress updates
This commit is contained in:
@@ -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/animation_utils.dart';
|
||||||
import 'package:spotiflac_android/widgets/selection_action_button.dart';
|
import 'package:spotiflac_android/widgets/selection_action_button.dart';
|
||||||
import 'package:spotiflac_android/widgets/selection_bottom_bar.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_helpers.dart';
|
||||||
part 'queue_tab_widgets.dart';
|
part 'queue_tab_widgets.dart';
|
||||||
@@ -57,14 +58,20 @@ part 'queue_tab_item_widgets.dart';
|
|||||||
|
|
||||||
String _formatDownloadSizeMB(num bytes) => '${formatMegabytes(bytes)} MB';
|
String _formatDownloadSizeMB(num bytes) => '${formatMegabytes(bytes)} MB';
|
||||||
|
|
||||||
String _formatDownloadProgressLabel(BuildContext context, DownloadItem item) {
|
String _formatDownloadProgressLabel(
|
||||||
final progress = item.progress.clamp(0.0, 1.0);
|
BuildContext context,
|
||||||
|
DownloadItem item, {
|
||||||
|
double? visualProgress,
|
||||||
|
}) {
|
||||||
|
final progress = (visualProgress ?? item.progress).clamp(0.0, 1.0);
|
||||||
final speedSuffix = item.speedMBps > 0
|
final speedSuffix = item.speedMBps > 0
|
||||||
? ' • ${item.speedMBps.toStringAsFixed(1)} MB/s'
|
? ' • ${item.speedMBps.toStringAsFixed(1)} MB/s'
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
if (item.bytesTotal > 0) {
|
if (item.bytesTotal > 0) {
|
||||||
final received = item.bytesReceived > 0
|
final received = visualProgress != null
|
||||||
|
? item.bytesTotal * progress
|
||||||
|
: item.bytesReceived > 0
|
||||||
? item.bytesReceived
|
? item.bytesReceived
|
||||||
: item.bytesTotal * progress;
|
: item.bytesTotal * progress;
|
||||||
final percent = (progress * 100).toStringAsFixed(0);
|
final percent = (progress * 100).toStringAsFixed(0);
|
||||||
@@ -111,17 +118,28 @@ String _formatDownloadProgressLabel(BuildContext context, DownloadItem item) {
|
|||||||
return context.l10n.queueDownloadStarting;
|
return context.l10n.queueDownloadStarting;
|
||||||
}
|
}
|
||||||
|
|
||||||
String _formatDownloadStatusLine(BuildContext context, DownloadItem item) {
|
String _formatDownloadStatusLine(
|
||||||
final base = _formatDownloadProgressLabel(context, item);
|
BuildContext context,
|
||||||
final eta = _formatDownloadEta(item);
|
DownloadItem item, {
|
||||||
|
double? visualProgress,
|
||||||
|
}) {
|
||||||
|
final base = _formatDownloadProgressLabel(
|
||||||
|
context,
|
||||||
|
item,
|
||||||
|
visualProgress: visualProgress,
|
||||||
|
);
|
||||||
|
final eta = _formatDownloadEta(item, visualProgress: visualProgress);
|
||||||
return eta == null ? base : '$base • $eta';
|
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;
|
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.bytesReceived
|
||||||
: (item.bytesTotal * item.progress).round();
|
: (item.bytesTotal * progress).round();
|
||||||
final remaining = item.bytesTotal - received;
|
final remaining = item.bytesTotal - received;
|
||||||
if (remaining <= 0) return null;
|
if (remaining <= 0) return null;
|
||||||
final seconds = remaining / (item.speedMBps * 1024 * 1024);
|
final seconds = remaining / (item.speedMBps * 1024 * 1024);
|
||||||
@@ -132,6 +150,16 @@ String? _formatDownloadEta(DownloadItem item) {
|
|||||||
return '~${minutes}m${secs.toString().padLeft(2, '0')}s';
|
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(
|
DownloadHistoryItem? _historyItemForCompletionBridge(
|
||||||
DownloadItem item,
|
DownloadItem item,
|
||||||
List<DownloadHistoryItem> historyItems,
|
List<DownloadHistoryItem> historyItems,
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ extension _QueueTabCollectionItemWidgets on _QueueTabState {
|
|||||||
final isQueued = item.status == DownloadStatus.queued;
|
final isQueued = item.status == DownloadStatus.queued;
|
||||||
final isFailed = item.status == DownloadStatus.failed;
|
final isFailed = item.status == DownloadStatus.failed;
|
||||||
final progress = item.progress.clamp(0.0, 1.0);
|
final progress = item.progress.clamp(0.0, 1.0);
|
||||||
final pct = (progress * 100).round();
|
|
||||||
|
|
||||||
final cover = item.track.coverUrl != null
|
final cover = item.track.coverUrl != null
|
||||||
? CachedCoverImage(
|
? CachedCoverImage(
|
||||||
@@ -34,95 +33,114 @@ extension _QueueTabCollectionItemWidgets on _QueueTabState {
|
|||||||
? () => ref.read(downloadQueueProvider.notifier).removeItem(item.id)
|
? () => ref.read(downloadQueueProvider.notifier).removeItem(item.id)
|
||||||
: () => _confirmCancelDownload(context, item);
|
: () => _confirmCancelDownload(context, item);
|
||||||
|
|
||||||
return GestureDetector(
|
return SmoothedProgressScope(
|
||||||
onTap: onTap,
|
value: item.progress,
|
||||||
child: Column(
|
animate: _shouldAnimateDownloadProgress(context, item),
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
child: GestureDetector(
|
||||||
children: [
|
onTap: onTap,
|
||||||
AspectRatio(
|
child: Column(
|
||||||
aspectRatio: 1,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
child: Stack(
|
children: [
|
||||||
fit: StackFit.expand,
|
AspectRatio(
|
||||||
children: [
|
aspectRatio: 1,
|
||||||
ClipRRect(borderRadius: radius, child: cover),
|
child: Stack(
|
||||||
if (isDownloading || isFinalizing || isQueued)
|
fit: StackFit.expand,
|
||||||
ClipRRect(
|
children: [
|
||||||
borderRadius: radius,
|
ClipRRect(borderRadius: radius, child: cover),
|
||||||
child: ColoredBox(
|
if (isDownloading || isFinalizing || isQueued)
|
||||||
color: Colors.black.withValues(alpha: 0.45),
|
ClipRRect(
|
||||||
|
borderRadius: radius,
|
||||||
|
child: ColoredBox(
|
||||||
|
color: Colors.black.withValues(alpha: 0.45),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
if (isDownloading || isFinalizing || isQueued)
|
||||||
if (isDownloading || isFinalizing || isQueued)
|
Center(
|
||||||
Center(
|
child: isDownloading && progress > 0
|
||||||
child: Column(
|
? SmoothedProgressBuilder(
|
||||||
mainAxisSize: MainAxisSize.min,
|
builder: (context, visualProgress, child) =>
|
||||||
children: [
|
Column(
|
||||||
SizedBox(
|
mainAxisSize: MainAxisSize.min,
|
||||||
width: 34,
|
children: [
|
||||||
height: 34,
|
SizedBox(
|
||||||
child: CircularProgressIndicator(
|
width: 34,
|
||||||
value: (isFinalizing || isQueued || progress <= 0)
|
height: 34,
|
||||||
? null
|
child: CircularProgressIndicator(
|
||||||
: progress,
|
value: visualProgress,
|
||||||
strokeWidth: 3,
|
strokeWidth: 3,
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
backgroundColor: Colors.white.withValues(
|
backgroundColor: Colors.white
|
||||||
alpha: 0.25,
|
.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),
|
||||||
const SizedBox(height: 6),
|
Text(
|
||||||
Text(
|
item.track.name,
|
||||||
item.track.name,
|
maxLines: 1,
|
||||||
maxLines: 1,
|
overflow: TextOverflow.ellipsis,
|
||||||
overflow: TextOverflow.ellipsis,
|
style: Theme.of(
|
||||||
style: Theme.of(
|
context,
|
||||||
context,
|
).textTheme.bodySmall?.copyWith(fontWeight: FontWeight.w500),
|
||||||
).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,
|
|
||||||
),
|
),
|
||||||
),
|
Text(
|
||||||
],
|
item.track.artistName,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -140,66 +140,76 @@ extension _QueueTabItemWidgets on _QueueTabState {
|
|||||||
item.status == DownloadStatus.downloading ||
|
item.status == DownloadStatus.downloading ||
|
||||||
item.status == DownloadStatus.finalizing;
|
item.status == DownloadStatus.finalizing;
|
||||||
|
|
||||||
return Dismissible(
|
return SmoothedProgressScope(
|
||||||
key: ValueKey('dismiss_${item.id}'),
|
value: item.progress,
|
||||||
direction: DismissDirection.endToStart,
|
animate: _shouldAnimateDownloadProgress(context, item),
|
||||||
confirmDismiss: isActive
|
child: Dismissible(
|
||||||
? (_) async {
|
key: ValueKey('dismiss_${item.id}'),
|
||||||
return await showDialog<bool>(
|
direction: DismissDirection.endToStart,
|
||||||
context: context,
|
confirmDismiss: isActive
|
||||||
builder: (ctx) => AlertDialog(
|
? (_) async {
|
||||||
title: Text(context.l10n.cancelDownloadTitle),
|
return await showDialog<bool>(
|
||||||
content: Text(
|
context: context,
|
||||||
context.l10n.cancelDownloadContent(item.track.name),
|
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(
|
false;
|
||||||
onPressed: () => Navigator.of(ctx).pop(false),
|
}
|
||||||
child: Text(context.l10n.cancelDownloadKeep),
|
: null,
|
||||||
),
|
onDismissed: (_) {
|
||||||
TextButton(
|
ref.read(downloadQueueProvider.notifier).dismissItem(item.id);
|
||||||
onPressed: () => Navigator.of(ctx).pop(true),
|
},
|
||||||
child: Text(context.l10n.dialogCancel),
|
background: Container(
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
) ??
|
|
||||||
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(
|
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||||
clipBehavior: Clip.antiAlias,
|
decoration: BoxDecoration(
|
||||||
child: InkWell(
|
color: colorScheme.errorContainer,
|
||||||
onTap: isCompleted
|
|
||||||
? () => _navigateToMetadataScreen(item)
|
|
||||||
: item.status == DownloadStatus.failed
|
|
||||||
? () => _showDownloadErrorDialog(context, item)
|
|
||||||
: null,
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
child: Stack(
|
),
|
||||||
children: [
|
alignment: Alignment.centerRight,
|
||||||
if (item.status == DownloadStatus.downloading)
|
padding: const EdgeInsets.only(right: 20),
|
||||||
Positioned.fill(
|
child: Icon(
|
||||||
child: Align(
|
Icons.delete_outline,
|
||||||
alignment: Alignment.centerLeft,
|
color: colorScheme.onErrorContainer,
|
||||||
child: FractionallySizedBox(
|
),
|
||||||
widthFactor: item.progress.clamp(0.0, 1.0),
|
),
|
||||||
|
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(
|
child: DecoratedBox(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
gradient: LinearGradient(
|
gradient: LinearGradient(
|
||||||
@@ -214,86 +224,94 @@ extension _QueueTabItemWidgets on _QueueTabState {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
Padding(
|
||||||
Padding(
|
padding: const EdgeInsets.all(12),
|
||||||
padding: const EdgeInsets.all(12),
|
child: Row(
|
||||||
child: Row(
|
children: [
|
||||||
children: [
|
isCompleted
|
||||||
isCompleted
|
? Hero(
|
||||||
? Hero(
|
tag: 'cover_${item.id}',
|
||||||
tag: 'cover_${item.id}',
|
child: _buildCoverArt(item, colorScheme),
|
||||||
child: _buildCoverArt(item, colorScheme),
|
)
|
||||||
)
|
: _buildCoverArt(item, colorScheme),
|
||||||
: _buildCoverArt(item, colorScheme),
|
const SizedBox(width: 12),
|
||||||
const SizedBox(width: 12),
|
Expanded(
|
||||||
Expanded(
|
child: Column(
|
||||||
child: Column(
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
children: [
|
||||||
children: [
|
Text(
|
||||||
Text(
|
item.track.name,
|
||||||
item.track.name,
|
maxLines: 1,
|
||||||
maxLines: 1,
|
overflow: TextOverflow.ellipsis,
|
||||||
overflow: TextOverflow.ellipsis,
|
style: Theme.of(context).textTheme.titleSmall
|
||||||
style: Theme.of(context).textTheme.titleSmall
|
?.copyWith(fontWeight: FontWeight.w600),
|
||||||
?.copyWith(fontWeight: FontWeight.w600),
|
),
|
||||||
),
|
const SizedBox(height: 2),
|
||||||
const SizedBox(height: 2),
|
ClickableArtistName(
|
||||||
ClickableArtistName(
|
artistName: item.track.artistName,
|
||||||
artistName: item.track.artistName,
|
artistId: item.track.artistId,
|
||||||
artistId: item.track.artistId,
|
coverUrl: item.track.coverUrl,
|
||||||
coverUrl: item.track.coverUrl,
|
extensionId: item.track.source,
|
||||||
extensionId: item.track.source,
|
maxLines: 1,
|
||||||
maxLines: 1,
|
overflow: TextOverflow.ellipsis,
|
||||||
overflow: TextOverflow.ellipsis,
|
style: Theme.of(context).textTheme.bodySmall
|
||||||
style: Theme.of(context).textTheme.bodySmall
|
?.copyWith(
|
||||||
?.copyWith(
|
color: colorScheme.onSurfaceVariant,
|
||||||
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,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
|
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),
|
||||||
const SizedBox(width: 8),
|
_buildActionButtons(context, item, colorScheme),
|
||||||
_buildActionButtons(context, item, colorScheme),
|
],
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -414,28 +432,47 @@ extension _QueueTabItemWidgets on _QueueTabState {
|
|||||||
borderRadius: radius,
|
borderRadius: radius,
|
||||||
child: ColoredBox(color: Colors.black.withValues(alpha: 0.45)),
|
child: ColoredBox(color: Colors.black.withValues(alpha: 0.45)),
|
||||||
),
|
),
|
||||||
Center(
|
if (indeterminate)
|
||||||
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)
|
|
||||||
Center(
|
Center(
|
||||||
child: Text(
|
child: SizedBox(
|
||||||
'${(progress * 100).round()}',
|
width: coverSize * 0.6,
|
||||||
style: const TextStyle(
|
height: coverSize * 0.6,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 3,
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
fontSize: 11,
|
backgroundColor: Colors.white.withValues(alpha: 0.25),
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
)
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
/// Smooths visual progress changes without increasing backend polling.
|
||||||
|
///
|
||||||
|
/// The first value is shown immediately so restored downloads never animate
|
||||||
|
/// from zero. Later monotonic increases are interpolated locally, while
|
||||||
|
/// resets, completion, and disabled animations snap to the authoritative
|
||||||
|
/// backend value.
|
||||||
|
class SmoothedProgressScope extends StatefulWidget {
|
||||||
|
final double value;
|
||||||
|
final bool animate;
|
||||||
|
final Duration duration;
|
||||||
|
final Curve curve;
|
||||||
|
final Widget child;
|
||||||
|
|
||||||
|
const SmoothedProgressScope({
|
||||||
|
super.key,
|
||||||
|
required this.value,
|
||||||
|
required this.child,
|
||||||
|
this.animate = true,
|
||||||
|
this.duration = const Duration(milliseconds: 900),
|
||||||
|
this.curve = Curves.linear,
|
||||||
|
});
|
||||||
|
|
||||||
|
static ValueListenable<double> of(BuildContext context) {
|
||||||
|
final scope = context
|
||||||
|
.dependOnInheritedWidgetOfExactType<_SmoothedProgressScopeData>();
|
||||||
|
assert(scope != null, 'No SmoothedProgressScope found in context');
|
||||||
|
return scope!.progress;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<SmoothedProgressScope> createState() => _SmoothedProgressScopeState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SmoothedProgressScopeState extends State<SmoothedProgressScope>
|
||||||
|
with SingleTickerProviderStateMixin {
|
||||||
|
late final AnimationController _controller;
|
||||||
|
late final ValueNotifier<double> _displayedProgress;
|
||||||
|
Animation<double>? _animation;
|
||||||
|
|
||||||
|
double _normalized(double value) => value.clamp(0.0, 1.0).toDouble();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
final initialValue = _normalized(widget.value);
|
||||||
|
_displayedProgress = ValueNotifier<double>(initialValue);
|
||||||
|
_controller = AnimationController(vsync: this, duration: widget.duration)
|
||||||
|
..addListener(_updateDisplayedProgress);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(covariant SmoothedProgressScope oldWidget) {
|
||||||
|
super.didUpdateWidget(oldWidget);
|
||||||
|
_controller.duration = widget.duration;
|
||||||
|
|
||||||
|
final target = _normalized(widget.value);
|
||||||
|
final current = _displayedProgress.value;
|
||||||
|
if ((target - current).abs() < 0.0001) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final shouldSnap =
|
||||||
|
!widget.animate ||
|
||||||
|
target < _normalized(oldWidget.value) ||
|
||||||
|
target <= current ||
|
||||||
|
target <= 0 ||
|
||||||
|
target >= 1 ||
|
||||||
|
widget.duration == Duration.zero;
|
||||||
|
if (shouldSnap) {
|
||||||
|
_controller.stop();
|
||||||
|
_animation = null;
|
||||||
|
_displayedProgress.value = target;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_animation = Tween<double>(
|
||||||
|
begin: current,
|
||||||
|
end: target,
|
||||||
|
).animate(CurvedAnimation(parent: _controller, curve: widget.curve));
|
||||||
|
_controller.forward(from: 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _updateDisplayedProgress() {
|
||||||
|
final animation = _animation;
|
||||||
|
if (animation != null) {
|
||||||
|
_displayedProgress.value = animation.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_controller
|
||||||
|
..removeListener(_updateDisplayedProgress)
|
||||||
|
..dispose();
|
||||||
|
_displayedProgress.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return _SmoothedProgressScopeData(
|
||||||
|
progress: _displayedProgress,
|
||||||
|
child: widget.child,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SmoothedProgressScopeData extends InheritedWidget {
|
||||||
|
final ValueListenable<double> progress;
|
||||||
|
|
||||||
|
const _SmoothedProgressScopeData({
|
||||||
|
required this.progress,
|
||||||
|
required super.child,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool updateShouldNotify(_SmoothedProgressScopeData oldWidget) =>
|
||||||
|
progress != oldWidget.progress;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rebuilds only the visual fragment that consumes smoothed progress.
|
||||||
|
class SmoothedProgressBuilder extends StatelessWidget {
|
||||||
|
final Widget? child;
|
||||||
|
final Widget Function(BuildContext context, double progress, Widget? child)
|
||||||
|
builder;
|
||||||
|
|
||||||
|
const SmoothedProgressBuilder({super.key, required this.builder, this.child});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return ValueListenableBuilder<double>(
|
||||||
|
valueListenable: SmoothedProgressScope.of(context),
|
||||||
|
builder: builder,
|
||||||
|
child: child,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:spotiflac_android/widgets/smoothed_progress.dart';
|
||||||
|
|
||||||
|
Widget _testApp({
|
||||||
|
required double progress,
|
||||||
|
bool animate = true,
|
||||||
|
Duration duration = const Duration(milliseconds: 900),
|
||||||
|
}) {
|
||||||
|
return MaterialApp(
|
||||||
|
home: SmoothedProgressScope(
|
||||||
|
value: progress,
|
||||||
|
animate: animate,
|
||||||
|
duration: duration,
|
||||||
|
child: SmoothedProgressBuilder(
|
||||||
|
builder: (context, value, child) => Text(
|
||||||
|
value.toStringAsFixed(3),
|
||||||
|
key: const ValueKey('smoothed-progress-value'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
double _displayedProgress(WidgetTester tester) {
|
||||||
|
final text = tester.widget<Text>(
|
||||||
|
find.byKey(const ValueKey('smoothed-progress-value')),
|
||||||
|
);
|
||||||
|
return double.parse(text.data!);
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
testWidgets('shows the first authoritative value immediately', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await tester.pumpWidget(_testApp(progress: 0.42));
|
||||||
|
|
||||||
|
expect(_displayedProgress(tester), 0.42);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('interpolates monotonic progress updates locally', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await tester.pumpWidget(_testApp(progress: 0.2));
|
||||||
|
await tester.pumpWidget(_testApp(progress: 0.8));
|
||||||
|
await tester.pump(const Duration(milliseconds: 450));
|
||||||
|
|
||||||
|
final halfway = _displayedProgress(tester);
|
||||||
|
expect(halfway, greaterThan(0.2));
|
||||||
|
expect(halfway, lessThan(0.8));
|
||||||
|
|
||||||
|
await tester.pump(const Duration(milliseconds: 450));
|
||||||
|
expect(_displayedProgress(tester), 0.8);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('snaps resets and completion to the backend value', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await tester.pumpWidget(_testApp(progress: 0.4));
|
||||||
|
await tester.pumpWidget(_testApp(progress: 0.9));
|
||||||
|
await tester.pump(const Duration(milliseconds: 300));
|
||||||
|
|
||||||
|
await tester.pumpWidget(_testApp(progress: 0.7));
|
||||||
|
expect(_displayedProgress(tester), 0.7);
|
||||||
|
|
||||||
|
await tester.pumpWidget(_testApp(progress: 0.1));
|
||||||
|
expect(_displayedProgress(tester), 0.1);
|
||||||
|
|
||||||
|
await tester.pumpWidget(_testApp(progress: 1));
|
||||||
|
expect(_displayedProgress(tester), 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('snaps updates when animation is disabled', (tester) async {
|
||||||
|
await tester.pumpWidget(_testApp(progress: 0.2));
|
||||||
|
await tester.pumpWidget(_testApp(progress: 0.8, animate: false));
|
||||||
|
|
||||||
|
expect(_displayedProgress(tester), 0.8);
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user