chain-studio/lib/widgets/guided_setup_dialog.dart
flemming-it ddac84ce8e feat: guided setup — persona re-audit fixes (grade-1 round)
- Regulated path finishes without a terminal: the signed-source
  state offers 'Add a signed source…' (stores dialog with pin-a-key)
  plus the per-module install buttons and a plain-language hint why
  pinning the publisher's key matters — instead of a hint with no
  affordance.
- Apply warnings (e.g. the empty-trusted-publishers caveat) surface
  selectable in the done state instead of being swallowed.
- Truthful preview: new lines state which machine is being set up
  (server/container targets configure THIS machine), that regulated
  profiles always get the hash-chained audit log (even with WORM
  off), and that the curated reading list is stored with the setup
  record.
- Language pass: onboarding checklist in Sie-form + 'System-KI'
  (was du-form + 'System-AI'), 'Audit-Sperre' jargon replaced,
  answers file moved to a private per-dialog temp dir.
- Screenshot harness: GUIDE_SHOTS_THEME=light for light-parity
  proof runs.

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
2026-07-13 01:20:59 +02:00

1065 lines
37 KiB
Dart

// 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';
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<String, dynamic>? 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<String, dynamic>) 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<String, dynamic>? 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;
const GuidedSetupDialog({
super.key,
this.shell,
this.debugPlan,
this.debugApplied = false,
this.debugSkipAiProbe = false,
});
static Future<void> show(BuildContext context) {
final shell = StudioShellState.of(context);
return showDialog<void>(
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<GuidedSetupDialog> createState() => _GuidedSetupDialogState();
}
class _GuidedSetupDialogState extends State<GuidedSetupDialog> {
// 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<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 = {};
// 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<void> _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;
Future<String> _writeAnswers() async {
_answersDir ??= await Directory.systemTemp.createTemp('chain-setup-');
final f = File(
'${_answersDir!.path}${Platform.pathSeparator}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<void> _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<String, dynamic>;
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);
}
}
/// 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<void> _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) {
final warnings = r.stderr
.split('\n')
.where((line) => line.toLowerCase().contains('warn'))
.join('\n')
.trim();
setState(() {
_applied = true;
_applyWarnings = warnings;
});
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,
);
}
}
/// 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<void> _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<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)!;
final reviewing = _step >= _totalSteps;
final title = reviewing
? l.setupReviewTitle
: _showReflection
? l.setupReflectionTitle
: _stepTitle(l);
return AlertDialog(
title: Text(title),
content: SizedBox(
width: 480,
child: SingleChildScrollView(
child: reviewing
? _reviewStep(l)
: _showReflection
? _reflectionView(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),
),
],
// 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 = <String>[
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<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)
..._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<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. 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: () {
Navigator.of(context).pop();
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<String> _planLines(AppLocalizations l, Map<String, dynamic> plan) {
final profile = plan['profile'] as String? ?? '';
final lines = <String>[];
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<String>() ?? 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<Widget> _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 (_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
? () => 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,
),
),
],
),
),
],
),
),
),
),
);
}
}