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:
parent
140408d26a
commit
1f1e050b42
8 changed files with 704 additions and 51 deletions
|
|
@ -4,7 +4,7 @@
|
||||||
"errModuleDownloadHint": "Die Bundle-URL des Moduls ist nicht erreichbar (Statuscode unten). Der Hub läuft — URL/Host korrigieren oder anderen Store wählen.",
|
"errModuleDownloadHint": "Die Bundle-URL des Moduls ist nicht erreichbar (Statuscode unten). Der Hub läuft — URL/Host korrigieren oder anderen Store wählen.",
|
||||||
"storeFromSource": "aus {store}",
|
"storeFromSource": "aus {store}",
|
||||||
"guidedSetupTitle": "Setup-Assistent",
|
"guidedSetupTitle": "Setup-Assistent",
|
||||||
"guidedSetupIntro": "Beantworte drei Fragen — Ch∆In stellt passende Konfiguration, Modul-Set und Start-Flow zusammen.",
|
"guidedSetupIntro": "Beantworten Sie drei Fragen — Ch∆In stellt passende Konfiguration, Modul-Set und Beispiel-Flow zusammen.",
|
||||||
"guidedSetupScenario": "Szenario",
|
"guidedSetupScenario": "Szenario",
|
||||||
"guidedSetupIntent": "Aufgabe",
|
"guidedSetupIntent": "Aufgabe",
|
||||||
"guidedSetupTarget": "Wo läuft es?",
|
"guidedSetupTarget": "Wo läuft es?",
|
||||||
|
|
@ -1747,7 +1747,23 @@
|
||||||
"setupApplied": "Fertig — Ch∆In ist eingerichtet.",
|
"setupApplied": "Fertig — Ch∆In ist eingerichtet.",
|
||||||
"setupNextTitle": "Nächste Schritte",
|
"setupNextTitle": "Nächste Schritte",
|
||||||
"setupNextHubStart": "Hub starten",
|
"setupNextHubStart": "Hub starten",
|
||||||
"setupNextInstall": "Die Module installieren",
|
"setupHubRunning": "Der Hub läuft.",
|
||||||
"setupNextRunFlow": "Den Beispiel-Flow ausführen",
|
"setupStartHubFirst": "Starten Sie zuerst den Hub — dann lassen sich die Module hier installieren.",
|
||||||
"setupStartCta": "In 3 Fragen loslegen"
|
"setupActionInstall": "{module} installieren",
|
||||||
|
"@setupActionInstall": {"placeholders": {"module": {"type": "String"}}},
|
||||||
|
"setupActionInstalled": "{module} ist installiert.",
|
||||||
|
"@setupActionInstalled": {"placeholders": {"module": {"type": "String"}}},
|
||||||
|
"setupActionOpenFlow": "Beispiel-Flow „{flow}“ öffnen",
|
||||||
|
"@setupActionOpenFlow": {"placeholders": {"flow": {"type": "String"}}},
|
||||||
|
"setupModulesOfflineHint": "Die Module kommen ohne Internet als Offline-Paket auf den Server — die Anleitung dazu steht in der Dokumentation unter „Betrieb ohne Internet“.",
|
||||||
|
"setupModulesSignedHint": "Die Module kommen aus einer eigenen, signierten Quelle — nicht aus dem öffentlichen Store.",
|
||||||
|
"setupSigPublicStoreNotice": "Diese Einrichtung lässt nur signierte Module zu. Der öffentliche Store liefert zurzeit unsignierte Pakete — die vorbereiteten Module kommen daher aus einer eigenen, signierten Quelle.",
|
||||||
|
"setupAllowUnsigned": "Installation aus dem öffentlichen Store erlauben",
|
||||||
|
"setupAllowUnsignedSub": "Lockert die Signaturpflicht bewusst. Gut zum Ausprobieren — für den regulierten Betrieb später wieder aktivieren.",
|
||||||
|
"setupStartCta": "In 3 Fragen loslegen",
|
||||||
|
"setupProfileDev": "unkomplizierter Arbeitsplatz",
|
||||||
|
"setupProfileEnterprise": "abgesicherter Team-Betrieb",
|
||||||
|
"setupProfileAirgapped": "vollständig lokaler, regulierter Betrieb",
|
||||||
|
"welcomeChecklistSetupDone": "Die Grundeinrichtung ({profile}) hat der Einrichtungs-Assistent bereits erledigt — hier sehen Sie, was auf dem Hub schon passiert ist.",
|
||||||
|
"@welcomeChecklistSetupDone": {"placeholders": {"profile": {"type": "String"}}}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1786,7 +1786,23 @@
|
||||||
"setupApplied": "Done — Ch∆In is set up.",
|
"setupApplied": "Done — Ch∆In is set up.",
|
||||||
"setupNextTitle": "Next steps",
|
"setupNextTitle": "Next steps",
|
||||||
"setupNextHubStart": "Start the hub",
|
"setupNextHubStart": "Start the hub",
|
||||||
"setupNextInstall": "Install the modules",
|
"setupHubRunning": "The hub is running.",
|
||||||
"setupNextRunFlow": "Run the example flow",
|
"setupStartHubFirst": "Start the hub first — then the modules can be installed right here.",
|
||||||
"setupStartCta": "Get started in 3 questions"
|
"setupActionInstall": "Install {module}",
|
||||||
|
"@setupActionInstall": {"placeholders": {"module": {"type": "String"}}},
|
||||||
|
"setupActionInstalled": "{module} is installed.",
|
||||||
|
"@setupActionInstalled": {"placeholders": {"module": {"type": "String"}}},
|
||||||
|
"setupActionOpenFlow": "Open the “{flow}” example flow",
|
||||||
|
"@setupActionOpenFlow": {"placeholders": {"flow": {"type": "String"}}},
|
||||||
|
"setupModulesOfflineHint": "The modules arrive as an offline bundle, without internet — the documentation explains this under “running without internet”.",
|
||||||
|
"setupModulesSignedHint": "The modules come from your own, signed source — not from the public store.",
|
||||||
|
"setupSigPublicStoreNotice": "This setup only accepts signed modules. The public store currently ships unsigned bundles — the prepared modules therefore come from your own, signed source.",
|
||||||
|
"setupAllowUnsigned": "Allow installing from the public store",
|
||||||
|
"setupAllowUnsignedSub": "Deliberately relaxes the signature requirement. Fine for trying things out — re-enable it for regulated operation.",
|
||||||
|
"setupStartCta": "Get started in 3 questions",
|
||||||
|
"setupProfileDev": "straightforward workstation",
|
||||||
|
"setupProfileEnterprise": "hardened team operation",
|
||||||
|
"setupProfileAirgapped": "fully local, regulated operation",
|
||||||
|
"welcomeChecklistSetupDone": "The setup assistant already took care of the basics ({profile}) — this list shows what has actually happened on your hub.",
|
||||||
|
"@welcomeChecklistSetupDone": {"placeholders": {"profile": {"type": "String"}}}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5329,23 +5329,95 @@ abstract class AppLocalizations {
|
||||||
/// **'Start the hub'**
|
/// **'Start the hub'**
|
||||||
String get setupNextHubStart;
|
String get setupNextHubStart;
|
||||||
|
|
||||||
/// No description provided for @setupNextInstall.
|
/// No description provided for @setupHubRunning.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'Install the modules'**
|
/// **'The hub is running.'**
|
||||||
String get setupNextInstall;
|
String get setupHubRunning;
|
||||||
|
|
||||||
/// No description provided for @setupNextRunFlow.
|
/// No description provided for @setupStartHubFirst.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'Run the example flow'**
|
/// **'Start the hub first — then the modules can be installed right here.'**
|
||||||
String get setupNextRunFlow;
|
String get setupStartHubFirst;
|
||||||
|
|
||||||
|
/// No description provided for @setupActionInstall.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Install {module}'**
|
||||||
|
String setupActionInstall(String module);
|
||||||
|
|
||||||
|
/// No description provided for @setupActionInstalled.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'{module} is installed.'**
|
||||||
|
String setupActionInstalled(String module);
|
||||||
|
|
||||||
|
/// No description provided for @setupActionOpenFlow.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Open the “{flow}” example flow'**
|
||||||
|
String setupActionOpenFlow(String flow);
|
||||||
|
|
||||||
|
/// No description provided for @setupModulesOfflineHint.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'The modules arrive as an offline bundle, without internet — the documentation explains this under “running without internet”.'**
|
||||||
|
String get setupModulesOfflineHint;
|
||||||
|
|
||||||
|
/// No description provided for @setupModulesSignedHint.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'The modules come from your own, signed source — not from the public store.'**
|
||||||
|
String get setupModulesSignedHint;
|
||||||
|
|
||||||
|
/// No description provided for @setupSigPublicStoreNotice.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'This setup only accepts signed modules. The public store currently ships unsigned bundles — the prepared modules therefore come from your own, signed source.'**
|
||||||
|
String get setupSigPublicStoreNotice;
|
||||||
|
|
||||||
|
/// No description provided for @setupAllowUnsigned.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Allow installing from the public store'**
|
||||||
|
String get setupAllowUnsigned;
|
||||||
|
|
||||||
|
/// No description provided for @setupAllowUnsignedSub.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Deliberately relaxes the signature requirement. Fine for trying things out — re-enable it for regulated operation.'**
|
||||||
|
String get setupAllowUnsignedSub;
|
||||||
|
|
||||||
/// No description provided for @setupStartCta.
|
/// No description provided for @setupStartCta.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'Get started in 3 questions'**
|
/// **'Get started in 3 questions'**
|
||||||
String get setupStartCta;
|
String get setupStartCta;
|
||||||
|
|
||||||
|
/// No description provided for @setupProfileDev.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'straightforward workstation'**
|
||||||
|
String get setupProfileDev;
|
||||||
|
|
||||||
|
/// No description provided for @setupProfileEnterprise.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'hardened team operation'**
|
||||||
|
String get setupProfileEnterprise;
|
||||||
|
|
||||||
|
/// No description provided for @setupProfileAirgapped.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'fully local, regulated operation'**
|
||||||
|
String get setupProfileAirgapped;
|
||||||
|
|
||||||
|
/// No description provided for @welcomeChecklistSetupDone.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'The setup assistant already took care of the basics ({profile}) — this list shows what has actually happened on your hub.'**
|
||||||
|
String welcomeChecklistSetupDone(String profile);
|
||||||
}
|
}
|
||||||
|
|
||||||
class _AppLocalizationsDelegate
|
class _AppLocalizationsDelegate
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get guidedSetupIntro =>
|
String get guidedSetupIntro =>
|
||||||
'Beantworte drei Fragen — Ch∆In stellt passende Konfiguration, Modul-Set und Start-Flow zusammen.';
|
'Beantworten Sie drei Fragen — Ch∆In stellt passende Konfiguration, Modul-Set und Beispiel-Flow zusammen.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get guidedSetupScenario => 'Szenario';
|
String get guidedSetupScenario => 'Szenario';
|
||||||
|
|
@ -3138,11 +3138,62 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
String get setupNextHubStart => 'Hub starten';
|
String get setupNextHubStart => 'Hub starten';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get setupNextInstall => 'Die Module installieren';
|
String get setupHubRunning => 'Der Hub läuft.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get setupNextRunFlow => 'Den Beispiel-Flow ausführen';
|
String get setupStartHubFirst =>
|
||||||
|
'Starten Sie zuerst den Hub — dann lassen sich die Module hier installieren.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String setupActionInstall(String module) {
|
||||||
|
return '$module installieren';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String setupActionInstalled(String module) {
|
||||||
|
return '$module ist installiert.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String setupActionOpenFlow(String flow) {
|
||||||
|
return 'Beispiel-Flow „$flow“ öffnen';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get setupModulesOfflineHint =>
|
||||||
|
'Die Module kommen ohne Internet als Offline-Paket auf den Server — die Anleitung dazu steht in der Dokumentation unter „Betrieb ohne Internet“.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get setupModulesSignedHint =>
|
||||||
|
'Die Module kommen aus einer eigenen, signierten Quelle — nicht aus dem öffentlichen Store.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get setupSigPublicStoreNotice =>
|
||||||
|
'Diese Einrichtung lässt nur signierte Module zu. Der öffentliche Store liefert zurzeit unsignierte Pakete — die vorbereiteten Module kommen daher aus einer eigenen, signierten Quelle.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get setupAllowUnsigned =>
|
||||||
|
'Installation aus dem öffentlichen Store erlauben';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get setupAllowUnsignedSub =>
|
||||||
|
'Lockert die Signaturpflicht bewusst. Gut zum Ausprobieren — für den regulierten Betrieb später wieder aktivieren.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get setupStartCta => 'In 3 Fragen loslegen';
|
String get setupStartCta => 'In 3 Fragen loslegen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get setupProfileDev => 'unkomplizierter Arbeitsplatz';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get setupProfileEnterprise => 'abgesicherter Team-Betrieb';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get setupProfileAirgapped =>
|
||||||
|
'vollständig lokaler, regulierter Betrieb';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String welcomeChecklistSetupDone(String profile) {
|
||||||
|
return 'Die Grundeinrichtung ($profile) hat der Einrichtungs-Assistent bereits erledigt — hier sehen Sie, was auf dem Hub schon passiert ist.';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3136,11 +3136,60 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
String get setupNextHubStart => 'Start the hub';
|
String get setupNextHubStart => 'Start the hub';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get setupNextInstall => 'Install the modules';
|
String get setupHubRunning => 'The hub is running.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get setupNextRunFlow => 'Run the example flow';
|
String get setupStartHubFirst =>
|
||||||
|
'Start the hub first — then the modules can be installed right here.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String setupActionInstall(String module) {
|
||||||
|
return 'Install $module';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String setupActionInstalled(String module) {
|
||||||
|
return '$module is installed.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String setupActionOpenFlow(String flow) {
|
||||||
|
return 'Open the “$flow” example flow';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get setupModulesOfflineHint =>
|
||||||
|
'The modules arrive as an offline bundle, without internet — the documentation explains this under “running without internet”.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get setupModulesSignedHint =>
|
||||||
|
'The modules come from your own, signed source — not from the public store.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get setupSigPublicStoreNotice =>
|
||||||
|
'This setup only accepts signed modules. The public store currently ships unsigned bundles — the prepared modules therefore come from your own, signed source.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get setupAllowUnsigned => 'Allow installing from the public store';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get setupAllowUnsignedSub =>
|
||||||
|
'Deliberately relaxes the signature requirement. Fine for trying things out — re-enable it for regulated operation.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get setupStartCta => 'Get started in 3 questions';
|
String get setupStartCta => 'Get started in 3 questions';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get setupProfileDev => 'straightforward workstation';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get setupProfileEnterprise => 'hardened team operation';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get setupProfileAirgapped => 'fully local, regulated operation';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String welcomeChecklistSetupDone(String profile) {
|
||||||
|
return 'The setup assistant already took care of the basics ($profile) — this list shows what has actually happened on your hub.';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,9 +22,44 @@ import '../theme/tokens.dart';
|
||||||
import '../widgets/guided_setup_dialog.dart';
|
import '../widgets/guided_setup_dialog.dart';
|
||||||
import '../widgets/widgets.dart';
|
import '../widgets/widgets.dart';
|
||||||
|
|
||||||
class WelcomePage extends StatelessWidget {
|
class WelcomePage extends StatefulWidget {
|
||||||
const WelcomePage({super.key});
|
const WelcomePage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<WelcomePage> createState() => _WelcomePageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _WelcomePageState extends State<WelcomePage> {
|
||||||
|
/// Bumped when the guided-setup dialog closes so the onboarding
|
||||||
|
/// checklist remounts and re-probes — the assistant may have just
|
||||||
|
/// installed the modules the checklist looks for.
|
||||||
|
int _checklistEpoch = 0;
|
||||||
|
|
||||||
|
/// One auto-open per app run: a fresh install that dismisses the
|
||||||
|
/// wizard should not have it spring back on every rebuild.
|
||||||
|
static bool _autoOpenedThisRun = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) => _maybeAutoOpen());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// On a fresh hub (no config, no recorded setup plan) the wizard
|
||||||
|
/// IS the onboarding — open it rather than hoping the operator
|
||||||
|
/// finds the button. Once a setup exists it steps back for good.
|
||||||
|
Future<void> _maybeAutoOpen() async {
|
||||||
|
if (_autoOpenedThisRun || !mounted) return;
|
||||||
|
if (!GuidedSetupDialog.isFreshInstall()) return;
|
||||||
|
_autoOpenedThisRun = true;
|
||||||
|
await _openSetup();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _openSetup() async {
|
||||||
|
await GuidedSetupDialog.show(context);
|
||||||
|
if (mounted) setState(() => _checklistEpoch++);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
|
|
@ -65,11 +100,12 @@ class WelcomePage extends StatelessWidget {
|
||||||
alignment: Alignment.centerLeft,
|
alignment: Alignment.centerLeft,
|
||||||
// High-emphasis: the guided setup is the fastest path
|
// High-emphasis: the guided setup is the fastest path
|
||||||
// to a working first run, so lead with a filled button
|
// to a working first run, so lead with a filled button
|
||||||
// rather than a low-key outlined one.
|
// rather than a low-key outlined one. Honest framing:
|
||||||
|
// "get started in 3 questions", not tool-speak.
|
||||||
child: FilledButton.icon(
|
child: FilledButton.icon(
|
||||||
onPressed: () => GuidedSetupDialog.show(context),
|
onPressed: _openSetup,
|
||||||
icon: const Icon(Icons.auto_fix_high),
|
icon: const Icon(Icons.auto_fix_high),
|
||||||
label: Text(AppLocalizations.of(context)!.guidedSetupTitle),
|
label: Text(l.setupStartCta),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: ChainSpace.xxl),
|
const SizedBox(height: ChainSpace.xxl),
|
||||||
|
|
@ -81,7 +117,7 @@ class WelcomePage extends StatelessWidget {
|
||||||
const _HubDownHero(),
|
const _HubDownHero(),
|
||||||
const SizedBox(height: ChainSpace.xxl),
|
const SizedBox(height: ChainSpace.xxl),
|
||||||
] else
|
] else
|
||||||
const _OnboardingChecklist(),
|
_OnboardingChecklist(key: ValueKey(_checklistEpoch)),
|
||||||
const _PillarRow(),
|
const _PillarRow(),
|
||||||
const SizedBox(height: ChainSpace.xxl),
|
const SizedBox(height: ChainSpace.xxl),
|
||||||
const _SectionLabel(textKey: _SectionLabelKey.trust),
|
const _SectionLabel(textKey: _SectionLabelKey.trust),
|
||||||
|
|
@ -515,7 +551,7 @@ const String _kChecklistDismissedKey = 'welcome.checklist.dismissed';
|
||||||
/// parallel; failures stay false and the checklist still
|
/// parallel; failures stay false and the checklist still
|
||||||
/// renders (with the "Refresh" button for retry).
|
/// renders (with the "Refresh" button for retry).
|
||||||
class _OnboardingChecklist extends StatefulWidget {
|
class _OnboardingChecklist extends StatefulWidget {
|
||||||
const _OnboardingChecklist();
|
const _OnboardingChecklist({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<_OnboardingChecklist> createState() => _OnboardingChecklistState();
|
State<_OnboardingChecklist> createState() => _OnboardingChecklistState();
|
||||||
|
|
@ -530,6 +566,12 @@ class _OnboardingChecklistState extends State<_OnboardingChecklist> {
|
||||||
bool _moduleOk = false;
|
bool _moduleOk = false;
|
||||||
bool _flowOk = false;
|
bool _flowOk = false;
|
||||||
|
|
||||||
|
/// Profile the guided setup applied (from `setup-plan.yaml`), or
|
||||||
|
/// null when the assistant never ran. Connects the two onboarding
|
||||||
|
/// surfaces: the checklist says what the assistant already covered
|
||||||
|
/// instead of ignoring it.
|
||||||
|
String? _setupProfile;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
|
@ -541,6 +583,7 @@ class _OnboardingChecklistState extends State<_OnboardingChecklist> {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_dismissed = prefs.getBool(_kChecklistDismissedKey) ?? false;
|
_dismissed = prefs.getBool(_kChecklistDismissedKey) ?? false;
|
||||||
|
_setupProfile = GuidedSetupDialog.appliedSetupProfile();
|
||||||
_initialised = true;
|
_initialised = true;
|
||||||
});
|
});
|
||||||
if (!_dismissed) {
|
if (!_dismissed) {
|
||||||
|
|
@ -598,6 +641,13 @@ class _OnboardingChecklistState extends State<_OnboardingChecklist> {
|
||||||
setState(() => _dismissed = true);
|
setState(() => _dismissed = true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Localized plain-language label for the applied setup profile.
|
||||||
|
String _profileLabel(AppLocalizations l) => switch (_setupProfile) {
|
||||||
|
'enterprise' => l.setupProfileEnterprise,
|
||||||
|
'air-gapped' => l.setupProfileAirgapped,
|
||||||
|
_ => l.setupProfileDev,
|
||||||
|
};
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
if (!_initialised || _dismissed) return const SizedBox.shrink();
|
if (!_initialised || _dismissed) return const SizedBox.shrink();
|
||||||
|
|
@ -674,6 +724,27 @@ class _OnboardingChecklistState extends State<_OnboardingChecklist> {
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
// The guided setup already ran: say so, so the checklist
|
||||||
|
// reads as "what's left", not as a second, disconnected
|
||||||
|
// onboarding that ignores the assistant's work.
|
||||||
|
if (_setupProfile != null) ...[
|
||||||
|
const SizedBox(height: ChainSpace.sm),
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.check_circle, size: 16, color: ChainColors.success),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
l.welcomeChecklistSetupDone(_profileLabel(l)),
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
const SizedBox(height: ChainSpace.md),
|
const SizedBox(height: ChainSpace.md),
|
||||||
Container(
|
Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
|
|
|
||||||
|
|
@ -9,14 +9,17 @@
|
||||||
// plain-language summary — never the CLI's English prose. Every
|
// plain-language summary — never the CLI's English prose. Every
|
||||||
// option carries a one-line explanation of what it configures.
|
// option carries a one-line explanation of what it configures.
|
||||||
|
|
||||||
|
import 'dart:async' show unawaited;
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../data/error_presentation.dart';
|
import '../data/error_presentation.dart';
|
||||||
|
import '../data/hub.dart';
|
||||||
import '../data/system_actions.dart';
|
import '../data/system_actions.dart';
|
||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
|
import '../main.dart' show StudioShellState;
|
||||||
import '../theme/tokens.dart';
|
import '../theme/tokens.dart';
|
||||||
|
|
||||||
/// A selectable setup option: the stable kebab wire value plus the
|
/// A selectable setup option: the stable kebab wire value plus the
|
||||||
|
|
@ -29,12 +32,74 @@ class _Option {
|
||||||
}
|
}
|
||||||
|
|
||||||
class GuidedSetupDialog extends StatefulWidget {
|
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>(
|
/// Test seam: start on the review step with this parsed plan
|
||||||
context: context,
|
/// instead of shelling out to `chain init --plan-json`.
|
||||||
builder: (_) => const GuidedSetupDialog(),
|
@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
|
@override
|
||||||
State<GuidedSetupDialog> createState() => _GuidedSetupDialogState();
|
State<GuidedSetupDialog> createState() => _GuidedSetupDialogState();
|
||||||
|
|
@ -71,16 +136,37 @@ class _GuidedSetupDialogState extends State<GuidedSetupDialog> {
|
||||||
String _target = 'this-laptop';
|
String _target = 'this-laptop';
|
||||||
bool _requireApproval = false;
|
bool _requireApproval = false;
|
||||||
bool _dataLocal = false;
|
bool _dataLocal = false;
|
||||||
|
bool _allowUnsigned = false;
|
||||||
bool _busy = false;
|
bool _busy = false;
|
||||||
Map<String, dynamic>? _plan; // parsed SetupPlan from --plan-json
|
Map<String, dynamic>? _plan; // parsed SetupPlan from --plan-json
|
||||||
bool _applied = false;
|
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() =>
|
String _answersYaml() =>
|
||||||
'scenario: $_scenario\n'
|
'scenario: $_scenario\n'
|
||||||
'intent: $_intent\n'
|
'intent: $_intent\n'
|
||||||
'target: $_target\n'
|
'target: $_target\n'
|
||||||
'require_approval: $_requireApproval\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 {
|
Future<String> _writeAnswers() async {
|
||||||
final f = File('${Directory.systemTemp.path}/chain-setup-answers.yaml');
|
final f = File('${Directory.systemTemp.path}/chain-setup-answers.yaml');
|
||||||
|
|
@ -131,11 +217,65 @@ class _GuidedSetupDialogState extends State<GuidedSetupDialog> {
|
||||||
setState(() => _busy = false);
|
setState(() => _busy = false);
|
||||||
if (r.ok) {
|
if (r.ok) {
|
||||||
setState(() => _applied = true);
|
setState(() => _applied = true);
|
||||||
|
unawaited(_probeHub());
|
||||||
} else {
|
} else {
|
||||||
showFaiProcessError(context, 'chain init --apply', r.stdout, r.stderr);
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final l = AppLocalizations.of(context)!;
|
final l = AppLocalizations.of(context)!;
|
||||||
|
|
@ -210,26 +350,16 @@ class _GuidedSetupDialogState extends State<GuidedSetupDialog> {
|
||||||
if (plan == null) return const SizedBox.shrink();
|
if (plan == null) return const SizedBox.shrink();
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
final lines = _planLines(l, plan);
|
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(
|
return Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
if (_applied) ...[
|
if (_applied)
|
||||||
Row(
|
..._appliedSteps(l, plan)
|
||||||
children: [
|
else ...[
|
||||||
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 ...[
|
|
||||||
for (final line in lines)
|
for (final line in lines)
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(bottom: 6),
|
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()) ...[
|
if (_configExists()) ...[
|
||||||
const SizedBox(height: ChainSpace.sm),
|
const SizedBox(height: ChainSpace.sm),
|
||||||
Row(
|
Row(
|
||||||
|
|
@ -266,13 +434,122 @@ class _GuidedSetupDialogState extends State<GuidedSetupDialog> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _next(String label) => Padding(
|
/// Post-apply next steps as real Studio actions: start the hub,
|
||||||
padding: const EdgeInsets.only(top: 4),
|
/// 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(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(Icons.arrow_right, size: 18, color: Theme.of(context).colorScheme.primary),
|
Icon(Icons.check_circle, size: 18, color: ChainColors.success),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 6),
|
||||||
Expanded(child: Text(label, style: Theme.of(context).textTheme.bodyMedium)),
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,41 @@ Widget _host() => MaterialApp(
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/// Host that opens the dialog pre-seeded on the review / applied
|
||||||
|
/// step with [plan] (the parsed `--plan-json` shape), bypassing the
|
||||||
|
/// CLI subprocess.
|
||||||
|
Widget _seededHost(Map<String, dynamic> plan, {bool applied = false}) =>
|
||||||
|
MaterialApp(
|
||||||
|
locale: const Locale('de'),
|
||||||
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||||
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
|
home: Builder(
|
||||||
|
builder: (context) => Scaffold(
|
||||||
|
body: Center(
|
||||||
|
child: ElevatedButton(
|
||||||
|
onPressed: () => showDialog<void>(
|
||||||
|
context: context,
|
||||||
|
builder: (_) =>
|
||||||
|
GuidedSetupDialog(debugPlan: plan, debugApplied: applied),
|
||||||
|
),
|
||||||
|
child: const Text('open'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> _regulatedPlan({bool signed = true}) => {
|
||||||
|
'profile': 'enterprise',
|
||||||
|
'modules': ['text.extract', 'text.summarize'],
|
||||||
|
'starter_flow': 'extract-summarize',
|
||||||
|
'runbook': 'service',
|
||||||
|
'curated_docs': <String>[],
|
||||||
|
'require_signatures': signed,
|
||||||
|
'worm_audit': false,
|
||||||
|
'approval_step': true,
|
||||||
|
};
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
testWidgets('step 1 shows scenario options in German with explanations', (
|
testWidgets('step 1 shows scenario options in German with explanations', (
|
||||||
tester,
|
tester,
|
||||||
|
|
@ -75,4 +110,70 @@ void main() {
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(find.text('Schritt 2 von 3'), findsOneWidget);
|
expect(find.text('Schritt 2 von 3'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'review of a regulated plan explains the signature dead end and offers the relaxation switch',
|
||||||
|
(tester) async {
|
||||||
|
await tester.pumpWidget(_seededHost(_regulatedPlan()));
|
||||||
|
await tester.tap(find.text('open'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// The plain-language notice + the deliberate one-click switch.
|
||||||
|
expect(
|
||||||
|
find.textContaining('Der öffentliche Store liefert zurzeit unsignierte Pakete'),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
find.text('Installation aus dem öffentlichen Store erlauben'),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
// No raw CLI prose anywhere.
|
||||||
|
expect(find.textContaining('chain install'), findsNothing);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'applied state renders clickable next steps — start hub, per-module install, open flow',
|
||||||
|
(tester) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
_seededHost(_regulatedPlan(signed: false), applied: true),
|
||||||
|
);
|
||||||
|
await tester.tap(find.text('open'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('Fertig — Ch∆In ist eingerichtet.'), findsOneWidget);
|
||||||
|
// Hub not probed up in tests → the start action + the
|
||||||
|
// install-needs-hub hint show, and install buttons exist
|
||||||
|
// (disabled) for each plan module.
|
||||||
|
expect(find.text('Hub starten'), findsOneWidget);
|
||||||
|
expect(
|
||||||
|
find.textContaining('Starten Sie zuerst den Hub'),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
expect(find.text('text.extract installieren'), findsOneWidget);
|
||||||
|
expect(find.text('text.summarize installieren'), findsOneWidget);
|
||||||
|
expect(
|
||||||
|
find.text('Beispiel-Flow „extract-summarize“ öffnen'),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
// No CLI text in the GUI path.
|
||||||
|
expect(find.textContaining('chain serve'), findsNothing);
|
||||||
|
expect(find.textContaining('chain install'), findsNothing);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'applied regulated (signed) state guides to the signed source instead of dead-end install buttons',
|
||||||
|
(tester) async {
|
||||||
|
await tester.pumpWidget(_seededHost(_regulatedPlan(), applied: true));
|
||||||
|
await tester.tap(find.text('open'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
find.textContaining('signierten Quelle'),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
expect(find.text('text.extract installieren'), findsNothing);
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue