mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-29 07:18:49 +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/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,
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user