The wizard's first step now offers 'or just describe what you want
to do': the goal goes to the configured system AI, which maps it
onto the menu answers (validated against strict enum whitelists —
a hallucinated value can never reach the engine). The suggestion
comes back as an editable plain-language reflection ('this is how I
read your task') the operator can adjust step-by-step or take to
the same preview/apply the menu path uses. Trust rules per
guided-setup.md: suggestion only (never auto-apply), a privacy line
states whether the description is processed locally or sent to a
provider, and without a configured system AI the section explains
that the menu always works — no dead end.
Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
991 lines
34 KiB
Dart
991 lines
34 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';
|
|
|
|
/// 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();
|
|
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';
|
|
|
|
Future<String> _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<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);
|
|
}
|
|
}
|
|
|
|
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) {
|
|
setState(() => _applied = true);
|
|
unawaited(_probeHub());
|
|
} else {
|
|
showFaiProcessError(context, 'chain init --apply', r.stdout, r.stderr);
|
|
}
|
|
}
|
|
|
|
/// Refresh the "is the hub reachable" signal driving the
|
|
/// post-apply action rows.
|
|
Future<void> _probeHub() async {
|
|
final up = await HubService.instance
|
|
.healthy()
|
|
.catchError((Object _) => false);
|
|
if (mounted) setState(() => _hubUp = up);
|
|
}
|
|
|
|
/// Start the local daemon, then poll until it answers (the start
|
|
/// command returns before the gRPC endpoint is up). A failed start
|
|
/// often just means "already running", so probe before reporting.
|
|
Future<void> _startHub() async {
|
|
setState(() => _startingHub = true);
|
|
final r = await SystemActions.chainDaemon(['start']);
|
|
var up = false;
|
|
for (var i = 0; i < 6 && !up; i++) {
|
|
await Future<void>.delayed(const Duration(milliseconds: 700));
|
|
up = await HubService.instance
|
|
.healthy()
|
|
.catchError((Object _) => false);
|
|
}
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_startingHub = false;
|
|
_hubUp = up;
|
|
});
|
|
if (!up) {
|
|
await showFaiProcessErrorDialog(
|
|
context,
|
|
'chain daemon start',
|
|
r.stdout,
|
|
r.stderr,
|
|
);
|
|
}
|
|
}
|
|
|
|
/// 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.
|
|
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<String> _planLines(AppLocalizations l, Map<String, dynamic> plan) {
|
|
final lines = <String>[];
|
|
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<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));
|
|
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,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|