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()],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue