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);
|
||||
}
|
||||
|
||||
/// 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;
|
||||
|
|
|
|||
|
|
@ -489,6 +489,12 @@
|
|||
}
|
||||
},
|
||||
"storeInstallProgressBody": "Holen, verifizieren, entpacken…",
|
||||
"installPhaseResolve": "Modul wird gesucht",
|
||||
"installPhaseDownload": "Wird heruntergeladen",
|
||||
"installPhaseInstall": "Wird geprüft und entpackt",
|
||||
"installPhaseDone": "Fertig",
|
||||
"installBytes": "{done} von {total} MB",
|
||||
"installElapsed": "{seconds} s vergangen",
|
||||
"storeLoadDocs": "Dokumentation laden",
|
||||
"storeDocsHint": "README inline gerendert; nutzt das Registry-Token des Hubs, falls nötig.",
|
||||
"storeDocsFetching": "README wird geladen…",
|
||||
|
|
|
|||
|
|
@ -507,6 +507,29 @@
|
|||
}
|
||||
},
|
||||
"storeInstallProgressBody": "Fetching, verifying, unpacking…",
|
||||
"installPhaseResolve": "Looking up the module",
|
||||
"installPhaseDownload": "Downloading",
|
||||
"installPhaseInstall": "Verifying and unpacking",
|
||||
"installPhaseDone": "Done",
|
||||
"installBytes": "{done} of {total} MB",
|
||||
"@installBytes": {
|
||||
"placeholders": {
|
||||
"done": {
|
||||
"type": "String"
|
||||
},
|
||||
"total": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"installElapsed": "{seconds} s elapsed",
|
||||
"@installElapsed": {
|
||||
"placeholders": {
|
||||
"seconds": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"storeLoadDocs": "Load documentation",
|
||||
"storeDocsHint": "README rendered inline; uses the hub's registry token when needed.",
|
||||
"storeDocsFetching": "Fetching README…",
|
||||
|
|
|
|||
|
|
@ -1982,6 +1982,42 @@ abstract class AppLocalizations {
|
|||
/// **'Fetching, verifying, unpacking…'**
|
||||
String get storeInstallProgressBody;
|
||||
|
||||
/// No description provided for @installPhaseResolve.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Looking up the module'**
|
||||
String get installPhaseResolve;
|
||||
|
||||
/// No description provided for @installPhaseDownload.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Downloading'**
|
||||
String get installPhaseDownload;
|
||||
|
||||
/// No description provided for @installPhaseInstall.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Verifying and unpacking'**
|
||||
String get installPhaseInstall;
|
||||
|
||||
/// No description provided for @installPhaseDone.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Done'**
|
||||
String get installPhaseDone;
|
||||
|
||||
/// No description provided for @installBytes.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{done} of {total} MB'**
|
||||
String installBytes(String done, String total);
|
||||
|
||||
/// No description provided for @installElapsed.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{seconds} s elapsed'**
|
||||
String installElapsed(int seconds);
|
||||
|
||||
/// No description provided for @storeLoadDocs.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
|
|
|||
|
|
@ -1089,6 +1089,28 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
@override
|
||||
String get storeInstallProgressBody => 'Holen, verifizieren, entpacken…';
|
||||
|
||||
@override
|
||||
String get installPhaseResolve => 'Modul wird gesucht';
|
||||
|
||||
@override
|
||||
String get installPhaseDownload => 'Wird heruntergeladen';
|
||||
|
||||
@override
|
||||
String get installPhaseInstall => 'Wird geprüft und entpackt';
|
||||
|
||||
@override
|
||||
String get installPhaseDone => 'Fertig';
|
||||
|
||||
@override
|
||||
String installBytes(String done, String total) {
|
||||
return '$done von $total MB';
|
||||
}
|
||||
|
||||
@override
|
||||
String installElapsed(int seconds) {
|
||||
return '$seconds s vergangen';
|
||||
}
|
||||
|
||||
@override
|
||||
String get storeLoadDocs => 'Dokumentation laden';
|
||||
|
||||
|
|
|
|||
|
|
@ -1105,6 +1105,28 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
@override
|
||||
String get storeInstallProgressBody => 'Fetching, verifying, unpacking…';
|
||||
|
||||
@override
|
||||
String get installPhaseResolve => 'Looking up the module';
|
||||
|
||||
@override
|
||||
String get installPhaseDownload => 'Downloading';
|
||||
|
||||
@override
|
||||
String get installPhaseInstall => 'Verifying and unpacking';
|
||||
|
||||
@override
|
||||
String get installPhaseDone => 'Done';
|
||||
|
||||
@override
|
||||
String installBytes(String done, String total) {
|
||||
return '$done of $total MB';
|
||||
}
|
||||
|
||||
@override
|
||||
String installElapsed(int seconds) {
|
||||
return '$seconds s elapsed';
|
||||
}
|
||||
|
||||
@override
|
||||
String get storeLoadDocs => 'Load documentation';
|
||||
|
||||
|
|
|
|||
|
|
@ -2847,15 +2847,83 @@ class _InstallProgressDialog extends StatefulWidget {
|
|||
}
|
||||
|
||||
class _InstallProgressDialogState extends State<_InstallProgressDialog> {
|
||||
late final Future<({String name, String version})> _future;
|
||||
StreamSubscription<InstallStep>? _sub;
|
||||
Timer? _clock;
|
||||
final Stopwatch _elapsed = Stopwatch();
|
||||
|
||||
/// Latest observation from the hub. Null until the first one lands.
|
||||
InstallStep? _step;
|
||||
({String name, String version})? _result;
|
||||
Object? _error;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_elapsed.start();
|
||||
// A second, independent signal that something is happening: the
|
||||
// elapsed clock keeps counting even if the hub goes quiet, which
|
||||
// is what tells an operator "slow" apart from "stuck".
|
||||
_clock = Timer.periodic(const Duration(seconds: 1), (_) {
|
||||
if (mounted) setState(() {});
|
||||
});
|
||||
// Capability-name install: hub resolves the wasm_url from the
|
||||
// bundled store-index. No source prompt needed for entries
|
||||
// whose seed.yaml already has wasm_url.
|
||||
_future = HubService.instance.installModule(source: widget.item.name);
|
||||
_sub = HubService.instance
|
||||
.installModuleStreaming(
|
||||
source: widget.item.name,
|
||||
onFinished: (r) {
|
||||
if (mounted) setState(() => _result = r);
|
||||
},
|
||||
)
|
||||
.listen(
|
||||
(step) {
|
||||
if (mounted) setState(() => _step = step);
|
||||
},
|
||||
onError: (Object e) {
|
||||
if (mounted) setState(() => _error = e);
|
||||
_stopClock();
|
||||
},
|
||||
onDone: _stopClock,
|
||||
);
|
||||
}
|
||||
|
||||
void _stopClock() {
|
||||
_elapsed.stop();
|
||||
_clock?.cancel();
|
||||
_clock = null;
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_sub?.cancel();
|
||||
_clock?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Plain-language label for the phase the hub last reported.
|
||||
String _stageLabel(AppLocalizations l) {
|
||||
if (_result != null) return l.installPhaseDone;
|
||||
switch (_step?.phase) {
|
||||
case 'download':
|
||||
return l.installPhaseDownload;
|
||||
case 'install':
|
||||
return l.installPhaseInstall;
|
||||
case 'resolve':
|
||||
return l.installPhaseResolve;
|
||||
default:
|
||||
return l.installPhaseResolve;
|
||||
}
|
||||
}
|
||||
|
||||
/// "3,4 of 12,0 MB" while the size is known, otherwise null so the
|
||||
/// bar falls back to showing the percentage.
|
||||
String? _detail(AppLocalizations l) {
|
||||
final step = _step;
|
||||
if (step == null || !step.hasSize || step.phase != 'download') return null;
|
||||
String mb(int bytes) => (bytes / 1048576).toStringAsFixed(1);
|
||||
return l.installBytes(mb(step.bytesReceived), mb(step.bytesTotal));
|
||||
}
|
||||
|
||||
@override
|
||||
|
|
@ -2869,64 +2937,7 @@ class _InstallProgressDialogState extends State<_InstallProgressDialog> {
|
|||
),
|
||||
content: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 480, maxHeight: 360),
|
||||
child: FutureBuilder<({String name, String version})>(
|
||||
future: _future,
|
||||
builder: (context, snap) {
|
||||
if (snap.connectionState == ConnectionState.waiting) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const SizedBox(height: ChainSpace.md),
|
||||
const CircularProgressIndicator(),
|
||||
const SizedBox(height: ChainSpace.md),
|
||||
Text(
|
||||
l.storeInstallProgressBody,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
if (snap.hasError) {
|
||||
return SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.error_outline,
|
||||
color: theme.colorScheme.error,
|
||||
size: 32,
|
||||
),
|
||||
const SizedBox(height: ChainSpace.md),
|
||||
ChainErrorBox(
|
||||
error: snap.error,
|
||||
isError: true,
|
||||
maxHeight: 200,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
final r = snap.data!;
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.check_circle_outline,
|
||||
size: 32,
|
||||
color: ChainColors.success,
|
||||
),
|
||||
const SizedBox(height: ChainSpace.md),
|
||||
Text(
|
||||
l.storeInstalledToast(r.name, r.version),
|
||||
style: theme.textTheme.titleMedium,
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
child: _buildBody(context, theme, l),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
|
|
@ -2936,6 +2947,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) {
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import '../data/system_actions.dart';
|
|||
import '../l10n/app_localizations.dart';
|
||||
import '../main.dart' show StudioShellState;
|
||||
import '../theme/tokens.dart';
|
||||
import 'chain_progress_bar.dart';
|
||||
import 'chain_stores_dialog.dart';
|
||||
|
||||
/// Allowed wire values per answer — an AI suggestion is validated
|
||||
|
|
@ -570,17 +571,43 @@ class _GuidedSetupDialogState extends State<GuidedSetupDialog> {
|
|||
|
||||
/// Install one plan module by capability name — the hub resolves
|
||||
/// the bundle URL from its store index.
|
||||
/// Latest progress observation per module being installed.
|
||||
final Map<String, InstallStep> _installSteps = {};
|
||||
|
||||
Future<void> _install(String module) async {
|
||||
setState(() => _installing.add(module));
|
||||
try {
|
||||
await HubService.instance.installModule(source: module);
|
||||
// Streamed, not awaited blind: a module bundle can take a while
|
||||
// on a slow line, and a dead-looking button is what made people
|
||||
// click twice.
|
||||
await for (final step
|
||||
in HubService.instance.installModuleStreaming(source: module)) {
|
||||
if (mounted) setState(() => _installSteps[module] = step);
|
||||
}
|
||||
if (mounted) setState(() => _installed.add(module));
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
await showChainErrorDialog(context, 'install $module', e);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _installing.remove(module));
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_installing.remove(module);
|
||||
_installSteps.remove(module);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Plain-language label for a module's current install phase.
|
||||
String _installStage(AppLocalizations l, String module) {
|
||||
switch (_installSteps[module]?.phase) {
|
||||
case 'download':
|
||||
return l.installPhaseDownload;
|
||||
case 'install':
|
||||
return l.installPhaseInstall;
|
||||
default:
|
||||
return l.installPhaseResolve;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -966,6 +993,10 @@ 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
|
||||
|
|
@ -974,11 +1005,28 @@ class _GuidedSetupDialogState extends State<GuidedSetupDialog> {
|
|||
? const SizedBox(
|
||||
width: 14,
|
||||
height: 14,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
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