Some checks are pending
Security / Security check (push) Waiting to run
Stefan's live findings, all four addressed at the root:
- 'In 3 Fragen loslegen' read like ad copy → the entry is now plainly
'Einrichtung starten' / 'Start setup'.
- The setup button sat permanently on the Welcome page of a running
app ('setup after the app runs is backwards' — reported twice). A
fresh install now starts INSIDE the setup: SetupGateScreen hosts
the wizard embedded as the page (new embedded/onFinished modes on
GuidedSetupDialog), with an explicit 'Später einrichten' skip.
Welcome loses the setup button entirely and stays a calm intro.
- Re-running the setup later lives in Settings → General ('Run setup
again…'), the single post-first-run home.
- 'You must grant access first and only then see what will be done':
the preview used to spawn the chain CLI, whose first run could pop
the macOS permission prompt BEFORE the plan was ever shown. The
preview now calls the new PlanSetup RPC over the live hub
connection (no subprocess, nothing granted); the CLI remains only
a fallback when no hub is reachable — and applying stays the
explicit, separate step.
Widget tests: gate hosts the wizard + skip/cancel leave it; CLI-path
tests drive the fallback through the new hub-preview test seam.
Suite 76 green, analyze clean.
Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
1410 lines
47 KiB
Dart
1410 lines
47 KiB
Dart
// Welcome — first surface a new operator sees, default sidebar
|
|
// destination on launch. Pure introduction: what Ch∆In 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/guided_setup_dialog.dart';
|
|
import '../widgets/widgets.dart';
|
|
|
|
class WelcomePage extends StatefulWidget {
|
|
const WelcomePage({super.key});
|
|
|
|
@override
|
|
State<WelcomePage> createState() => _WelcomePageState();
|
|
}
|
|
|
|
class _WelcomePageState extends State<WelcomePage> {
|
|
// Setup no longer lives on this page. A fresh install starts in
|
|
// the SetupGateScreen (setup BEFORE the app, not a dialog over
|
|
// it), and re-running the setup later is a Settings action —
|
|
// Welcome stays a calm introduction, not a second setup surface.
|
|
|
|
@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: ChainSpace.xl, title: Text(l.navWelcome)),
|
|
body: SingleChildScrollView(
|
|
child: Padding(
|
|
padding: const EdgeInsets.fromLTRB(
|
|
ChainSpace.xl,
|
|
ChainSpace.lg,
|
|
ChainSpace.xl,
|
|
ChainSpace.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: ChainSpace.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: ChainSpace.xxl),
|
|
] else
|
|
const _OnboardingChecklist(),
|
|
const _PillarRow(),
|
|
const SizedBox(height: ChainSpace.xxl),
|
|
const _SectionLabel(textKey: _SectionLabelKey.trust),
|
|
const SizedBox(height: ChainSpace.md),
|
|
const _TrustPosture(),
|
|
const SizedBox(height: ChainSpace.xxl),
|
|
const _SectionLabel(textKey: _SectionLabelKey.docs),
|
|
const SizedBox(height: ChainSpace.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(ChainSpace.xxl),
|
|
decoration: BoxDecoration(
|
|
borderRadius: BorderRadius.circular(ChainRadius.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(ChainSpace.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: ChainSpace.lg),
|
|
Expanded(
|
|
child: Text(
|
|
l.welcomeHeroTitle,
|
|
style: theme.textTheme.displaySmall?.copyWith(
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: ChainSpace.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<void> _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.chainBinaryExists();
|
|
return Container(
|
|
width: double.infinity,
|
|
padding: const EdgeInsets.all(ChainSpace.xl),
|
|
decoration: BoxDecoration(
|
|
color: theme.colorScheme.errorContainer.withValues(alpha: 0.35),
|
|
borderRadius: BorderRadius.circular(ChainRadius.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: ChainSpace.md),
|
|
Expanded(
|
|
child: Text(
|
|
l.welcomeHubDownTitle,
|
|
style: theme.textTheme.titleLarge?.copyWith(
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: ChainSpace.md),
|
|
Text(
|
|
l.welcomeHubDownBody,
|
|
style: theme.textTheme.bodyMedium?.copyWith(
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
height: 1.45,
|
|
),
|
|
),
|
|
const SizedBox(height: ChainSpace.xs),
|
|
Text(
|
|
l.welcomeHubDownEndpoint(HubService.instance.endpointLabel),
|
|
style: ChainTheme.mono(
|
|
size: 11,
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
const SizedBox(height: ChainSpace.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 ChainBinaryRecovery(),
|
|
const SizedBox(height: ChainSpace.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: ChainSpace.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: ChainSpace.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(ChainSpace.lg),
|
|
decoration: BoxDecoration(
|
|
color: theme.colorScheme.surfaceContainer,
|
|
borderRadius: BorderRadius.circular(ChainRadius.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: ChainSpace.md),
|
|
Text(
|
|
title,
|
|
style: theme.textTheme.titleLarge?.copyWith(
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
const SizedBox(height: ChainSpace.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: ChainSpace.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(ChainSpace.lg),
|
|
decoration: BoxDecoration(
|
|
color: theme.colorScheme.surfaceContainer,
|
|
borderRadius: BorderRadius.circular(ChainRadius.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: ChainSpace.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;
|
|
|
|
/// 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();
|
|
_bootstrap();
|
|
}
|
|
|
|
Future<void> _bootstrap() async {
|
|
final prefs = await SharedPreferences.getInstance();
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_dismissed = prefs.getBool(_kChecklistDismissedKey) ?? false;
|
|
_setupProfile = GuidedSetupDialog.appliedSetupProfile();
|
|
_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()
|
|
// "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<void> _dismiss() async {
|
|
final prefs = await SharedPreferences.getInstance();
|
|
await prefs.setBool(_kChecklistDismissedKey, true);
|
|
if (!mounted) return;
|
|
setState(() => _dismissed = true);
|
|
}
|
|
|
|
/// Open Settings (where System-AI + MCP are configured) and
|
|
/// re-probe when it closes. The Settings dialog overlays the
|
|
/// welcome page rather than navigating, so without this the
|
|
/// checklist kept showing the pre-config state (e.g. "connect
|
|
/// System-AI" stayed unchecked right after saving a working
|
|
/// config). Navigation-based rows self-heal on return; these
|
|
/// dialog-based ones did not.
|
|
Future<void> _openSettingsThenRefresh() async {
|
|
await ChainSettingsDialog.show(context);
|
|
if (mounted) await _refresh();
|
|
}
|
|
|
|
/// 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();
|
|
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,
|
|
_openSettingsThenRefresh,
|
|
),
|
|
(
|
|
_mcpOk,
|
|
l.welcomeChecklistMcp,
|
|
l.welcomeChecklistMcpHint,
|
|
_openSettingsThenRefresh,
|
|
),
|
|
(
|
|
_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: ChainSpace.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,
|
|
),
|
|
),
|
|
// 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(
|
|
color: theme.colorScheme.surfaceContainer,
|
|
borderRadius: BorderRadius.circular(ChainRadius.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: ChainSpace.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(ChainSpace.lg),
|
|
decoration: BoxDecoration(
|
|
color: ChainColors.success.withValues(alpha: 0.08),
|
|
borderRadius: BorderRadius.circular(ChainRadius.md),
|
|
border: Border.all(color: ChainColors.success.withValues(alpha: 0.35)),
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Icon(Icons.celebration_outlined, color: ChainColors.success),
|
|
const SizedBox(width: ChainSpace.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: ChainSpace.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: ChainSpace.sm),
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Icon(icon, size: 18, color: theme.colorScheme.primary),
|
|
const SizedBox(width: ChainSpace.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: ChainSpace.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(ChainSpace.lg),
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Icon(
|
|
done ? Icons.check_circle : Icons.radio_button_unchecked,
|
|
size: 20,
|
|
color: done ? ChainColors.success : theme.colorScheme.outline,
|
|
),
|
|
const SizedBox(width: ChainSpace.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: ChainSpace.md),
|
|
ChainPill(
|
|
label: done ? l.welcomeChecklistDone : l.welcomeChecklistTodo,
|
|
tone: done ? ChainPillTone.success : ChainPillTone.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 `<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,
|
|
),
|
|
_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<void>? 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: ChainSpace.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: ChainSpace.md),
|
|
_DocCard(entry: _kDocs[i]),
|
|
],
|
|
],
|
|
);
|
|
}
|
|
// Pair the cards into equal-height rows: IntrinsicHeight
|
|
// + stretch makes the two cards in a row match height
|
|
// instead of the Wrap's ragged tops (their blurbs differ
|
|
// in length, especially across locales). An odd trailing
|
|
// card spans the full width so it reads as intentional
|
|
// rather than a lonely half-box with dead space beside it.
|
|
final rows = <Widget>[];
|
|
for (var i = 0; i < _kDocs.length; i += 2) {
|
|
if (rows.isNotEmpty) {
|
|
rows.add(const SizedBox(height: ChainSpace.md));
|
|
}
|
|
final left = _kDocs[i];
|
|
final right = i + 1 < _kDocs.length ? _kDocs[i + 1] : null;
|
|
if (right == null) {
|
|
rows.add(_DocCard(entry: left));
|
|
} else {
|
|
rows.add(
|
|
IntrinsicHeight(
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
Expanded(child: _DocCard(entry: left)),
|
|
const SizedBox(width: ChainSpace.md),
|
|
Expanded(child: _DocCard(entry: right)),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
return Column(children: rows);
|
|
},
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
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: ChainMotion.base,
|
|
curve: ChainMotion.easing,
|
|
transform: Matrix4.translationValues(0, _hovered ? -2 : 0, 0),
|
|
decoration: BoxDecoration(
|
|
borderRadius: BorderRadius.circular(ChainRadius.md),
|
|
boxShadow: _hovered ? ChainElevation.medium(theme.brightness) : null,
|
|
),
|
|
child: Material(
|
|
color: theme.colorScheme.surfaceContainer,
|
|
borderRadius: BorderRadius.circular(ChainRadius.md),
|
|
child: InkWell(
|
|
onTap: () => _DocReaderSheet.show(context, entry),
|
|
borderRadius: BorderRadius.circular(ChainRadius.md),
|
|
child: Container(
|
|
padding: const EdgeInsets.all(ChainSpace.lg),
|
|
decoration: BoxDecoration(
|
|
borderRadius: BorderRadius.circular(ChainRadius.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: ChainSpace.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/chain/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 (chain 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,
|
|
elevation: 8,
|
|
shape: const RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.vertical(top: Radius.circular(ChainRadius.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 ChainErrorBox.
|
|
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: ChainSpace.sm),
|
|
child: Container(
|
|
width: 40,
|
|
height: 4,
|
|
decoration: BoxDecoration(
|
|
color: theme.colorScheme.outlineVariant,
|
|
borderRadius: BorderRadius.circular(2),
|
|
),
|
|
),
|
|
),
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(
|
|
ChainSpace.xxl,
|
|
ChainSpace.sm,
|
|
ChainSpace.lg,
|
|
ChainSpace.sm,
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Icon(
|
|
widget.entry.icon,
|
|
size: 20,
|
|
color: theme.colorScheme.primary,
|
|
),
|
|
const SizedBox(width: ChainSpace.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(ChainSpace.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(ChainSpace.xxl),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
l.welcomeDocFailedToLoad(''),
|
|
style: theme.textTheme.bodyMedium?.copyWith(
|
|
color: theme.colorScheme.error,
|
|
),
|
|
),
|
|
const SizedBox(height: ChainSpace.sm),
|
|
if (structured != null)
|
|
ChainErrorBox(
|
|
text: structured.localizedBody(l),
|
|
isError: true,
|
|
maxHeight: 200,
|
|
)
|
|
else
|
|
ChainErrorBox(
|
|
error: err,
|
|
isError: true,
|
|
maxHeight: 200,
|
|
),
|
|
if (structured != null) ...[
|
|
const SizedBox(height: ChainSpace.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(ChainSpace.xxl),
|
|
selectable: true,
|
|
onTapLink: (text, href, title) async {
|
|
if (href != null && href.isNotEmpty) {
|
|
await SystemActions.openInOs(href);
|
|
}
|
|
},
|
|
styleSheet: ChainTheme.markdownStyle(theme),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|