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
+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,
);
}
}