// Guided-setup wizard. Collects scenario / intent / target (plus two // adaptive toggles) across three explained steps, then previews the // assembled plan in localized PLAIN LANGUAGE and applies it. All the // policy lives in the Rust deterministic engine // (chain_core::guided_setup); this is a thin UI over `chain init`. // // The preview is rendered from `chain init --answers … --plan-json` // (a structured SetupPlan), so the operator sees a German/English // 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/friendly_error.dart'; import '../data/hub.dart'; import '../data/system_actions.dart'; import '../l10n/app_localizations.dart'; import '../main.dart' show StudioShellState; import '../theme/tokens.dart'; import 'chain_stores_dialog.dart'; /// Allowed wire values per answer — an AI suggestion is validated /// against these; anything else is rejected as a parse failure so a /// hallucinated enum can never reach the engine. const _kScenarioValues = [ 'trying-out', 'team-hub', 'regulated-production', 'building-modules', ]; const _kIntentValues = [ 'hello-world', 'extract-text', 'extract-summarize', 'classify-documents', 'build-module', ]; const _kTargetValues = [ 'this-laptop', 'home-server', 'air-gapped-server', 'container', ]; /// Extract + validate the system AI's setup suggestion: the first /// JSON object in [text] with valid enum values for all three /// answers. Returns null when nothing safe could be parsed. @visibleForTesting Map? parseSetupSuggestion(String text) { final start = text.indexOf('{'); final end = text.lastIndexOf('}'); if (start < 0 || end <= start) return null; final Object? decoded; try { decoded = jsonDecode(text.substring(start, end + 1)); } on FormatException { return null; } if (decoded is! Map) return null; final scenario = decoded['scenario']; final intent = decoded['intent']; final target = decoded['target']; if (!_kScenarioValues.contains(scenario) || !_kIntentValues.contains(intent) || !_kTargetValues.contains(target)) { return null; } return { 'scenario': scenario, 'intent': intent, 'target': target, 'require_approval': decoded['require_approval'] == true, 'data_must_stay_local': decoded['data_must_stay_local'] == true, }; } /// A selectable setup option: the stable kebab wire value plus the /// localized label + one-line explanation resolved at build time. class _Option { final String value; final String Function(AppLocalizations) label; final String Function(AppLocalizations) sub; const _Option(this.value, this.label, this.sub); } class GuidedSetupDialog extends StatefulWidget { /// 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; /// 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; /// Test seam: skip the live system-AI probe so the free-text /// section renders its deterministic "no AI configured" state. @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) { final shell = StudioShellState.of(context); // Not barrier-dismissible: a stray click outside the dialog must // not throw away a half-answered wizard (and error SnackBars used // to lure exactly that click — the operator aimed for the message // below the barrier and lost everything). Leaving is explicit: // Abbrechen on step 1, Zurück everywhere else. return showDialog( context: context, barrierDismissible: false, 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(); } class _GuidedSetupDialogState extends State { // Canonical SetupAnswers wire values (serde kebab-case in Rust) with // their localized label + explanation. static final _scenarios = <_Option>[ _Option('trying-out', (l) => l.setupScenTryingOut, (l) => l.setupScenTryingOutSub), _Option('team-hub', (l) => l.setupScenTeamHub, (l) => l.setupScenTeamHubSub), _Option('regulated-production', (l) => l.setupScenRegulated, (l) => l.setupScenRegulatedSub), _Option('building-modules', (l) => l.setupScenBuildModules, (l) => l.setupScenBuildModulesSub), ]; static final _intents = <_Option>[ _Option('hello-world', (l) => l.setupIntentHello, (l) => l.setupIntentHelloSub), _Option('extract-text', (l) => l.setupIntentExtract, (l) => l.setupIntentExtractSub), _Option('extract-summarize', (l) => l.setupIntentExtractSummarize, (l) => l.setupIntentExtractSummarizeSub), _Option('classify-documents', (l) => l.setupIntentClassify, (l) => l.setupIntentClassifySub), _Option('build-module', (l) => l.setupIntentBuildModule, (l) => l.setupIntentBuildModuleSub), ]; static final _targets = <_Option>[ _Option('this-laptop', (l) => l.setupTargetLaptop, (l) => l.setupTargetLaptopSub), _Option('home-server', (l) => l.setupTargetHomeServer, (l) => l.setupTargetHomeServerSub), _Option('air-gapped-server', (l) => l.setupTargetAirgapped, (l) => l.setupTargetAirgappedSub), _Option('container', (l) => l.setupTargetContainer, (l) => l.setupTargetContainerSub), ]; static const _totalSteps = 3; // three answer steps; the review is step 4 int _step = 0; // 0..2 answers, 3 = review/apply String _scenario = 'trying-out'; String _intent = 'extract-summarize'; 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 = {}; // Free-text (AI) path state. The menu path is always complete on // its own; the AI merely PRE-SELECTS answers for review — it never // applies anything (trust rule from guided-setup.md). final TextEditingController _goalCtl = TextEditingController(); SystemAiStatus? _aiStatus; // null until probed / when unreachable bool _aiProbed = false; bool _suggestBusy = false; bool _showReflection = false; @override void initState() { super.initState(); final seeded = widget.debugPlan; if (seeded != null) { _plan = seeded; _step = _totalSteps; _applied = widget.debugApplied; _aiProbed = true; // tests: skip the live probe } else if (widget.debugSkipAiProbe) { _aiProbed = true; } else { unawaited(_probeAi()); } } @override void dispose() { _goalCtl.dispose(); try { _answersDir?.deleteSync(recursive: true); } on FileSystemException { // Best-effort cleanup; the OS temp reaper covers the rest. } super.dispose(); } /// Probe whether a system AI is configured — the free-text path /// needs one; without it the menu path stands alone (no dead end). Future _probeAi() async { SystemAiStatus? status; try { status = await HubService.instance.systemAiStatus(); } catch (_) { status = null; } if (!mounted) return; setState(() { _aiStatus = status; _aiProbed = true; }); } String _answersYaml() => 'scenario: $_scenario\n' 'intent: $_intent\n' 'target: $_target\n' 'require_approval: $_requireApproval\n' 'data_must_stay_local: $_dataLocal\n' 'allow_unsigned_modules: $_allowUnsigned\n'; /// Per-dialog private temp dir for the answers file — a fixed /// name in the shared system temp would be world-readable and /// racy on multi-user machines. Cleaned up in [dispose]. Directory? _answersDir; String _writeAnswers() { // Sync on purpose: the file is a handful of lines, and the async // dart:io variants never complete inside the fake-async zone // widget tests run in — the wizard would hang there forever. _answersDir ??= Directory.systemTemp.createTempSync('chain-setup-'); final f = File( '${_answersDir!.path}${Platform.pathSeparator}answers.yaml', ); f.writeAsStringSync(_answersYaml()); return f.path; } /// True when a config file already exists (so applying overwrites it). bool _configExists() { final home = Platform.environment['HOME'] ?? Platform.environment['USERPROFILE']; if (home == null) return false; final sep = Platform.pathSeparator; return File('$home$sep.chain${sep}config.yaml').existsSync(); } Future _goReview() async { setState(() { _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; setState(() => _busy = false); if (r.ok) { try { final plan = jsonDecode(r.stdout.trim()) as Map; setState(() { _plan = plan; _step = _totalSteps; // review }); } catch (_) { await _showCliError('chain init --plan-json', r.stdout, r.stderr); } } else { await _showCliError('chain init --plan-json', r.stdout, r.stderr); } } /// Surface a failed `chain init` call as a modal dialog stacked /// ABOVE this wizard. A SnackBar would land BEHIND the wizard's /// modal barrier: visible but dimmed, its copy button unreachable — /// and the operator's attempt to click it hits the barrier instead. /// Known failure shapes get a localized plain-language headline; /// the verbatim CLI output stays copyable behind "Details". Future _showCliError(String source, String stdout, String stderr) { final l = AppLocalizations.of(context)!; final raw = [ stderr.trim(), stdout.trim(), ].where((s) => s.isNotEmpty).join('\n\n'); final bin = SystemActions.resolvedChainBinary(); final FriendlyError friendly; if (stderr.trim() == kFaiBinaryNotFound) { // Sentinel, never shown verbatim (see SystemActions docs). friendly = FriendlyError( headline: l.setupNoBinary, detail: '', hint: l.setupNoBinaryHint, ); } else if (raw.contains('unexpected argument')) { // Version skew: this Studio speaks a newer `chain init` dialect // than the binary it found (e.g. a stale build behind a channel // symlink). Name the binary and the way out instead of leaking // a raw clap usage error. friendly = FriendlyError( headline: l.setupCliTooOld, detail: raw, hint: l.setupCliTooOldHint(bin ?? 'chain'), ); } else { friendly = FriendlyError( headline: source == 'chain init --apply' ? l.setupApplyFailed : l.setupPreviewFailed, detail: raw.isEmpty ? 'process exited non-zero (no output)' : raw, hint: bin == null ? null : l.setupCliUsedBinary(bin), ); } return showChainErrorDialog(context, source, friendly); } /// Physical location of the resolved `chain` binary (symlinks /// followed) — the path that decides whether macOS shows a folder- /// permission prompt when Studio executes it, regardless of how /// harmless the symlink's own path looks. String? _physicalChainBinary() { final bin = SystemActions.resolvedChainBinary(); if (bin == null) return null; try { return File(bin).resolveSymbolicLinksSync(); } on FileSystemException { return bin; } } /// True when executing the resolved binary can trigger a macOS /// folder-permission (TCC) prompt because it physically lives in a /// protected folder. The wizard says so BEFORE the first exec — /// an unexplained "Studio wants access to Documents" prompt in the /// middle of setup reads as a trust break. bool _binaryNeedsFolderPermission() { if (!Platform.isMacOS) return false; final physical = _physicalChainBinary(); final home = Platform.environment['HOME']; if (physical == null || home == null || home.isEmpty) return false; return physical.startsWith('$home/Documents/') || physical.startsWith('$home/Desktop/') || physical.startsWith('$home/Downloads/'); } /// Warning lines the apply emitted on success (e.g. the empty /// trusted_publishers caveat). Swallowing them made the wizard /// claim more than the config delivers — show them instead. String _applyWarnings = ''; Future _apply() async { setState(() => _busy = true); final path = _writeAnswers(); final r = await SystemActions.chainInit( ['--answers', path, '--apply', '--force'], ); if (!mounted) return; setState(() => _busy = false); if (r.ok) { final warnings = r.stderr .split('\n') .where((line) => line.toLowerCase().contains('warn')) .join('\n') .trim(); setState(() { _applied = true; _applyWarnings = warnings; }); unawaited(_probeHub()); } else { await _showCliError('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, ); } } /// Structured prompt for the system AI. English, fixed shape, and /// the reply is validated against the enum whitelists — the model /// only ever pre-selects menu answers, it cannot inject config. String _suggestionPrompt(String goal) => 'You configure the Ch∆In workflow platform. Map the operator\'s ' 'goal to setup answers.\n' 'Goal: """$goal"""\n' 'Reply with ONLY one JSON object, no prose, of this exact shape:\n' '{"scenario":"trying-out|team-hub|regulated-production|building-modules",' '"intent":"hello-world|extract-text|extract-summarize|classify-documents|build-module",' '"target":"this-laptop|home-server|air-gapped-server|container",' '"require_approval":true|false,"data_must_stay_local":true|false}\n' 'Pick the closest match for each field.'; /// Ask the system AI to map the free-text goal onto the menu /// answers, then show the editable reflection. Never applies — /// the suggestion only pre-selects; preview + apply stay manual. Future _suggest() async { final goal = _goalCtl.text.trim(); if (goal.isEmpty) return; setState(() => _suggestBusy = true); final l = AppLocalizations.of(context)!; try { final r = await HubService.instance.askAi(_suggestionPrompt(goal)); if (!mounted) return; setState(() => _suggestBusy = false); if (r.errorKind.isNotEmpty) { await showChainErrorDialog(context, 'system-ai', r.text); return; } final parsed = parseSetupSuggestion(r.text); if (parsed == null) { await showChainErrorDialog( context, 'system-ai', '${l.setupFreeTextParseError}\n\n${r.text}', ); return; } setState(() { _scenario = parsed['scenario'] as String; _intent = parsed['intent'] as String; _target = parsed['target'] as String; _requireApproval = parsed['require_approval'] as bool; _dataLocal = parsed['data_must_stay_local'] as bool; _showReflection = true; }); } catch (e) { if (!mounted) return; setState(() => _suggestBusy = false); await showChainErrorDialog(context, 'system-ai', e); } } /// 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)); } } /// 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)!; final reviewing = _step >= _totalSteps; final title = reviewing ? l.setupReviewTitle : _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: body, ), actions: _actions(l), ); } String _stepTitle(AppLocalizations l) => switch (_step) { 0 => l.setupStep1Title, 1 => l.setupStep2Title, _ => l.setupStep3Title, }; Widget _answerStep(AppLocalizations l) { final (options, selected, onSelect) = switch (_step) { 0 => (_scenarios, _scenario, (String v) => setState(() => _scenario = v)), 1 => (_intents, _intent, (String v) => setState(() => _intent = v)), _ => (_targets, _target, (String v) => setState(() => _target = v)), }; return Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( padding: const EdgeInsets.only(bottom: ChainSpace.sm), child: Text( l.setupStepOf(_step + 1, _totalSteps), style: Theme.of(context).textTheme.labelSmall?.copyWith( color: Theme.of(context).colorScheme.onSurfaceVariant, ), ), ), for (final o in options) _OptionCard( title: o.label(l), subtitle: o.sub(l), selected: selected == o.value, onTap: () => onSelect(o.value), ), // The two adaptive toggles live on the last answer step, in // plain language (no "air-gapped" jargon). if (_step == 2) ...[ const SizedBox(height: ChainSpace.sm), SwitchListTile( contentPadding: EdgeInsets.zero, title: Text(l.setupApprovalPlain), value: _requireApproval, onChanged: (v) => setState(() => _requireApproval = v), ), SwitchListTile( contentPadding: EdgeInsets.zero, title: Text(l.setupDataLocalPlain), value: _dataLocal, onChanged: (v) => setState(() => _dataLocal = v), ), // Exec transparency: the next click runs the `chain` binary. // Say which one — and warn when its physical location will // make macOS ask for folder access, so the prompt (if any) // arrives explained instead of as a trust break. const SizedBox(height: ChainSpace.sm), if (!SystemActions.chainBinaryExists()) _hintRow(l.setupNoBinaryStepHint) else ...[ _hintRow(l.setupExecHint(SystemActions.resolvedChainBinary()!)), if (_binaryNeedsFolderPermission()) _hintRow(l.setupExecHintTcc(_physicalChainBinary()!)), ], ], // The free-text alternative lives on the first step: describe // the goal, the system AI pre-selects the menu answers. Menu // path always stands alone (air-gap-safe, no dead end). if (_step == 0 && _aiProbed) ...[ const SizedBox(height: ChainSpace.md), Text( l.setupChooseFreeText, style: Theme.of(context).textTheme.labelLarge, ), const SizedBox(height: ChainSpace.sm), if (_aiStatus?.enabled == true) ...[ TextField( controller: _goalCtl, minLines: 2, maxLines: 4, decoration: InputDecoration( hintText: l.setupFreeTextHint, border: const OutlineInputBorder(), ), ), const SizedBox(height: ChainSpace.xs), // Trust rule: be transparent about where the description // goes before the operator types anything sensitive. Text( _aiIsLocal() ? l.setupFreeTextPrivacyLocal(_aiStatus?.model ?? '') : l.setupFreeTextPrivacyRemote( _aiStatus?.model ?? '', _aiStatus?.provider ?? '', ), style: Theme.of(context).textTheme.bodySmall?.copyWith( color: Theme.of(context).colorScheme.onSurfaceVariant, ), ), const SizedBox(height: ChainSpace.sm), Align( alignment: Alignment.centerLeft, child: FilledButton.tonalIcon( onPressed: _suggestBusy ? null : _suggest, icon: _suggestBusy ? const SizedBox( width: 14, height: 14, child: CircularProgressIndicator(strokeWidth: 2), ) : const Icon(Icons.auto_awesome, size: 18), label: Text(l.setupFreeTextSuggest), ), ), ] else _hintRow(l.setupFreeTextUnavailable), ], ], ); } /// True when the configured system AI runs on this machine — the /// privacy line says so instead of naming a cloud provider. bool _aiIsLocal() { final s = _aiStatus; if (s == null) return false; return s.provider == 'ollama' || s.endpoint.contains('localhost') || s.endpoint.contains('127.0.0.1'); } /// Editable reflection of the AI suggestion: "this is how I read /// your task", in the same localized labels the menu uses. The /// operator either adjusts (steps, pre-selected) or goes straight /// to the same preview the menu path uses. Nothing auto-applies. Widget _reflectionView(AppLocalizations l) { final theme = Theme.of(context); String labelFor(List<_Option> opts, String value) => opts.firstWhere((o) => o.value == value, orElse: () => opts.first) .label(l); final lines = [ l.setupReflectionScenario(labelFor(_scenarios, _scenario)), l.setupReflectionIntent(labelFor(_intents, _intent)), l.setupReflectionTarget(labelFor(_targets, _target)), if (_requireApproval) l.setupReflectionApproval, if (_dataLocal) l.setupReflectionDataLocal, ]; return Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ for (final line in lines) Padding( padding: const EdgeInsets.only(bottom: 6), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text('· ', style: theme.textTheme.bodyMedium), Expanded( child: Text(line, style: theme.textTheme.bodyMedium), ), ], ), ), const SizedBox(height: ChainSpace.xs), Text( l.setupReflectionEditHint, style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), ], ); } Widget _reviewStep(AppLocalizations l) { final plan = _plan; 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) ..._appliedSteps(l, plan) else ...[ for (final line in lines) Padding( padding: const EdgeInsets.only(bottom: 6), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text('· ', style: theme.textTheme.bodyMedium), Expanded( child: Text(line, style: theme.textTheme.bodyMedium), ), ], ), ), // 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( crossAxisAlignment: CrossAxisAlignment.start, children: [ Icon(Icons.info_outline, size: 16, color: theme.colorScheme.tertiary), const SizedBox(width: 6), Expanded( child: Text( l.setupPlanOverwriteWarn, style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.tertiary, ), ), ), ], ), ], ], ], ); } /// 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. On a signature-strict plan the path // stays clickable: add a signed source (the stores dialog with // its pin-a-key field), then install — no terminal, no dead end. if (modules.isNotEmpty) ...[ if (airGapped) _hintRow(l.setupModulesOfflineHint) else ...[ if (sigGate) ...[ _hintRow(l.setupModulesSignedHint), _hintRow(l.setupTrustedPublishersHint), _actionRow( OutlinedButton.icon( onPressed: () => ChainStoresDialog.show(context), icon: const Icon(Icons.add_moderator_outlined, size: 18), label: Text(l.setupAddSignedSource), ), ), ], 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: () { _close(); widget.shell?.navigateTo('flows'); }, icon: const Icon(Icons.account_tree_outlined, size: 18), label: Text(l.setupActionOpenFlow(flow)), ), ), // Honesty: warnings the apply emitted (e.g. the empty // trusted-publishers caveat), selectable so they can go // verbatim into a compliance note. if (_applyWarnings.isNotEmpty) ...[ const SizedBox(height: ChainSpace.sm), Text(l.setupApplyNotes, style: theme.textTheme.labelLarge), const SizedBox(height: 4), SelectableText( _applyWarnings, style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), ], ]; } 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.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, ), ), ), ], ), ); /// Build the localized plain-language plan lines from the structured /// SetupPlan (never the CLI prose). List _planLines(AppLocalizations l, Map plan) { final profile = plan['profile'] as String? ?? ''; final lines = []; switch (profile) { case 'air-gapped': lines.add(l.setupPlanIntroAirgapped); case 'enterprise': lines.add(l.setupPlanIntroEnterprise); default: lines.add(l.setupPlanIntroDev); } // Where this actually lands: the wizard always configures the // machine Studio runs on — say so, especially when the operator // picked a server/container target. switch (plan['runbook'] as String? ?? '') { case 'service': lines.add(l.setupPlanRunbookService); case 'air-gap-transfer': lines.add(l.setupPlanRunbookAirgap); case 'container': lines.add(l.setupPlanRunbookContainer); default: lines.add(l.setupPlanRunbookLocal); } if (plan['require_signatures'] == true) lines.add(l.setupPlanSignatures); // The audit chain is always on for regulated profiles — the // scenario card promises a tamper-evident log, so the preview // states what is delivered (and the WORM line covers the rest). if (profile == 'enterprise' || profile == 'air-gapped') { lines.add(l.setupPlanAuditChain); } if (plan['worm_audit'] == true) lines.add(l.setupPlanWorm); if (plan['approval_step'] == true) lines.add(l.setupPlanApproval); final modules = (plan['modules'] as List?)?.cast() ?? const []; if (modules.isNotEmpty) lines.add(l.setupPlanModules(modules.join(', '))); final flow = plan['starter_flow'] as String? ?? ''; if (flow.isNotEmpty) lines.add(l.setupPlanFlow(flow)); final docs = (plan['curated_docs'] as List?)?.length ?? 0; if (docs > 0) lines.add(l.setupPlanDocs(docs)); lines.add(l.setupPlanFileChanged('~/.chain/config.yaml')); return lines; } List _actions(AppLocalizations l) { if (_busy) { return const [ Padding( padding: EdgeInsets.all(12), child: SizedBox( width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2), ), ), ]; } if (_applied) { return [ FilledButton( onPressed: _close, child: Text(l.guidedSetupClose), ), ]; } if (_showReflection && _step < _totalSteps) { // AI reflection: adjust step-by-step (answers stay pre-selected) // or continue to the same preview the menu path uses. return [ TextButton( onPressed: () => setState(() => _showReflection = false), child: Text(l.setupReflectionAdjust), ), FilledButton( onPressed: () { setState(() => _showReflection = false); _goReview(); }, child: Text(l.setupReflectionToPreview), ), ]; } if (_step < _totalSteps) { return [ TextButton( onPressed: _step == 0 ? _close : () => setState(() => _step -= 1), child: Text(_step == 0 ? l.guidedSetupCancel : l.guidedSetupBack), ), FilledButton( onPressed: _step < _totalSteps - 1 ? () => setState(() => _step += 1) : _goReview, child: Text(l.guidedSetupNext), ), ]; } // Review step. return [ TextButton( onPressed: () => setState(() => _step = _totalSteps - 1), child: Text(l.guidedSetupBack), ), FilledButton(onPressed: _apply, child: Text(l.guidedSetupApply)), ]; } } /// A selectable card: title + one-line explanation, highlighted when /// selected. Replaces the bare dropdowns so every choice explains /// itself in place (zero-learning-curve). class _OptionCard extends StatelessWidget { final String title; final String subtitle; final bool selected; final VoidCallback onTap; const _OptionCard({ required this.title, required this.subtitle, required this.selected, required this.onTap, }); @override Widget build(BuildContext context) { final theme = Theme.of(context); final accent = theme.colorScheme.primary; return Padding( padding: const EdgeInsets.only(bottom: ChainSpace.sm), child: Material( color: selected ? accent.withValues(alpha: 0.10) : theme.colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(ChainRadius.md), child: InkWell( borderRadius: BorderRadius.circular(ChainRadius.md), onTap: onTap, child: Container( padding: const EdgeInsets.symmetric( horizontal: ChainSpace.md, vertical: ChainSpace.sm, ), decoration: BoxDecoration( borderRadius: BorderRadius.circular(ChainRadius.md), border: Border.all( color: selected ? accent : theme.colorScheme.outlineVariant, width: selected ? 1.5 : 1, ), ), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Icon( selected ? Icons.radio_button_checked : Icons.radio_button_unchecked, size: 18, color: selected ? accent : theme.colorScheme.onSurfaceVariant, ), const SizedBox(width: ChainSpace.sm), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( title, style: theme.textTheme.titleSmall?.copyWith( fontWeight: FontWeight.w600, ), ), const SizedBox(height: 2), Text( subtitle, style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), ], ), ), ], ), ), ), ), ); } }