feat: guided setup — clickable next steps, signature relaxation, fresh-install auto-open

Post-apply the wizard now renders real Studio actions instead of CLI
text: a start-hub button that polls until the daemon answers,
per-module install buttons (capability-name install via the hub's
store index) with done/progress states, and an open-the-starter-flow
button that navigates to the Flows page. Regulated plans explain in
plain language that modules come from a signed source; the preview
offers 'allow installing from the public store' as one deliberate,
reversible switch that re-assembles the plan (allow_unsigned_modules).

Fresh installs (no config, no setup-plan.yaml) auto-open the wizard
once per run — the wizard IS the onboarding — and it steps back once
a setup exists. The welcome CTA is framed honestly ('get started in
3 questions'), and after the wizard closes the onboarding checklist
remounts, re-probes, and says what the assistant already covered
(profile line from setup-plan.yaml) instead of acting as a second,
disconnected onboarding surface.

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
flemming-it 2026-07-12 23:18:41 +02:00
parent 140408d26a
commit 1f1e050b42
8 changed files with 704 additions and 51 deletions

View file

@ -9,14 +9,17 @@
// 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
@ -29,12 +32,74 @@ class _Option {
}
class GuidedSetupDialog extends StatefulWidget {
const GuidedSetupDialog({super.key});
/// 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;
static Future<void> show(BuildContext context) => showDialog<void>(
context: context,
builder: (_) => const GuidedSetupDialog(),
);
/// 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;
const GuidedSetupDialog({
super.key,
this.shell,
this.debugPlan,
this.debugApplied = 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();
@ -71,16 +136,37 @@ class _GuidedSetupDialogState extends State<GuidedSetupDialog> {
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 = {};
@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';
'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');
@ -131,11 +217,65 @@ class _GuidedSetupDialogState extends State<GuidedSetupDialog> {
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,
);
}
}
/// 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)!;
@ -210,26 +350,16 @@ class _GuidedSetupDialogState extends State<GuidedSetupDialog> {
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) ...[
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 ...[
if (_applied)
..._appliedSteps(l, plan)
else ...[
for (final line in lines)
Padding(
padding: const EdgeInsets.only(bottom: 6),
@ -243,6 +373,44 @@ class _GuidedSetupDialogState extends State<GuidedSetupDialog> {
],
),
),
// 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(
@ -266,13 +434,122 @@ class _GuidedSetupDialogState extends State<GuidedSetupDialog> {
);
}
Widget _next(String label) => Padding(
padding: const EdgeInsets.only(top: 4),
/// 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.arrow_right, size: 18, color: Theme.of(context).colorScheme.primary),
const SizedBox(width: 4),
Expanded(child: Text(label, style: Theme.of(context).textTheme.bodyMedium)),
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,
),
),
),
],
),
);