feat: guided setup — clickable next steps, signature relaxation, fresh-install auto-open

Post-apply the wizard now renders real Studio actions instead of CLI
text: a start-hub button that polls until the daemon answers,
per-module install buttons (capability-name install via the hub's
store index) with done/progress states, and an open-the-starter-flow
button that navigates to the Flows page. Regulated plans explain in
plain language that modules come from a signed source; the preview
offers 'allow installing from the public store' as one deliberate,
reversible switch that re-assembles the plan (allow_unsigned_modules).

Fresh installs (no config, no setup-plan.yaml) auto-open the wizard
once per run — the wizard IS the onboarding — and it steps back once
a setup exists. The welcome CTA is framed honestly ('get started in
3 questions'), and after the wizard closes the onboarding checklist
remounts, re-probes, and says what the assistant already covered
(profile line from setup-plan.yaml) instead of acting as a second,
disconnected onboarding surface.

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
flemming-it 2026-07-12 23:18:41 +02:00
parent 140408d26a
commit 1f1e050b42
8 changed files with 704 additions and 51 deletions

View file

@ -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"}}}
}

View file

@ -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"}}}
}

View file

@ -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

View file

@ -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.';
}
}

View file

@ -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.';
}
}

View file

@ -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<WelcomePage> createState() => _WelcomePageState();
}
class _WelcomePageState extends State<WelcomePage> {
/// 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<void> _maybeAutoOpen() async {
if (_autoOpenedThisRun || !mounted) return;
if (!GuidedSetupDialog.isFreshInstall()) return;
_autoOpenedThisRun = true;
await _openSetup();
}
Future<void> _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(

View file

@ -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<void> show(BuildContext context) => showDialog<void>(
/// Test seam: start on the review step with this parsed plan
/// instead of shelling out to `chain init --plan-json`.
@visibleForTesting
final Map<String, dynamic>? 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<void> show(BuildContext context) {
final shell = StudioShellState.of(context);
return showDialog<void>(
context: context,
builder: (_) => const GuidedSetupDialog(),
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<GuidedSetupDialog> createState() => _GuidedSetupDialogState();
@ -71,16 +136,37 @@ class _GuidedSetupDialogState extends State<GuidedSetupDialog> {
String _target = 'this-laptop';
bool _requireApproval = false;
bool _dataLocal = false;
bool _allowUnsigned = false;
bool _busy = false;
Map<String, dynamic>? _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<String> _installing = {};
final Set<String> _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<String> _writeAnswers() async {
final f = File('${Directory.systemTemp.path}/chain-setup-answers.yaml');
@ -131,11 +217,65 @@ class _GuidedSetupDialogState extends State<GuidedSetupDialog> {
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<void> _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<void> _startHub() async {
setState(() => _startingHub = true);
final r = await SystemActions.chainDaemon(['start']);
var up = false;
for (var i = 0; i < 6 && !up; i++) {
await Future<void>.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<void> _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<GuidedSetupDialog> {
if (plan == null) return const SizedBox.shrink();
final theme = Theme.of(context);
final lines = _planLines(l, plan);
final modules = (plan['modules'] as List?)?.cast<String>() ?? 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<GuidedSetupDialog> {
],
),
),
// 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<GuidedSetupDialog> {
);
}
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<Widget> _appliedSteps(AppLocalizations l, Map<String, dynamic> plan) {
final theme = Theme.of(context);
final modules = (plan['modules'] as List?)?.cast<String>() ?? 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,
),
),
),
],
),
);

View file

@ -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<String, dynamic> 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<void>(
context: context,
builder: (_) =>
GuidedSetupDialog(debugPlan: plan, debugApplied: applied),
),
child: const Text('open'),
),
),
),
),
);
Map<String, dynamic> _regulatedPlan({bool signed = true}) => {
'profile': 'enterprise',
'modules': ['text.extract', 'text.summarize'],
'starter_flow': 'extract-summarize',
'runbook': 'service',
'curated_docs': <String>[],
'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);
},
);
}