feat(studio): guided-setup wizard (reuses chain init engine)
A Welcome 'Setup assistant' button opens a wizard that collects scenario / intent / target (+ approval & data-local toggles), then calls `chain init --answers` to preview the assembled plan and `--apply --force` to write the config — reusing the Rust deterministic engine, no logic duplicated. New SystemActions.chainInit; copyable errors via showFaiProcessError; EN+DE l10n. analyze clean; smoke test + existing welcome/sidebar tests pass. Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
parent
36a8252f67
commit
c9fa068991
9 changed files with 509 additions and 0 deletions
257
lib/widgets/guided_setup_dialog.dart
Normal file
257
lib/widgets/guided_setup_dialog.dart
Normal file
|
|
@ -0,0 +1,257 @@
|
|||
// Guided-setup wizard. Collects scenario / intent / target (plus two
|
||||
// adaptive toggles), then calls `chain init --answers` to PREVIEW the
|
||||
// assembled plan and `--apply` to write the config. All the policy lives
|
||||
// in the Rust deterministic engine (chain_core::guided_setup) — this is
|
||||
// a thin UI over the CLI, so the logic never diverges.
|
||||
//
|
||||
// NOTE: the dropdown option labels are humanised from the canonical
|
||||
// kebab-case enum values (trying-out → "Trying Out"); localising those
|
||||
// per option is a follow-up. The structural UI + the applied plan
|
||||
// (rendered from the CLI's localisable output) are the meaningful parts.
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../data/error_presentation.dart';
|
||||
import '../data/system_actions.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
|
||||
class GuidedSetupDialog extends StatefulWidget {
|
||||
const GuidedSetupDialog({super.key});
|
||||
|
||||
/// Launch the wizard as a modal dialog.
|
||||
static Future<void> show(BuildContext context) => showDialog<void>(
|
||||
context: context,
|
||||
builder: (_) => const GuidedSetupDialog(),
|
||||
);
|
||||
|
||||
@override
|
||||
State<GuidedSetupDialog> createState() => _GuidedSetupDialogState();
|
||||
}
|
||||
|
||||
class _GuidedSetupDialogState extends State<GuidedSetupDialog> {
|
||||
// Canonical SetupAnswers enum values (serde kebab-case in Rust).
|
||||
static const _scenarios = <String>[
|
||||
'trying-out',
|
||||
'team-hub',
|
||||
'regulated-production',
|
||||
'building-modules',
|
||||
];
|
||||
static const _intents = <String>[
|
||||
'hello-world',
|
||||
'extract-text',
|
||||
'extract-summarize',
|
||||
'classify-documents',
|
||||
'build-module',
|
||||
];
|
||||
static const _targets = <String>[
|
||||
'this-laptop',
|
||||
'home-server',
|
||||
'air-gapped-server',
|
||||
'container',
|
||||
];
|
||||
|
||||
int _step = 0; // 0 = answers, 1 = review/apply
|
||||
String _scenario = 'trying-out';
|
||||
String _intent = 'extract-summarize';
|
||||
String _target = 'this-laptop';
|
||||
bool _requireApproval = false;
|
||||
bool _dataLocal = false;
|
||||
bool _busy = false;
|
||||
String? _preview;
|
||||
String? _applied;
|
||||
|
||||
String _humanize(String kebab) => kebab
|
||||
.split('-')
|
||||
.map((w) => w.isEmpty ? w : '${w[0].toUpperCase()}${w.substring(1)}')
|
||||
.join(' ');
|
||||
|
||||
String _answersYaml() => 'scenario: $_scenario\n'
|
||||
'intent: $_intent\n'
|
||||
'target: $_target\n'
|
||||
'require_approval: $_requireApproval\n'
|
||||
'data_must_stay_local: $_dataLocal\n';
|
||||
|
||||
Future<String> _writeAnswers() async {
|
||||
final f = File('${Directory.systemTemp.path}/chain-setup-answers.yaml');
|
||||
await f.writeAsString(_answersYaml());
|
||||
return f.path;
|
||||
}
|
||||
|
||||
Future<void> _goReview() async {
|
||||
setState(() {
|
||||
_busy = true;
|
||||
_preview = null;
|
||||
});
|
||||
final path = await _writeAnswers();
|
||||
final r = await SystemActions.chainInit(['--answers', path]);
|
||||
if (!mounted) return;
|
||||
setState(() => _busy = false);
|
||||
if (r.ok) {
|
||||
setState(() {
|
||||
_preview = r.stdout.trim();
|
||||
_step = 1;
|
||||
});
|
||||
} else {
|
||||
showFaiProcessError(context, 'chain init --answers', 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 = r.stdout.trim());
|
||||
} else {
|
||||
showFaiProcessError(context, 'chain init --apply', r.stdout, r.stderr);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l = AppLocalizations.of(context)!;
|
||||
return AlertDialog(
|
||||
title: Text(l.guidedSetupTitle),
|
||||
content: SizedBox(
|
||||
width: 460,
|
||||
child: SingleChildScrollView(
|
||||
child: _step == 0 ? _answersStep(l) : _reviewStep(l),
|
||||
),
|
||||
),
|
||||
actions: _actions(l),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _answersStep(AppLocalizations l) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(l.guidedSetupIntro),
|
||||
const SizedBox(height: 16),
|
||||
_dropdown(l.guidedSetupScenario, _scenarios, _scenario,
|
||||
(v) => setState(() => _scenario = v)),
|
||||
const SizedBox(height: 12),
|
||||
_dropdown(l.guidedSetupIntent, _intents, _intent,
|
||||
(v) => setState(() => _intent = v)),
|
||||
const SizedBox(height: 12),
|
||||
_dropdown(l.guidedSetupTarget, _targets, _target,
|
||||
(v) => setState(() => _target = v)),
|
||||
const SizedBox(height: 8),
|
||||
SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: Text(l.guidedSetupApproval),
|
||||
value: _requireApproval,
|
||||
onChanged: (v) => setState(() => _requireApproval = v),
|
||||
),
|
||||
SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: Text(l.guidedSetupDataLocal),
|
||||
value: _dataLocal,
|
||||
onChanged: (v) => setState(() => _dataLocal = v),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _dropdown(
|
||||
String label,
|
||||
List<String> items,
|
||||
String value,
|
||||
ValueChanged<String> onChanged,
|
||||
) {
|
||||
return InputDecorator(
|
||||
decoration: InputDecoration(
|
||||
labelText: label,
|
||||
border: const OutlineInputBorder(),
|
||||
isDense: true,
|
||||
),
|
||||
child: DropdownButtonHideUnderline(
|
||||
child: DropdownButton<String>(
|
||||
isExpanded: true,
|
||||
value: value,
|
||||
items: [
|
||||
for (final i in items)
|
||||
DropdownMenuItem<String>(value: i, child: Text(_humanize(i))),
|
||||
],
|
||||
onChanged: (v) {
|
||||
if (v != null) onChanged(v);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _reviewStep(AppLocalizations l) {
|
||||
final applied = _applied;
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(applied != null
|
||||
? l.guidedSetupApplied
|
||||
: l.guidedSetupReviewHeading),
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
// SelectableText keeps the plan copyable (project rule:
|
||||
// anything an operator might want to keep is selectable).
|
||||
child: SelectableText(
|
||||
applied ?? _preview ?? '',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
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 != null) {
|
||||
return [
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: Text(l.guidedSetupClose),
|
||||
),
|
||||
];
|
||||
}
|
||||
if (_step == 0) {
|
||||
return [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: Text(l.guidedSetupCancel),
|
||||
),
|
||||
FilledButton(onPressed: _goReview, child: Text(l.guidedSetupNext)),
|
||||
];
|
||||
}
|
||||
return [
|
||||
TextButton(
|
||||
onPressed: () => setState(() => _step = 0),
|
||||
child: Text(l.guidedSetupBack),
|
||||
),
|
||||
FilledButton(onPressed: _apply, child: Text(l.guidedSetupApply)),
|
||||
];
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue