From 2bc556e0f58b42d87dc6ec71e7b4ca4081a9b9d4 Mon Sep 17 00:00:00 2001 From: flemming-it Date: Tue, 8 Sep 2026 13:20:19 +0200 Subject: [PATCH 1/5] 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 --- lib/widgets/chain_progress_bar.dart | 217 ++++++++++++++++++++++++++++ lib/widgets/widgets.dart | 1 + test/progress_bar_test.dart | 113 +++++++++++++++ 3 files changed, 331 insertions(+) create mode 100644 lib/widgets/chain_progress_bar.dart create mode 100644 test/progress_bar_test.dart diff --git a/lib/widgets/chain_progress_bar.dart b/lib/widgets/chain_progress_bar.dart new file mode 100644 index 0000000..39be805 --- /dev/null +++ b/lib/widgets/chain_progress_bar.dart @@ -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 createState() => _ChainProgressBarState(); +} + +class _ChainProgressBarState extends State + 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( + tween: Tween(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()], + ), + ), + ], + ), + ), + ], + ], + ), + ); + } +} diff --git a/lib/widgets/widgets.dart b/lib/widgets/widgets.dart index 9870981..6918f7d 100644 --- a/lib/widgets/widgets.dart +++ b/lib/widgets/widgets.dart @@ -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'; diff --git a/test/progress_bar_test.dart b/test/progress_bar_test.dart new file mode 100644 index 0000000..647fba4 --- /dev/null +++ b/test/progress_bar_test.dart @@ -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( + 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(); + }); +} From 1549334767e630e54d50c842f1335853a66b78da Mon Sep 17 00:00:00 2001 From: flemming-it Date: Tue, 8 Sep 2026 14:00:24 +0200 Subject: [PATCH 2/5] 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 --- lib/data/hub.dart | 62 +++++++++ lib/l10n/app_de.arb | 6 + lib/l10n/app_en.arb | 23 ++++ lib/l10n/app_localizations.dart | 36 +++++ lib/l10n/app_localizations_de.dart | 22 +++ lib/l10n/app_localizations_en.dart | 22 +++ lib/pages/store.dart | 193 ++++++++++++++++++--------- lib/widgets/guided_setup_dialog.dart | 76 +++++++++-- test/progress_bar_visual_test.dart | 124 +++++++++++++++++ 9 files changed, 490 insertions(+), 74 deletions(-) create mode 100644 test/progress_bar_visual_test.dart 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()); + }); +} From 14f9217007791887d07e2aad41611bb1ad393180 Mon Sep 17 00:00:00 2001 From: flemming-it Date: Tue, 8 Sep 2026 17:41:21 +0200 Subject: [PATCH 3/5] feat(studio): show what a running update is doing, not just that it runs Applying an update downloads and swaps a binary and takes minutes; the doctor showed a spinner inside a disabled button for the whole time. SystemActions gained a streaming CLI runner that hands each line to the caller as it arrives, with CHAIN_PLAIN=1 set for the child so the CLI emits one line per transition instead of its redraw-in-place block. The update card now shows an indeterminate bar labelled with the running step and an elapsed counter on its own timer, so the counter keeps moving between lines that can be minutes apart. No invented percentage: the CLI reports steps, not a measurable total. The streaming path still defers to debugRunFaiOverride, so tests stay hermetic and never spawn a process; three guards pin that. Signed-off-by: flemming-it --- lib/data/system_actions.dart | 58 ++++++++++++++++++++++++++-- lib/pages/doctor.dart | 52 ++++++++++++++++++++++++- test/update_apply_progress_test.dart | 55 ++++++++++++++++++++++++++ 3 files changed, 160 insertions(+), 5 deletions(-) create mode 100644 test/update_apply_progress_test.dart diff --git a/lib/data/system_actions.dart b/lib/data/system_actions.dart index 0a3a96f..a0f3059 100644 --- a/lib/data/system_actions.dart +++ b/lib/data/system_actions.dart @@ -16,6 +16,7 @@ // some operators don't restart their shell after install — the // fallback covers that case. +import 'dart:convert'; import 'dart:io'; import 'package:meta/meta.dart'; @@ -178,9 +179,13 @@ class SystemActions { /// Run `chain update apply --channel `. Long-running on a slow /// network — caller should show a spinner. static Future<({bool ok, String stdout, String stderr})> chainUpdateApply( - String channel, - ) async { - return _runFai(['update', 'apply', '--channel', channel]); + String channel, { + void Function(String line)? onLine, + }) async { + // Streamed: applying an update downloads and swaps a binary and + // takes minutes. Without the lines, the operator stares at a + // spinner with no way to tell slow from stuck. + return _runFaiStreaming(['update', 'apply', '--channel', channel], onLine); } /// Switch the active channel pointer at `~/.chain/current-channel`. @@ -289,6 +294,50 @@ class SystemActions { } } + /// Like [_runFai], but hands each stdout/stderr line to [onLine] as + /// it arrives. Falls back to the buffered path when a test override + /// is installed, so tests stay hermetic and never spawn a process. + /// + /// `CHAIN_PLAIN=1` is set for the child: the CLI's redraw-in-place + /// block is unreadable when captured, and the plain mode emits one + /// line per transition, which is exactly what a caller can show. + static Future<({bool ok, String stdout, String stderr})> _runFaiStreaming( + List args, + void Function(String line)? onLine, + ) async { + final runOverride = debugRunFaiOverride; + if (runOverride != null) return runOverride(args); + if (onLine == null) return _runFai(args); + final exe = _faiExecutable(); + if (exe == null) { + return (ok: false, stdout: '', stderr: kFaiBinaryNotFound); + } + try { + final process = await Process.start( + exe, + args, + environment: {'CHAIN_PLAIN': '1'}, + ); + final out = StringBuffer(); + final err = StringBuffer(); + Stream lines(Stream> raw) => + raw.transform(utf8.decoder).transform(const LineSplitter()); + final stdoutDone = lines(process.stdout).listen((line) { + out.writeln(line); + if (line.trim().isNotEmpty) onLine(line.trim()); + }).asFuture(); + final stderrDone = lines(process.stderr).listen((line) { + err.writeln(line); + if (line.trim().isNotEmpty) onLine(line.trim()); + }).asFuture(); + final code = await process.exitCode; + await Future.wait([stdoutDone, stderrDone]); + return (ok: code == 0, stdout: out.toString(), stderr: err.toString()); + } catch (e) { + return (ok: false, stdout: '', stderr: e.toString()); + } + } + static ({String executable, List args}) _openCommand() { if (Platform.isMacOS) return (executable: 'open', args: const []); if (Platform.isWindows) return (executable: 'explorer', args: const []); @@ -316,7 +365,8 @@ class SystemActions { final isWindows = Platform.isWindows; // Post-rename the entry-point binary on PATH is `chain`; still // accept a legacy `fai` for installs that predate the rename. - final fromPath = _whichFai(isWindows ? 'chain.exe' : 'chain') ?? + final fromPath = + _whichFai(isWindows ? 'chain.exe' : 'chain') ?? _whichFai(isWindows ? 'fai.exe' : 'fai'); if (fromPath != null) return fromPath; diff --git a/lib/pages/doctor.dart b/lib/pages/doctor.dart index f4fe89d..f4be094 100644 --- a/lib/pages/doctor.dart +++ b/lib/pages/doctor.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -1357,15 +1359,48 @@ class _UpdateBannerState extends State<_UpdateBanner> { bool _applying = false; String? _applyOutput; + /// Last line the CLI printed, shown as the bar's label. + String? _applyLine; + + /// When the apply started, for the elapsed counter. + DateTime? _applySince; + + /// Redraws the elapsed counter between CLI lines, which can be + /// minutes apart during a download. + Timer? _applyClock; + + @override + void dispose() { + _applyClock?.cancel(); + super.dispose(); + } + Future _applyUpdate() async { setState(() { _applying = true; _applyOutput = null; + _applyLine = null; + _applySince = DateTime.now(); }); - final r = await SystemActions.chainUpdateApply(widget.status.channel); + _applyClock?.cancel(); + _applyClock = Timer.periodic(const Duration(seconds: 1), (_) { + if (mounted && _applying) setState(() {}); + }); + final r = await SystemActions.chainUpdateApply( + widget.status.channel, + // Each line the CLI prints becomes the label under the bar, so + // the operator sees which step is running rather than a spinner + // that could mean anything. + onLine: (line) { + if (mounted) setState(() => _applyLine = line); + }, + ); + _applyClock?.cancel(); + _applyClock = null; if (!mounted) return; setState(() { _applying = false; + _applyLine = null; _applyOutput = r.ok ? AppLocalizations.of(context)!.doctorApplyDone : (r.stderr.isEmpty ? r.stdout : r.stderr).trim(); @@ -1473,6 +1508,21 @@ class _UpdateBannerState extends State<_UpdateBanner> { ), ], ), + if (_applying) ...[ + const SizedBox(height: ChainSpace.md), + // No percentage to be had: the CLI reports steps, not a + // measurable total. So the bar runs indeterminate and + // carries the running step plus an elapsed counter, which + // is what separates "slow" from "stuck". + ChainProgressBar( + stage: _applyLine ?? l.doctorApplying, + detail: _applySince == null + ? null + : l.installElapsed( + DateTime.now().difference(_applySince!).inSeconds, + ), + ), + ], if (_applyOutput != null) ...[ const SizedBox(height: ChainSpace.md), ChainErrorBox(text: _applyOutput!, maxHeight: 240), diff --git a/test/update_apply_progress_test.dart b/test/update_apply_progress_test.dart new file mode 100644 index 0000000..34c8240 --- /dev/null +++ b/test/update_apply_progress_test.dart @@ -0,0 +1,55 @@ +// Guards for the streamed `chain update apply` path. +// +// Applying an update downloads and swaps a binary and takes minutes. +// The bug class: a caller that passes a line callback and silently +// gets nothing back, or a streaming path that stops honouring the +// test override and starts spawning real processes in the suite. + +import 'package:chain_studio/data/system_actions.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + tearDown(() => SystemActions.debugRunFaiOverride = null); + + test('the test override still wins over the streaming path', () async { + // Hermetic by construction: with an override installed, no process + // may be started even when a line callback is supplied. + var seenArgs = []; + SystemActions.debugRunFaiOverride = (args) async { + seenArgs = args; + return (ok: true, stdout: 'done', stderr: ''); + }; + + var lines = []; + final r = await SystemActions.chainUpdateApply( + 'stable', + onLine: lines.add, + ); + + expect(r.ok, isTrue); + expect(r.stdout, 'done'); + expect(seenArgs, ['update', 'apply', '--channel', 'stable']); + expect(lines, isEmpty, reason: 'the override produces no live lines'); + }); + + test('the channel reaches the CLI unchanged', () async { + var seenArgs = []; + SystemActions.debugRunFaiOverride = (args) async { + seenArgs = args; + return (ok: true, stdout: '', stderr: ''); + }; + + await SystemActions.chainUpdateApply('beta', onLine: (_) {}); + expect(seenArgs, contains('beta')); + }); + + test('callers without a line callback keep the buffered behaviour', + () async { + SystemActions.debugRunFaiOverride = + (args) async => (ok: false, stdout: '', stderr: 'boom'); + + final r = await SystemActions.chainUpdateApply('stable'); + expect(r.ok, isFalse); + expect(r.stderr, 'boom'); + }); +} From fb809a4cbd0f263daff0c2000891a546bb9e78bb Mon Sep 17 00:00:00 2001 From: flemming-it Date: Tue, 8 Sep 2026 17:51:28 +0200 Subject: [PATCH 4/5] feat(studio): show the model download instead of a spinning button Pulling a model moves gigabytes and could take fifteen minutes behind a 14-pixel spinner. The model picker now follows the hub's pull stream and shows a bar with the backend's own status and the megabytes for the layer in flight. The byte counts describe the current layer, not the whole model, so the bar restarts per layer. That is deliberate and stated in the code: the backend never says how many layers are still coming, so a single overall number would have to be invented. Signed-off-by: flemming-it --- lib/data/hub.dart | 59 +++++++++++++++++++++ lib/widgets/chain_system_ai_editor.dart | 69 +++++++++++++++++++++---- 2 files changed, 119 insertions(+), 9 deletions(-) diff --git a/lib/data/hub.dart b/lib/data/hub.dart index 8426185..922171d 100644 --- a/lib/data/hub.dart +++ b/lib/data/hub.dart @@ -605,6 +605,36 @@ class HubService { return (errorKind: r.errorKind, text: r.text, elapsedMs: r.elapsedMs); } + /// Pull a model while watching it happen. + /// + /// Emits one [PullStep] per observation from the backend. Completes + /// when the pull ends; [onFinished] carries the outcome, whose + /// `errorKind` is non-empty on failure. + Stream pullSystemAiModelStreaming({ + required String endpoint, + required String model, + String apiKeyEnv = '', + void Function(({String errorKind, String text, int elapsedMs}) result)? + onFinished, + }) { + return _client + .pullSystemAiModelStreaming( + endpoint: endpoint, + model: model, + apiKeyEnv: apiKeyEnv, + onFinished: (r) => onFinished?.call( + (errorKind: r.errorKind, text: r.text, elapsedMs: r.elapsedMs), + ), + ) + .map( + (u) => PullStep( + status: u.status, + completed: u.completed.toInt(), + total: u.total.toInt(), + ), + ); + } + /// Detected host hardware. Used by the System-AI editor to /// mark models as "recommended" / "may be slow" against the /// operator's actual machine. @@ -1573,6 +1603,35 @@ 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 model downloads. +/// +/// [status] is the backend's own wording ("pulling manifest", +/// "verifying sha256:..."), passed through rather than translated: it +/// names layers and digests Studio cannot reconstruct. Byte counts +/// refer to the layer that status is about, not to the whole model, +/// so a bar built from them restarts per layer — which is honest, and +/// better than a single number that would have to be invented. +class PullStep { + /// The backend's status text. + final String status; + + /// Bytes fetched so far for the current layer. + final int completed; + + /// Size of the current layer; 0 when the status carries no size. + final int total; + + const PullStep({ + required this.status, + required this.completed, + required this.total, + }); + + /// Fraction of the current layer, or null while not measurable. + double? get fraction => + total > 0 ? (completed / total).clamp(0.0, 1.0) : null; +} + /// One observation while a module installs. /// /// [percent] is the overall value across all phases and may stand diff --git a/lib/widgets/chain_system_ai_editor.dart b/lib/widgets/chain_system_ai_editor.dart index 154ce2e..e5972eb 100644 --- a/lib/widgets/chain_system_ai_editor.dart +++ b/lib/widgets/chain_system_ai_editor.dart @@ -13,6 +13,7 @@ import '../theme/theme.dart'; import '../theme/tokens.dart'; import 'chain_error_box.dart'; import 'chain_pill.dart'; +import 'chain_progress_bar.dart'; /// Provider preset metadata kept in sync with /// `chain_hub::operator_config::SystemLlmProvider`. Anything that @@ -348,6 +349,9 @@ class _FaiSystemAiEditorState extends State { }); } + /// Latest observation while a model downloads. + PullStep? _pullStep; + Future _pullModel() async { final wanted = _model.text.trim(); final l = AppLocalizations.of(context)!; @@ -357,16 +361,29 @@ class _FaiSystemAiEditorState extends State { } setState(() { _pulling = true; + _pullStep = null; _error = null; }); - final r = await HubService.instance.pullSystemAiModel( - endpoint: _endpoint.text.trim(), - model: wanted, - apiKeyEnv: _apiKeyEnv.text.trim(), - ); + ({String errorKind, String text, int elapsedMs})? outcome; + try { + // Streamed: a model is gigabytes. The unary call left this + // button spinning for minutes with nothing to show. + await for (final step in HubService.instance.pullSystemAiModelStreaming( + endpoint: _endpoint.text.trim(), + model: wanted, + apiKeyEnv: _apiKeyEnv.text.trim(), + onFinished: (r) => outcome = r, + )) { + if (mounted) setState(() => _pullStep = step); + } + } catch (e) { + outcome = (errorKind: 'network', text: e.toString(), elapsedMs: 0); + } if (!mounted) return; + final r = outcome ?? (errorKind: '', text: '', elapsedMs: 0); setState(() { _pulling = false; + _pullStep = null; if (r.errorKind.isNotEmpty) { _error = l.systemAiPullFailedError(r.text); } else { @@ -446,6 +463,7 @@ class _FaiSystemAiEditorState extends State { onPull: _saving || _testing || _loadingModels || _pulling ? null : _pullModel, + pullStep: _pullStep, ), const SizedBox(height: ChainSpace.md), TextField( @@ -677,8 +695,10 @@ class _TestResultPanel extends StatelessWidget { if (ok) SelectableText( l.systemAiReplyPrefix(result.text), - style: - ChainTheme.mono(size: 11, color: theme.colorScheme.onSurface), + style: ChainTheme.mono( + size: 11, + color: theme.colorScheme.onSurface, + ), ) else ChainErrorBox(text: result.text, isError: true, maxHeight: 200), @@ -718,6 +738,9 @@ class _ModelPicker extends StatelessWidget { final VoidCallback? onRefresh; final VoidCallback? onPull; + /// Latest observation while a pull runs; null when idle. + final PullStep? pullStep; + const _ModelPicker({ required this.controller, required this.preset, @@ -729,6 +752,7 @@ class _ModelPicker extends StatelessWidget { required this.curatedById, required this.onRefresh, required this.onPull, + this.pullStep, }); bool get _isOllama => preset.wire == 'ollama'; @@ -794,6 +818,27 @@ class _ModelPicker extends StatelessWidget { ], ], ), + if (pulling) ...[ + const SizedBox(height: ChainSpace.sm), + // The byte counts describe the layer currently downloading, + // not the whole model, so the bar restarts per layer. That + // is honest; a single overall number would have to be + // invented, because the backend never says how many layers + // are still coming. + ChainProgressBar( + value: pullStep?.fraction, + stage: pullStep?.status.isNotEmpty == true + ? pullStep!.status + : l.systemAiPulling, + detail: (pullStep?.total ?? 0) > 0 + ? l.installBytes( + (pullStep!.completed / 1048576).toStringAsFixed(1), + (pullStep!.total / 1048576).toStringAsFixed(1), + ) + : null, + height: 6, + ), + ], if (modelsError != null) ...[ const SizedBox(height: 4), Text( @@ -1058,7 +1103,10 @@ class _HardwareBanner extends StatelessWidget { ? l.systemAiHwReviewed(lastReviewed!) : ''; return Container( - padding: const EdgeInsets.symmetric(horizontal: ChainSpace.sm, vertical: 6), + padding: const EdgeInsets.symmetric( + horizontal: ChainSpace.sm, + vertical: 6, + ), decoration: BoxDecoration( color: theme.colorScheme.surfaceContainerHigh, borderRadius: BorderRadius.circular(ChainRadius.sm), @@ -1159,7 +1207,10 @@ class _CacheStatusRow extends StatelessWidget { final theme = Theme.of(context); final l = AppLocalizations.of(context)!; return Container( - padding: const EdgeInsets.symmetric(horizontal: ChainSpace.sm, vertical: 6), + padding: const EdgeInsets.symmetric( + horizontal: ChainSpace.sm, + vertical: 6, + ), decoration: BoxDecoration( color: theme.colorScheme.surfaceContainerHigh, borderRadius: BorderRadius.circular(ChainRadius.sm), From 35a79d0bb6c3cb68d414b5dc5934f41a8df44e7b Mon Sep 17 00:00:00 2001 From: flemming-it Date: Wed, 9 Sep 2026 12:36:45 +0200 Subject: [PATCH 5/5] feat(studio): show a source module's data terms before the install button The store detail sheet now carries a data-source block for source.* modules: publisher, upstream url, the terms in plain words, and any attribution the operator has to carry with the output. It sits above maintainers and above the install button, because it is a decision input rather than a footnote. The values are selectable: compliance notes get written by copying, not retyping. A note names whose terms these are, so nobody reads them as the module's own licence. Four guards, including that an empty attribution renders no empty row. Signed-off-by: flemming-it --- lib/data/hub.dart | 38 +++++++ lib/l10n/app_de.arb | 4 + lib/l10n/app_en.arb | 4 + lib/l10n/app_localizations.dart | 24 ++++ lib/l10n/app_localizations_de.dart | 13 +++ lib/l10n/app_localizations_en.dart | 13 +++ lib/pages/store.dart | 170 +++++++++++++++++++++-------- test/store_data_source_test.dart | 85 +++++++++++++++ 8 files changed, 307 insertions(+), 44 deletions(-) create mode 100644 test/store_data_source_test.dart diff --git a/lib/data/hub.dart b/lib/data/hub.dart index 922171d..59487a2 100644 --- a/lib/data/hub.dart +++ b/lib/data/hub.dart @@ -983,6 +983,14 @@ class HubService { canonicalCategoryLabel: e.canonicalCategoryLabel, installVerification: e.installVerification, maintainers: e.maintainers, + dataSource: e.hasDataSource() + ? DataProvenance( + name: e.dataSource.name, + url: e.dataSource.url, + license: e.dataSource.license, + attribution: e.dataSource.attribution, + ) + : null, ), ) .toList(); @@ -2454,6 +2462,11 @@ class StoreItem { /// any — the detail sheet renders an honest "not specified". final List maintainers; + /// Where a `source.*` module's data comes from, and under what + /// terms. Null for every other module: the module's own licence + /// covers its code, this covers material it reaches at runtime. + final DataProvenance? dataSource; + /// How an install of this entry would be verified under the /// hub's CURRENT policy — computed hub-side with the same /// resolvers the install gate enforces, so this can never @@ -2496,5 +2509,30 @@ class StoreItem { this.canonicalCategoryLabel = '', this.installVerification = '', this.maintainers = const [], + this.dataSource, + }); +} + +/// Provenance of the material a source module fetches. +class DataProvenance { + /// Publisher, as a person would name it. + final String name; + + /// Canonical URL of the upstream source. + final String url; + + /// Terms in plain words, not SPDX: statutes carry no software + /// licence at all. + final String license; + + /// Attribution the operator must carry with the output; empty when + /// the upstream requires none. + final String attribution; + + const DataProvenance({ + required this.name, + required this.url, + required this.license, + required this.attribution, }); } diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index c4f1e1a..08cb179 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -1670,6 +1670,10 @@ "installConfirmTrustTitle": "Vertrauen & Sicherheit", "installConfirmTrustBody": "Das Modul läuft in einer Sandbox: Es darf nur auf die Netzwerk-Endpunkte, Dateien und Umgebungsvariablen zugreifen, die es selbst deklariert — der Hub setzt diese Liste durch. Die vollständige Berechtigungsliste sehen Sie nach der Installation in den Modul-Details.", "storeSectionMaintainers": "Maintainer", + "storeSectionDataSource": "Datenquelle", + "storeDataSourceLicense": "Bedingungen", + "storeDataSourceAttribution": "Namensnennung erforderlich", + "storeDataSourceNote": "Diese Bedingungen gelten für das Material, das dieses Modul abruft, nicht für das Modul selbst. Es lädt in Ihrem Auftrag und liefert keine Kopie der Daten mit.", "storeMaintainersNone": "nicht angegeben", "installConfirmMaintainers": "Maintainer", "storePolicyUnverifiedNotice": "Die Signaturpflicht ist in der Hub-Richtlinie ausgeschaltet — Installationen werden nicht kryptografisch geprüft. Der Installations-Dialog zeigt den Status je Modul; für geprüfte Installationen security.require_signatures aktivieren.", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index da04c7f..eeb2da6 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1711,6 +1711,10 @@ "installConfirmTrustTitle": "Trust & security", "installConfirmTrustBody": "The module runs in a sandbox: it may only touch the network endpoints, files, and environment variables it declares itself — the hub enforces that list. The full permission list is visible in the module details after installation.", "storeSectionMaintainers": "Maintainers", + "storeSectionDataSource": "Data source", + "storeDataSourceLicense": "Terms", + "storeDataSourceAttribution": "Attribution required", + "storeDataSourceNote": "These terms cover the material this module fetches, not the module itself. It downloads on your behalf and ships no copy of the data.", "storeMaintainersNone": "not specified", "installConfirmMaintainers": "Maintainers", "storePolicyUnverifiedNotice": "Signature enforcement is switched off in the hub policy — installs are not cryptographically verified. The install dialog shows the per-module status; enable security.require_signatures for verified installs.", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index d1185b0..0e6b66c 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -5174,6 +5174,30 @@ abstract class AppLocalizations { /// **'Maintainers'** String get storeSectionMaintainers; + /// No description provided for @storeSectionDataSource. + /// + /// In en, this message translates to: + /// **'Data source'** + String get storeSectionDataSource; + + /// No description provided for @storeDataSourceLicense. + /// + /// In en, this message translates to: + /// **'Terms'** + String get storeDataSourceLicense; + + /// No description provided for @storeDataSourceAttribution. + /// + /// In en, this message translates to: + /// **'Attribution required'** + String get storeDataSourceAttribution; + + /// No description provided for @storeDataSourceNote. + /// + /// In en, this message translates to: + /// **'These terms cover the material this module fetches, not the module itself. It downloads on your behalf and ships no copy of the data.'** + String get storeDataSourceNote; + /// No description provided for @storeMaintainersNone. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 6970439..a0f9a3a 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -3056,6 +3056,19 @@ class AppLocalizationsDe extends AppLocalizations { @override String get storeSectionMaintainers => 'Maintainer'; + @override + String get storeSectionDataSource => 'Datenquelle'; + + @override + String get storeDataSourceLicense => 'Bedingungen'; + + @override + String get storeDataSourceAttribution => 'Namensnennung erforderlich'; + + @override + String get storeDataSourceNote => + 'Diese Bedingungen gelten für das Material, das dieses Modul abruft, nicht für das Modul selbst. Es lädt in Ihrem Auftrag und liefert keine Kopie der Daten mit.'; + @override String get storeMaintainersNone => 'nicht angegeben'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 587e16c..abc3267 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -3052,6 +3052,19 @@ class AppLocalizationsEn extends AppLocalizations { @override String get storeSectionMaintainers => 'Maintainers'; + @override + String get storeSectionDataSource => 'Data source'; + + @override + String get storeDataSourceLicense => 'Terms'; + + @override + String get storeDataSourceAttribution => 'Attribution required'; + + @override + String get storeDataSourceNote => + 'These terms cover the material this module fetches, not the module itself. It downloads on your behalf and ships no copy of the data.'; + @override String get storeMaintainersNone => 'not specified'; diff --git a/lib/pages/store.dart b/lib/pages/store.dart index 6897976..0b2cc0c 100644 --- a/lib/pages/store.dart +++ b/lib/pages/store.dart @@ -253,8 +253,9 @@ class _StorePageState extends State { icon: const Icon(Icons.add_business_outlined, size: 16), label: Text(l.storesManagerButton), onPressed: () => ChainStoresDialog.show(context), - style: - OutlinedButton.styleFrom(visualDensity: VisualDensity.compact), + style: OutlinedButton.styleFrom( + visualDensity: VisualDensity.compact, + ), ), ), IconButton( @@ -365,8 +366,7 @@ class _StorePageState extends State { // Modules vs Studio plugins/themes — a theme // extends the GUI, a module runs in a flow. Padding( - padding: - const EdgeInsets.only(bottom: ChainSpace.md), + padding: const EdgeInsets.only(bottom: ChainSpace.md), child: ChainSegments( items: [ ChainSegmentItem( @@ -381,8 +381,7 @@ class _StorePageState extends State { ), ], value: _showStudio, - onChanged: (v) => - setState(() => _showStudio = v), + onChanged: (v) => setState(() => _showStudio = v), ), ), // ONE page-level notice when the hub says @@ -1657,24 +1656,24 @@ class _StoreGrid extends StatelessWidget { }); Widget grid(List gi) => GridView.builder( - padding: EdgeInsets.zero, - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: cols, - mainAxisSpacing: ChainSpace.md, - crossAxisSpacing: ChainSpace.md, - mainAxisExtent: 168, - ), - itemCount: gi.length, - itemBuilder: (context, i) => _StoreCard( - item: gi[i], - locale: locale, - installedVersion: installedVersions[gi[i].name], - onTap: () => onTap(gi[i]), - onInstall: () => onInstall(gi[i]), - ), - ); + padding: EdgeInsets.zero, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: cols, + mainAxisSpacing: ChainSpace.md, + crossAxisSpacing: ChainSpace.md, + mainAxisExtent: 168, + ), + itemCount: gi.length, + itemBuilder: (context, i) => _StoreCard( + item: gi[i], + locale: locale, + installedVersion: installedVersions[gi[i].name], + onTap: () => onTap(gi[i]), + onInstall: () => onInstall(gi[i]), + ), + ); // A single category (e.g. the store is already filtered to // one) renders without a redundant header. @@ -1695,20 +1694,17 @@ class _StoreGrid extends StatelessWidget { children: [ Text( _canonicalCatLabel(context, slug, labels[slug] ?? ''), - style: Theme.of(context) - .textTheme - .titleSmall - ?.copyWith(fontWeight: FontWeight.w700), + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w700, + ), ), const SizedBox(width: ChainSpace.sm), Text( '${groups[slug]!.length}', style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context) - .colorScheme - .onSurfaceVariant, - fontFeatures: const [FontFeature.tabularFigures()], - ), + color: Theme.of(context).colorScheme.onSurfaceVariant, + fontFeatures: const [FontFeature.tabularFigures()], + ), ), ], ), @@ -1932,18 +1928,14 @@ class _FeaturedTile extends StatelessWidget { ), ), const SizedBox(width: ChainSpace.xs), - if (item.status.isNotEmpty) - _statusPill(context, item.status), + if (item.status.isNotEmpty) _statusPill(context, item.status), // License on the card, not only in the detail // sheet — buyers scan the grid for exactly this // (usertest finding: no license/cost signal per // module before clicking). if (item.license.isNotEmpty) ...[ const SizedBox(width: ChainSpace.xs), - ChainPill( - label: item.license, - tone: ChainPillTone.neutral, - ), + ChainPill(label: item.license, tone: ChainPillTone.neutral), ], const Spacer(), if (item.installed) @@ -2230,7 +2222,9 @@ class _StoreDetailSheet extends StatefulWidget { backgroundColor: Theme.of(context).colorScheme.surfaceContainer, elevation: 8, shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(ChainRadius.md)), + borderRadius: BorderRadius.vertical( + top: Radius.circular(ChainRadius.md), + ), ), builder: (_) => _StoreDetailSheet(item: item, locale: locale), ); @@ -2314,8 +2308,10 @@ class _StoreDetailSheetState extends State<_StoreDetailSheet> { Future _install() async { // Same trust gate as the store card — the detail sheet's // install button must not be a quieter bypass. - final confirmed = - await ChainInstallConfirmDialog.show(context, widget.item); + final confirmed = await ChainInstallConfirmDialog.show( + context, + widget.item, + ); if (!confirmed || !mounted) return; setState(() { _busy = true; @@ -2686,6 +2682,8 @@ class _StoreDetailSheetState extends State<_StoreDetailSheet> { _DocsPanel(text: _docsResult!.text), const SizedBox(height: ChainSpace.lg), ], + if (item.dataSource != null) + StoreDataSourceSection(source: item.dataSource!), StoreMaintainersSection(maintainers: item.maintainers), if (item.repository.isNotEmpty) ...[ _SectionHeader(l.storeSectionSource), @@ -2781,7 +2779,9 @@ class _StoreDetailSheetState extends State<_StoreDetailSheet> { ), decoration: BoxDecoration( color: theme.colorScheme.surfaceContainer, - borderRadius: BorderRadius.circular(ChainRadius.sm), + borderRadius: BorderRadius.circular( + ChainRadius.sm, + ), border: Border.all( color: theme.colorScheme.outlineVariant, ), @@ -2969,7 +2969,11 @@ class _InstallProgressDialogState extends State<_InstallProgressDialog> { mainAxisSize: MainAxisSize.min, children: [ if (done) ...[ - Icon(Icons.check_circle_outline, size: 32, color: ChainColors.success), + Icon( + Icons.check_circle_outline, + size: 32, + color: ChainColors.success, + ), const SizedBox(height: ChainSpace.md), Text( l.storeInstalledToast(_result!.name, _result!.version), @@ -3947,6 +3951,84 @@ class StoreMaintainersSection extends StatelessWidget { } } +/// Provenance block for `source.*` modules: who publishes the data +/// the module fetches, and under what terms. Sits above the install +/// button because it is a decision input, not a footnote — the terms +/// of the material are separate from the module's own licence, and +/// an operator taking on an attribution duty should see it first. +/// Public so the widget test pumps it directly. +class StoreDataSourceSection extends StatelessWidget { + final DataProvenance source; + + const StoreDataSourceSection({super.key, required this.source}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final l = AppLocalizations.of(context)!; + final muted = theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _SectionHeader(l.storeSectionDataSource), + const SizedBox(height: ChainSpace.sm), + SelectableText(source.name, style: theme.textTheme.bodySmall), + if (source.url.isNotEmpty) + SelectableText( + source.url, + style: ChainTheme.mono(size: 11, color: theme.colorScheme.primary), + ), + const SizedBox(height: ChainSpace.sm), + _ProvenanceRow(label: l.storeDataSourceLicense, value: source.license), + if (source.attribution.isNotEmpty) + _ProvenanceRow( + label: l.storeDataSourceAttribution, + value: source.attribution, + ), + const SizedBox(height: ChainSpace.sm), + Text(l.storeDataSourceNote, style: muted), + const SizedBox(height: ChainSpace.lg), + ], + ); + } +} + +/// Label and value on one line, value selectable so an attribution +/// string can be copied straight into a compliance note. +class _ProvenanceRow extends StatelessWidget { + final String label; + final String value; + + const _ProvenanceRow({required this.label, required this.value}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Padding( + padding: const EdgeInsets.only(bottom: 2), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 150, + child: Text( + label, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + Expanded( + child: SelectableText(value, style: theme.textTheme.bodySmall), + ), + ], + ), + ); + } +} + /// Signature-verification warning pill. Same philosophy as the /// provenance pill: the GOOD path (signature checked at install) /// stays quiet, and so does the policy-off case — that one is a diff --git a/test/store_data_source_test.dart b/test/store_data_source_test.dart new file mode 100644 index 0000000..4f391e0 --- /dev/null +++ b/test/store_data_source_test.dart @@ -0,0 +1,85 @@ +// Guards for the data-provenance block in the store detail sheet. +// +// The rule it protects (docs/architecture/store-format.md): the terms +// of the material a source module fetches are separate from the +// module's own licence, and an operator must see them before the +// install button, not in a README afterwards. + +import 'package:chain_studio/data/hub.dart'; +import 'package:chain_studio/l10n/app_localizations.dart'; +import 'package:chain_studio/pages/store.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +Widget _host(Widget child) => MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + locale: const Locale('de'), + home: Scaffold(body: SingleChildScrollView(child: child)), + ); + +void main() { + const bund = DataProvenance( + name: 'gesetze-im-internet.de (BMJ / juris GmbH)', + url: 'https://www.gesetze-im-internet.de/', + license: 'Amtliches Werk (§ 5 UrhG), gemeinfrei', + attribution: 'Quelle: gesetze-im-internet.de (Bundesministerium der Justiz)', + ); + + testWidgets('publisher, terms and attribution are all shown', + (tester) async { + await tester.pumpWidget(_host(const StoreDataSourceSection(source: bund))); + await tester.pumpAndSettle(); + + expect(find.text(bund.name), findsOneWidget); + expect(find.text(bund.url), findsOneWidget); + expect(find.text(bund.license), findsOneWidget); + expect(find.text(bund.attribution), findsOneWidget, + reason: 'an attribution duty must be visible before installing'); + }); + + testWidgets('the attribution row is omitted when none is required', + (tester) async { + const noAttribution = DataProvenance( + name: 'Vom Betreiber gepflegter Metadaten-Katalog', + url: '', + license: 'Katalog des Betreibers', + attribution: '', + ); + await tester + .pumpWidget(_host(const StoreDataSourceSection(source: noAttribution))); + await tester.pumpAndSettle(); + + expect(find.text(noAttribution.name), findsOneWidget); + final l = AppLocalizations.of( + tester.element(find.byType(StoreDataSourceSection)), + )!; + expect(find.text(l.storeDataSourceAttribution), findsNothing, + reason: 'an empty attribution must not render an empty row'); + }); + + testWidgets('the terms are selectable so they can be copied', + (tester) async { + await tester.pumpWidget(_host(const StoreDataSourceSection(source: bund))); + await tester.pumpAndSettle(); + + // Compliance notes get written by copying, not retyping. + expect( + find.byWidgetPredicate( + (w) => w is SelectableText && w.data == bund.attribution, + ), + findsOneWidget, + ); + }); + + testWidgets('the note says whose terms these are', (tester) async { + await tester.pumpWidget(_host(const StoreDataSourceSection(source: bund))); + await tester.pumpAndSettle(); + + final l = AppLocalizations.of( + tester.element(find.byType(StoreDataSourceSection)), + )!; + // Without this line an operator reads the terms as the module's. + expect(find.text(l.storeDataSourceNote), findsOneWidget); + }); +}