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

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

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

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

View file

@ -22,9 +22,44 @@ import '../theme/tokens.dart';
import '../widgets/guided_setup_dialog.dart';
import '../widgets/widgets.dart';
class WelcomePage extends StatelessWidget {
class WelcomePage extends StatefulWidget {
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
Widget build(BuildContext context) {
final theme = Theme.of(context);
@ -65,11 +100,12 @@ class WelcomePage extends StatelessWidget {
alignment: Alignment.centerLeft,
// High-emphasis: the guided setup is the fastest path
// 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(
onPressed: () => GuidedSetupDialog.show(context),
onPressed: _openSetup,
icon: const Icon(Icons.auto_fix_high),
label: Text(AppLocalizations.of(context)!.guidedSetupTitle),
label: Text(l.setupStartCta),
),
),
const SizedBox(height: ChainSpace.xxl),
@ -81,7 +117,7 @@ class WelcomePage extends StatelessWidget {
const _HubDownHero(),
const SizedBox(height: ChainSpace.xxl),
] else
const _OnboardingChecklist(),
_OnboardingChecklist(key: ValueKey(_checklistEpoch)),
const _PillarRow(),
const SizedBox(height: ChainSpace.xxl),
const _SectionLabel(textKey: _SectionLabelKey.trust),
@ -515,7 +551,7 @@ const String _kChecklistDismissedKey = 'welcome.checklist.dismissed';
/// parallel; failures stay false and the checklist still
/// renders (with the "Refresh" button for retry).
class _OnboardingChecklist extends StatefulWidget {
const _OnboardingChecklist();
const _OnboardingChecklist({super.key});
@override
State<_OnboardingChecklist> createState() => _OnboardingChecklistState();
@ -530,6 +566,12 @@ class _OnboardingChecklistState extends State<_OnboardingChecklist> {
bool _moduleOk = 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
void initState() {
super.initState();
@ -541,6 +583,7 @@ class _OnboardingChecklistState extends State<_OnboardingChecklist> {
if (!mounted) return;
setState(() {
_dismissed = prefs.getBool(_kChecklistDismissedKey) ?? false;
_setupProfile = GuidedSetupDialog.appliedSetupProfile();
_initialised = true;
});
if (!_dismissed) {
@ -598,6 +641,13 @@ class _OnboardingChecklistState extends State<_OnboardingChecklist> {
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
Widget build(BuildContext context) {
if (!_initialised || _dismissed) return const SizedBox.shrink();
@ -674,6 +724,27 @@ class _OnboardingChecklistState extends State<_OnboardingChecklist> {
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),
Container(
decoration: BoxDecoration(