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/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,
+101 -83
View File
@@ -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,
),
),
],
),
), ),
); );
} }
+187 -150
View File
@@ -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,
),
),
),
],
),
), ),
], ],
), ),
+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);
});
}