diff --git a/lib/data/hub.dart b/lib/data/hub.dart index 68a149e..992e9af 100644 --- a/lib/data/hub.dart +++ b/lib/data/hub.dart @@ -259,6 +259,41 @@ class HubService { /// hub is up and needs a token fix, not an endpoint fix. Future probeHealth() => _client.probe(); + /// Assemble the guided-setup plan over the live hub connection — + /// a pure computation, nothing applied. Returned in exactly the + /// `chain init --plan-json` map shape so the wizard renders the + /// same preview from either source. Using the hub avoids spawning + /// the CLI for the preview: on macOS that subprocess can be the + /// app's first file-system touch and pop a permission prompt + /// BEFORE the operator saw what would be set up. + Future> planSetup({ + required String scenario, + required String intent, + required String target, + bool requireApproval = false, + bool dataMustStayLocal = false, + bool allowUnsignedModules = false, + }) async { + final r = await _client.planSetup( + scenario: scenario, + intent: intent, + target: target, + requireApproval: requireApproval, + dataMustStayLocal: dataMustStayLocal, + allowUnsignedModules: allowUnsignedModules, + ); + return { + 'profile': r.profile, + 'modules': List.from(r.modules), + 'starter_flow': r.starterFlow, + 'runbook': r.runbook, + 'curated_docs': List.from(r.curatedDocs), + 'require_signatures': r.requireSignatures, + 'worm_audit': r.wormAudit, + 'approval_step': r.approvalStep, + }; + } + /// Configured module stores (+ the bundled seed) for the store manager. Future> listStores() => _client.listStores(); diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index eb50312..b3e54d4 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -1791,7 +1791,11 @@ "setupSigPublicStoreNotice": "Diese Einrichtung lässt nur signierte Module zu. Der öffentliche Store liefert zurzeit unsignierte Pakete — die vorbereiteten Module kommen daher aus einer eigenen, signierten Quelle.", "setupAllowUnsigned": "Installation aus dem öffentlichen Store erlauben", "setupAllowUnsignedSub": "Lockert die Signaturpflicht bewusst. Gut zum Ausprobieren — für den regulierten Betrieb später wieder aktivieren.", - "setupStartCta": "In 3 Fragen loslegen", + "setupStartCta": "Einrichtung starten", + "setupGateIntro": "Bevor es losgeht: drei Fragen, dann richtet Ch∆In diesen Rechner passend ein. Nichts wird ohne Ihre Bestätigung geändert.", + "setupGateSkip": "Später einrichten", + "settingsRunSetup": "Einrichtung erneut ausführen…", + "settingsRunSetupHint": "Öffnet den Einrichtungs-Assistenten (drei Fragen, Vorschau, Übernehmen). Die bestehende Konfiguration wird erst nach Ihrer Bestätigung ersetzt.", "setupPlanRunbookLocal": "Eingerichtet wird dieser Rechner; der Hub läuft lokal und wird bei Bedarf gestartet.", "setupPlanRunbookService": "Eingerichtet wird dieser Rechner: Der Hub startet künftig automatisch beim Hochfahren (Hintergrund-Dienst). Für einen anderen Server führen Sie die Einrichtung dort aus.", "setupPlanRunbookAirgap": "Vorgesehen für einen isolierten Server ohne Internet — Module und Updates kommen als Offline-Paket.", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index bb44a2b..45898d0 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1830,7 +1830,11 @@ "setupSigPublicStoreNotice": "This setup only accepts signed modules. The public store currently ships unsigned bundles — the prepared modules therefore come from your own, signed source.", "setupAllowUnsigned": "Allow installing from the public store", "setupAllowUnsignedSub": "Deliberately relaxes the signature requirement. Fine for trying things out — re-enable it for regulated operation.", - "setupStartCta": "Get started in 3 questions", + "setupStartCta": "Start setup", + "setupGateIntro": "Before you start: three questions, then Ch∆In sets this machine up to match. Nothing changes without your confirmation.", + "setupGateSkip": "Set up later", + "settingsRunSetup": "Run setup again…", + "settingsRunSetupHint": "Opens the setup assistant (three questions, preview, apply). Your existing configuration is only replaced after you confirm.", "setupPlanRunbookLocal": "This machine is being set up; the hub runs locally and starts on demand.", "setupPlanRunbookService": "This machine is being set up: the hub will start automatically on boot (background service). For a different server, run the setup there.", "setupPlanRunbookAirgap": "Intended for an isolated server without internet — modules and updates arrive as offline bundles.", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 47232ad..c794973 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -5524,9 +5524,33 @@ abstract class AppLocalizations { /// No description provided for @setupStartCta. /// /// In en, this message translates to: - /// **'Get started in 3 questions'** + /// **'Start setup'** String get setupStartCta; + /// No description provided for @setupGateIntro. + /// + /// In en, this message translates to: + /// **'Before you start: three questions, then Ch∆In sets this machine up to match. Nothing changes without your confirmation.'** + String get setupGateIntro; + + /// No description provided for @setupGateSkip. + /// + /// In en, this message translates to: + /// **'Set up later'** + String get setupGateSkip; + + /// No description provided for @settingsRunSetup. + /// + /// In en, this message translates to: + /// **'Run setup again…'** + String get settingsRunSetup; + + /// No description provided for @settingsRunSetupHint. + /// + /// In en, this message translates to: + /// **'Opens the setup assistant (three questions, preview, apply). Your existing configuration is only replaced after you confirm.'** + String get settingsRunSetupHint; + /// No description provided for @setupPlanRunbookLocal. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 2a7ccc0..4a4a454 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -3270,7 +3270,21 @@ class AppLocalizationsDe extends AppLocalizations { 'Lockert die Signaturpflicht bewusst. Gut zum Ausprobieren — für den regulierten Betrieb später wieder aktivieren.'; @override - String get setupStartCta => 'In 3 Fragen loslegen'; + String get setupStartCta => 'Einrichtung starten'; + + @override + String get setupGateIntro => + 'Bevor es losgeht: drei Fragen, dann richtet Ch∆In diesen Rechner passend ein. Nichts wird ohne Ihre Bestätigung geändert.'; + + @override + String get setupGateSkip => 'Später einrichten'; + + @override + String get settingsRunSetup => 'Einrichtung erneut ausführen…'; + + @override + String get settingsRunSetupHint => + 'Öffnet den Einrichtungs-Assistenten (drei Fragen, Vorschau, Übernehmen). Die bestehende Konfiguration wird erst nach Ihrer Bestätigung ersetzt.'; @override String get setupPlanRunbookLocal => diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index c9a0a47..18289f3 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -3265,7 +3265,21 @@ class AppLocalizationsEn extends AppLocalizations { 'Deliberately relaxes the signature requirement. Fine for trying things out — re-enable it for regulated operation.'; @override - String get setupStartCta => 'Get started in 3 questions'; + String get setupStartCta => 'Start setup'; + + @override + String get setupGateIntro => + 'Before you start: three questions, then Ch∆In sets this machine up to match. Nothing changes without your confirmation.'; + + @override + String get setupGateSkip => 'Set up later'; + + @override + String get settingsRunSetup => 'Run setup again…'; + + @override + String get settingsRunSetupHint => + 'Opens the setup assistant (three questions, preview, apply). Your existing configuration is only replaced after you confirm.'; @override String get setupPlanRunbookLocal => diff --git a/lib/main.dart b/lib/main.dart index 66e2832..bf3f29a 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -17,6 +17,8 @@ import 'data/system_actions.dart'; import 'data/theme_plugin.dart'; import 'l10n/app_localizations.dart'; import 'pages/approvals.dart'; +import 'pages/setup_gate.dart'; +import 'widgets/guided_setup_dialog.dart'; import 'pages/audit.dart'; import 'pages/doctor.dart'; import 'pages/federation.dart'; @@ -114,6 +116,13 @@ class StudioApp extends StatefulWidget { } class StudioAppState extends State { + /// First-run gate: on a fresh install (no config, no recorded + /// setup plan) the app starts INSIDE the setup instead of layering + /// a dialog over a shell that isn't set up yet. Flips to true when + /// the operator finishes or skips; also true from the start on any + /// already-configured machine. + bool _setupGateDone = !GuidedSetupDialog.isFreshInstall(); + /// Exposed so any descendant can listen via /// `ValueListenableBuilder` and rebuild instantly when the /// theme flips. setState alone wouldn't propagate through @@ -263,9 +272,14 @@ class StudioAppState extends State { supportedLocales: AppLocalizations.supportedLocales, localizationsDelegates: AppLocalizations.localizationsDelegates, - home: StudioShell( - startSidebarExpanded: widget.startSidebarExpanded, - ), + home: _setupGateDone + ? StudioShell( + startSidebarExpanded: widget.startSidebarExpanded, + ) + : SetupGateScreen( + onDone: () => + setState(() => _setupGateDone = true), + ), ); }, ), diff --git a/lib/pages/setup_gate.dart b/lib/pages/setup_gate.dart new file mode 100644 index 0000000..32555ba --- /dev/null +++ b/lib/pages/setup_gate.dart @@ -0,0 +1,81 @@ +// First-run gate. On a fresh install (no ~/.chain/config.yaml, no +// setup-plan.yaml) the app opens INTO the setup instead of showing +// the full shell with a wizard dialog on top — setup before the +// app, not after. The guided-setup wizard renders embedded as the +// page content; "Später einrichten" skips into the app for this +// run (the gate returns on the next launch as long as nothing is +// configured — the wizard IS the onboarding until then). + +import 'package:flutter/material.dart'; + +import '../l10n/app_localizations.dart'; +import '../theme/tokens.dart'; +import '../widgets/guided_setup_dialog.dart'; + +class SetupGateScreen extends StatelessWidget { + /// Called when the operator finishes the setup or skips — the + /// app swaps this screen for the normal shell. + final VoidCallback onDone; + + const SetupGateScreen({super.key, required this.onDone}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final l = AppLocalizations.of(context)!; + return Scaffold( + backgroundColor: theme.scaffoldBackgroundColor, + body: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.all(ChainSpace.xl), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 560), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Ch∆In Studio', + style: theme.textTheme.titleMedium?.copyWith( + color: theme.colorScheme.primary, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: ChainSpace.xs), + Text( + l.setupGateIntro, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: ChainSpace.lg), + Container( + padding: const EdgeInsets.all(ChainSpace.xl), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerLow, + borderRadius: BorderRadius.circular(ChainRadius.md), + border: Border.all( + color: theme.colorScheme.outlineVariant, + ), + ), + child: GuidedSetupDialog( + embedded: true, + onFinished: onDone, + ), + ), + const SizedBox(height: ChainSpace.sm), + Align( + alignment: Alignment.centerRight, + child: TextButton( + onPressed: onDone, + child: Text(l.setupGateSkip), + ), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/pages/welcome.dart b/lib/pages/welcome.dart index 301a5c4..52bcaaf 100644 --- a/lib/pages/welcome.dart +++ b/lib/pages/welcome.dart @@ -30,35 +30,10 @@ class WelcomePage extends StatefulWidget { } class _WelcomePageState extends State { - /// Bumped when the guided-setup dialog closes so the onboarding - /// checklist remounts and re-probes — the assistant may have just - /// installed the modules the checklist looks for. - int _checklistEpoch = 0; - - /// One auto-open per app run: a fresh install that dismisses the - /// wizard should not have it spring back on every rebuild. - static bool _autoOpenedThisRun = false; - - @override - void initState() { - super.initState(); - WidgetsBinding.instance.addPostFrameCallback((_) => _maybeAutoOpen()); - } - - /// On a fresh hub (no config, no recorded setup plan) the wizard - /// IS the onboarding — open it rather than hoping the operator - /// finds the button. Once a setup exists it steps back for good. - Future _maybeAutoOpen() async { - if (_autoOpenedThisRun || !mounted) return; - if (!GuidedSetupDialog.isFreshInstall()) return; - _autoOpenedThisRun = true; - await _openSetup(); - } - - Future _openSetup() async { - await GuidedSetupDialog.show(context); - if (mounted) setState(() => _checklistEpoch++); - } + // Setup no longer lives on this page. A fresh install starts in + // the SetupGateScreen (setup BEFORE the app, not a dialog over + // it), and re-running the setup later is a Settings action — + // Welcome stays a calm introduction, not a second setup surface. @override Widget build(BuildContext context) { @@ -95,19 +70,6 @@ class _WelcomePageState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ const _Hero(), - const SizedBox(height: ChainSpace.md), - Align( - alignment: Alignment.centerLeft, - // High-emphasis: the guided setup is the fastest path - // to a working first run, so lead with a filled button - // rather than a low-key outlined one. Honest framing: - // "get started in 3 questions", not tool-speak. - child: FilledButton.icon( - onPressed: _openSetup, - icon: const Icon(Icons.auto_fix_high), - label: Text(l.setupStartCta), - ), - ), const SizedBox(height: ChainSpace.xxl), // When the hub is down the onboarding checklist // would render dead (all-unchecked, nothing to @@ -117,7 +79,7 @@ class _WelcomePageState extends State { const _HubDownHero(), const SizedBox(height: ChainSpace.xxl), ] else - _OnboardingChecklist(key: ValueKey(_checklistEpoch)), + const _OnboardingChecklist(), const _PillarRow(), const SizedBox(height: ChainSpace.xxl), const _SectionLabel(textKey: _SectionLabelKey.trust), @@ -551,7 +513,7 @@ const String _kChecklistDismissedKey = 'welcome.checklist.dismissed'; /// parallel; failures stay false and the checklist still /// renders (with the "Refresh" button for retry). class _OnboardingChecklist extends StatefulWidget { - const _OnboardingChecklist({super.key}); + const _OnboardingChecklist(); @override State<_OnboardingChecklist> createState() => _OnboardingChecklistState(); diff --git a/lib/widgets/chain_settings_dialog.dart b/lib/widgets/chain_settings_dialog.dart index 2a77dfc..35f78ef 100644 --- a/lib/widgets/chain_settings_dialog.dart +++ b/lib/widgets/chain_settings_dialog.dart @@ -15,6 +15,7 @@ import '../pages/welcome.dart' show showFaiDoc; import '../theme/theme.dart'; import '../theme/tokens.dart'; import 'chain_error_box.dart'; +import 'guided_setup_dialog.dart'; import 'chain_pill.dart'; import 'chain_system_ai_editor.dart'; import 'hub_auth_policy_panel.dart'; @@ -572,6 +573,26 @@ class _FaiSettingsDialogState extends State { ], const SizedBox(height: ChainSpace.xl), const _DefaultScopePanel(), + const SizedBox(height: ChainSpace.xl), + // Re-run the guided setup. This is the ONLY place it lives + // after first run — the first-run gate owns the fresh-install + // case, and Welcome stays a calm introduction instead of a + // permanent setup surface. + Align( + alignment: Alignment.centerLeft, + child: OutlinedButton.icon( + onPressed: () => GuidedSetupDialog.show(context), + icon: const Icon(Icons.auto_fix_high, size: 18), + label: Text(l.settingsRunSetup), + ), + ), + const SizedBox(height: 4), + Text( + l.settingsRunSetupHint, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), ]; } diff --git a/lib/widgets/guided_setup_dialog.dart b/lib/widgets/guided_setup_dialog.dart index 399c03e..227132c 100644 --- a/lib/widgets/guided_setup_dialog.dart +++ b/lib/widgets/guided_setup_dialog.dart @@ -109,12 +109,29 @@ class GuidedSetupDialog extends StatefulWidget { @visibleForTesting final bool debugSkipAiProbe; + /// Test seam: replaces the hub-first plan preview (a live gRPC + /// call) so widget tests can drive the CLI fallback — or return a + /// canned plan — without a hub. + @visibleForTesting + static Future> Function()? debugPlanViaHubOverride; + + /// Render as page content instead of an [AlertDialog] — used by + /// the first-run gate, where the wizard IS the screen. + final bool embedded; + + /// Called instead of popping a dialog route when the wizard is + /// [embedded] (the gate owns what comes next). Also invoked after + /// a completed setup so the gate can enter the app. + final VoidCallback? onFinished; + const GuidedSetupDialog({ super.key, this.shell, this.debugPlan, this.debugApplied = false, this.debugSkipAiProbe = false, + this.embedded = false, + this.onFinished, }); static Future show(BuildContext context) { @@ -309,6 +326,33 @@ class _GuidedSetupDialogState extends State { _busy = true; _plan = null; }); + // Preview order matters for trust: try the LIVE HUB first — a + // pure PlanSetup RPC over the existing connection, no subprocess. + // The CLI fallback (hub down, first run without a daemon) spawns + // `chain`, which on macOS can be the app's very first file-system + // touch and pop a permission prompt; that must never happen + // BEFORE the operator has seen the plan when a hub is available. + try { + final plan = GuidedSetupDialog.debugPlanViaHubOverride != null + ? await GuidedSetupDialog.debugPlanViaHubOverride!() + : await HubService.instance.planSetup( + scenario: _scenario, + intent: _intent, + target: _target, + requireApproval: _requireApproval, + dataMustStayLocal: _dataLocal, + allowUnsignedModules: _allowUnsigned, + ); + if (!mounted) return; + setState(() { + _busy = false; + _plan = plan; + _step = _totalSteps; // review + }); + return; + } catch (_) { + // Hub unreachable / RPC unavailable — fall through to the CLI. + } final path = _writeAnswers(); final r = await SystemActions.chainInit(['--answers', path, '--plan-json']); if (!mounted) return; @@ -536,6 +580,18 @@ class _GuidedSetupDialogState extends State { } } + /// Leave the wizard: pop the dialog route, or — embedded full + /// screen in the first-run gate, where there is no route to pop — + /// hand control back to the gate. + void _close() { + final onFinished = widget.onFinished; + if (onFinished != null) { + onFinished(); + } else { + Navigator.of(context).pop(); + } + } + @override Widget build(BuildContext context) { final l = AppLocalizations.of(context)!; @@ -545,17 +601,43 @@ class _GuidedSetupDialogState extends State { : _showReflection ? l.setupReflectionTitle : _stepTitle(l); + final body = SingleChildScrollView( + child: reviewing + ? _reviewStep(l) + : _showReflection + ? _reflectionView(l) + : _answerStep(l), + ); + if (widget.embedded) { + // First-run gate: same content as the dialog, hosted as a + // full page instead of a modal over an app that isn't set + // up yet ("setup after the app runs" reads backwards). + final theme = Theme.of(context); + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: theme.textTheme.headlineSmall), + const SizedBox(height: ChainSpace.md), + Flexible(child: body), + const SizedBox(height: ChainSpace.md), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + for (final a in _actions(l)) ...[ + const SizedBox(width: ChainSpace.sm), + a, + ], + ], + ), + ], + ); + } return AlertDialog( title: Text(title), content: SizedBox( width: 480, - child: SingleChildScrollView( - child: reviewing - ? _reviewStep(l) - : _showReflection - ? _reflectionView(l) - : _answerStep(l), - ), + child: body, ), actions: _actions(l), ); @@ -901,7 +983,7 @@ class _GuidedSetupDialogState extends State { _actionRow( OutlinedButton.icon( onPressed: () { - Navigator.of(context).pop(); + _close(); widget.shell?.navigateTo('flows'); }, icon: const Icon(Icons.account_tree_outlined, size: 18), @@ -1027,7 +1109,7 @@ class _GuidedSetupDialogState extends State { if (_applied) { return [ FilledButton( - onPressed: () => Navigator.of(context).pop(), + onPressed: _close, child: Text(l.guidedSetupClose), ), ]; @@ -1053,7 +1135,7 @@ class _GuidedSetupDialogState extends State { return [ TextButton( onPressed: _step == 0 - ? () => Navigator.of(context).pop() + ? _close : () => setState(() => _step -= 1), child: Text(_step == 0 ? l.guidedSetupCancel : l.guidedSetupBack), ), diff --git a/test/guided_setup_test.dart b/test/guided_setup_test.dart index 6e640fd..23e8e42 100644 --- a/test/guided_setup_test.dart +++ b/test/guided_setup_test.dart @@ -259,12 +259,17 @@ void main() { setUp(() { tmp = Directory.systemTemp.createTempSync('wizard-test-'); ChainLog.testPathOverride = '${tmp.path}/studio-errors.log'; + // These tests exercise the CLI fallback — make the hub-first + // preview fail immediately instead of attempting a live RPC. + GuidedSetupDialog.debugPlanViaHubOverride = () async => + throw Exception('hub unavailable (test)'); }); tearDown(() { ChainLog.testPathOverride = null; SystemActions.debugRunFaiOverride = null; SystemActions.debugResolveOverride = null; + GuidedSetupDialog.debugPlanViaHubOverride = null; tmp.deleteSync(recursive: true); }); diff --git a/test/setup_gate_test.dart b/test/setup_gate_test.dart new file mode 100644 index 0000000..ee547e0 --- /dev/null +++ b/test/setup_gate_test.dart @@ -0,0 +1,55 @@ +// First-run gate: on a fresh install the app opens INTO the setup +// (embedded wizard as the page), with an explicit skip. Setup +// before the app — not a dialog over a shell that isn't set up. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:chain_studio/l10n/app_localizations.dart'; +import 'package:chain_studio/pages/setup_gate.dart'; + +void main() { + testWidgets('gate hosts the embedded wizard + a skip action', ( + tester, + ) async { + var done = false; + await tester.pumpWidget( + MaterialApp( + locale: const Locale('de'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: SetupGateScreen(onDone: () => done = true), + ), + ); + await tester.pumpAndSettle(); + + // The wizard renders as PAGE content (step 1 visible, no dialog). + expect(find.text('Worum geht es?'), findsOneWidget); + expect(find.text('Erst mal ausprobieren'), findsOneWidget); + expect(find.byType(AlertDialog), findsNothing); + // Honest framing + a way past the gate. + expect(find.textContaining('Nichts wird ohne Ihre'), findsOneWidget); + + await tester.ensureVisible(find.text('Später einrichten')); + await tester.tap(find.text('Später einrichten')); + await tester.pumpAndSettle(); + expect(done, isTrue, reason: 'skip must hand control to the app'); + }); + + testWidgets('cancel on step 1 also leaves the gate', (tester) async { + var done = false; + await tester.pumpWidget( + MaterialApp( + locale: const Locale('de'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: SetupGateScreen(onDone: () => done = true), + ), + ); + await tester.pumpAndSettle(); + await tester.ensureVisible(find.text('Abbrechen')); + await tester.tap(find.text('Abbrechen')); + await tester.pumpAndSettle(); + expect(done, isTrue); + }); +}