Compare commits

..

5 commits

Author SHA1 Message Date
flemming-it
35a79d0bb6 feat(studio): show a source module's data terms before the install button
Some checks failed
Security / Security check (push) Failing after 2s
The store detail sheet now carries a data-source block for source.*
modules: publisher, upstream url, the terms in plain words, and any
attribution the operator has to carry with the output. It sits above
maintainers and above the install button, because it is a decision
input rather than a footnote.

The values are selectable: compliance notes get written by copying,
not retyping. A note names whose terms these are, so nobody reads them
as the module's own licence. Four guards, including that an empty
attribution renders no empty row.

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
2026-09-09 12:36:45 +02:00
flemming-it
fb809a4cbd feat(studio): show the model download instead of a spinning button
Pulling a model moves gigabytes and could take fifteen minutes behind
a 14-pixel spinner. The model picker now follows the hub's pull stream
and shows a bar with the backend's own status and the megabytes for
the layer in flight.

The byte counts describe the current layer, not the whole model, so
the bar restarts per layer. That is deliberate and stated in the code:
the backend never says how many layers are still coming, so a single
overall number would have to be invented.

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
2026-09-08 17:51:28 +02:00
flemming-it
14f9217007 feat(studio): show what a running update is doing, not just that it runs
Applying an update downloads and swaps a binary and takes minutes; the
doctor showed a spinner inside a disabled button for the whole time.
SystemActions gained a streaming CLI runner that hands each line to the
caller as it arrives, with CHAIN_PLAIN=1 set for the child so the CLI
emits one line per transition instead of its redraw-in-place block.

The update card now shows an indeterminate bar labelled with the
running step and an elapsed counter on its own timer, so the counter
keeps moving between lines that can be minutes apart. No invented
percentage: the CLI reports steps, not a measurable total.

The streaming path still defers to debugRunFaiOverride, so tests stay
hermetic and never spawn a process; three guards pin that.

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
2026-09-08 17:41:21 +02:00
flemming-it
1549334767 feat(studio): real install progress in the store dialog and the wizard
Both surfaces showed an indeterminate spinner for the whole install,
because the hub only offered a unary call. They now follow
HubAdmin/InstallModuleStream:

- The store's install dialog shows the phase in plain language, the
  overall percentage, megabytes while the size is known, and a seconds
  counter. The counter is the second, independent signal: it keeps
  running even if the hub goes quiet, which is what tells 'slow' apart
  from 'stuck'.
- The setup wizard shows the same bar per module instead of a spinner
  inside the button.
- New strings in both locales for the phases, the byte line and the
  elapsed counter.

progress_bar_visual_test writes the four waiting states to
build/progress/ in either theme, so the result can be judged without
launching the desktop app. Writing it turned up a real trap worth
recording: awaiting toImage() directly in a widget test leaves the
binding waiting forever - the file passed in six seconds, then sat
there until the ten-minute timeout failed the whole suite.
tester.runAsync() is the fix. Full suite: 186 passed.

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
2026-09-08 14:00:24 +02:00
flemming-it
2bc556e0f5 feat(studio): ChainProgressBar - value and sign of life, kept apart
A waiting user asks two questions that a plain LinearProgressIndicator
answers as one: how far along is it, and is anything still happening.
The widget keeps them apart - the value may stand still for minutes
while a highlight driven by 'busy' keeps crossing the bar, so a
stalled percentage no longer reads as a frozen app.

Also: glides to new values over 600 ms so the polling interval behind
it stays invisible, refuses to walk backwards, renders indeterminate
rather than an empty bar when no value is known, stops animating once
the work ends, and honours reduce-motion.

Eight guards in test/progress_bar_test.dart cover each of those rules.
Writing them found a real defect: with busy: false the late-final
controller was first constructed inside dispose(), where the ticker's
context lookup is already unsafe.

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
2026-09-08 13:20:19 +02:00
17 changed files with 1406 additions and 131 deletions

View file

@ -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<PullStep> 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<InstallStep> installModuleStreaming({
required String source,
void Function(({String name, String version}) result)? onFinished,
}) {
return _client
.installModuleStreaming(
source: source,
onFinished: (r) => onFinished?.call((name: r.name, version: r.version)),
)
.map(
(u) => InstallStep(
phase: u.phase,
// `percent` is absent while the extent is unknowable; the
// bar shows that as indeterminate rather than as zero.
percent: u.hasPercent() ? u.percent : null,
bytesReceived: u.bytesReceived.toInt(),
bytesTotal: u.bytesTotal.toInt(),
phaseDone: u.phaseDone,
),
);
}
/// The shared hub's project registry (`general` first) — the
/// 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<String> 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,
});
}

View file

@ -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 <c>`. 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<String> 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<String> lines(Stream<List<int>> 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<void>();
final stderrDone = lines(process.stderr).listen((line) {
err.writeln(line);
if (line.trim().isNotEmpty) onLine(line.trim());
}).asFuture<void>();
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<String> 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;

View file

@ -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.",

View file

@ -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.",

View file

@ -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:

View file

@ -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';

View file

@ -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';

View file

@ -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<void> _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),

View file

@ -253,8 +253,9 @@ class _StorePageState extends State<StorePage> {
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<StorePage> {
// 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<bool>(
items: [
ChainSegmentItem(
@ -381,8 +381,7 @@ class _StorePageState extends State<StorePage> {
),
],
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<StoreItem> 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<void> _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<InstallStep>? _sub;
Timer? _clock;
final Stopwatch _elapsed = Stopwatch();
/// Latest observation from the hub. Null until the first one lands.
InstallStep? _step;
({String name, String version})? _result;
Object? _error;
@override
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

View file

@ -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<ChainProgressBar> createState() => _ChainProgressBarState();
}
class _ChainProgressBarState extends State<ChainProgressBar>
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<double>(
tween: Tween<double>(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()],
),
),
],
),
),
],
],
),
);
}
}

View file

@ -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<ChainSystemAiEditor> {
});
}
/// Latest observation while a model downloads.
PullStep? _pullStep;
Future<void> _pullModel() async {
final wanted = _model.text.trim();
final l = AppLocalizations.of(context)!;
@ -357,16 +361,29 @@ class _FaiSystemAiEditorState extends State<ChainSystemAiEditor> {
}
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<ChainSystemAiEditor> {
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),

View file

@ -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<GuidedSetupDialog> {
/// 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<String, InstallStep> _installSteps = {};
Future<void> _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<GuidedSetupDialog> {
_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,
),
),
],
],
),
),
],

View file

@ -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';

113
test/progress_bar_test.dart Normal file
View file

@ -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<LinearProgressIndicator>(
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();
});
}

View file

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

View file

@ -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);
});
}

View file

@ -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 = <String>[];
SystemActions.debugRunFaiOverride = (args) async {
seenArgs = args;
return (ok: true, stdout: 'done', stderr: '');
};
var lines = <String>[];
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 = <String>[];
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');
});
}