diff --git a/lib/data/system_actions.dart b/lib/data/system_actions.dart index 24598a5..b17fdb8 100644 --- a/lib/data/system_actions.dart +++ b/lib/data/system_actions.dart @@ -143,6 +143,15 @@ class SystemActions { return _runFai(['daemon', ...args]); } + /// Run a `chain init ...` subcommand. Used by the guided-setup + /// wizard to preview a plan (`--answers `) and apply it + /// (`--answers --apply --force`). + static Future<({bool ok, String stdout, String stderr})> chainInit( + List args, + ) async { + return _runFai(['init', ...args]); + } + /// Run `chain update apply --channel `. Long-running on a slow /// network — caller should show a spinner. static Future<({bool ok, String stdout, String stderr})> chainUpdateApply( diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index d74a093..1c54747 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -1,5 +1,19 @@ { "@@locale": "de", + "guidedSetupTitle": "Setup-Assistent", + "guidedSetupIntro": "Beantworte drei Fragen — Ch∆In stellt passende Konfiguration, Modul-Set und Start-Flow zusammen.", + "guidedSetupScenario": "Szenario", + "guidedSetupIntent": "Aufgabe", + "guidedSetupTarget": "Wo läuft es?", + "guidedSetupApproval": "Menschlichen Freigabe-Schritt vor dem KI-Schritt verlangen", + "guidedSetupDataLocal": "Daten müssen lokal bleiben (air-gapped)", + "guidedSetupReviewHeading": "Diesen Plan wird Ch∆In anwenden:", + "guidedSetupApply": "Setup übernehmen", + "guidedSetupApplied": "Setup übernommen. Schließe mit den Schritten unten ab:", + "guidedSetupBack": "Zurück", + "guidedSetupNext": "Weiter", + "guidedSetupCancel": "Abbrechen", + "guidedSetupClose": "Schließen", "appTitle": "Ch∆In Studio", "navWelcome": "Willkommen", "welcomeHeroTitle": "Ch∆In Platform", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 2f5c0e1..55a1b58 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1,5 +1,19 @@ { "@@locale": "en", + "guidedSetupTitle": "Setup assistant", + "guidedSetupIntro": "Answer three questions and Ch∆In assembles a tailored config, module set and starter flow.", + "guidedSetupScenario": "Scenario", + "guidedSetupIntent": "Task", + "guidedSetupTarget": "Where will it run?", + "guidedSetupApproval": "Require a human approval step before the AI step", + "guidedSetupDataLocal": "Data must stay local (air-gapped posture)", + "guidedSetupReviewHeading": "This is the plan Ch∆In will apply:", + "guidedSetupApply": "Apply setup", + "guidedSetupApplied": "Setup applied. Finish with the steps below:", + "guidedSetupBack": "Back", + "guidedSetupNext": "Next", + "guidedSetupCancel": "Cancel", + "guidedSetupClose": "Close", "appTitle": "Ch∆In Studio", "@appTitle": { "description": "App-bar title in the OS task switcher." diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 5948825..31a683d 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -98,6 +98,90 @@ abstract class AppLocalizations { Locale('en'), ]; + /// No description provided for @guidedSetupTitle. + /// + /// In en, this message translates to: + /// **'Setup assistant'** + String get guidedSetupTitle; + + /// No description provided for @guidedSetupIntro. + /// + /// In en, this message translates to: + /// **'Answer three questions and Ch∆In assembles a tailored config, module set and starter flow.'** + String get guidedSetupIntro; + + /// No description provided for @guidedSetupScenario. + /// + /// In en, this message translates to: + /// **'Scenario'** + String get guidedSetupScenario; + + /// No description provided for @guidedSetupIntent. + /// + /// In en, this message translates to: + /// **'Task'** + String get guidedSetupIntent; + + /// No description provided for @guidedSetupTarget. + /// + /// In en, this message translates to: + /// **'Where will it run?'** + String get guidedSetupTarget; + + /// No description provided for @guidedSetupApproval. + /// + /// In en, this message translates to: + /// **'Require a human approval step before the AI step'** + String get guidedSetupApproval; + + /// No description provided for @guidedSetupDataLocal. + /// + /// In en, this message translates to: + /// **'Data must stay local (air-gapped posture)'** + String get guidedSetupDataLocal; + + /// No description provided for @guidedSetupReviewHeading. + /// + /// In en, this message translates to: + /// **'This is the plan Ch∆In will apply:'** + String get guidedSetupReviewHeading; + + /// No description provided for @guidedSetupApply. + /// + /// In en, this message translates to: + /// **'Apply setup'** + String get guidedSetupApply; + + /// No description provided for @guidedSetupApplied. + /// + /// In en, this message translates to: + /// **'Setup applied. Finish with the steps below:'** + String get guidedSetupApplied; + + /// No description provided for @guidedSetupBack. + /// + /// In en, this message translates to: + /// **'Back'** + String get guidedSetupBack; + + /// No description provided for @guidedSetupNext. + /// + /// In en, this message translates to: + /// **'Next'** + String get guidedSetupNext; + + /// No description provided for @guidedSetupCancel. + /// + /// In en, this message translates to: + /// **'Cancel'** + String get guidedSetupCancel; + + /// No description provided for @guidedSetupClose. + /// + /// In en, this message translates to: + /// **'Close'** + String get guidedSetupClose; + /// App-bar title in the OS task switcher. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 6ba161e..4c037c9 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -8,6 +8,51 @@ import 'app_localizations.dart'; class AppLocalizationsDe extends AppLocalizations { AppLocalizationsDe([String locale = 'de']) : super(locale); + @override + String get guidedSetupTitle => 'Setup-Assistent'; + + @override + String get guidedSetupIntro => + 'Beantworte drei Fragen — Ch∆In stellt passende Konfiguration, Modul-Set und Start-Flow zusammen.'; + + @override + String get guidedSetupScenario => 'Szenario'; + + @override + String get guidedSetupIntent => 'Aufgabe'; + + @override + String get guidedSetupTarget => 'Wo läuft es?'; + + @override + String get guidedSetupApproval => + 'Menschlichen Freigabe-Schritt vor dem KI-Schritt verlangen'; + + @override + String get guidedSetupDataLocal => 'Daten müssen lokal bleiben (air-gapped)'; + + @override + String get guidedSetupReviewHeading => 'Diesen Plan wird Ch∆In anwenden:'; + + @override + String get guidedSetupApply => 'Setup übernehmen'; + + @override + String get guidedSetupApplied => + 'Setup übernommen. Schließe mit den Schritten unten ab:'; + + @override + String get guidedSetupBack => 'Zurück'; + + @override + String get guidedSetupNext => 'Weiter'; + + @override + String get guidedSetupCancel => 'Abbrechen'; + + @override + String get guidedSetupClose => 'Schließen'; + @override String get appTitle => 'Ch∆In Studio'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index c1114a9..55b0a43 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -8,6 +8,52 @@ import 'app_localizations.dart'; class AppLocalizationsEn extends AppLocalizations { AppLocalizationsEn([String locale = 'en']) : super(locale); + @override + String get guidedSetupTitle => 'Setup assistant'; + + @override + String get guidedSetupIntro => + 'Answer three questions and Ch∆In assembles a tailored config, module set and starter flow.'; + + @override + String get guidedSetupScenario => 'Scenario'; + + @override + String get guidedSetupIntent => 'Task'; + + @override + String get guidedSetupTarget => 'Where will it run?'; + + @override + String get guidedSetupApproval => + 'Require a human approval step before the AI step'; + + @override + String get guidedSetupDataLocal => + 'Data must stay local (air-gapped posture)'; + + @override + String get guidedSetupReviewHeading => 'This is the plan Ch∆In will apply:'; + + @override + String get guidedSetupApply => 'Apply setup'; + + @override + String get guidedSetupApplied => + 'Setup applied. Finish with the steps below:'; + + @override + String get guidedSetupBack => 'Back'; + + @override + String get guidedSetupNext => 'Next'; + + @override + String get guidedSetupCancel => 'Cancel'; + + @override + String get guidedSetupClose => 'Close'; + @override String get appTitle => 'Ch∆In Studio'; diff --git a/lib/pages/welcome.dart b/lib/pages/welcome.dart index 6afa872..bffebee 100644 --- a/lib/pages/welcome.dart +++ b/lib/pages/welcome.dart @@ -19,6 +19,7 @@ import '../l10n/app_localizations.dart'; import '../main.dart' show StudioShellState; import '../theme/theme.dart'; import '../theme/tokens.dart'; +import '../widgets/guided_setup_dialog.dart'; import '../widgets/widgets.dart'; class WelcomePage extends StatelessWidget { @@ -59,6 +60,15 @@ class WelcomePage extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ const _Hero(), + const SizedBox(height: ChainSpace.md), + Align( + alignment: Alignment.centerLeft, + child: OutlinedButton.icon( + onPressed: () => GuidedSetupDialog.show(context), + icon: const Icon(Icons.auto_fix_high), + label: Text(AppLocalizations.of(context)!.guidedSetupTitle), + ), + ), const SizedBox(height: ChainSpace.xxl), // When the hub is down the onboarding checklist // would render dead (all-unchecked, nothing to diff --git a/lib/widgets/guided_setup_dialog.dart b/lib/widgets/guided_setup_dialog.dart new file mode 100644 index 0000000..94f7b8c --- /dev/null +++ b/lib/widgets/guided_setup_dialog.dart @@ -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 show(BuildContext context) => showDialog( + context: context, + builder: (_) => const GuidedSetupDialog(), + ); + + @override + State createState() => _GuidedSetupDialogState(); +} + +class _GuidedSetupDialogState extends State { + // Canonical SetupAnswers enum values (serde kebab-case in Rust). + static const _scenarios = [ + 'trying-out', + 'team-hub', + 'regulated-production', + 'building-modules', + ]; + static const _intents = [ + 'hello-world', + 'extract-text', + 'extract-summarize', + 'classify-documents', + 'build-module', + ]; + static const _targets = [ + '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 _writeAnswers() async { + final f = File('${Directory.systemTemp.path}/chain-setup-answers.yaml'); + await f.writeAsString(_answersYaml()); + return f.path; + } + + Future _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 _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 items, + String value, + ValueChanged onChanged, + ) { + return InputDecorator( + decoration: InputDecoration( + labelText: label, + border: const OutlineInputBorder(), + isDense: true, + ), + child: DropdownButtonHideUnderline( + child: DropdownButton( + isExpanded: true, + value: value, + items: [ + for (final i in items) + DropdownMenuItem(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 _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)), + ]; + } +} diff --git a/test/guided_setup_test.dart b/test/guided_setup_test.dart new file mode 100644 index 0000000..b6fa967 --- /dev/null +++ b/test/guided_setup_test.dart @@ -0,0 +1,30 @@ +// Smoke test for the guided-setup wizard: it mounts and renders its +// first step (three choice dropdowns) without touching the CLI. The +// `chain init` shell-out only happens on Next/Apply, so mounting is +// side-effect-free and safe to assert in a widget test. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:chain_studio/l10n/app_localizations.dart'; +import 'package:chain_studio/widgets/guided_setup_dialog.dart'; + +void main() { + testWidgets('guided setup wizard mounts and shows the first step', + (tester) async { + await tester.pumpWidget( + MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: const Scaffold(body: GuidedSetupDialog()), + ), + ); + await tester.pump(); + + // Title from l10n + the three scenario/intent/target dropdowns. + expect(find.text('Setup assistant'), findsWidgets); + expect(find.byType(DropdownButton), findsNWidgets(3)); + // The "Next" action is present (advances to the review/apply step). + expect(find.widgetWithText(FilledButton, 'Next'), findsOneWidget); + }); +}