From 5481f8e8cf867b6ce30b993fddfdc8a2a07ed84e Mon Sep 17 00:00:00 2001 From: zarzet Date: Thu, 23 Jul 2026 13:36:22 +0700 Subject: [PATCH] feat(ui): smooth download progress updates --- lib/screens/queue_tab.dart | 46 ++- lib/screens/queue_tab_collection_items.dart | 184 ++++++----- lib/screens/queue_tab_item_widgets.dart | 337 +++++++++++--------- lib/widgets/smoothed_progress.dart | 140 ++++++++ test/smoothed_progress_test.dart | 79 +++++ 5 files changed, 544 insertions(+), 242 deletions(-) create mode 100644 lib/widgets/smoothed_progress.dart create mode 100644 test/smoothed_progress_test.dart diff --git a/lib/screens/queue_tab.dart b/lib/screens/queue_tab.dart index 442a58b2..9fd1dc38 100644 --- a/lib/screens/queue_tab.dart +++ b/lib/screens/queue_tab.dart @@ -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 historyItems, diff --git a/lib/screens/queue_tab_collection_items.dart b/lib/screens/queue_tab_collection_items.dart index 33e8dc42..97fd4d5f 100644 --- a/lib/screens/queue_tab_collection_items.dart +++ b/lib/screens/queue_tab_collection_items.dart @@ -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, + ), + ), + ], + ), ), ); } diff --git a/lib/screens/queue_tab_item_widgets.dart b/lib/screens/queue_tab_item_widgets.dart index 61394e80..8e66cd10 100644 --- a/lib/screens/queue_tab_item_widgets.dart +++ b/lib/screens/queue_tab_item_widgets.dart @@ -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( - 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( + 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, + ), + ), + ), + ], + ), ), ], ), diff --git a/lib/widgets/smoothed_progress.dart b/lib/widgets/smoothed_progress.dart new file mode 100644 index 00000000..b0cd41a2 --- /dev/null +++ b/lib/widgets/smoothed_progress.dart @@ -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 of(BuildContext context) { + final scope = context + .dependOnInheritedWidgetOfExactType<_SmoothedProgressScopeData>(); + assert(scope != null, 'No SmoothedProgressScope found in context'); + return scope!.progress; + } + + @override + State createState() => _SmoothedProgressScopeState(); +} + +class _SmoothedProgressScopeState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + late final ValueNotifier _displayedProgress; + Animation? _animation; + + double _normalized(double value) => value.clamp(0.0, 1.0).toDouble(); + + @override + void initState() { + super.initState(); + final initialValue = _normalized(widget.value); + _displayedProgress = ValueNotifier(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( + 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 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( + valueListenable: SmoothedProgressScope.of(context), + builder: builder, + child: child, + ); + } +} diff --git a/test/smoothed_progress_test.dart b/test/smoothed_progress_test.dart new file mode 100644 index 00000000..d6768679 --- /dev/null +++ b/test/smoothed_progress_test.dart @@ -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( + 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); + }); +}