feat(studio): ChainProgressBar - value and sign of life, kept apart
A waiting user asks two questions that a plain LinearProgressIndicator answers as one: how far along is it, and is anything still happening. The widget keeps them apart - the value may stand still for minutes while a highlight driven by 'busy' keeps crossing the bar, so a stalled percentage no longer reads as a frozen app. Also: glides to new values over 600 ms so the polling interval behind it stays invisible, refuses to walk backwards, renders indeterminate rather than an empty bar when no value is known, stops animating once the work ends, and honours reduce-motion. Eight guards in test/progress_bar_test.dart cover each of those rules. Writing them found a real defect: with busy: false the late-final controller was first constructed inside dispose(), where the ticker's context lookup is already unsafe. Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
parent
f6f52cc830
commit
2bc556e0f5
3 changed files with 331 additions and 0 deletions
217
lib/widgets/chain_progress_bar.dart
Normal file
217
lib/widgets/chain_progress_bar.dart
Normal file
|
|
@ -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<ChainProgressBar> createState() => _ChainProgressBarState();
|
||||
}
|
||||
|
||||
class _ChainProgressBarState extends State<ChainProgressBar>
|
||||
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<double>(
|
||||
tween: Tween<double>(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()],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
113
test/progress_bar_test.dart
Normal file
113
test/progress_bar_test.dart
Normal file
|
|
@ -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<LinearProgressIndicator>(
|
||||
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();
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue