feat(studio): first-run UX, recovery affordances, l10n, bundled fonts
- Connection-aware Welcome: when the hub is down, show a hero with a primary "Start hub" CTA + install fallback instead of a dead, all-unchecked onboarding checklist (the first-run cliff). - Actionable binary-not-found (file picker + install link, not a "set FAI_BIN" dead end) and a connect-failure banner after repeated failed health polls. - Localize six hardcoded English error/toast clusters (DE+EN ARB). - Bundle Inter + JetBrains Mono as assets; drop the runtime google_fonts fetch (air-gap / KRITIS safe, no font-swap flash). Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
parent
7511867774
commit
5313266cc4
25 changed files with 1231 additions and 234 deletions
|
|
@ -973,7 +973,7 @@ class _ExplanationPanel extends StatelessWidget {
|
|||
: theme.colorScheme.error,
|
||||
),
|
||||
),
|
||||
if (result!.fixHint.isNotEmpty) ...[
|
||||
if (result!.fixHint(l).isNotEmpty) ...[
|
||||
const SizedBox(height: FaiSpace.sm),
|
||||
Text(
|
||||
l.auditFixLabel,
|
||||
|
|
@ -984,7 +984,7 @@ class _ExplanationPanel extends StatelessWidget {
|
|||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
result!.fixHint,
|
||||
result!.fixHint(l),
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurface,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -28,6 +28,11 @@ class WelcomePage extends StatelessWidget {
|
|||
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)),
|
||||
|
|
@ -52,22 +57,27 @@ class WelcomePage extends StatelessWidget {
|
|||
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(),
|
||||
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(),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
@ -149,6 +159,113 @@ class _Hero extends StatelessWidget {
|
|||
}
|
||||
}
|
||||
|
||||
/// 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.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 {
|
||||
|
|
@ -904,58 +1021,82 @@ class _DocsRow extends StatelessWidget {
|
|||
}
|
||||
}
|
||||
|
||||
class _DocCard extends StatelessWidget {
|
||||
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)!;
|
||||
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(
|
||||
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),
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(FaiSpace.lg),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(FaiRadius.md),
|
||||
border: Border.all(color: theme.colorScheme.outlineVariant),
|
||||
),
|
||||
Icon(
|
||||
Icons.chevron_right,
|
||||
size: 18,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
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,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
@ -983,6 +1124,15 @@ class _DocReaderError {
|
|||
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'
|
||||
|
|
@ -1006,6 +1156,7 @@ class _DocReaderSheet extends StatefulWidget {
|
|||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Theme.of(context).colorScheme.surfaceContainer,
|
||||
elevation: 8,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(FaiRadius.md)),
|
||||
),
|
||||
|
|
@ -1135,7 +1286,18 @@ class _DocReaderSheetState extends State<_DocReaderSheet> {
|
|||
),
|
||||
),
|
||||
const SizedBox(height: FaiSpace.sm),
|
||||
FaiErrorBox(error: err, isError: true, maxHeight: 200),
|
||||
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(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue