// 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/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 /// 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; 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(); } 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 = {}; @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' 'allow_unsigned_modules: $_allowUnsigned\n'; Future _writeAnswers() async { final f = File('${Directory.systemTemp.path}/chain-setup-answers.yaml'); await f.writeAsString(_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; }); final path = await _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 (_) { showFaiProcessError(context, 'chain init --plan-json', r.stdout, r.stderr); } } else { showFaiProcessError(context, 'chain init --plan-json', r.stdout, r.stderr); } } Future _apply() async { setState(() => _busy = true); final path = await _writeAnswers(); final r = await SystemActions.chainInit( ['--answers', path, '--apply', '--force'], ); if (!mounted) return; 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)!; final reviewing = _step >= _totalSteps; return AlertDialog( title: Text(reviewing ? l.setupReviewTitle : _stepTitle(l)), content: SizedBox( width: 480, child: SingleChildScrollView( child: reviewing ? _reviewStep(l) : _answerStep(l), ), ), 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), ), ], ], ); } 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. 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.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 lines = []; switch (plan['profile'] as String? ?? '') { case 'air-gapped': lines.add(l.setupPlanIntroAirgapped); case 'enterprise': lines.add(l.setupPlanIntroEnterprise); default: lines.add(l.setupPlanIntroDev); } if (plan['require_signatures'] == true) lines.add(l.setupPlanSignatures); 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)); 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: () => Navigator.of(context).pop(), child: Text(l.guidedSetupClose), ), ]; } if (_step < _totalSteps) { return [ TextButton( onPressed: _step == 0 ? () => Navigator.of(context).pop() : () => 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, ), ), ], ), ), ], ), ), ), ), ); } }