diff --git a/lib/data/hub.dart b/lib/data/hub.dart index 9ff0af9..8426185 100644 --- a/lib/data/hub.dart +++ b/lib/data/hub.dart @@ -972,6 +972,34 @@ class HubService { 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 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 /// lightweight labels grouping flows, runs, approvals and audit /// events. Sealed areas never appear here: they are their own @@ -1545,6 +1573,40 @@ class ModuleSummary { /// slug plus renamable presentation metadata. `isolation` is /// `open` (pure label) or `protected` (logically separated — no /// 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 { final String slug; final String name; diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index b038016..c4f1e1a 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -489,6 +489,12 @@ } }, "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", "storeDocsHint": "README inline gerendert; nutzt das Registry-Token des Hubs, falls nötig.", "storeDocsFetching": "README wird geladen…", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 3070276..da04c7f 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -507,6 +507,29 @@ } }, "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", "storeDocsHint": "README rendered inline; uses the hub's registry token when needed.", "storeDocsFetching": "Fetching README…", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index b87bb43..d1185b0 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -1982,6 +1982,42 @@ abstract class AppLocalizations { /// **'Fetching, verifying, unpacking…'** 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. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 65a2489..6970439 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -1089,6 +1089,28 @@ class AppLocalizationsDe extends AppLocalizations { @override 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 String get storeLoadDocs => 'Dokumentation laden'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 42e37fa..587e16c 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -1105,6 +1105,28 @@ class AppLocalizationsEn extends AppLocalizations { @override 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 String get storeLoadDocs => 'Load documentation'; diff --git a/lib/pages/store.dart b/lib/pages/store.dart index 48f00ab..6897976 100644 --- a/lib/pages/store.dart +++ b/lib/pages/store.dart @@ -2847,15 +2847,83 @@ class _InstallProgressDialog extends StatefulWidget { } class _InstallProgressDialogState extends State<_InstallProgressDialog> { - late final Future<({String name, String version})> _future; + StreamSubscription? _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) { diff --git a/lib/widgets/guided_setup_dialog.dart b/lib/widgets/guided_setup_dialog.dart index b943060..c07a00f 100644 --- a/lib/widgets/guided_setup_dialog.dart +++ b/lib/widgets/guided_setup_dialog.dart @@ -22,6 +22,7 @@ import '../data/system_actions.dart'; import '../l10n/app_localizations.dart'; import '../main.dart' show StudioShellState; import '../theme/tokens.dart'; +import 'chain_progress_bar.dart'; import 'chain_stores_dialog.dart'; /// Allowed wire values per answer — an AI suggestion is validated @@ -570,17 +571,43 @@ class _GuidedSetupDialogState extends State { /// Install one plan module by capability name — the hub resolves /// the bundle URL from its store index. + /// Latest progress observation per module being installed. + final Map _installSteps = {}; + Future _install(String module) async { setState(() => _installing.add(module)); 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)); } catch (e) { if (mounted) { await showChainErrorDialog(context, 'install $module', e); } } 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,18 +993,39 @@ class _GuidedSetupDialogState extends State { _installed.contains(m) ? _doneRow(l.setupActionInstalled(m)) : _actionRow( - FilledButton.tonalIcon( - onPressed: (!_hubUp || _installing.contains(m)) - ? null - : () => _install(m), - icon: _installing.contains(m) - ? const SizedBox( - width: 14, - height: 14, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Icon(Icons.download, size: 18), - label: Text(l.setupActionInstall(m)), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + FilledButton.tonalIcon( + onPressed: (!_hubUp || _installing.contains(m)) + ? null + : () => _install(m), + icon: _installing.contains(m) + ? const SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator( + strokeWidth: 2, + ), + ) + : const Icon(Icons.download, size: 18), + 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, + ), + ), + ], + ], ), ), ], diff --git a/test/progress_bar_visual_test.dart b/test/progress_bar_visual_test.dart new file mode 100644 index 0000000..287828f --- /dev/null +++ b/test/progress_bar_visual_test.dart @@ -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 _capture(WidgetTester tester, String name) async { + final boundary = tester.renderObject( + 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()); + }); +}