// 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:convert'; import 'dart:io'; import 'package:flutter/material.dart'; import '../data/error_presentation.dart'; import '../data/system_actions.dart'; import '../l10n/app_localizations.dart'; 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 { const GuidedSetupDialog({super.key}); static Future show(BuildContext context) => showDialog( context: context, builder: (_) => const GuidedSetupDialog(), ); @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 _busy = false; Map? _plan; // parsed SetupPlan from --plan-json bool _applied = false; String _answersYaml() => 'scenario: $_scenario\n' 'intent: $_intent\n' 'target: $_target\n' 'require_approval: $_requireApproval\n' 'data_must_stay_local: $_dataLocal\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); } else { showFaiProcessError(context, 'chain init --apply', r.stdout, r.stderr); } } @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); 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 ...[ 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), ), ], ), ), 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, ), ), ), ], ), ], ], ], ); } Widget _next(String label) => Padding( padding: const EdgeInsets.only(top: 4), 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)), ], ), ); /// 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, ), ), ], ), ), ], ), ), ), ), ); } }