diff --git a/lib/widgets/chain_progress_bar.dart b/lib/widgets/chain_progress_bar.dart new file mode 100644 index 0000000..39be805 --- /dev/null +++ b/lib/widgets/chain_progress_bar.dart @@ -0,0 +1,217 @@ +// 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()], + ), + ), + ], + ), + ), + ], + ], + ), + ); + } +} diff --git a/lib/widgets/widgets.dart b/lib/widgets/widgets.dart index 9870981..6918f7d 100644 --- a/lib/widgets/widgets.dart +++ b/lib/widgets/widgets.dart @@ -19,6 +19,7 @@ export 'chain_install_confirm.dart'; export 'chain_log_viewer.dart'; export 'chain_module_sheet.dart'; export 'chain_pill.dart'; +export 'chain_progress_bar.dart'; export 'chain_settings_dialog.dart'; export 'chain_stores_dialog.dart'; export 'chain_status_dot.dart'; diff --git a/test/progress_bar_test.dart b/test/progress_bar_test.dart new file mode 100644 index 0000000..647fba4 --- /dev/null +++ b/test/progress_bar_test.dart @@ -0,0 +1,113 @@ +// Guards for ChainProgressBar — the rules from shared/PROGRESS.md that +// are easy to break by accident when someone "simplifies" the widget. + +import 'package:chain_studio/widgets/chain_progress_bar.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +Widget _host(Widget child, {bool disableAnimations = false}) => MaterialApp( + home: Scaffold( + body: MediaQuery( + data: MediaQueryData(disableAnimations: disableAnimations), + child: Padding(padding: const EdgeInsets.all(20), child: child), + ), + ), + ); + +LinearProgressIndicator _indicator(WidgetTester tester) => + tester.widget( + find.byType(LinearProgressIndicator), + ); + +void main() { + testWidgets('no value renders indeterminate, never an empty bar', + (tester) async { + await tester.pumpWidget(_host(const ChainProgressBar(stage: 'Verbinden'))); + await tester.pump(const Duration(milliseconds: 100)); + expect(_indicator(tester).value, isNull, + reason: 'a null value must stay indeterminate, not render as 0'); + expect(find.text('Verbinden'), findsOneWidget); + }); + + testWidgets('a value glides in and is shown as a rounded percentage', + (tester) async { + await tester.pumpWidget( + _host(const ChainProgressBar(value: 0.42, stage: 'Herunterladen')), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 700)); + expect(_indicator(tester).value, closeTo(0.42, 0.001)); + expect(find.text('42 %'), findsOneWidget); + }); + + testWidgets('the value never walks backwards', (tester) async { + await tester.pumpWidget(_host(const ChainProgressBar(value: 0.6))); + await tester.pump(const Duration(milliseconds: 700)); + expect(_indicator(tester).value, closeTo(0.6, 0.001)); + + // A recomputation that comes out lower must be ignored: a bar that + // jumps back destroys trust for the rest of the session. + await tester.pumpWidget(_host(const ChainProgressBar(value: 0.2))); + await tester.pump(const Duration(milliseconds: 700)); + expect(_indicator(tester).value, closeTo(0.6, 0.001)); + }); + + testWidgets('a standing value still animates while busy', (tester) async { + await tester.pumpWidget( + _host(const ChainProgressBar(value: 0.42, busy: true)), + ); + await tester.pump(const Duration(milliseconds: 700)); + // The highlight is a repeating animation; pumpAndSettle would time + // out precisely because something is still moving. That is the + // property under test: the value stands, the bar does not. + expect(tester.hasRunningAnimations, isTrue); + }); + + testWidgets('nothing animates once the work is done', (tester) async { + await tester.pumpWidget( + _host(const ChainProgressBar(value: 1.0, busy: false)), + ); + await tester.pumpAndSettle(); + expect(tester.hasRunningAnimations, isFalse, + reason: 'an idle surface must be still'); + }); + + testWidgets('reduce-motion drops the highlight but keeps the value', + (tester) async { + await tester.pumpWidget( + _host( + const ChainProgressBar(value: 0.5, busy: true), + disableAnimations: true, + ), + ); + await tester.pump(const Duration(milliseconds: 700)); + expect(_indicator(tester).value, closeTo(0.5, 0.001)); + }); + + testWidgets('detail text replaces the percentage when given', + (tester) async { + await tester.pumpWidget( + _host(const ChainProgressBar( + value: 0.3, + stage: 'Herunterladen', + detail: '3,4 von 12,0 MB', + )), + ); + await tester.pump(const Duration(milliseconds: 700)); + expect(find.text('3,4 von 12,0 MB'), findsOneWidget); + expect(find.text('30 %'), findsNothing); + }); + + testWidgets('screen readers get stage and percentage', (tester) async { + final handle = tester.ensureSemantics(); + await tester.pumpWidget( + _host(const ChainProgressBar(value: 0.25, stage: 'Wird entpackt')), + ); + await tester.pump(const Duration(milliseconds: 700)); + expect( + tester.getSemantics(find.byType(ChainProgressBar)), + matchesSemantics(label: 'Wird entpackt', value: '25 %'), + ); + handle.dispose(); + }); +}