Compare commits
No commits in common. "35a79d0bb6c3cb68d414b5dc5934f41a8df44e7b" and "f6f52cc830072afd2a89628f35f7203ea6860da1" have entirely different histories.
35a79d0bb6
...
f6f52cc830
17 changed files with 131 additions and 1406 deletions
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
}
|
||||
|
|
@ -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());
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue