diff --git a/lib/data/hub.dart b/lib/data/hub.dart index 9ff0af9..59487a2 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. @@ -953,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(); @@ -972,6 +1010,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 +1611,69 @@ 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 +/// 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; @@ -2333,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 @@ -2375,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/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/l10n/app_de.arb b/lib/l10n/app_de.arb index b038016..08cb179 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…", @@ -1664,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 3070276..eeb2da6 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…", @@ -1688,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 b87bb43..0e6b66c 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: @@ -5138,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 65a2489..a0f9a3a 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'; @@ -3034,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 42e37fa..abc3267 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'; @@ -3030,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/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/lib/pages/store.dart b/lib/pages/store.dart index 48f00ab..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, ), @@ -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,72 @@ 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) { @@ -3874,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/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/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), 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/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(); + }); +} 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()); + }); +} 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); + }); +} 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'); + }); +}