diff --git a/lib/data/hub.dart b/lib/data/hub.dart index 59487a2..9ff0af9 100644 --- a/lib/data/hub.dart +++ b/lib/data/hub.dart @@ -605,36 +605,6 @@ 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. @@ -983,14 +953,6 @@ 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(); @@ -1010,34 +972,6 @@ 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 @@ -1611,69 +1545,6 @@ 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; @@ -2462,11 +2333,6 @@ 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 @@ -2509,30 +2375,5 @@ 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 a0f3059..0a3a96f 100644 --- a/lib/data/system_actions.dart +++ b/lib/data/system_actions.dart @@ -16,7 +16,6 @@ // 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'; @@ -179,13 +178,9 @@ 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, { - 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); + String channel, + ) async { + return _runFai(['update', 'apply', '--channel', channel]); } /// Switch the active channel pointer at `~/.chain/current-channel`. @@ -294,50 +289,6 @@ 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 []); @@ -365,8 +316,7 @@ 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 08cb179..b038016 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -489,12 +489,6 @@ } }, "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…", @@ -1670,10 +1664,6 @@ "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 eeb2da6..3070276 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -507,29 +507,6 @@ } }, "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…", @@ -1711,10 +1688,6 @@ "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 0e6b66c..b87bb43 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -1982,42 +1982,6 @@ 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: @@ -5174,30 +5138,6 @@ 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 a0f9a3a..65a2489 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -1089,28 +1089,6 @@ 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'; @@ -3056,19 +3034,6 @@ 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 abc3267..42e37fa 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -1105,28 +1105,6 @@ 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'; @@ -3052,19 +3030,6 @@ 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 f4be094..f4fe89d 100644 --- a/lib/pages/doctor.dart +++ b/lib/pages/doctor.dart @@ -1,5 +1,3 @@ -import 'dart:async'; - import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -1359,48 +1357,15 @@ 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(); }); - _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; + final r = await SystemActions.chainUpdateApply(widget.status.channel); if (!mounted) return; setState(() { _applying = false; - _applyLine = null; _applyOutput = r.ok ? AppLocalizations.of(context)!.doctorApplyDone : (r.stderr.isEmpty ? r.stdout : r.stderr).trim(); @@ -1508,21 +1473,6 @@ 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 0b2cc0c..48f00ab 100644 --- a/lib/pages/store.dart +++ b/lib/pages/store.dart @@ -253,9 +253,8 @@ 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( @@ -366,7 +365,8 @@ 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,7 +381,8 @@ class _StorePageState extends State { ), ], value: _showStudio, - onChanged: (v) => setState(() => _showStudio = v), + onChanged: (v) => + setState(() => _showStudio = v), ), ), // ONE page-level notice when the hub says @@ -1656,24 +1657,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. @@ -1694,17 +1695,20 @@ 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()], + ), ), ], ), @@ -1928,14 +1932,18 @@ 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) @@ -2222,9 +2230,7 @@ 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), ); @@ -2308,10 +2314,8 @@ 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; @@ -2682,8 +2686,6 @@ 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), @@ -2779,9 +2781,7 @@ 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,83 +2847,15 @@ class _InstallProgressDialog extends StatefulWidget { } class _InstallProgressDialogState extends State<_InstallProgressDialog> { - 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; + late final Future<({String name, String version})> _future; @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. - _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)); + _future = HubService.instance.installModule(source: widget.item.name); } @override @@ -2937,7 +2869,64 @@ class _InstallProgressDialogState extends State<_InstallProgressDialog> { ), content: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 480, maxHeight: 360), - child: _buildBody(context, theme, l), + 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, + ), + ], + ); + }, + ), ), actions: [ TextButton( @@ -2947,72 +2936,6 @@ 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) { @@ -3951,84 +3874,6 @@ 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 deleted file mode 100644 index 39be805..0000000 --- a/lib/widgets/chain_progress_bar.dart +++ /dev/null @@ -1,217 +0,0 @@ -// 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 e5972eb..154ce2e 100644 --- a/lib/widgets/chain_system_ai_editor.dart +++ b/lib/widgets/chain_system_ai_editor.dart @@ -13,7 +13,6 @@ 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 @@ -349,9 +348,6 @@ 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)!; @@ -361,29 +357,16 @@ class _FaiSystemAiEditorState extends State { } setState(() { _pulling = true; - _pullStep = null; _error = null; }); - ({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); - } + final r = await HubService.instance.pullSystemAiModel( + endpoint: _endpoint.text.trim(), + model: wanted, + apiKeyEnv: _apiKeyEnv.text.trim(), + ); 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 { @@ -463,7 +446,6 @@ class _FaiSystemAiEditorState extends State { onPull: _saving || _testing || _loadingModels || _pulling ? null : _pullModel, - pullStep: _pullStep, ), const SizedBox(height: ChainSpace.md), TextField( @@ -695,10 +677,8 @@ 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), @@ -738,9 +718,6 @@ 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, @@ -752,7 +729,6 @@ class _ModelPicker extends StatelessWidget { required this.curatedById, required this.onRefresh, required this.onPull, - this.pullStep, }); bool get _isOllama => preset.wire == 'ollama'; @@ -818,27 +794,6 @@ 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( @@ -1103,10 +1058,7 @@ 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), @@ -1207,10 +1159,7 @@ 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 c07a00f..b943060 100644 --- a/lib/widgets/guided_setup_dialog.dart +++ b/lib/widgets/guided_setup_dialog.dart @@ -22,7 +22,6 @@ 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 @@ -571,43 +570,17 @@ 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 { - // 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); - } + await HubService.instance.installModule(source: module); if (mounted) setState(() => _installed.add(module)); } catch (e) { if (mounted) { await showChainErrorDialog(context, 'install $module', e); } } finally { - 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; + if (mounted) setState(() => _installing.remove(module)); } } @@ -993,39 +966,18 @@ class _GuidedSetupDialogState extends State { _installed.contains(m) ? _doneRow(l.setupActionInstalled(m)) : _actionRow( - 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, - ), - ), - ], - ], + 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)), ), ), ], diff --git a/lib/widgets/widgets.dart b/lib/widgets/widgets.dart index 6918f7d..9870981 100644 --- a/lib/widgets/widgets.dart +++ b/lib/widgets/widgets.dart @@ -19,7 +19,6 @@ 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 deleted file mode 100644 index 647fba4..0000000 --- a/test/progress_bar_test.dart +++ /dev/null @@ -1,113 +0,0 @@ -// 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 deleted file mode 100644 index 287828f..0000000 --- a/test/progress_bar_visual_test.dart +++ /dev/null @@ -1,124 +0,0 @@ -// 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 deleted file mode 100644 index 4f391e0..0000000 --- a/test/store_data_source_test.dart +++ /dev/null @@ -1,85 +0,0 @@ -// 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 deleted file mode 100644 index 34c8240..0000000 --- a/test/update_apply_progress_test.dart +++ /dev/null @@ -1,55 +0,0 @@ -// 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'); - }); -}