diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index cefdd8d..75b8838 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -4,7 +4,7 @@ "errModuleDownloadHint": "Die Bundle-URL des Moduls ist nicht erreichbar (Statuscode unten). Der Hub läuft — URL/Host korrigieren oder anderen Store wählen.", "storeFromSource": "aus {store}", "guidedSetupTitle": "Setup-Assistent", - "guidedSetupIntro": "Beantworte drei Fragen — Ch∆In stellt passende Konfiguration, Modul-Set und Start-Flow zusammen.", + "guidedSetupIntro": "Beantworten Sie drei Fragen — Ch∆In stellt passende Konfiguration, Modul-Set und Beispiel-Flow zusammen.", "guidedSetupScenario": "Szenario", "guidedSetupIntent": "Aufgabe", "guidedSetupTarget": "Wo läuft es?", @@ -1747,7 +1747,23 @@ "setupApplied": "Fertig — Ch∆In ist eingerichtet.", "setupNextTitle": "Nächste Schritte", "setupNextHubStart": "Hub starten", - "setupNextInstall": "Die Module installieren", - "setupNextRunFlow": "Den Beispiel-Flow ausführen", - "setupStartCta": "In 3 Fragen loslegen" + "setupHubRunning": "Der Hub läuft.", + "setupStartHubFirst": "Starten Sie zuerst den Hub — dann lassen sich die Module hier installieren.", + "setupActionInstall": "{module} installieren", + "@setupActionInstall": {"placeholders": {"module": {"type": "String"}}}, + "setupActionInstalled": "{module} ist installiert.", + "@setupActionInstalled": {"placeholders": {"module": {"type": "String"}}}, + "setupActionOpenFlow": "Beispiel-Flow „{flow}“ öffnen", + "@setupActionOpenFlow": {"placeholders": {"flow": {"type": "String"}}}, + "setupModulesOfflineHint": "Die Module kommen ohne Internet als Offline-Paket auf den Server — die Anleitung dazu steht in der Dokumentation unter „Betrieb ohne Internet“.", + "setupModulesSignedHint": "Die Module kommen aus einer eigenen, signierten Quelle — nicht aus dem öffentlichen Store.", + "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", + "setupProfileDev": "unkomplizierter Arbeitsplatz", + "setupProfileEnterprise": "abgesicherter Team-Betrieb", + "setupProfileAirgapped": "vollständig lokaler, regulierter Betrieb", + "welcomeChecklistSetupDone": "Die Grundeinrichtung ({profile}) hat der Einrichtungs-Assistent bereits erledigt — hier sehen Sie, was auf dem Hub schon passiert ist.", + "@welcomeChecklistSetupDone": {"placeholders": {"profile": {"type": "String"}}} } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index cfb73e2..6314d57 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1786,7 +1786,23 @@ "setupApplied": "Done — Ch∆In is set up.", "setupNextTitle": "Next steps", "setupNextHubStart": "Start the hub", - "setupNextInstall": "Install the modules", - "setupNextRunFlow": "Run the example flow", - "setupStartCta": "Get started in 3 questions" + "setupHubRunning": "The hub is running.", + "setupStartHubFirst": "Start the hub first — then the modules can be installed right here.", + "setupActionInstall": "Install {module}", + "@setupActionInstall": {"placeholders": {"module": {"type": "String"}}}, + "setupActionInstalled": "{module} is installed.", + "@setupActionInstalled": {"placeholders": {"module": {"type": "String"}}}, + "setupActionOpenFlow": "Open the “{flow}” example flow", + "@setupActionOpenFlow": {"placeholders": {"flow": {"type": "String"}}}, + "setupModulesOfflineHint": "The modules arrive as an offline bundle, without internet — the documentation explains this under “running without internet”.", + "setupModulesSignedHint": "The modules come from your own, signed source — not from the public store.", + "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", + "setupProfileDev": "straightforward workstation", + "setupProfileEnterprise": "hardened team operation", + "setupProfileAirgapped": "fully local, regulated operation", + "welcomeChecklistSetupDone": "The setup assistant already took care of the basics ({profile}) — this list shows what has actually happened on your hub.", + "@welcomeChecklistSetupDone": {"placeholders": {"profile": {"type": "String"}}} } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index e0be5a4..5b7ec8e 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -5329,23 +5329,95 @@ abstract class AppLocalizations { /// **'Start the hub'** String get setupNextHubStart; - /// No description provided for @setupNextInstall. + /// No description provided for @setupHubRunning. /// /// In en, this message translates to: - /// **'Install the modules'** - String get setupNextInstall; + /// **'The hub is running.'** + String get setupHubRunning; - /// No description provided for @setupNextRunFlow. + /// No description provided for @setupStartHubFirst. /// /// In en, this message translates to: - /// **'Run the example flow'** - String get setupNextRunFlow; + /// **'Start the hub first — then the modules can be installed right here.'** + String get setupStartHubFirst; + + /// No description provided for @setupActionInstall. + /// + /// In en, this message translates to: + /// **'Install {module}'** + String setupActionInstall(String module); + + /// No description provided for @setupActionInstalled. + /// + /// In en, this message translates to: + /// **'{module} is installed.'** + String setupActionInstalled(String module); + + /// No description provided for @setupActionOpenFlow. + /// + /// In en, this message translates to: + /// **'Open the “{flow}” example flow'** + String setupActionOpenFlow(String flow); + + /// No description provided for @setupModulesOfflineHint. + /// + /// In en, this message translates to: + /// **'The modules arrive as an offline bundle, without internet — the documentation explains this under “running without internet”.'** + String get setupModulesOfflineHint; + + /// No description provided for @setupModulesSignedHint. + /// + /// In en, this message translates to: + /// **'The modules come from your own, signed source — not from the public store.'** + String get setupModulesSignedHint; + + /// No description provided for @setupSigPublicStoreNotice. + /// + /// In en, this message translates to: + /// **'This setup only accepts signed modules. The public store currently ships unsigned bundles — the prepared modules therefore come from your own, signed source.'** + String get setupSigPublicStoreNotice; + + /// No description provided for @setupAllowUnsigned. + /// + /// In en, this message translates to: + /// **'Allow installing from the public store'** + String get setupAllowUnsigned; + + /// No description provided for @setupAllowUnsignedSub. + /// + /// In en, this message translates to: + /// **'Deliberately relaxes the signature requirement. Fine for trying things out — re-enable it for regulated operation.'** + String get setupAllowUnsignedSub; /// No description provided for @setupStartCta. /// /// In en, this message translates to: /// **'Get started in 3 questions'** String get setupStartCta; + + /// No description provided for @setupProfileDev. + /// + /// In en, this message translates to: + /// **'straightforward workstation'** + String get setupProfileDev; + + /// No description provided for @setupProfileEnterprise. + /// + /// In en, this message translates to: + /// **'hardened team operation'** + String get setupProfileEnterprise; + + /// No description provided for @setupProfileAirgapped. + /// + /// In en, this message translates to: + /// **'fully local, regulated operation'** + String get setupProfileAirgapped; + + /// No description provided for @welcomeChecklistSetupDone. + /// + /// In en, this message translates to: + /// **'The setup assistant already took care of the basics ({profile}) — this list shows what has actually happened on your hub.'** + String welcomeChecklistSetupDone(String profile); } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index e78a64b..2c3bc1b 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -25,7 +25,7 @@ class AppLocalizationsDe extends AppLocalizations { @override String get guidedSetupIntro => - 'Beantworte drei Fragen — Ch∆In stellt passende Konfiguration, Modul-Set und Start-Flow zusammen.'; + 'Beantworten Sie drei Fragen — Ch∆In stellt passende Konfiguration, Modul-Set und Beispiel-Flow zusammen.'; @override String get guidedSetupScenario => 'Szenario'; @@ -3138,11 +3138,62 @@ class AppLocalizationsDe extends AppLocalizations { String get setupNextHubStart => 'Hub starten'; @override - String get setupNextInstall => 'Die Module installieren'; + String get setupHubRunning => 'Der Hub läuft.'; @override - String get setupNextRunFlow => 'Den Beispiel-Flow ausführen'; + String get setupStartHubFirst => + 'Starten Sie zuerst den Hub — dann lassen sich die Module hier installieren.'; + + @override + String setupActionInstall(String module) { + return '$module installieren'; + } + + @override + String setupActionInstalled(String module) { + return '$module ist installiert.'; + } + + @override + String setupActionOpenFlow(String flow) { + return 'Beispiel-Flow „$flow“ öffnen'; + } + + @override + String get setupModulesOfflineHint => + 'Die Module kommen ohne Internet als Offline-Paket auf den Server — die Anleitung dazu steht in der Dokumentation unter „Betrieb ohne Internet“.'; + + @override + String get setupModulesSignedHint => + 'Die Module kommen aus einer eigenen, signierten Quelle — nicht aus dem öffentlichen Store.'; + + @override + String get 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.'; + + @override + String get setupAllowUnsigned => + 'Installation aus dem öffentlichen Store erlauben'; + + @override + String get setupAllowUnsignedSub => + 'Lockert die Signaturpflicht bewusst. Gut zum Ausprobieren — für den regulierten Betrieb später wieder aktivieren.'; @override String get setupStartCta => 'In 3 Fragen loslegen'; + + @override + String get setupProfileDev => 'unkomplizierter Arbeitsplatz'; + + @override + String get setupProfileEnterprise => 'abgesicherter Team-Betrieb'; + + @override + String get setupProfileAirgapped => + 'vollständig lokaler, regulierter Betrieb'; + + @override + String welcomeChecklistSetupDone(String profile) { + return 'Die Grundeinrichtung ($profile) hat der Einrichtungs-Assistent bereits erledigt — hier sehen Sie, was auf dem Hub schon passiert ist.'; + } } diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 6a7d777..4342406 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -3136,11 +3136,60 @@ class AppLocalizationsEn extends AppLocalizations { String get setupNextHubStart => 'Start the hub'; @override - String get setupNextInstall => 'Install the modules'; + String get setupHubRunning => 'The hub is running.'; @override - String get setupNextRunFlow => 'Run the example flow'; + String get setupStartHubFirst => + 'Start the hub first — then the modules can be installed right here.'; + + @override + String setupActionInstall(String module) { + return 'Install $module'; + } + + @override + String setupActionInstalled(String module) { + return '$module is installed.'; + } + + @override + String setupActionOpenFlow(String flow) { + return 'Open the “$flow” example flow'; + } + + @override + String get setupModulesOfflineHint => + 'The modules arrive as an offline bundle, without internet — the documentation explains this under “running without internet”.'; + + @override + String get setupModulesSignedHint => + 'The modules come from your own, signed source — not from the public store.'; + + @override + String get setupSigPublicStoreNotice => + 'This setup only accepts signed modules. The public store currently ships unsigned bundles — the prepared modules therefore come from your own, signed source.'; + + @override + String get setupAllowUnsigned => 'Allow installing from the public store'; + + @override + String get setupAllowUnsignedSub => + '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'; + + @override + String get setupProfileDev => 'straightforward workstation'; + + @override + String get setupProfileEnterprise => 'hardened team operation'; + + @override + String get setupProfileAirgapped => 'fully local, regulated operation'; + + @override + String welcomeChecklistSetupDone(String profile) { + return 'The setup assistant already took care of the basics ($profile) — this list shows what has actually happened on your hub.'; + } } diff --git a/lib/pages/welcome.dart b/lib/pages/welcome.dart index 144b52f..985f665 100644 --- a/lib/pages/welcome.dart +++ b/lib/pages/welcome.dart @@ -22,9 +22,44 @@ import '../theme/tokens.dart'; import '../widgets/guided_setup_dialog.dart'; import '../widgets/widgets.dart'; -class WelcomePage extends StatelessWidget { +class WelcomePage extends StatefulWidget { const WelcomePage({super.key}); + @override + State createState() => _WelcomePageState(); +} + +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++); + } + @override Widget build(BuildContext context) { final theme = Theme.of(context); @@ -65,11 +100,12 @@ class WelcomePage extends StatelessWidget { 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. + // rather than a low-key outlined one. Honest framing: + // "get started in 3 questions", not tool-speak. child: FilledButton.icon( - onPressed: () => GuidedSetupDialog.show(context), + onPressed: _openSetup, icon: const Icon(Icons.auto_fix_high), - label: Text(AppLocalizations.of(context)!.guidedSetupTitle), + label: Text(l.setupStartCta), ), ), const SizedBox(height: ChainSpace.xxl), @@ -81,7 +117,7 @@ class WelcomePage extends StatelessWidget { const _HubDownHero(), const SizedBox(height: ChainSpace.xxl), ] else - const _OnboardingChecklist(), + _OnboardingChecklist(key: ValueKey(_checklistEpoch)), const _PillarRow(), const SizedBox(height: ChainSpace.xxl), const _SectionLabel(textKey: _SectionLabelKey.trust), @@ -515,7 +551,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(); + const _OnboardingChecklist({super.key}); @override State<_OnboardingChecklist> createState() => _OnboardingChecklistState(); @@ -530,6 +566,12 @@ class _OnboardingChecklistState extends State<_OnboardingChecklist> { bool _moduleOk = false; bool _flowOk = false; + /// Profile the guided setup applied (from `setup-plan.yaml`), or + /// null when the assistant never ran. Connects the two onboarding + /// surfaces: the checklist says what the assistant already covered + /// instead of ignoring it. + String? _setupProfile; + @override void initState() { super.initState(); @@ -541,6 +583,7 @@ class _OnboardingChecklistState extends State<_OnboardingChecklist> { if (!mounted) return; setState(() { _dismissed = prefs.getBool(_kChecklistDismissedKey) ?? false; + _setupProfile = GuidedSetupDialog.appliedSetupProfile(); _initialised = true; }); if (!_dismissed) { @@ -598,6 +641,13 @@ class _OnboardingChecklistState extends State<_OnboardingChecklist> { setState(() => _dismissed = true); } + /// Localized plain-language label for the applied setup profile. + String _profileLabel(AppLocalizations l) => switch (_setupProfile) { + 'enterprise' => l.setupProfileEnterprise, + 'air-gapped' => l.setupProfileAirgapped, + _ => l.setupProfileDev, + }; + @override Widget build(BuildContext context) { if (!_initialised || _dismissed) return const SizedBox.shrink(); @@ -674,6 +724,27 @@ class _OnboardingChecklistState extends State<_OnboardingChecklist> { color: theme.colorScheme.onSurfaceVariant, ), ), + // The guided setup already ran: say so, so the checklist + // reads as "what's left", not as a second, disconnected + // onboarding that ignores the assistant's work. + if (_setupProfile != null) ...[ + const SizedBox(height: ChainSpace.sm), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(Icons.check_circle, size: 16, color: ChainColors.success), + const SizedBox(width: 6), + Expanded( + child: Text( + l.welcomeChecklistSetupDone(_profileLabel(l)), + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + ], + ), + ], const SizedBox(height: ChainSpace.md), Container( decoration: BoxDecoration( diff --git a/lib/widgets/guided_setup_dialog.dart b/lib/widgets/guided_setup_dialog.dart index a5dd4e6..6ad01ff 100644 --- a/lib/widgets/guided_setup_dialog.dart +++ b/lib/widgets/guided_setup_dialog.dart @@ -9,14 +9,17 @@ // plain-language summary — never the CLI's English prose. Every // option carries a one-line explanation of what it configures. +import 'dart:async' show unawaited; import 'dart:convert'; import 'dart:io'; import 'package:flutter/material.dart'; import '../data/error_presentation.dart'; +import '../data/hub.dart'; import '../data/system_actions.dart'; import '../l10n/app_localizations.dart'; +import '../main.dart' show StudioShellState; import '../theme/tokens.dart'; /// A selectable setup option: the stable kebab wire value plus the @@ -29,12 +32,74 @@ class _Option { } class GuidedSetupDialog extends StatefulWidget { - const GuidedSetupDialog({super.key}); + /// The shell, resolved by [show] before the dialog route detaches + /// from the shell's subtree. Drives the post-apply "open the + /// starter flow" navigation; null in tests. + final StudioShellState? shell; - static Future show(BuildContext context) => showDialog( - context: context, - builder: (_) => const GuidedSetupDialog(), - ); + /// Test seam: start on the review step with this parsed plan + /// instead of shelling out to `chain init --plan-json`. + @visibleForTesting + final Map? debugPlan; + + /// Test seam: combined with [debugPlan], start in the applied + /// (next-steps) state. + @visibleForTesting + final bool debugApplied; + + const GuidedSetupDialog({ + super.key, + this.shell, + this.debugPlan, + this.debugApplied = false, + }); + + static Future show(BuildContext context) { + final shell = StudioShellState.of(context); + return showDialog( + context: context, + builder: (_) => GuidedSetupDialog(shell: shell), + ); + } + + /// Path of the operator dir (`~/.chain`), or null when no home + /// directory can be resolved. + static String? _chainDir() { + final home = + Platform.environment['HOME'] ?? Platform.environment['USERPROFILE']; + if (home == null) return null; + return '$home${Platform.pathSeparator}.chain'; + } + + /// True on a fresh install: neither a config nor a recorded setup + /// plan exists yet. Drives the welcome page's auto-open. + static bool isFreshInstall() { + final dir = _chainDir(); + if (dir == null) return false; + final sep = Platform.pathSeparator; + return !File('$dir${sep}config.yaml').existsSync() && + !File('$dir${sep}setup-plan.yaml').existsSync(); + } + + /// The `profile:` of the recorded setup plan (`setup-plan.yaml`), + /// or null when the guided setup never applied one. Lets the + /// onboarding checklist say "the assistant covered this" instead + /// of presenting a second, disconnected onboarding surface. + static String? appliedSetupProfile() { + final dir = _chainDir(); + if (dir == null) return null; + final f = File('$dir${Platform.pathSeparator}setup-plan.yaml'); + if (!f.existsSync()) return null; + try { + for (final line in f.readAsLinesSync()) { + final m = RegExp(r'^profile:\s*(\S+)').firstMatch(line.trim()); + if (m != null) return m.group(1); + } + } on FileSystemException { + return null; + } + return null; + } @override State createState() => _GuidedSetupDialogState(); @@ -71,16 +136,37 @@ class _GuidedSetupDialogState extends State { String _target = 'this-laptop'; bool _requireApproval = false; bool _dataLocal = false; + bool _allowUnsigned = false; bool _busy = false; Map? _plan; // parsed SetupPlan from --plan-json bool _applied = false; + // Post-apply action state: hub reachability + per-module install + // progress, so the next steps are buttons that report back instead + // of CLI commands to retype. + bool _hubUp = false; + bool _startingHub = false; + final Set _installing = {}; + final Set _installed = {}; + + @override + void initState() { + super.initState(); + final seeded = widget.debugPlan; + if (seeded != null) { + _plan = seeded; + _step = _totalSteps; + _applied = widget.debugApplied; + } + } + String _answersYaml() => 'scenario: $_scenario\n' 'intent: $_intent\n' 'target: $_target\n' 'require_approval: $_requireApproval\n' - 'data_must_stay_local: $_dataLocal\n'; + 'data_must_stay_local: $_dataLocal\n' + 'allow_unsigned_modules: $_allowUnsigned\n'; Future _writeAnswers() async { final f = File('${Directory.systemTemp.path}/chain-setup-answers.yaml'); @@ -131,11 +217,65 @@ class _GuidedSetupDialogState extends State { setState(() => _busy = false); if (r.ok) { setState(() => _applied = true); + unawaited(_probeHub()); } else { showFaiProcessError(context, 'chain init --apply', r.stdout, r.stderr); } } + /// Refresh the "is the hub reachable" signal driving the + /// post-apply action rows. + Future _probeHub() async { + final up = await HubService.instance + .healthy() + .catchError((Object _) => false); + if (mounted) setState(() => _hubUp = up); + } + + /// Start the local daemon, then poll until it answers (the start + /// command returns before the gRPC endpoint is up). A failed start + /// often just means "already running", so probe before reporting. + Future _startHub() async { + setState(() => _startingHub = true); + final r = await SystemActions.chainDaemon(['start']); + var up = false; + for (var i = 0; i < 6 && !up; i++) { + await Future.delayed(const Duration(milliseconds: 700)); + up = await HubService.instance + .healthy() + .catchError((Object _) => false); + } + if (!mounted) return; + setState(() { + _startingHub = false; + _hubUp = up; + }); + if (!up) { + await showFaiProcessErrorDialog( + context, + 'chain daemon start', + r.stdout, + r.stderr, + ); + } + } + + /// Install one plan module by capability name — the hub resolves + /// the bundle URL from its store index. + Future _install(String module) async { + setState(() => _installing.add(module)); + try { + 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)); + } + } + @override Widget build(BuildContext context) { final l = AppLocalizations.of(context)!; @@ -210,26 +350,16 @@ class _GuidedSetupDialogState extends State { if (plan == null) return const SizedBox.shrink(); final theme = Theme.of(context); final lines = _planLines(l, plan); + final modules = (plan['modules'] as List?)?.cast() ?? const []; + final airGapped = (plan['profile'] as String? ?? '') == 'air-gapped'; + final sigGate = plan['require_signatures'] == true; return Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (_applied) ...[ - Row( - children: [ - Icon(Icons.check_circle, color: ChainColors.success, size: 20), - const SizedBox(width: ChainSpace.sm), - Text(l.setupApplied, style: theme.textTheme.titleSmall), - ], - ), - const SizedBox(height: ChainSpace.md), - Text(l.setupNextTitle, style: theme.textTheme.labelLarge), - const SizedBox(height: 4), - _next(l.setupNextHubStart), - if ((plan['modules'] as List?)?.isNotEmpty ?? false) - _next(l.setupNextInstall), - _next(l.setupNextRunFlow), - ] else ...[ + if (_applied) + ..._appliedSteps(l, plan) + else ...[ for (final line in lines) Padding( padding: const EdgeInsets.only(bottom: 6), @@ -243,6 +373,44 @@ class _GuidedSetupDialogState extends State { ], ), ), + // Signature dead end (SEC-01): a regulated connected plan + // refuses the public store's unsigned bundles — the very + // installs suggested after apply. Explain it and offer the + // relaxation as one deliberate, reversible switch. The plan + // is re-assembled on toggle so the preview stays truthful. + if (!airGapped && modules.isNotEmpty && (sigGate || _allowUnsigned)) ...[ + const SizedBox(height: ChainSpace.sm), + if (sigGate) + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + Icons.verified_user_outlined, + size: 16, + color: theme.colorScheme.tertiary, + ), + const SizedBox(width: 6), + Expanded( + child: Text( + l.setupSigPublicStoreNotice, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.tertiary, + ), + ), + ), + ], + ), + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: Text(l.setupAllowUnsigned), + subtitle: Text(l.setupAllowUnsignedSub), + value: _allowUnsigned, + onChanged: (v) { + setState(() => _allowUnsigned = v); + _goReview(); + }, + ), + ], if (_configExists()) ...[ const SizedBox(height: ChainSpace.sm), Row( @@ -266,13 +434,122 @@ class _GuidedSetupDialogState extends State { ); } - Widget _next(String label) => Padding( - padding: const EdgeInsets.only(top: 4), + /// Post-apply next steps as real Studio actions: start the hub, + /// install each plan module, open the starter flow — no CLI text. + List _appliedSteps(AppLocalizations l, Map plan) { + final theme = Theme.of(context); + final modules = (plan['modules'] as List?)?.cast() ?? const []; + final airGapped = (plan['profile'] as String? ?? '') == 'air-gapped'; + final sigGate = plan['require_signatures'] == true; + final flow = plan['starter_flow'] as String? ?? ''; + return [ + Row( + children: [ + Icon(Icons.check_circle, color: ChainColors.success, size: 20), + const SizedBox(width: ChainSpace.sm), + Text(l.setupApplied, style: theme.textTheme.titleSmall), + ], + ), + const SizedBox(height: ChainSpace.md), + Text(l.setupNextTitle, style: theme.textTheme.labelLarge), + const SizedBox(height: ChainSpace.sm), + // 1 — the hub. Everything else needs it. + if (_hubUp) + _doneRow(l.setupHubRunning) + else + _actionRow( + FilledButton.tonalIcon( + onPressed: _startingHub ? null : _startHub, + icon: _startingHub + ? const SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.play_arrow, size: 18), + label: Text(l.setupNextHubStart), + ), + ), + // 2 — the plan's modules. + if (modules.isNotEmpty) ...[ + if (airGapped) + _hintRow(l.setupModulesOfflineHint) + else if (sigGate) + _hintRow(l.setupModulesSignedHint) + else ...[ + if (!_hubUp) _hintRow(l.setupStartHubFirst), + for (final m in modules) + _installed.contains(m) + ? _doneRow(l.setupActionInstalled(m)) + : _actionRow( + 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)), + ), + ), + ], + ], + // 3 — the starter flow (auto-imported on first hub start). + if (flow.isNotEmpty) + _actionRow( + OutlinedButton.icon( + onPressed: () { + Navigator.of(context).pop(); + widget.shell?.navigateTo('flows'); + }, + icon: const Icon(Icons.account_tree_outlined, size: 18), + label: Text(l.setupActionOpenFlow(flow)), + ), + ), + ]; + } + + Widget _actionRow(Widget child) => Padding( + padding: const EdgeInsets.only(top: ChainSpace.xs), + child: Align(alignment: Alignment.centerLeft, child: child), + ); + + Widget _doneRow(String label) => Padding( + padding: const EdgeInsets.only(top: ChainSpace.xs), child: Row( children: [ - Icon(Icons.arrow_right, size: 18, color: Theme.of(context).colorScheme.primary), - const SizedBox(width: 4), - Expanded(child: Text(label, style: Theme.of(context).textTheme.bodyMedium)), + Icon(Icons.check_circle, size: 18, color: ChainColors.success), + const SizedBox(width: 6), + Expanded( + child: Text(label, style: Theme.of(context).textTheme.bodyMedium), + ), + ], + ), + ); + + Widget _hintRow(String text) => Padding( + padding: const EdgeInsets.only(top: ChainSpace.xs), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + Icons.info_outline, + size: 16, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 6), + Expanded( + child: Text( + text, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ), ], ), ); diff --git a/test/guided_setup_test.dart b/test/guided_setup_test.dart index 23d2836..326484f 100644 --- a/test/guided_setup_test.dart +++ b/test/guided_setup_test.dart @@ -26,6 +26,41 @@ Widget _host() => MaterialApp( ), ); +/// Host that opens the dialog pre-seeded on the review / applied +/// step with [plan] (the parsed `--plan-json` shape), bypassing the +/// CLI subprocess. +Widget _seededHost(Map plan, {bool applied = false}) => + MaterialApp( + locale: const Locale('de'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Builder( + builder: (context) => Scaffold( + body: Center( + child: ElevatedButton( + onPressed: () => showDialog( + context: context, + builder: (_) => + GuidedSetupDialog(debugPlan: plan, debugApplied: applied), + ), + child: const Text('open'), + ), + ), + ), + ), + ); + +Map _regulatedPlan({bool signed = true}) => { + 'profile': 'enterprise', + 'modules': ['text.extract', 'text.summarize'], + 'starter_flow': 'extract-summarize', + 'runbook': 'service', + 'curated_docs': [], + 'require_signatures': signed, + 'worm_audit': false, + 'approval_step': true, +}; + void main() { testWidgets('step 1 shows scenario options in German with explanations', ( tester, @@ -75,4 +110,70 @@ void main() { await tester.pumpAndSettle(); expect(find.text('Schritt 2 von 3'), findsOneWidget); }); + + testWidgets( + 'review of a regulated plan explains the signature dead end and offers the relaxation switch', + (tester) async { + await tester.pumpWidget(_seededHost(_regulatedPlan())); + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + + // The plain-language notice + the deliberate one-click switch. + expect( + find.textContaining('Der öffentliche Store liefert zurzeit unsignierte Pakete'), + findsOneWidget, + ); + expect( + find.text('Installation aus dem öffentlichen Store erlauben'), + findsOneWidget, + ); + // No raw CLI prose anywhere. + expect(find.textContaining('chain install'), findsNothing); + }, + ); + + testWidgets( + 'applied state renders clickable next steps — start hub, per-module install, open flow', + (tester) async { + await tester.pumpWidget( + _seededHost(_regulatedPlan(signed: false), applied: true), + ); + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + + expect(find.text('Fertig — Ch∆In ist eingerichtet.'), findsOneWidget); + // Hub not probed up in tests → the start action + the + // install-needs-hub hint show, and install buttons exist + // (disabled) for each plan module. + expect(find.text('Hub starten'), findsOneWidget); + expect( + find.textContaining('Starten Sie zuerst den Hub'), + findsOneWidget, + ); + expect(find.text('text.extract installieren'), findsOneWidget); + expect(find.text('text.summarize installieren'), findsOneWidget); + expect( + find.text('Beispiel-Flow „extract-summarize“ öffnen'), + findsOneWidget, + ); + // No CLI text in the GUI path. + expect(find.textContaining('chain serve'), findsNothing); + expect(find.textContaining('chain install'), findsNothing); + }, + ); + + testWidgets( + 'applied regulated (signed) state guides to the signed source instead of dead-end install buttons', + (tester) async { + await tester.pumpWidget(_seededHost(_regulatedPlan(), applied: true)); + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + + expect( + find.textContaining('signierten Quelle'), + findsOneWidget, + ); + expect(find.text('text.extract installieren'), findsNothing); + }, + ); }