feat(studio): Welcome page Phase C — live getting-started checklist (v0.39.0)

Final slice of the Welcome surface. Four steps that prove
the operator has the basic loop wired up; each step is
a live probe against the running hub, no manual ticks.

The four checked signals:

  1. System AI configured
     → `HubService.systemAiStatus().enabled`
  2. Public capability source added
     → `listMcpClients()` returns at least one entry
  3. Text module installed
     → `listModules()` contains a name starting with `text.`
  4. Saved flow run
     → any `flow.completed` event in the last 100 audit
       events

All four probes fire in parallel from `_OnboardingChecklist`'s
`_refresh()`. Failures stay false (catchError) so a hub that
is briefly unreachable doesn't blank the whole row — the
operator hits the explicit Refresh icon to retry.

UX:

- Renders between hero and pillars so the actionable path
  beats the educational content for screen real estate.
- Each row: status icon (○ pending / ✓ done) + title +
  one-line hint pointing the operator at the right Studio
  surface + status pill.
- Done rows strike-through the title and dim it; pending
  rows stay full-strength.
- When all four flip to done, an "All four steps complete"
  footer appears with a `Hide checklist` button. Clicking
  persists `welcome.checklist.dismissed = true` via
  SharedPreferences; the section is gone for good (we don't
  re-nag operators who chose a non-default path).

15 new ARB keys for the section header, body, four rows
plus done/pending pills, all-done footer, dismiss /
refresh / refreshing button labels.

Three-phase plan from `docs/landing-page-design.md` is now
fully shipped: Phase A scaffolding (v0.37.0), Phase B
embedded doc reader (v0.38.0), Phase C live checklist
(v0.39.0).

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
flemming-it 2026-05-09 00:28:41 +02:00
parent 3b07d340d5
commit 930e246649
8 changed files with 496 additions and 2 deletions

View file

@ -11,11 +11,14 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart' show rootBundle;
import 'package:flutter_markdown/flutter_markdown.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../data/hub.dart';
import '../data/system_actions.dart';
import '../l10n/app_localizations.dart';
import '../theme/theme.dart';
import '../theme/tokens.dart';
import '../widgets/widgets.dart';
class WelcomePage extends StatelessWidget {
const WelcomePage({super.key});
@ -45,6 +48,10 @@ class WelcomePage extends StatelessWidget {
children: const [
_Hero(),
SizedBox(height: FaiSpace.xxl),
// Checklist comes second operator-actionable
// path beats educational content. Auto-hides
// once the operator dismisses it.
_OnboardingChecklist(),
_SectionLabel(textKey: _SectionLabelKey.pillars),
SizedBox(height: FaiSpace.md),
_PillarRow(),
@ -359,6 +366,259 @@ class _TrustRow extends StatelessWidget {
}
}
/// SharedPreferences key the checklist persists "operator
/// dismissed me" under. Once true, the checklist is gone for
/// good this is intentional; we don't want to nag operators
/// who chose a non-default path (e.g. CLI-only, no Studio
/// onboarding needed).
const String _kChecklistDismissedKey = 'welcome.checklist.dismissed';
/// Live getting-started checklist. Probes the running hub for
/// four signals that the operator has the basic loop wired up
/// (System AI configured, MCP source added, text module
/// installed, any flow run). Each signal is fetched in
/// parallel; failures stay false and the checklist still
/// renders (with the "Refresh" button for retry).
class _OnboardingChecklist extends StatefulWidget {
const _OnboardingChecklist();
@override
State<_OnboardingChecklist> createState() => _OnboardingChecklistState();
}
class _OnboardingChecklistState extends State<_OnboardingChecklist> {
bool _dismissed = false;
bool _initialised = false;
bool _refreshing = false;
bool _aiOk = false;
bool _mcpOk = false;
bool _moduleOk = false;
bool _flowOk = false;
@override
void initState() {
super.initState();
_bootstrap();
}
Future<void> _bootstrap() async {
final prefs = await SharedPreferences.getInstance();
if (!mounted) return;
setState(() {
_dismissed = prefs.getBool(_kChecklistDismissedKey) ?? false;
_initialised = true;
});
if (!_dismissed) {
await _refresh();
}
}
Future<void> _refresh() async {
setState(() => _refreshing = true);
final hub = HubService.instance;
// Probe each signal independently. A failure in one (e.g.
// hub temporarily unreachable) leaves the others
// computable; the checklist degrades gracefully.
final ai = hub
.systemAiStatus()
.then((s) => s.enabled)
.catchError((_) => false);
final mcp = hub
.listMcpClients()
.then((c) => c.isNotEmpty)
.catchError((_) => false);
final module = hub
.listModules()
.then((m) => m.any((x) => x.name.startsWith('text.')))
.catchError((_) => false);
final flow = hub
.recentEvents(limit: 100)
.then((events) => events.any((e) => e.type == 'flow.completed'))
.catchError((_) => false);
final results = await Future.wait([ai, mcp, module, flow]);
if (!mounted) return;
setState(() {
_refreshing = false;
_aiOk = results[0];
_mcpOk = results[1];
_moduleOk = results[2];
_flowOk = results[3];
});
}
Future<void> _dismiss() async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_kChecklistDismissedKey, true);
if (!mounted) return;
setState(() => _dismissed = true);
}
@override
Widget build(BuildContext context) {
if (!_initialised || _dismissed) return const SizedBox.shrink();
final theme = Theme.of(context);
final l = AppLocalizations.of(context)!;
final allDone = _aiOk && _mcpOk && _moduleOk && _flowOk;
final items = <(bool, String, String)>[
(_aiOk, l.welcomeChecklistAi, l.welcomeChecklistAiHint),
(_mcpOk, l.welcomeChecklistMcp, l.welcomeChecklistMcpHint),
(_moduleOk, l.welcomeChecklistModule, l.welcomeChecklistModuleHint),
(_flowOk, l.welcomeChecklistFlow, l.welcomeChecklistFlowHint),
];
return Padding(
padding: const EdgeInsets.only(bottom: FaiSpace.xxl),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
l.welcomeChecklistHeader,
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
letterSpacing: 0.6,
fontSize: 10,
),
),
const Spacer(),
IconButton(
icon: _refreshing
? const SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.refresh, size: 16),
visualDensity: VisualDensity.compact,
tooltip: _refreshing
? l.welcomeChecklistRefreshing
: l.welcomeChecklistRefresh,
onPressed: _refreshing ? null : _refresh,
),
],
),
const SizedBox(height: 4),
Text(
l.welcomeChecklistBody,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: FaiSpace.md),
Container(
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainer,
borderRadius: BorderRadius.circular(FaiRadius.md),
border: Border.all(color: theme.colorScheme.outlineVariant),
),
child: Column(
children: [
for (var i = 0; i < items.length; i++) ...[
if (i > 0)
Divider(
height: 1,
color: theme.colorScheme.outlineVariant,
),
_ChecklistRow(
done: items[i].$1,
title: items[i].$2,
hint: items[i].$3,
),
],
],
),
),
if (allDone) ...[
const SizedBox(height: FaiSpace.md),
Row(
children: [
Icon(
Icons.check_circle,
size: 16,
color: FaiColors.success,
),
const SizedBox(width: FaiSpace.xs),
Text(
l.welcomeChecklistAllDone,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const Spacer(),
FilledButton.tonal(
onPressed: _dismiss,
child: Text(l.welcomeChecklistDismiss),
),
],
),
],
],
),
);
}
}
class _ChecklistRow extends StatelessWidget {
final bool done;
final String title;
final String hint;
const _ChecklistRow({
required this.done,
required this.title,
required this.hint,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l = AppLocalizations.of(context)!;
return Padding(
padding: const EdgeInsets.all(FaiSpace.lg),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
done ? Icons.check_circle : Icons.radio_button_unchecked,
size: 20,
color: done ? FaiColors.success : theme.colorScheme.outline,
),
const SizedBox(width: FaiSpace.lg),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
color: done
? theme.colorScheme.onSurfaceVariant
: theme.colorScheme.onSurface,
decoration: done ? TextDecoration.lineThrough : null,
),
),
const SizedBox(height: 2),
Text(
hint,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
),
const SizedBox(width: FaiSpace.md),
FaiPill(
label: done ? l.welcomeChecklistDone : l.welcomeChecklistTodo,
tone: done ? FaiPillTone.success : FaiPillTone.neutral,
),
],
),
);
}
}
/// Doc-card entries each links to a bundled markdown file
/// under `assets/docs/`. The slug is the filename stem; the
/// loader picks `<slug>.md` or `<slug>_de.md` per locale.