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