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,
),
),
),
],
),
),
],
),
+140
View File
@@ -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,
);
}
}
+79
View File
@@ -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);
});
}