feat(studio): real install progress in the store dialog and the wizard

Both surfaces showed an indeterminate spinner for the whole install,
because the hub only offered a unary call. They now follow
HubAdmin/InstallModuleStream:

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

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

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
flemming-it 2026-09-08 14:00:24 +02:00
parent 2bc556e0f5
commit 1549334767
9 changed files with 490 additions and 74 deletions

View file

@ -972,6 +972,34 @@ class HubService {
return (name: r.name, version: r.version);
}
/// Install a module while watching it happen.
///
/// Emits one [InstallStep] per phase change and, during the
/// download, as bytes arrive. Completes when the install
/// succeeds; throws the hub's error otherwise, exactly like
/// [installModule]. [onFinished] receives name and version.
Stream<InstallStep> installModuleStreaming({
required String source,
void Function(({String name, String version}) result)? onFinished,
}) {
return _client
.installModuleStreaming(
source: source,
onFinished: (r) => onFinished?.call((name: r.name, version: r.version)),
)
.map(
(u) => InstallStep(
phase: u.phase,
// `percent` is absent while the extent is unknowable; the
// bar shows that as indeterminate rather than as zero.
percent: u.hasPercent() ? u.percent : null,
bytesReceived: u.bytesReceived.toInt(),
bytesTotal: u.bytesTotal.toInt(),
phaseDone: u.phaseDone,
),
);
}
/// The shared hub's project registry (`general` first) — the
/// lightweight labels grouping flows, runs, approvals and audit
/// events. Sealed areas never appear here: they are their own
@ -1545,6 +1573,40 @@ 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 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;