Some checks failed
Security / Security check (push) Failing after 2s
Welcome page used ConstrainedBox(maxWidth: 960) inside a left-aligned Column, so on the 1440x900 default window the content hugged the left edge and left ~370 px of empty space on the right. With Stefan's M4 sidebar collapsed to 72 px the asymmetry was the dominant visual in the first thing every operator sees. Wrap the ConstrainedBox in a Center widget. 960 px stays an opinionated reading width — long-form prose gets uncomfortable past ~1100 px — but the slack now splits evenly so the content sits with ~190 px breathing room on both sides instead of all on one. Bumps the editor-package lock pointer to ad2e5b5 (same 0.2.2 version, picks up the extract-with-approval test commit on top of f8138f3) as a side-effect of the flutter pub upgrade run during verification. Version 0.52.2 -> 0.52.3. Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
1113 lines
36 KiB
Dart
1113 lines
36 KiB
Dart
// Welcome — first surface a new operator sees, default sidebar
|
|
// destination on launch. Pure introduction: what F∆I is, the
|
|
// three concepts, the trust posture. No live data, no
|
|
// federation chrome — that's the Store's role.
|
|
//
|
|
// See docs/landing-page-design.md for the multi-phase plan;
|
|
// this file is Phase A (scaffolding + static content). Phase B
|
|
// adds an embedded markdown reader; Phase C adds a live
|
|
// getting-started checklist.
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart' show rootBundle;
|
|
import 'package:flutter_markdown_plus/flutter_markdown_plus.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});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
final l = AppLocalizations.of(context)!;
|
|
return Scaffold(
|
|
backgroundColor: theme.scaffoldBackgroundColor,
|
|
appBar: AppBar(titleSpacing: FaiSpace.xl, title: Text(l.navWelcome)),
|
|
body: SingleChildScrollView(
|
|
child: Padding(
|
|
padding: const EdgeInsets.fromLTRB(
|
|
FaiSpace.xl,
|
|
FaiSpace.lg,
|
|
FaiSpace.xl,
|
|
FaiSpace.xxl,
|
|
),
|
|
// Center the 960-px content column on wide windows
|
|
// so the right margin matches the left rather than
|
|
// dumping all the slack onto one side. 960 px stays
|
|
// an opinionated reading width — long-form prose
|
|
// gets uncomfortable past ~1100 px — but on Stefan's
|
|
// 1440-px default it now sits with ~190 px of
|
|
// breathing room on both sides instead of 380 px
|
|
// on the right and 20 px on the left.
|
|
child: Center(
|
|
child: ConstrainedBox(
|
|
constraints: const BoxConstraints(maxWidth: 960),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: const [
|
|
_Hero(),
|
|
SizedBox(height: FaiSpace.xxl),
|
|
// Checklist comes second — operator-actionable
|
|
// path beats educational content. Auto-hides
|
|
// once the operator dismisses it.
|
|
_OnboardingChecklist(),
|
|
_PillarRow(),
|
|
SizedBox(height: FaiSpace.xxl),
|
|
_SectionLabel(textKey: _SectionLabelKey.trust),
|
|
SizedBox(height: FaiSpace.md),
|
|
_TrustPosture(),
|
|
SizedBox(height: FaiSpace.xxl),
|
|
_SectionLabel(textKey: _SectionLabelKey.docs),
|
|
SizedBox(height: FaiSpace.md),
|
|
_DocsRow(),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Top hero — gradient backdrop matching the Today-Hero in the
|
|
/// store so the visual language carries between pages. Title +
|
|
/// subtitle, no CTAs.
|
|
class _Hero extends StatelessWidget {
|
|
const _Hero();
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
final l = AppLocalizations.of(context)!;
|
|
return Container(
|
|
width: double.infinity,
|
|
padding: const EdgeInsets.all(FaiSpace.xxl),
|
|
decoration: BoxDecoration(
|
|
borderRadius: BorderRadius.circular(FaiRadius.md),
|
|
gradient: LinearGradient(
|
|
colors: [
|
|
theme.colorScheme.primary.withValues(alpha: 0.18),
|
|
theme.colorScheme.primary.withValues(alpha: 0.04),
|
|
theme.colorScheme.surfaceContainer.withValues(alpha: 0.0),
|
|
],
|
|
stops: const [0.0, 0.55, 1.0],
|
|
begin: Alignment.topLeft,
|
|
end: Alignment.bottomRight,
|
|
),
|
|
border: Border.all(
|
|
color: theme.colorScheme.primary.withValues(alpha: 0.25),
|
|
),
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Container(
|
|
padding: const EdgeInsets.all(FaiSpace.md),
|
|
decoration: BoxDecoration(
|
|
color: theme.colorScheme.primary.withValues(alpha: 0.14),
|
|
shape: BoxShape.circle,
|
|
),
|
|
child: Icon(
|
|
Icons.waving_hand_outlined,
|
|
size: 28,
|
|
color: theme.colorScheme.primary,
|
|
),
|
|
),
|
|
const SizedBox(width: FaiSpace.lg),
|
|
Expanded(
|
|
child: Text(
|
|
l.welcomeHeroTitle,
|
|
style: theme.textTheme.displaySmall?.copyWith(
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: FaiSpace.lg),
|
|
Text(
|
|
l.welcomeHeroSubtitle,
|
|
style: theme.textTheme.titleMedium?.copyWith(
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
height: 1.45,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
enum _SectionLabelKey { trust, docs }
|
|
|
|
class _SectionLabel extends StatelessWidget {
|
|
final _SectionLabelKey textKey;
|
|
const _SectionLabel({required this.textKey});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
final l = AppLocalizations.of(context)!;
|
|
final text = switch (textKey) {
|
|
_SectionLabelKey.trust => l.welcomeTrustHeader,
|
|
_SectionLabelKey.docs => l.welcomeDocsHeader,
|
|
};
|
|
return Text(
|
|
text,
|
|
style: theme.textTheme.labelSmall?.copyWith(
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
letterSpacing: 0.6,
|
|
fontSize: 10,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// "Hub. Module. Flow." three-card row. Stacks on narrow
|
|
/// windows so card text never gets cropped.
|
|
class _PillarRow extends StatelessWidget {
|
|
const _PillarRow();
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l = AppLocalizations.of(context)!;
|
|
final pillars = <(IconData, String, String)>[
|
|
(Icons.hub_outlined, l.welcomePillarHubTitle, l.welcomePillarHubBody),
|
|
(
|
|
Icons.extension_outlined,
|
|
l.welcomePillarModuleTitle,
|
|
l.welcomePillarModuleBody,
|
|
),
|
|
(
|
|
Icons.account_tree_outlined,
|
|
l.welcomePillarFlowTitle,
|
|
l.welcomePillarFlowBody,
|
|
),
|
|
];
|
|
return LayoutBuilder(
|
|
builder: (context, constraints) {
|
|
// ~640 dp is the comfortable threshold for three cards
|
|
// side by side; below that we stack so each card
|
|
// gets its full text width.
|
|
final stacked = constraints.maxWidth < 640;
|
|
if (stacked) {
|
|
return Column(
|
|
children: [
|
|
for (final p in pillars) ...[
|
|
_Pillar(icon: p.$1, title: p.$2, body: p.$3),
|
|
const SizedBox(height: FaiSpace.md),
|
|
],
|
|
],
|
|
);
|
|
}
|
|
// IntrinsicHeight bounds the Row's height so
|
|
// `crossAxisAlignment: stretch` has something to
|
|
// stretch against. Without it the Row inherits the
|
|
// unbounded height of the surrounding
|
|
// `SingleChildScrollView` and Flutter throws
|
|
// "BoxConstraints forces an infinite height".
|
|
return IntrinsicHeight(
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
for (var i = 0; i < pillars.length; i++) ...[
|
|
if (i > 0) const SizedBox(width: FaiSpace.md),
|
|
Expanded(
|
|
child: _Pillar(
|
|
icon: pillars[i].$1,
|
|
title: pillars[i].$2,
|
|
body: pillars[i].$3,
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
class _Pillar extends StatelessWidget {
|
|
final IconData icon;
|
|
final String title;
|
|
final String body;
|
|
|
|
const _Pillar({required this.icon, required this.title, required this.body});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
return Container(
|
|
padding: const EdgeInsets.all(FaiSpace.lg),
|
|
decoration: BoxDecoration(
|
|
color: theme.colorScheme.surfaceContainer,
|
|
borderRadius: BorderRadius.circular(FaiRadius.md),
|
|
border: Border.all(color: theme.colorScheme.outlineVariant),
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Icon(icon, size: 28, color: theme.colorScheme.primary),
|
|
const SizedBox(height: FaiSpace.md),
|
|
Text(
|
|
title,
|
|
style: theme.textTheme.titleLarge?.copyWith(
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
const SizedBox(height: FaiSpace.sm),
|
|
Text(
|
|
body,
|
|
style: theme.textTheme.bodyMedium?.copyWith(
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
height: 1.45,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Three trust-posture rows: Sandbox / Audit / Air-gap. Used to
|
|
/// live as carousel slides in the store; reading them here as
|
|
/// a single deck reads better than rotating through them.
|
|
class _TrustPosture extends StatelessWidget {
|
|
const _TrustPosture();
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l = AppLocalizations.of(context)!;
|
|
final rows = <(IconData, String, String)>[
|
|
(
|
|
Icons.shield_outlined,
|
|
l.welcomeTrustSandboxTitle,
|
|
l.welcomeTrustSandboxBody,
|
|
),
|
|
(
|
|
Icons.verified_outlined,
|
|
l.welcomeTrustAuditTitle,
|
|
l.welcomeTrustAuditBody,
|
|
),
|
|
(
|
|
Icons.rocket_launch_outlined,
|
|
l.welcomeTrustAirgapTitle,
|
|
l.welcomeTrustAirgapBody,
|
|
),
|
|
];
|
|
return Column(
|
|
children: [
|
|
for (var i = 0; i < rows.length; i++) ...[
|
|
if (i > 0) const SizedBox(height: FaiSpace.sm),
|
|
_TrustRow(icon: rows[i].$1, title: rows[i].$2, body: rows[i].$3),
|
|
],
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class _TrustRow extends StatelessWidget {
|
|
final IconData icon;
|
|
final String title;
|
|
final String body;
|
|
|
|
const _TrustRow({
|
|
required this.icon,
|
|
required this.title,
|
|
required this.body,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
return Container(
|
|
padding: const EdgeInsets.all(FaiSpace.lg),
|
|
decoration: BoxDecoration(
|
|
color: theme.colorScheme.surfaceContainer,
|
|
borderRadius: BorderRadius.circular(FaiRadius.md),
|
|
border: Border.all(color: theme.colorScheme.outlineVariant),
|
|
),
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Icon(icon, size: 22, color: theme.colorScheme.primary),
|
|
const SizedBox(width: FaiSpace.lg),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
title,
|
|
style: theme.textTheme.titleSmall?.copyWith(
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
body,
|
|
style: theme.textTheme.bodyMedium?.copyWith(
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
height: 1.45,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// 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()
|
|
// ModuleSummary.name is the module identifier
|
|
// (e.g. `text-extract`, with a hyphen). The Onboarding-
|
|
// Hinweis fragt nach einem Text-Modul — das ist auf
|
|
// Capability-Ebene das `text.*`-Präfix (mit Punkt).
|
|
// Vorher haben wir den Modul-Namen geprüft und damit
|
|
// nie gematcht, obwohl text-extract / text-summarize /
|
|
// text-translate installiert waren.
|
|
.then(
|
|
(m) => m.any((x) => x.capabilities.any((c) => c.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),
|
|
_AllDoneCelebration(onDismiss: _dismiss),
|
|
],
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Shown beneath the four-step checklist once every signal
|
|
/// flips to done. Three concrete next-threads the operator
|
|
/// can pull on, plus the dismiss button. Each suggestion
|
|
/// links somewhere meaningful inside Studio (Audit page,
|
|
/// Today doc, Flows doc) — no external browser calls.
|
|
class _AllDoneCelebration extends StatelessWidget {
|
|
final VoidCallback onDismiss;
|
|
const _AllDoneCelebration({required this.onDismiss});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
final l = AppLocalizations.of(context)!;
|
|
return Container(
|
|
padding: const EdgeInsets.all(FaiSpace.lg),
|
|
decoration: BoxDecoration(
|
|
color: FaiColors.success.withValues(alpha: 0.08),
|
|
borderRadius: BorderRadius.circular(FaiRadius.md),
|
|
border: Border.all(color: FaiColors.success.withValues(alpha: 0.35)),
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Icon(Icons.celebration_outlined, color: FaiColors.success),
|
|
const SizedBox(width: FaiSpace.sm),
|
|
Expanded(
|
|
child: Text(
|
|
l.welcomeChecklistAllSetTitle,
|
|
style: theme.textTheme.titleSmall?.copyWith(
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
),
|
|
TextButton(
|
|
onPressed: onDismiss,
|
|
child: Text(l.welcomeChecklistDismiss),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
l.welcomeChecklistAllSetBody,
|
|
style: theme.textTheme.bodySmall?.copyWith(
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
const SizedBox(height: FaiSpace.md),
|
|
_NextStepRow(
|
|
icon: Icons.timeline_outlined,
|
|
title: l.welcomeChecklistNextAuditTitle,
|
|
body: l.welcomeChecklistNextAuditBody,
|
|
buttonLabel: l.welcomeChecklistNextAuditButton,
|
|
doc: null,
|
|
onOpenDoc: null,
|
|
),
|
|
_NextStepRow(
|
|
icon: Icons.auto_awesome,
|
|
title: l.welcomeChecklistNextTodayTitle,
|
|
body: l.welcomeChecklistNextTodayBody,
|
|
buttonLabel: l.welcomeChecklistNextTodayButton,
|
|
doc: _kDocs.firstWhere(
|
|
(d) => d.slug == 'flows',
|
|
orElse: () => _kDocs.first,
|
|
),
|
|
onOpenDoc: (entry) => _DocReaderSheet.show(context, entry),
|
|
),
|
|
_NextStepRow(
|
|
icon: Icons.extension_outlined,
|
|
title: l.welcomeChecklistNextModuleTitle,
|
|
body: l.welcomeChecklistNextModuleBody,
|
|
buttonLabel: l.welcomeChecklistNextModuleButton,
|
|
doc: _kDocs.first,
|
|
onOpenDoc: (entry) => _DocReaderSheet.show(context, entry),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _NextStepRow extends StatelessWidget {
|
|
final IconData icon;
|
|
final String title;
|
|
final String body;
|
|
final String buttonLabel;
|
|
final _DocEntry? doc;
|
|
final void Function(_DocEntry)? onOpenDoc;
|
|
|
|
const _NextStepRow({
|
|
required this.icon,
|
|
required this.title,
|
|
required this.body,
|
|
required this.buttonLabel,
|
|
required this.doc,
|
|
required this.onOpenDoc,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
final canActivate = doc != null && onOpenDoc != null;
|
|
return Padding(
|
|
padding: const EdgeInsets.only(bottom: FaiSpace.sm),
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Icon(icon, size: 18, color: theme.colorScheme.primary),
|
|
const SizedBox(width: FaiSpace.md),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
title,
|
|
style: theme.textTheme.bodyMedium?.copyWith(
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
const SizedBox(height: 2),
|
|
Text(
|
|
body,
|
|
style: theme.textTheme.bodySmall?.copyWith(
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(width: FaiSpace.md),
|
|
OutlinedButton(
|
|
onPressed: canActivate ? () => onOpenDoc!(doc!) : null,
|
|
child: Text(buttonLabel),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
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.
|
|
class _DocEntry {
|
|
final String slug;
|
|
final IconData icon;
|
|
final String Function(AppLocalizations) title;
|
|
final String Function(AppLocalizations) blurb;
|
|
const _DocEntry({
|
|
required this.slug,
|
|
required this.icon,
|
|
required this.title,
|
|
required this.blurb,
|
|
});
|
|
}
|
|
|
|
final List<_DocEntry> _kDocs = <_DocEntry>[
|
|
_DocEntry(
|
|
slug: 'architecture',
|
|
icon: Icons.account_tree_outlined,
|
|
title: (l) => l.welcomeDocArchitectureTitle,
|
|
blurb: (l) => l.welcomeDocArchitectureBlurb,
|
|
),
|
|
_DocEntry(
|
|
slug: 'security',
|
|
icon: Icons.shield_outlined,
|
|
title: (l) => l.welcomeDocSecurityTitle,
|
|
blurb: (l) => l.welcomeDocSecurityBlurb,
|
|
),
|
|
_DocEntry(
|
|
slug: 'audit',
|
|
icon: Icons.verified_outlined,
|
|
title: (l) => l.welcomeDocAuditTitle,
|
|
blurb: (l) => l.welcomeDocAuditBlurb,
|
|
),
|
|
_DocEntry(
|
|
slug: 'flows',
|
|
icon: Icons.alt_route_outlined,
|
|
title: (l) => l.welcomeDocFlowsTitle,
|
|
blurb: (l) => l.welcomeDocFlowsBlurb,
|
|
),
|
|
];
|
|
|
|
/// Two-by-two doc grid on wide windows, single column on
|
|
/// narrow. Each card opens `_DocReaderSheet` for its slug.
|
|
class _DocsRow extends StatelessWidget {
|
|
const _DocsRow();
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l = AppLocalizations.of(context)!;
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
l.welcomeDocsBody,
|
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
const SizedBox(height: FaiSpace.md),
|
|
LayoutBuilder(
|
|
builder: (context, constraints) {
|
|
final twoCols = constraints.maxWidth >= 640;
|
|
// Narrow windows stack to a single column. Putting
|
|
// `width: double.infinity` SizedBoxes into a Wrap
|
|
// breaks Wrap's intrinsic-width math; a Column with
|
|
// unconstrained child widths is the correct fit.
|
|
if (!twoCols) {
|
|
return Column(
|
|
children: [
|
|
for (var i = 0; i < _kDocs.length; i++) ...[
|
|
if (i > 0) const SizedBox(height: FaiSpace.md),
|
|
_DocCard(entry: _kDocs[i]),
|
|
],
|
|
],
|
|
);
|
|
}
|
|
final cardWidth = (constraints.maxWidth - FaiSpace.md) / 2;
|
|
return Wrap(
|
|
spacing: FaiSpace.md,
|
|
runSpacing: FaiSpace.md,
|
|
children: [
|
|
for (final d in _kDocs)
|
|
SizedBox(
|
|
width: cardWidth,
|
|
child: _DocCard(entry: d),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class _DocCard extends StatelessWidget {
|
|
final _DocEntry entry;
|
|
const _DocCard({required this.entry});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
final l = AppLocalizations.of(context)!;
|
|
return Material(
|
|
color: theme.colorScheme.surfaceContainer,
|
|
borderRadius: BorderRadius.circular(FaiRadius.md),
|
|
child: InkWell(
|
|
onTap: () => _DocReaderSheet.show(context, entry),
|
|
borderRadius: BorderRadius.circular(FaiRadius.md),
|
|
child: Container(
|
|
padding: const EdgeInsets.all(FaiSpace.lg),
|
|
decoration: BoxDecoration(
|
|
borderRadius: BorderRadius.circular(FaiRadius.md),
|
|
border: Border.all(color: theme.colorScheme.outlineVariant),
|
|
),
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Icon(entry.icon, size: 22, color: theme.colorScheme.primary),
|
|
const SizedBox(width: FaiSpace.lg),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
entry.title(l),
|
|
style: theme.textTheme.titleSmall?.copyWith(
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
entry.blurb(l),
|
|
style: theme.textTheme.bodySmall?.copyWith(
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
height: 1.4,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
Icon(
|
|
Icons.chevron_right,
|
|
size: 18,
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Structured error type for the doc reader. Allows the
|
|
/// error UI to render distinct lines for "what we tried",
|
|
/// "what to try next", and "the raw underlying error" — and
|
|
/// to surface the Forgejo browser fallback as a one-click
|
|
/// link instead of a sentence to copy.
|
|
class _DocReaderError {
|
|
final String slug;
|
|
final String locale;
|
|
final List<String> attempted;
|
|
final String underlying;
|
|
const _DocReaderError({
|
|
required this.slug,
|
|
required this.locale,
|
|
required this.attempted,
|
|
required this.underlying,
|
|
});
|
|
|
|
String get forgejoUrl =>
|
|
'https://git.flemming.ai/fai/platform/src/branch/main/docs/architecture/$slug.md';
|
|
|
|
@override
|
|
String toString() =>
|
|
'Could not load the bundled doc "$slug" ($locale).\n'
|
|
'Tried: ${attempted.join(", ")}\n'
|
|
'Underlying: $underlying\n'
|
|
'This typically means the Studio binary on disk is from\n'
|
|
'before the doc bundle changed. Rebuild Studio (fai studio\n'
|
|
'or the build pipeline) or open the Forgejo copy in browser.';
|
|
}
|
|
|
|
/// Modal bottom-sheet that loads the markdown for a doc entry
|
|
/// from `assets/docs/<slug>[_de].md` and renders it inline via
|
|
/// flutter_markdown. No browser, no network — KRITIS-friendly.
|
|
class _DocReaderSheet extends StatefulWidget {
|
|
final _DocEntry entry;
|
|
|
|
const _DocReaderSheet({required this.entry});
|
|
|
|
static Future<void> show(BuildContext context, _DocEntry entry) {
|
|
return showModalBottomSheet<void>(
|
|
context: context,
|
|
isScrollControlled: true,
|
|
backgroundColor: Theme.of(context).colorScheme.surfaceContainer,
|
|
shape: const RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.vertical(top: Radius.circular(FaiRadius.md)),
|
|
),
|
|
builder: (_) => _DocReaderSheet(entry: entry),
|
|
);
|
|
}
|
|
|
|
@override
|
|
State<_DocReaderSheet> createState() => _DocReaderSheetState();
|
|
}
|
|
|
|
class _DocReaderSheetState extends State<_DocReaderSheet> {
|
|
Future<String>? _content;
|
|
|
|
@override
|
|
void didChangeDependencies() {
|
|
super.didChangeDependencies();
|
|
// `_load()` needs `Localizations.localeOf(context)`. That call
|
|
// touches InheritedWidgets which aren't safe to read in
|
|
// initState() — must wait until didChangeDependencies. We
|
|
// gate on `_content == null` so it runs exactly once.
|
|
_content ??= _load();
|
|
}
|
|
|
|
Future<String> _load() async {
|
|
final locale = Localizations.localeOf(context).languageCode;
|
|
final localised = 'assets/docs/${widget.entry.slug}_$locale.md';
|
|
final fallback = 'assets/docs/${widget.entry.slug}.md';
|
|
// Cache-bust attempts. If the operator is running a stale
|
|
// build from before the flutter_markdown_plus migration,
|
|
// the .dart_tool asset manifest can be inconsistent with
|
|
// the compiled binary — surface "rebuild Studio" as a
|
|
// first-class hint rather than just dumping the raw
|
|
// PlatformException string into FaiErrorBox.
|
|
try {
|
|
return await rootBundle.loadString(localised);
|
|
} catch (firstErr) {
|
|
try {
|
|
return await rootBundle.loadString(fallback);
|
|
} catch (secondErr) {
|
|
throw _DocReaderError(
|
|
slug: widget.entry.slug,
|
|
locale: locale,
|
|
attempted: [localised, fallback],
|
|
underlying: '$firstErr / $secondErr',
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
final l = AppLocalizations.of(context)!;
|
|
final maxHeight = MediaQuery.of(context).size.height * 0.85;
|
|
return ConstrainedBox(
|
|
constraints: BoxConstraints(maxHeight: maxHeight),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: FaiSpace.sm),
|
|
child: Container(
|
|
width: 40,
|
|
height: 4,
|
|
decoration: BoxDecoration(
|
|
color: theme.colorScheme.outlineVariant,
|
|
borderRadius: BorderRadius.circular(2),
|
|
),
|
|
),
|
|
),
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(
|
|
FaiSpace.xxl,
|
|
FaiSpace.sm,
|
|
FaiSpace.lg,
|
|
FaiSpace.sm,
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Icon(
|
|
widget.entry.icon,
|
|
size: 20,
|
|
color: theme.colorScheme.primary,
|
|
),
|
|
const SizedBox(width: FaiSpace.sm),
|
|
Expanded(
|
|
child: Text(
|
|
widget.entry.title(l),
|
|
style: theme.textTheme.titleMedium?.copyWith(
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
),
|
|
IconButton(
|
|
icon: const Icon(Icons.close, size: 18),
|
|
visualDensity: VisualDensity.compact,
|
|
tooltip: l.welcomeDocClose,
|
|
onPressed: () => Navigator.pop(context),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const Divider(height: 1),
|
|
Flexible(
|
|
child: FutureBuilder<String>(
|
|
future: _content,
|
|
builder: (context, snap) {
|
|
if (snap.connectionState == ConnectionState.waiting) {
|
|
return const Padding(
|
|
padding: EdgeInsets.all(FaiSpace.xxl),
|
|
child: Center(child: CircularProgressIndicator()),
|
|
);
|
|
}
|
|
if (snap.hasError) {
|
|
final err = snap.error;
|
|
final structured = err is _DocReaderError ? err : null;
|
|
return Padding(
|
|
padding: const EdgeInsets.all(FaiSpace.xxl),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
l.welcomeDocFailedToLoad(''),
|
|
style: theme.textTheme.bodyMedium?.copyWith(
|
|
color: theme.colorScheme.error,
|
|
),
|
|
),
|
|
const SizedBox(height: FaiSpace.sm),
|
|
FaiErrorBox(error: err, isError: true, maxHeight: 200),
|
|
if (structured != null) ...[
|
|
const SizedBox(height: FaiSpace.md),
|
|
TextButton.icon(
|
|
icon: const Icon(Icons.open_in_new, size: 16),
|
|
label: Text(structured.forgejoUrl),
|
|
onPressed: () =>
|
|
SystemActions.openInOs(structured.forgejoUrl),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
);
|
|
}
|
|
return Markdown(
|
|
data: snap.data ?? '',
|
|
padding: const EdgeInsets.all(FaiSpace.xxl),
|
|
selectable: true,
|
|
onTapLink: (text, href, title) async {
|
|
if (href != null && href.isNotEmpty) {
|
|
await SystemActions.openInOs(href);
|
|
}
|
|
},
|
|
styleSheet: FaiTheme.markdownStyle(theme),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|