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

@ -972,6 +972,34 @@ class HubService {
return (name: r.name, version: r.version); return (name: r.name, version: r.version);
} }
/// Install a module while watching it happen.
///
/// Emits one [InstallStep] per phase change and, during the
/// download, as bytes arrive. Completes when the install
/// succeeds; throws the hub's error otherwise, exactly like
/// [installModule]. [onFinished] receives name and version.
Stream<InstallStep> installModuleStreaming({
required String source,
void Function(({String name, String version}) result)? onFinished,
}) {
return _client
.installModuleStreaming(
source: source,
onFinished: (r) => onFinished?.call((name: r.name, version: r.version)),
)
.map(
(u) => InstallStep(
phase: u.phase,
// `percent` is absent while the extent is unknowable; the
// bar shows that as indeterminate rather than as zero.
percent: u.hasPercent() ? u.percent : null,
bytesReceived: u.bytesReceived.toInt(),
bytesTotal: u.bytesTotal.toInt(),
phaseDone: u.phaseDone,
),
);
}
/// The shared hub's project registry (`general` first) — the /// The shared hub's project registry (`general` first) — the
/// lightweight labels grouping flows, runs, approvals and audit /// lightweight labels grouping flows, runs, approvals and audit
/// events. Sealed areas never appear here: they are their own /// events. Sealed areas never appear here: they are their own
@ -1545,6 +1573,40 @@ class ModuleSummary {
/// slug plus renamable presentation metadata. `isolation` is /// slug plus renamable presentation metadata. `isolation` is
/// `open` (pure label) or `protected` (logically separated no /// `open` (pure label) or `protected` (logically separated no
/// hard process barrier; every UI keeps that honest wording). /// hard process barrier; every UI keeps that honest wording).
/// One observation while a module installs.
///
/// [percent] is the overall value across all phases and may stand
/// still for a long time during a big download; that is expected and
/// is why the bar carries its own sign of life. A null [percent]
/// means the extent isn't knowable yet.
class InstallStep {
/// Stable phase id: `resolve`, `download` or `install`.
final String phase;
/// Overall progress 0..100, or null while indeterminate.
final double? percent;
/// Bytes received so far (download phase only).
final int bytesReceived;
/// Total bytes announced by the server; 0 when unknown.
final int bytesTotal;
/// This phase just finished.
final bool phaseDone;
const InstallStep({
required this.phase,
required this.percent,
required this.bytesReceived,
required this.bytesTotal,
required this.phaseDone,
});
/// True while the server told us how big the download is.
bool get hasSize => bytesTotal > 0;
}
class ProjectRef { class ProjectRef {
final String slug; final String slug;
final String name; final String name;

View file

@ -489,6 +489,12 @@
} }
}, },
"storeInstallProgressBody": "Holen, verifizieren, entpacken…", "storeInstallProgressBody": "Holen, verifizieren, entpacken…",
"installPhaseResolve": "Modul wird gesucht",
"installPhaseDownload": "Wird heruntergeladen",
"installPhaseInstall": "Wird geprüft und entpackt",
"installPhaseDone": "Fertig",
"installBytes": "{done} von {total} MB",
"installElapsed": "{seconds} s vergangen",
"storeLoadDocs": "Dokumentation laden", "storeLoadDocs": "Dokumentation laden",
"storeDocsHint": "README inline gerendert; nutzt das Registry-Token des Hubs, falls nötig.", "storeDocsHint": "README inline gerendert; nutzt das Registry-Token des Hubs, falls nötig.",
"storeDocsFetching": "README wird geladen…", "storeDocsFetching": "README wird geladen…",

View file

@ -507,6 +507,29 @@
} }
}, },
"storeInstallProgressBody": "Fetching, verifying, unpacking…", "storeInstallProgressBody": "Fetching, verifying, unpacking…",
"installPhaseResolve": "Looking up the module",
"installPhaseDownload": "Downloading",
"installPhaseInstall": "Verifying and unpacking",
"installPhaseDone": "Done",
"installBytes": "{done} of {total} MB",
"@installBytes": {
"placeholders": {
"done": {
"type": "String"
},
"total": {
"type": "String"
}
}
},
"installElapsed": "{seconds} s elapsed",
"@installElapsed": {
"placeholders": {
"seconds": {
"type": "int"
}
}
},
"storeLoadDocs": "Load documentation", "storeLoadDocs": "Load documentation",
"storeDocsHint": "README rendered inline; uses the hub's registry token when needed.", "storeDocsHint": "README rendered inline; uses the hub's registry token when needed.",
"storeDocsFetching": "Fetching README…", "storeDocsFetching": "Fetching README…",

View file

@ -1982,6 +1982,42 @@ abstract class AppLocalizations {
/// **'Fetching, verifying, unpacking…'** /// **'Fetching, verifying, unpacking…'**
String get storeInstallProgressBody; String get storeInstallProgressBody;
/// No description provided for @installPhaseResolve.
///
/// In en, this message translates to:
/// **'Looking up the module'**
String get installPhaseResolve;
/// No description provided for @installPhaseDownload.
///
/// In en, this message translates to:
/// **'Downloading'**
String get installPhaseDownload;
/// No description provided for @installPhaseInstall.
///
/// In en, this message translates to:
/// **'Verifying and unpacking'**
String get installPhaseInstall;
/// No description provided for @installPhaseDone.
///
/// In en, this message translates to:
/// **'Done'**
String get installPhaseDone;
/// No description provided for @installBytes.
///
/// In en, this message translates to:
/// **'{done} of {total} MB'**
String installBytes(String done, String total);
/// No description provided for @installElapsed.
///
/// In en, this message translates to:
/// **'{seconds} s elapsed'**
String installElapsed(int seconds);
/// No description provided for @storeLoadDocs. /// No description provided for @storeLoadDocs.
/// ///
/// In en, this message translates to: /// In en, this message translates to:

View file

@ -1089,6 +1089,28 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get storeInstallProgressBody => 'Holen, verifizieren, entpacken…'; String get storeInstallProgressBody => 'Holen, verifizieren, entpacken…';
@override
String get installPhaseResolve => 'Modul wird gesucht';
@override
String get installPhaseDownload => 'Wird heruntergeladen';
@override
String get installPhaseInstall => 'Wird geprüft und entpackt';
@override
String get installPhaseDone => 'Fertig';
@override
String installBytes(String done, String total) {
return '$done von $total MB';
}
@override
String installElapsed(int seconds) {
return '$seconds s vergangen';
}
@override @override
String get storeLoadDocs => 'Dokumentation laden'; String get storeLoadDocs => 'Dokumentation laden';

View file

@ -1105,6 +1105,28 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get storeInstallProgressBody => 'Fetching, verifying, unpacking…'; String get storeInstallProgressBody => 'Fetching, verifying, unpacking…';
@override
String get installPhaseResolve => 'Looking up the module';
@override
String get installPhaseDownload => 'Downloading';
@override
String get installPhaseInstall => 'Verifying and unpacking';
@override
String get installPhaseDone => 'Done';
@override
String installBytes(String done, String total) {
return '$done of $total MB';
}
@override
String installElapsed(int seconds) {
return '$seconds s elapsed';
}
@override @override
String get storeLoadDocs => 'Load documentation'; String get storeLoadDocs => 'Load documentation';

View file

@ -2847,15 +2847,83 @@ class _InstallProgressDialog extends StatefulWidget {
} }
class _InstallProgressDialogState extends State<_InstallProgressDialog> { 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 @override
void initState() { void initState() {
super.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 // Capability-name install: hub resolves the wasm_url from the
// bundled store-index. No source prompt needed for entries // bundled store-index. No source prompt needed for entries
// whose seed.yaml already has wasm_url. // 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 @override
@ -2869,64 +2937,7 @@ class _InstallProgressDialogState extends State<_InstallProgressDialog> {
), ),
content: ConstrainedBox( content: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 480, maxHeight: 360), constraints: const BoxConstraints(maxWidth: 480, maxHeight: 360),
child: FutureBuilder<({String name, String version})>( child: _buildBody(context, theme, l),
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,
),
],
);
},
),
), ),
actions: [ actions: [
TextButton( 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) { ChainPillTone _toneForStatus(String s) {

View file

@ -22,6 +22,7 @@ import '../data/system_actions.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
import '../main.dart' show StudioShellState; import '../main.dart' show StudioShellState;
import '../theme/tokens.dart'; import '../theme/tokens.dart';
import 'chain_progress_bar.dart';
import 'chain_stores_dialog.dart'; import 'chain_stores_dialog.dart';
/// Allowed wire values per answer an AI suggestion is validated /// Allowed wire values per answer an AI suggestion is validated
@ -570,17 +571,43 @@ class _GuidedSetupDialogState extends State<GuidedSetupDialog> {
/// Install one plan module by capability name the hub resolves /// Install one plan module by capability name the hub resolves
/// the bundle URL from its store index. /// the bundle URL from its store index.
/// Latest progress observation per module being installed.
final Map<String, InstallStep> _installSteps = {};
Future<void> _install(String module) async { Future<void> _install(String module) async {
setState(() => _installing.add(module)); setState(() => _installing.add(module));
try { try {
await HubService.instance.installModule(source: module); // Streamed, not awaited blind: a module bundle can take a while
// on a slow line, and a dead-looking button is what made people
// click twice.
await for (final step
in HubService.instance.installModuleStreaming(source: module)) {
if (mounted) setState(() => _installSteps[module] = step);
}
if (mounted) setState(() => _installed.add(module)); if (mounted) setState(() => _installed.add(module));
} catch (e) { } catch (e) {
if (mounted) { if (mounted) {
await showChainErrorDialog(context, 'install $module', e); await showChainErrorDialog(context, 'install $module', e);
} }
} finally { } finally {
if (mounted) setState(() => _installing.remove(module)); if (mounted) {
setState(() {
_installing.remove(module);
_installSteps.remove(module);
});
}
}
}
/// Plain-language label for a module's current install phase.
String _installStage(AppLocalizations l, String module) {
switch (_installSteps[module]?.phase) {
case 'download':
return l.installPhaseDownload;
case 'install':
return l.installPhaseInstall;
default:
return l.installPhaseResolve;
} }
} }
@ -966,6 +993,10 @@ class _GuidedSetupDialogState extends State<GuidedSetupDialog> {
_installed.contains(m) _installed.contains(m)
? _doneRow(l.setupActionInstalled(m)) ? _doneRow(l.setupActionInstalled(m))
: _actionRow( : _actionRow(
Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
FilledButton.tonalIcon( FilledButton.tonalIcon(
onPressed: (!_hubUp || _installing.contains(m)) onPressed: (!_hubUp || _installing.contains(m))
? null ? null
@ -974,11 +1005,28 @@ class _GuidedSetupDialogState extends State<GuidedSetupDialog> {
? const SizedBox( ? const SizedBox(
width: 14, width: 14,
height: 14, height: 14,
child: CircularProgressIndicator(strokeWidth: 2), child: CircularProgressIndicator(
strokeWidth: 2,
),
) )
: const Icon(Icons.download, size: 18), : const Icon(Icons.download, size: 18),
label: Text(l.setupActionInstall(m)), label: Text(l.setupActionInstall(m)),
), ),
if (_installing.contains(m)) ...[
const SizedBox(height: ChainSpace.sm),
SizedBox(
width: 320,
child: ChainProgressBar(
value: _installSteps[m]?.percent == null
? null
: _installSteps[m]!.percent! / 100,
stage: _installStage(l, m),
height: 6,
),
),
],
],
),
), ),
], ],
], ],

View file

@ -0,0 +1,124 @@
// Renders the progress bar in the states an operator actually meets
// and writes PNGs to build/progress/, so the visual result can be
// judged without launching the desktop app and stealing focus.
//
// Not a golden comparison: there is no committed reference to diff
// against. It exists to produce evidence, and to guard that every one
// of these states renders at all in both themes.
import 'dart:io';
import 'dart:ui' as ui;
import 'package:chain_studio/theme/theme.dart';
import 'package:chain_studio/widgets/chain_progress_bar.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter_test/flutter_test.dart';
class _Case {
final String label;
final Widget bar;
const _Case(this.label, this.bar);
}
const _cases = <_Case>[
_Case(
'Nichts messbar: unbestimmt statt leer',
ChainProgressBar(stage: 'Modul wird gesucht'),
),
_Case(
'Download mit Groesse',
ChainProgressBar(
value: 0.42,
stage: 'Wird heruntergeladen',
detail: '5,0 von 12,0 MB',
),
),
_Case(
'Wert steht, Arbeit laeuft weiter',
ChainProgressBar(value: 0.74, stage: 'Wird geprueft und entpackt'),
),
_Case(
'Fertig: nichts bewegt sich mehr',
ChainProgressBar(value: 1.0, busy: false, stage: 'Fertig'),
),
];
Widget _sheet(ThemeData theme, String title) => MaterialApp(
theme: theme,
debugShowCheckedModeBanner: false,
home: Scaffold(
body: RepaintBoundary(
key: const ValueKey('sheet'),
child: Center(
child: SizedBox(
width: 520,
child: Card(
margin: const EdgeInsets.all(24),
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
Text(title, style: theme.textTheme.titleMedium),
const SizedBox(height: 20),
for (final c in _cases) ...[
Text(c.label, style: theme.textTheme.labelSmall),
const SizedBox(height: 6),
c.bar,
const SizedBox(height: 22),
],
],
),
),
),
),
),
),
),
);
Future<void> _capture(WidgetTester tester, String name) async {
final boundary = tester.renderObject<RenderRepaintBoundary>(
find.byKey(const ValueKey('sheet')),
);
// toImage is real asynchronous engine work. Awaiting it directly in a
// widget test leaves the binding waiting forever afterwards, which
// showed up as this file passing in six seconds and then sitting
// there until the ten-minute timeout killed the whole suite.
final image = await tester.runAsync(() => boundary.toImage(pixelRatio: 2));
if (image == null) return;
final bytes = await tester.runAsync(
() => image.toByteData(format: ui.ImageByteFormat.png),
);
final dir = Directory('build/progress')..createSync(recursive: true);
File('${dir.path}/$name.png').writeAsBytesSync(bytes!.buffer.asUint8List());
}
void main() {
// One capture per run: a second toImage() in the same test binding
// never completes, so the theme comes from the environment and the
// sheet is rendered twice from outside:
//
// flutter test test/progress_bar_visual_test.dart
// PROGRESS_THEME=dark flutter test test/progress_bar_visual_test.dart
testWidgets('renders every waiting state', (tester) async {
final dark = Platform.environment['PROGRESS_THEME'] == 'dark';
tester.view.physicalSize = const Size(1100, 900);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.reset);
await tester.pumpWidget(
_sheet(
dark ? ChainTheme.dark() : ChainTheme.light(),
dark ? 'Dunkel' : 'Hell',
),
);
// Mid-sweep, so the highlight sits inside the bar in the capture.
await tester.pump(const Duration(milliseconds: 900));
expect(find.byType(ChainProgressBar), findsNWidgets(_cases.length));
await _capture(tester, dark ? 'states-dark' : 'states-light');
await tester.pumpWidget(const SizedBox());
});
}