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:
parent
2bc556e0f5
commit
1549334767
9 changed files with 490 additions and 74 deletions
|
|
@ -972,6 +972,34 @@ class HubService {
|
||||||
return (name: r.name, version: r.version);
|
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
|
/// The shared hub's project registry (`general` first) — the
|
||||||
/// lightweight labels grouping flows, runs, approvals and audit
|
/// lightweight labels grouping flows, runs, approvals and audit
|
||||||
/// events. Sealed areas never appear here: they are their own
|
/// events. Sealed areas never appear here: they are their own
|
||||||
|
|
@ -1545,6 +1573,40 @@ class ModuleSummary {
|
||||||
/// slug plus renamable presentation metadata. `isolation` is
|
/// slug plus renamable presentation metadata. `isolation` is
|
||||||
/// `open` (pure label) or `protected` (logically separated — no
|
/// `open` (pure label) or `protected` (logically separated — no
|
||||||
/// hard process barrier; every UI keeps that honest wording).
|
/// 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 {
|
class ProjectRef {
|
||||||
final String slug;
|
final String slug;
|
||||||
final String name;
|
final String name;
|
||||||
|
|
|
||||||
|
|
@ -489,6 +489,12 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"storeInstallProgressBody": "Holen, verifizieren, entpacken…",
|
"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",
|
"storeLoadDocs": "Dokumentation laden",
|
||||||
"storeDocsHint": "README inline gerendert; nutzt das Registry-Token des Hubs, falls nötig.",
|
"storeDocsHint": "README inline gerendert; nutzt das Registry-Token des Hubs, falls nötig.",
|
||||||
"storeDocsFetching": "README wird geladen…",
|
"storeDocsFetching": "README wird geladen…",
|
||||||
|
|
|
||||||
|
|
@ -507,6 +507,29 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"storeInstallProgressBody": "Fetching, verifying, unpacking…",
|
"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",
|
"storeLoadDocs": "Load documentation",
|
||||||
"storeDocsHint": "README rendered inline; uses the hub's registry token when needed.",
|
"storeDocsHint": "README rendered inline; uses the hub's registry token when needed.",
|
||||||
"storeDocsFetching": "Fetching README…",
|
"storeDocsFetching": "Fetching README…",
|
||||||
|
|
|
||||||
|
|
@ -1982,6 +1982,42 @@ abstract class AppLocalizations {
|
||||||
/// **'Fetching, verifying, unpacking…'**
|
/// **'Fetching, verifying, unpacking…'**
|
||||||
String get storeInstallProgressBody;
|
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.
|
/// No description provided for @storeLoadDocs.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|
|
||||||
|
|
@ -1089,6 +1089,28 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get storeInstallProgressBody => 'Holen, verifizieren, entpacken…';
|
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
|
@override
|
||||||
String get storeLoadDocs => 'Dokumentation laden';
|
String get storeLoadDocs => 'Dokumentation laden';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1105,6 +1105,28 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get storeInstallProgressBody => 'Fetching, verifying, unpacking…';
|
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
|
@override
|
||||||
String get storeLoadDocs => 'Load documentation';
|
String get storeLoadDocs => 'Load documentation';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2847,15 +2847,83 @@ class _InstallProgressDialog extends StatefulWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
class _InstallProgressDialogState extends State<_InstallProgressDialog> {
|
class _InstallProgressDialogState extends State<_InstallProgressDialog> {
|
||||||
late final Future<({String name, String version})> _future;
|
StreamSubscription<InstallStep>? _sub;
|
||||||
|
Timer? _clock;
|
||||||
|
final Stopwatch _elapsed = Stopwatch();
|
||||||
|
|
||||||
|
/// Latest observation from the hub. Null until the first one lands.
|
||||||
|
InstallStep? _step;
|
||||||
|
({String name, String version})? _result;
|
||||||
|
Object? _error;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.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
|
// Capability-name install: hub resolves the wasm_url from the
|
||||||
// bundled store-index. No source prompt needed for entries
|
// bundled store-index. No source prompt needed for entries
|
||||||
// whose seed.yaml already has wasm_url.
|
// whose seed.yaml already has wasm_url.
|
||||||
_future = HubService.instance.installModule(source: widget.item.name);
|
_sub = HubService.instance
|
||||||
|
.installModuleStreaming(
|
||||||
|
source: widget.item.name,
|
||||||
|
onFinished: (r) {
|
||||||
|
if (mounted) setState(() => _result = r);
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.listen(
|
||||||
|
(step) {
|
||||||
|
if (mounted) setState(() => _step = step);
|
||||||
|
},
|
||||||
|
onError: (Object e) {
|
||||||
|
if (mounted) setState(() => _error = e);
|
||||||
|
_stopClock();
|
||||||
|
},
|
||||||
|
onDone: _stopClock,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _stopClock() {
|
||||||
|
_elapsed.stop();
|
||||||
|
_clock?.cancel();
|
||||||
|
_clock = null;
|
||||||
|
if (mounted) setState(() {});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_sub?.cancel();
|
||||||
|
_clock?.cancel();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Plain-language label for the phase the hub last reported.
|
||||||
|
String _stageLabel(AppLocalizations l) {
|
||||||
|
if (_result != null) return l.installPhaseDone;
|
||||||
|
switch (_step?.phase) {
|
||||||
|
case 'download':
|
||||||
|
return l.installPhaseDownload;
|
||||||
|
case 'install':
|
||||||
|
return l.installPhaseInstall;
|
||||||
|
case 'resolve':
|
||||||
|
return l.installPhaseResolve;
|
||||||
|
default:
|
||||||
|
return l.installPhaseResolve;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// "3,4 of 12,0 MB" while the size is known, otherwise null so the
|
||||||
|
/// bar falls back to showing the percentage.
|
||||||
|
String? _detail(AppLocalizations l) {
|
||||||
|
final step = _step;
|
||||||
|
if (step == null || !step.hasSize || step.phase != 'download') return null;
|
||||||
|
String mb(int bytes) => (bytes / 1048576).toStringAsFixed(1);
|
||||||
|
return l.installBytes(mb(step.bytesReceived), mb(step.bytesTotal));
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -2869,64 +2937,7 @@ class _InstallProgressDialogState extends State<_InstallProgressDialog> {
|
||||||
),
|
),
|
||||||
content: ConstrainedBox(
|
content: ConstrainedBox(
|
||||||
constraints: const BoxConstraints(maxWidth: 480, maxHeight: 360),
|
constraints: const BoxConstraints(maxWidth: 480, maxHeight: 360),
|
||||||
child: FutureBuilder<({String name, String version})>(
|
child: _buildBody(context, theme, l),
|
||||||
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: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
|
|
@ -2936,6 +2947,68 @@ 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) {
|
ChainPillTone _toneForStatus(String s) {
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ import '../data/system_actions.dart';
|
||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
import '../main.dart' show StudioShellState;
|
import '../main.dart' show StudioShellState;
|
||||||
import '../theme/tokens.dart';
|
import '../theme/tokens.dart';
|
||||||
|
import 'chain_progress_bar.dart';
|
||||||
import 'chain_stores_dialog.dart';
|
import 'chain_stores_dialog.dart';
|
||||||
|
|
||||||
/// Allowed wire values per answer — an AI suggestion is validated
|
/// Allowed wire values per answer — an AI suggestion is validated
|
||||||
|
|
@ -570,17 +571,43 @@ class _GuidedSetupDialogState extends State<GuidedSetupDialog> {
|
||||||
|
|
||||||
/// Install one plan module by capability name — the hub resolves
|
/// Install one plan module by capability name — the hub resolves
|
||||||
/// the bundle URL from its store index.
|
/// 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 {
|
Future<void> _install(String module) async {
|
||||||
setState(() => _installing.add(module));
|
setState(() => _installing.add(module));
|
||||||
try {
|
try {
|
||||||
await HubService.instance.installModule(source: module);
|
// Streamed, not awaited blind: a module bundle can take a while
|
||||||
|
// on a slow line, and a dead-looking button is what made people
|
||||||
|
// click twice.
|
||||||
|
await for (final step
|
||||||
|
in HubService.instance.installModuleStreaming(source: module)) {
|
||||||
|
if (mounted) setState(() => _installSteps[module] = step);
|
||||||
|
}
|
||||||
if (mounted) setState(() => _installed.add(module));
|
if (mounted) setState(() => _installed.add(module));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
await showChainErrorDialog(context, 'install $module', e);
|
await showChainErrorDialog(context, 'install $module', e);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) setState(() => _installing.remove(module));
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_installing.remove(module);
|
||||||
|
_installSteps.remove(module);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Plain-language label for a module's current install phase.
|
||||||
|
String _installStage(AppLocalizations l, String module) {
|
||||||
|
switch (_installSteps[module]?.phase) {
|
||||||
|
case 'download':
|
||||||
|
return l.installPhaseDownload;
|
||||||
|
case 'install':
|
||||||
|
return l.installPhaseInstall;
|
||||||
|
default:
|
||||||
|
return l.installPhaseResolve;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -966,18 +993,39 @@ class _GuidedSetupDialogState extends State<GuidedSetupDialog> {
|
||||||
_installed.contains(m)
|
_installed.contains(m)
|
||||||
? _doneRow(l.setupActionInstalled(m))
|
? _doneRow(l.setupActionInstalled(m))
|
||||||
: _actionRow(
|
: _actionRow(
|
||||||
FilledButton.tonalIcon(
|
Column(
|
||||||
onPressed: (!_hubUp || _installing.contains(m))
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
? null
|
mainAxisSize: MainAxisSize.min,
|
||||||
: () => _install(m),
|
children: [
|
||||||
icon: _installing.contains(m)
|
FilledButton.tonalIcon(
|
||||||
? const SizedBox(
|
onPressed: (!_hubUp || _installing.contains(m))
|
||||||
width: 14,
|
? null
|
||||||
height: 14,
|
: () => _install(m),
|
||||||
child: CircularProgressIndicator(strokeWidth: 2),
|
icon: _installing.contains(m)
|
||||||
)
|
? const SizedBox(
|
||||||
: const Icon(Icons.download, size: 18),
|
width: 14,
|
||||||
label: Text(l.setupActionInstall(m)),
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
|
||||||
124
test/progress_bar_visual_test.dart
Normal file
124
test/progress_bar_visual_test.dart
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
// Renders the progress bar in the states an operator actually meets
|
||||||
|
// and writes PNGs to build/progress/, so the visual result can be
|
||||||
|
// judged without launching the desktop app and stealing focus.
|
||||||
|
//
|
||||||
|
// Not a golden comparison: there is no committed reference to diff
|
||||||
|
// against. It exists to produce evidence, and to guard that every one
|
||||||
|
// of these states renders at all in both themes.
|
||||||
|
|
||||||
|
import 'dart:io';
|
||||||
|
import 'dart:ui' as ui;
|
||||||
|
|
||||||
|
import 'package:chain_studio/theme/theme.dart';
|
||||||
|
import 'package:chain_studio/widgets/chain_progress_bar.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/rendering.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
class _Case {
|
||||||
|
final String label;
|
||||||
|
final Widget bar;
|
||||||
|
const _Case(this.label, this.bar);
|
||||||
|
}
|
||||||
|
|
||||||
|
const _cases = <_Case>[
|
||||||
|
_Case(
|
||||||
|
'Nichts messbar: unbestimmt statt leer',
|
||||||
|
ChainProgressBar(stage: 'Modul wird gesucht'),
|
||||||
|
),
|
||||||
|
_Case(
|
||||||
|
'Download mit Groesse',
|
||||||
|
ChainProgressBar(
|
||||||
|
value: 0.42,
|
||||||
|
stage: 'Wird heruntergeladen',
|
||||||
|
detail: '5,0 von 12,0 MB',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_Case(
|
||||||
|
'Wert steht, Arbeit laeuft weiter',
|
||||||
|
ChainProgressBar(value: 0.74, stage: 'Wird geprueft und entpackt'),
|
||||||
|
),
|
||||||
|
_Case(
|
||||||
|
'Fertig: nichts bewegt sich mehr',
|
||||||
|
ChainProgressBar(value: 1.0, busy: false, stage: 'Fertig'),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
Widget _sheet(ThemeData theme, String title) => MaterialApp(
|
||||||
|
theme: theme,
|
||||||
|
debugShowCheckedModeBanner: false,
|
||||||
|
home: Scaffold(
|
||||||
|
body: RepaintBoundary(
|
||||||
|
key: const ValueKey('sheet'),
|
||||||
|
child: Center(
|
||||||
|
child: SizedBox(
|
||||||
|
width: 520,
|
||||||
|
child: Card(
|
||||||
|
margin: const EdgeInsets.all(24),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(title, style: theme.textTheme.titleMedium),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
for (final c in _cases) ...[
|
||||||
|
Text(c.label, style: theme.textTheme.labelSmall),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
c.bar,
|
||||||
|
const SizedBox(height: 22),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
Future<void> _capture(WidgetTester tester, String name) async {
|
||||||
|
final boundary = tester.renderObject<RenderRepaintBoundary>(
|
||||||
|
find.byKey(const ValueKey('sheet')),
|
||||||
|
);
|
||||||
|
// toImage is real asynchronous engine work. Awaiting it directly in a
|
||||||
|
// widget test leaves the binding waiting forever afterwards, which
|
||||||
|
// showed up as this file passing in six seconds and then sitting
|
||||||
|
// there until the ten-minute timeout killed the whole suite.
|
||||||
|
final image = await tester.runAsync(() => boundary.toImage(pixelRatio: 2));
|
||||||
|
if (image == null) return;
|
||||||
|
final bytes = await tester.runAsync(
|
||||||
|
() => image.toByteData(format: ui.ImageByteFormat.png),
|
||||||
|
);
|
||||||
|
final dir = Directory('build/progress')..createSync(recursive: true);
|
||||||
|
File('${dir.path}/$name.png').writeAsBytesSync(bytes!.buffer.asUint8List());
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
// One capture per run: a second toImage() in the same test binding
|
||||||
|
// never completes, so the theme comes from the environment and the
|
||||||
|
// sheet is rendered twice from outside:
|
||||||
|
//
|
||||||
|
// flutter test test/progress_bar_visual_test.dart
|
||||||
|
// PROGRESS_THEME=dark flutter test test/progress_bar_visual_test.dart
|
||||||
|
testWidgets('renders every waiting state', (tester) async {
|
||||||
|
final dark = Platform.environment['PROGRESS_THEME'] == 'dark';
|
||||||
|
tester.view.physicalSize = const Size(1100, 900);
|
||||||
|
tester.view.devicePixelRatio = 1.0;
|
||||||
|
addTearDown(tester.view.reset);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
_sheet(
|
||||||
|
dark ? ChainTheme.dark() : ChainTheme.light(),
|
||||||
|
dark ? 'Dunkel' : 'Hell',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
// Mid-sweep, so the highlight sits inside the bar in the capture.
|
||||||
|
await tester.pump(const Duration(milliseconds: 900));
|
||||||
|
expect(find.byType(ChainProgressBar), findsNWidgets(_cases.length));
|
||||||
|
await _capture(tester, dark ? 'states-dark' : 'states-light');
|
||||||
|
await tester.pumpWidget(const SizedBox());
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue