// ChainProgressBar — the one progress indicator for waits in Studio. // // Separates the two things a waiting user actually asks about, which a // plain LinearProgressIndicator conflates: // // how far → [value], which may legitimately stand still for minutes // is it alive → [busy], which drives a highlight that keeps moving // across the bar even while [value] does not change // // Without a value it runs indeterminate; with one it glides to the new // value instead of jumping, so the polling interval behind it is never // visible. The rules and their rationale live in shared/PROGRESS.md. import 'package:flutter/material.dart'; import '../theme/tokens.dart'; class ChainProgressBar extends StatefulWidget { /// Progress from 0.0 to 1.0, or null while the extent is unknown. /// A null value renders indeterminate rather than an empty bar, /// which would read as "stuck" instead of "starting". final double? value; /// Work is still in flight. Drives the moving highlight, and is what /// distinguishes a paused value from a dead application. Set false /// once the work ends, so nothing animates at rest. final bool busy; /// Short plain-language label for the current phase ("Wird /// heruntergeladen"). Shown left of the percentage. final String? stage; /// Extra detail on the right, replacing the percentage when given /// (for example "3,4 von 12,0 MB"). final String? detail; /// Hide the text row and render the bar alone. final bool showLabels; final double height; const ChainProgressBar({ super.key, this.value, this.busy = true, this.stage, this.detail, this.showLabels = true, this.height = 10, }); @override State createState() => _ChainProgressBarState(); } class _ChainProgressBarState extends State with SingleTickerProviderStateMixin { // 2.5s reads as calm; anything near 1s reads as agitated. static const _sweep = Duration(milliseconds: 2500); // Long enough to hide a two-second poll interval, short enough that // the bar still feels connected to the data behind it. static const _glide = Duration(milliseconds: 600); // Created eagerly in initState, not lazily: a bar built with // busy: false would otherwise construct its controller inside // dispose(), where the ticker's context lookup is already unsafe. late final AnimationController _shine; /// Highest value rendered so far. A bar that walks backwards costs /// more trust than one that pauses, so lower values are ignored. double _shown = 0; @override void initState() { super.initState(); _shine = AnimationController(vsync: this, duration: _sweep); if (widget.busy) _shine.repeat(); } @override void didUpdateWidget(covariant ChainProgressBar old) { super.didUpdateWidget(old); if (widget.busy && !_shine.isAnimating) { _shine.repeat(); } else if (!widget.busy && _shine.isAnimating) { _shine.stop(); _shine.value = 0; } } @override void dispose() { _shine.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final theme = Theme.of(context); final scheme = theme.colorScheme; // Respect the platform's reduce-motion setting: the value still // updates, only the decorative highlight stops. final reduceMotion = MediaQuery.maybeDisableAnimationsOf(context) ?? false; final target = widget.value?.clamp(_shown, 1.0).toDouble(); if (target != null && target > _shown) _shown = target; final bar = ClipRRect( borderRadius: BorderRadius.circular(widget.height), child: SizedBox( height: widget.height, child: Stack( children: [ Positioned.fill( child: TweenAnimationBuilder( tween: Tween(begin: 0, end: target ?? 0), duration: _glide, curve: Curves.easeInOutCubic, builder: (context, animated, _) => LinearProgressIndicator( value: target == null ? null : animated, minHeight: widget.height, backgroundColor: scheme.surfaceContainerHighest, color: scheme.primary, ), ), ), // The sign of life. It rides above the value and is driven // by [busy] alone, so a value stuck at 42 % still shows // that the hub is working. Suppressed while indeterminate, // where the indicator already travels on its own — never // two movements on one element. if (widget.busy && target != null && !reduceMotion) Positioned.fill( child: IgnorePointer( child: AnimatedBuilder( animation: _shine, builder: (context, _) => FractionallySizedBox( // Stays inside the track almost the whole cycle; // travelling further makes the highlight spend // most of its time off-screen and the bar reads // as dead again. alignment: Alignment(-1.1 + 2.2 * _shine.value, 0), widthFactor: 0.5, child: DecoratedBox( decoration: BoxDecoration( gradient: LinearGradient( colors: [ scheme.onPrimary.withValues(alpha: 0), scheme.onPrimary.withValues(alpha: 0.55), scheme.onPrimary.withValues(alpha: 0), ], ), ), ), ), ), ), ), ], ), ), ); if (!widget.showLabels) { return Semantics( label: widget.stage, value: target == null ? null : '${(target * 100).round()} %', child: bar, ); } final right = widget.detail ?? (target == null ? null : '${(target * 100).round()} %'); return Semantics( label: widget.stage, value: target == null ? null : '${(target * 100).round()} %', child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: MainAxisSize.min, children: [ bar, if (widget.stage != null || right != null) ...[ const SizedBox(height: ChainSpace.xs), // The bar already carries stage and percentage as its // semantics; without this a screen reader reads both again // from the visible text. ExcludeSemantics( child: Row( children: [ if (widget.stage != null) Expanded( child: Text( widget.stage!, style: theme.textTheme.bodySmall, overflow: TextOverflow.ellipsis, ), ) else const Spacer(), if (right != null) Text( right, style: theme.textTheme.bodySmall?.copyWith( fontFeatures: const [FontFeature.tabularFigures()], ), ), ], ), ), ], ], ), ); } }