Some checks are pending
Security / Security check (push) Waiting to run
Stefan's live findings, all four addressed at the root:
- 'In 3 Fragen loslegen' read like ad copy → the entry is now plainly
'Einrichtung starten' / 'Start setup'.
- The setup button sat permanently on the Welcome page of a running
app ('setup after the app runs is backwards' — reported twice). A
fresh install now starts INSIDE the setup: SetupGateScreen hosts
the wizard embedded as the page (new embedded/onFinished modes on
GuidedSetupDialog), with an explicit 'Später einrichten' skip.
Welcome loses the setup button entirely and stays a calm intro.
- Re-running the setup later lives in Settings → General ('Run setup
again…'), the single post-first-run home.
- 'You must grant access first and only then see what will be done':
the preview used to spawn the chain CLI, whose first run could pop
the macOS permission prompt BEFORE the plan was ever shown. The
preview now calls the new PlanSetup RPC over the live hub
connection (no subprocess, nothing granted); the CLI remains only
a fallback when no hub is reachable — and applying stays the
explicit, separate step.
Widget tests: gate hosts the wizard + skip/cancel leave it; CLI-path
tests drive the fallback through the new hub-preview test seam.
Suite 76 green, analyze clean.
Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
1241 lines
44 KiB
Dart
1241 lines
44 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/friendly_error.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;
|
|
|
|
/// Test seam: replaces the hub-first plan preview (a live gRPC
|
|
/// call) so widget tests can drive the CLI fallback — or return a
|
|
/// canned plan — without a hub.
|
|
@visibleForTesting
|
|
static Future<Map<String, dynamic>> Function()? debugPlanViaHubOverride;
|
|
|
|
/// Render as page content instead of an [AlertDialog] — used by
|
|
/// the first-run gate, where the wizard IS the screen.
|
|
final bool embedded;
|
|
|
|
/// Called instead of popping a dialog route when the wizard is
|
|
/// [embedded] (the gate owns what comes next). Also invoked after
|
|
/// a completed setup so the gate can enter the app.
|
|
final VoidCallback? onFinished;
|
|
|
|
const GuidedSetupDialog({
|
|
super.key,
|
|
this.shell,
|
|
this.debugPlan,
|
|
this.debugApplied = false,
|
|
this.debugSkipAiProbe = false,
|
|
this.embedded = false,
|
|
this.onFinished,
|
|
});
|
|
|
|
static Future<void> show(BuildContext context) {
|
|
final shell = StudioShellState.of(context);
|
|
// Not barrier-dismissible: a stray click outside the dialog must
|
|
// not throw away a half-answered wizard (and error SnackBars used
|
|
// to lure exactly that click — the operator aimed for the message
|
|
// below the barrier and lost everything). Leaving is explicit:
|
|
// Abbrechen on step 1, Zurück everywhere else.
|
|
return showDialog<void>(
|
|
context: context,
|
|
barrierDismissible: false,
|
|
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;
|
|
|
|
String _writeAnswers() {
|
|
// Sync on purpose: the file is a handful of lines, and the async
|
|
// dart:io variants never complete inside the fake-async zone
|
|
// widget tests run in — the wizard would hang there forever.
|
|
_answersDir ??= Directory.systemTemp.createTempSync('chain-setup-');
|
|
final f = File(
|
|
'${_answersDir!.path}${Platform.pathSeparator}answers.yaml',
|
|
);
|
|
f.writeAsStringSync(_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;
|
|
});
|
|
// Preview order matters for trust: try the LIVE HUB first — a
|
|
// pure PlanSetup RPC over the existing connection, no subprocess.
|
|
// The CLI fallback (hub down, first run without a daemon) spawns
|
|
// `chain`, which on macOS can be the app's very first file-system
|
|
// touch and pop a permission prompt; that must never happen
|
|
// BEFORE the operator has seen the plan when a hub is available.
|
|
try {
|
|
final plan = GuidedSetupDialog.debugPlanViaHubOverride != null
|
|
? await GuidedSetupDialog.debugPlanViaHubOverride!()
|
|
: await HubService.instance.planSetup(
|
|
scenario: _scenario,
|
|
intent: _intent,
|
|
target: _target,
|
|
requireApproval: _requireApproval,
|
|
dataMustStayLocal: _dataLocal,
|
|
allowUnsignedModules: _allowUnsigned,
|
|
);
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_busy = false;
|
|
_plan = plan;
|
|
_step = _totalSteps; // review
|
|
});
|
|
return;
|
|
} catch (_) {
|
|
// Hub unreachable / RPC unavailable — fall through to the CLI.
|
|
}
|
|
final path = _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 (_) {
|
|
await _showCliError('chain init --plan-json', r.stdout, r.stderr);
|
|
}
|
|
} else {
|
|
await _showCliError('chain init --plan-json', r.stdout, r.stderr);
|
|
}
|
|
}
|
|
|
|
/// Surface a failed `chain init` call as a modal dialog stacked
|
|
/// ABOVE this wizard. A SnackBar would land BEHIND the wizard's
|
|
/// modal barrier: visible but dimmed, its copy button unreachable —
|
|
/// and the operator's attempt to click it hits the barrier instead.
|
|
/// Known failure shapes get a localized plain-language headline;
|
|
/// the verbatim CLI output stays copyable behind "Details".
|
|
Future<void> _showCliError(String source, String stdout, String stderr) {
|
|
final l = AppLocalizations.of(context)!;
|
|
final raw = [
|
|
stderr.trim(),
|
|
stdout.trim(),
|
|
].where((s) => s.isNotEmpty).join('\n\n');
|
|
final bin = SystemActions.resolvedChainBinary();
|
|
final FriendlyError friendly;
|
|
if (stderr.trim() == kFaiBinaryNotFound) {
|
|
// Sentinel, never shown verbatim (see SystemActions docs).
|
|
friendly = FriendlyError(
|
|
headline: l.setupNoBinary,
|
|
detail: '',
|
|
hint: l.setupNoBinaryHint,
|
|
);
|
|
} else if (raw.contains('unexpected argument')) {
|
|
// Version skew: this Studio speaks a newer `chain init` dialect
|
|
// than the binary it found (e.g. a stale build behind a channel
|
|
// symlink). Name the binary and the way out instead of leaking
|
|
// a raw clap usage error.
|
|
friendly = FriendlyError(
|
|
headline: l.setupCliTooOld,
|
|
detail: raw,
|
|
hint: l.setupCliTooOldHint(bin ?? 'chain'),
|
|
);
|
|
} else {
|
|
friendly = FriendlyError(
|
|
headline: source == 'chain init --apply'
|
|
? l.setupApplyFailed
|
|
: l.setupPreviewFailed,
|
|
detail: raw.isEmpty ? 'process exited non-zero (no output)' : raw,
|
|
hint: bin == null ? null : l.setupCliUsedBinary(bin),
|
|
);
|
|
}
|
|
return showChainErrorDialog(context, source, friendly);
|
|
}
|
|
|
|
/// Physical location of the resolved `chain` binary (symlinks
|
|
/// followed) — the path that decides whether macOS shows a folder-
|
|
/// permission prompt when Studio executes it, regardless of how
|
|
/// harmless the symlink's own path looks.
|
|
String? _physicalChainBinary() {
|
|
final bin = SystemActions.resolvedChainBinary();
|
|
if (bin == null) return null;
|
|
try {
|
|
return File(bin).resolveSymbolicLinksSync();
|
|
} on FileSystemException {
|
|
return bin;
|
|
}
|
|
}
|
|
|
|
/// True when executing the resolved binary can trigger a macOS
|
|
/// folder-permission (TCC) prompt because it physically lives in a
|
|
/// protected folder. The wizard says so BEFORE the first exec —
|
|
/// an unexplained "Studio wants access to Documents" prompt in the
|
|
/// middle of setup reads as a trust break.
|
|
bool _binaryNeedsFolderPermission() {
|
|
if (!Platform.isMacOS) return false;
|
|
final physical = _physicalChainBinary();
|
|
final home = Platform.environment['HOME'];
|
|
if (physical == null || home == null || home.isEmpty) return false;
|
|
return physical.startsWith('$home/Documents/') ||
|
|
physical.startsWith('$home/Desktop/') ||
|
|
physical.startsWith('$home/Downloads/');
|
|
}
|
|
|
|
/// 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 = _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 {
|
|
await _showCliError('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));
|
|
}
|
|
}
|
|
|
|
/// Leave the wizard: pop the dialog route, or — embedded full
|
|
/// screen in the first-run gate, where there is no route to pop —
|
|
/// hand control back to the gate.
|
|
void _close() {
|
|
final onFinished = widget.onFinished;
|
|
if (onFinished != null) {
|
|
onFinished();
|
|
} else {
|
|
Navigator.of(context).pop();
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l = AppLocalizations.of(context)!;
|
|
final reviewing = _step >= _totalSteps;
|
|
final title = reviewing
|
|
? l.setupReviewTitle
|
|
: _showReflection
|
|
? l.setupReflectionTitle
|
|
: _stepTitle(l);
|
|
final body = SingleChildScrollView(
|
|
child: reviewing
|
|
? _reviewStep(l)
|
|
: _showReflection
|
|
? _reflectionView(l)
|
|
: _answerStep(l),
|
|
);
|
|
if (widget.embedded) {
|
|
// First-run gate: same content as the dialog, hosted as a
|
|
// full page instead of a modal over an app that isn't set
|
|
// up yet ("setup after the app runs" reads backwards).
|
|
final theme = Theme.of(context);
|
|
return Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(title, style: theme.textTheme.headlineSmall),
|
|
const SizedBox(height: ChainSpace.md),
|
|
Flexible(child: body),
|
|
const SizedBox(height: ChainSpace.md),
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.end,
|
|
children: [
|
|
for (final a in _actions(l)) ...[
|
|
const SizedBox(width: ChainSpace.sm),
|
|
a,
|
|
],
|
|
],
|
|
),
|
|
],
|
|
);
|
|
}
|
|
return AlertDialog(
|
|
title: Text(title),
|
|
content: SizedBox(
|
|
width: 480,
|
|
child: body,
|
|
),
|
|
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),
|
|
),
|
|
// Exec transparency: the next click runs the `chain` binary.
|
|
// Say which one — and warn when its physical location will
|
|
// make macOS ask for folder access, so the prompt (if any)
|
|
// arrives explained instead of as a trust break.
|
|
const SizedBox(height: ChainSpace.sm),
|
|
if (!SystemActions.chainBinaryExists())
|
|
_hintRow(l.setupNoBinaryStepHint)
|
|
else ...[
|
|
_hintRow(l.setupExecHint(SystemActions.resolvedChainBinary()!)),
|
|
if (_binaryNeedsFolderPermission())
|
|
_hintRow(l.setupExecHintTcc(_physicalChainBinary()!)),
|
|
],
|
|
],
|
|
// 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: () {
|
|
_close();
|
|
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: _close,
|
|
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
|
|
? _close
|
|
: () => 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,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|