feat(studio): real install progress in the store dialog and the wizard

Both surfaces showed an indeterminate spinner for the whole install,
because the hub only offered a unary call. They now follow
HubAdmin/InstallModuleStream:

- The store's install dialog shows the phase in plain language, the
  overall percentage, megabytes while the size is known, and a seconds
  counter. The counter is the second, independent signal: it keeps
  running even if the hub goes quiet, which is what tells 'slow' apart
  from 'stuck'.
- The setup wizard shows the same bar per module instead of a spinner
  inside the button.
- New strings in both locales for the phases, the byte line and the
  elapsed counter.

progress_bar_visual_test writes the four waiting states to
build/progress/ in either theme, so the result can be judged without
launching the desktop app. Writing it turned up a real trap worth
recording: awaiting toImage() directly in a widget test leaves the
binding waiting forever - the file passed in six seconds, then sat
there until the ten-minute timeout failed the whole suite.
tester.runAsync() is the fix. Full suite: 186 passed.

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
flemming-it 2026-09-08 14:00:24 +02:00
parent 2bc556e0f5
commit 1549334767
9 changed files with 490 additions and 74 deletions

View file

@ -2847,15 +2847,83 @@ class _InstallProgressDialog extends StatefulWidget {
}
class _InstallProgressDialogState extends State<_InstallProgressDialog> {
late final Future<({String name, String version})> _future;
StreamSubscription<InstallStep>? _sub;
Timer? _clock;
final Stopwatch _elapsed = Stopwatch();
/// Latest observation from the hub. Null until the first one lands.
InstallStep? _step;
({String name, String version})? _result;
Object? _error;
@override
void initState() {
super.initState();
_elapsed.start();
// A second, independent signal that something is happening: the
// elapsed clock keeps counting even if the hub goes quiet, which
// is what tells an operator "slow" apart from "stuck".
_clock = Timer.periodic(const Duration(seconds: 1), (_) {
if (mounted) setState(() {});
});
// Capability-name install: hub resolves the wasm_url from the
// bundled store-index. No source prompt needed for entries
// whose seed.yaml already has wasm_url.
_future = HubService.instance.installModule(source: widget.item.name);
_sub = HubService.instance
.installModuleStreaming(
source: widget.item.name,
onFinished: (r) {
if (mounted) setState(() => _result = r);
},
)
.listen(
(step) {
if (mounted) setState(() => _step = step);
},
onError: (Object e) {
if (mounted) setState(() => _error = e);
_stopClock();
},
onDone: _stopClock,
);
}
void _stopClock() {
_elapsed.stop();
_clock?.cancel();
_clock = null;
if (mounted) setState(() {});
}
@override
void dispose() {
_sub?.cancel();
_clock?.cancel();
super.dispose();
}
/// Plain-language label for the phase the hub last reported.
String _stageLabel(AppLocalizations l) {
if (_result != null) return l.installPhaseDone;
switch (_step?.phase) {
case 'download':
return l.installPhaseDownload;
case 'install':
return l.installPhaseInstall;
case 'resolve':
return l.installPhaseResolve;
default:
return l.installPhaseResolve;
}
}
/// "3,4 of 12,0 MB" while the size is known, otherwise null so the
/// bar falls back to showing the percentage.
String? _detail(AppLocalizations l) {
final step = _step;
if (step == null || !step.hasSize || step.phase != 'download') return null;
String mb(int bytes) => (bytes / 1048576).toStringAsFixed(1);
return l.installBytes(mb(step.bytesReceived), mb(step.bytesTotal));
}
@override
@ -2869,64 +2937,7 @@ class _InstallProgressDialogState extends State<_InstallProgressDialog> {
),
content: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 480, maxHeight: 360),
child: FutureBuilder<({String name, String version})>(
future: _future,
builder: (context, snap) {
if (snap.connectionState == ConnectionState.waiting) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: ChainSpace.md),
const CircularProgressIndicator(),
const SizedBox(height: ChainSpace.md),
Text(
l.storeInstallProgressBody,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
);
}
if (snap.hasError) {
return SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.error_outline,
color: theme.colorScheme.error,
size: 32,
),
const SizedBox(height: ChainSpace.md),
ChainErrorBox(
error: snap.error,
isError: true,
maxHeight: 200,
),
],
),
);
}
final r = snap.data!;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.check_circle_outline,
size: 32,
color: ChainColors.success,
),
const SizedBox(height: ChainSpace.md),
Text(
l.storeInstalledToast(r.name, r.version),
style: theme.textTheme.titleMedium,
),
],
);
},
),
child: _buildBody(context, theme, l),
),
actions: [
TextButton(
@ -2936,6 +2947,68 @@ class _InstallProgressDialogState extends State<_InstallProgressDialog> {
],
);
}
Widget _buildBody(BuildContext context, ThemeData theme, AppLocalizations l) {
if (_error != null) {
return SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.error_outline, color: theme.colorScheme.error, size: 32),
const SizedBox(height: ChainSpace.md),
ChainErrorBox(error: _error, isError: true, maxHeight: 200),
],
),
);
}
final done = _result != null;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
if (done) ...[
Icon(Icons.check_circle_outline, size: 32, color: ChainColors.success),
const SizedBox(height: ChainSpace.md),
Text(
l.storeInstalledToast(_result!.name, _result!.version),
style: theme.textTheme.titleMedium,
textAlign: TextAlign.center,
),
] else ...[
const SizedBox(height: ChainSpace.sm),
ChainProgressBar(
// Null until the hub can say how far along it is; the bar
// runs indeterminate rather than sitting at zero.
value: _step?.percent == null ? null : _step!.percent! / 100,
busy: true,
stage: _stageLabel(l),
detail: _detail(l),
),
const SizedBox(height: ChainSpace.md),
Row(
children: [
Expanded(
child: Text(
l.storeInstallProgressBody,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
Text(
l.installElapsed(_elapsed.elapsed.inSeconds),
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
],
],
);
}
}
ChainPillTone _toneForStatus(String s) {