// 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 '../main.dart' show StudioShellState; 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)!; // The shell polls hub health on a 5 s tick. When the hub is // down a brand-new operator otherwise sees a dead, all- // unchecked onboarding checklist with no obvious recovery — // so we lead with a "start the hub" hero instead. final hubDown = StudioShellState.of(context)?.connected == false; 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(), const SizedBox(height: FaiSpace.xxl), // When the hub is down the onboarding checklist // would render dead (all-unchecked, nothing to // probe). Lead with a "start the hub" card // instead; restore the checklist once connected. if (hubDown) ...[ const _HubDownHero(), const SizedBox(height: FaiSpace.xxl), ] else const _OnboardingChecklist(), const _PillarRow(), const SizedBox(height: FaiSpace.xxl), const _SectionLabel(textKey: _SectionLabelKey.trust), const SizedBox(height: FaiSpace.md), const _TrustPosture(), const SizedBox(height: FaiSpace.xxl), const _SectionLabel(textKey: _SectionLabelKey.docs), const SizedBox(height: FaiSpace.md), const _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, ), ), ], ), ); } } /// Prominent recovery hero shown on the Welcome page when the /// hub is not reachable. Headline + one-line explanation + a big /// "Start hub" CTA wired to the shell's daemon-start path, with /// a secondary link to the install guide for the case where no /// `fai` binary can be located. class _HubDownHero extends StatefulWidget { const _HubDownHero(); @override State<_HubDownHero> createState() => _HubDownHeroState(); } class _HubDownHeroState extends State<_HubDownHero> { bool _starting = false; Future _start() async { final shell = StudioShellState.of(context); if (shell == null) return; setState(() => _starting = true); await shell.startDaemon(); if (!mounted) return; setState(() => _starting = false); } @override Widget build(BuildContext context) { final theme = Theme.of(context); final l = AppLocalizations.of(context)!; // No `fai` binary → "Start hub" cannot work; lead the // operator to the install guide instead. final canStart = SystemActions.faiBinaryExists(); return Container( width: double.infinity, padding: const EdgeInsets.all(FaiSpace.xl), decoration: BoxDecoration( color: theme.colorScheme.errorContainer.withValues(alpha: 0.35), borderRadius: BorderRadius.circular(FaiRadius.md), border: Border.all( color: theme.colorScheme.error.withValues(alpha: 0.4), ), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Icon( Icons.cloud_off_outlined, size: 24, color: theme.colorScheme.error, ), const SizedBox(width: FaiSpace.md), Expanded( child: Text( l.welcomeHubDownTitle, style: theme.textTheme.titleLarge?.copyWith( fontWeight: FontWeight.w700, ), ), ), ], ), const SizedBox(height: FaiSpace.md), Text( l.welcomeHubDownBody, style: theme.textTheme.bodyMedium?.copyWith( color: theme.colorScheme.onSurfaceVariant, height: 1.45, ), ), const SizedBox(height: FaiSpace.xs), Text( l.welcomeHubDownEndpoint(HubService.instance.endpointLabel), style: FaiTheme.mono( size: 11, color: theme.colorScheme.onSurfaceVariant, ), ), const SizedBox(height: FaiSpace.lg), if (canStart) FilledButton.icon( onPressed: _starting ? null : _start, icon: _starting ? const SizedBox( width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2), ) : const Icon(Icons.play_arrow), label: Text( _starting ? l.welcomeHubDownStarting : l.welcomeHubDownStart, ), ) else const FaiBinaryRecovery(), const SizedBox(height: FaiSpace.sm), TextButton.icon( icon: const Icon(Icons.open_in_new, size: 16), label: Text(l.welcomeHubDownDocsLink), onPressed: () => SystemActions.openInOs(kFaiInstallDocsUrl), ), ], ), ); } } 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 _bootstrap() async { final prefs = await SharedPreferences.getInstance(); if (!mounted) return; setState(() { _dismissed = prefs.getBool(_kChecklistDismissedKey) ?? false; _initialised = true; }); if (!_dismissed) { await _refresh(); } } Future _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() // "A module is installed" = any installed module providing a // non-built-in capability. We match on the capability rather // than the module name (which is hyphenated, e.g. // `text-extract`), and accept ANY provider — so the doc'd // first install of `debug.echo` ticks the box, not just // `text.*`. `system.*` are built-ins, not installs. .then( (m) => m.any( (x) => x.capabilities.any((c) => !c.startsWith('system.')), ), ) .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 _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; // Each row gets an `onTap` that takes the operator to // the page where they can actually complete the item. // Replaces the old "read the hint then guess where the // feature lives" behaviour with a one-click hop. final shell = StudioShellState.of(context); final items = <(bool, String, String, VoidCallback?)>[ ( _aiOk, l.welcomeChecklistAi, l.welcomeChecklistAiHint, () => FaiSettingsDialog.show(context), ), ( _mcpOk, l.welcomeChecklistMcp, l.welcomeChecklistMcpHint, () => FaiSettingsDialog.show(context), ), ( _moduleOk, l.welcomeChecklistModule, l.welcomeChecklistModuleHint, shell == null ? null : () => shell.navigateTo('store'), ), ( _flowOk, l.welcomeChecklistFlow, l.welcomeChecklistFlowHint, shell == null ? null : () => shell.navigateTo('flows'), ), ]; 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, onTap: items[i].$4, ), ], ], ), ), 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; /// Tap handler — takes the operator to the page where /// they can actually complete the item. null = no-op /// (e.g. AI item when System AI was already configured). final VoidCallback? onTap; const _ChecklistRow({ required this.done, required this.title, required this.hint, this.onTap, }); @override Widget build(BuildContext context) { final theme = Theme.of(context); final l = AppLocalizations.of(context)!; final row = 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, ), ], ), ); // Wrap in InkWell only when a tap target exists so done // rows (no action needed) don't shimmer hover-feedback // they can't act on. if (onTap == null) return row; return InkWell(onTap: onTap, child: row); } } /// Doc-card entries — each links to a bundled markdown file /// under `assets/docs/`. The slug is the filename stem; the /// loader picks `.md` or `_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, ), _DocEntry( slug: 'approvals', icon: Icons.gavel_outlined, title: (l) => l.welcomeDocApprovalsTitle, blurb: (l) => l.welcomeDocApprovalsBlurb, ), ]; /// Public entry-point for the doc-reader sheet. Pass a slug /// ("approvals", "audit", "security", "architecture", "flows") /// and the bottom sheet opens with the localized markdown. /// Returns null when the slug isn't registered — caller can /// log + show a SnackBar. Future? showFaiDoc(BuildContext context, String slug) { final entry = _kDocs.firstWhere( (d) => d.slug == slug, orElse: () => _kDocs.first, ); return _DocReaderSheet.show(context, entry); } /// 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 StatefulWidget { final _DocEntry entry; const _DocCard({required this.entry}); @override State<_DocCard> createState() => _DocCardState(); } class _DocCardState extends State<_DocCard> { bool _hovered = false; @override Widget build(BuildContext context) { final theme = Theme.of(context); final l = AppLocalizations.of(context)!; final entry = widget.entry; // Hover-lift mirrors the Store cards: a gentle 2px rise plus // shadow bump. Resting state stays flat-with-border so the // doc strip reads calm until the cursor invites a click. return MouseRegion( onEnter: (_) => setState(() => _hovered = true), onExit: (_) => setState(() => _hovered = false), child: AnimatedContainer( duration: FaiMotion.base, curve: FaiMotion.easing, transform: Matrix4.translationValues(0, _hovered ? -2 : 0, 0), decoration: BoxDecoration( borderRadius: BorderRadius.circular(FaiRadius.md), boxShadow: _hovered ? FaiElevation.medium(theme.brightness) : null, ), child: 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 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'; /// Localized, operator-facing failure body. Use this for any /// on-screen rendering; [toString] stays English for logs. String localizedBody(AppLocalizations l) => l.welcomeDocLoadFailedBody( slug, locale, attempted.join(', '), underlying, ); @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/[_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 show(BuildContext context, _DocEntry entry) { return showModalBottomSheet( context: context, isScrollControlled: true, backgroundColor: Theme.of(context).colorScheme.surfaceContainer, elevation: 8, 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? _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 _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( 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), if (structured != null) FaiErrorBox( text: structured.localizedBody(l), isError: true, maxHeight: 200, ) else 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), ); }, ), ), ], ), ); } }