Compare commits

..

No commits in common. "35a79d0bb6c3cb68d414b5dc5934f41a8df44e7b" and "f6f52cc830072afd2a89628f35f7203ea6860da1" have entirely different histories.

17 changed files with 131 additions and 1406 deletions

View file

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

View file

@ -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 <c>`. 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<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 []);
@ -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;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -253,9 +253,8 @@ 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(
@ -366,7 +365,8 @@ 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,7 +381,8 @@ 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
@ -1656,24 +1657,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.
@ -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<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;
@ -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<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;
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

View file

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

View file

@ -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<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 {
// 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<GuidedSetupDialog> {
_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)),
),
),
],

View file

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

View file

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

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

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

View file

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