chain-studio/lib/main.dart
flemming-it 5830198566
Some checks failed
Security / Security check (push) Failing after 1s
fix(studio): sidebar — fixed-width icon column eliminates icon shift
Previous "left-anchor everything via padding" approach made
the math work on paper but the perceived shift persisted. The
root cause was structural, not padding-based: ListView gave
each item a tight cross-axis constraint equal to the rail's
current width (48 collapsed, 196 expanded). Inside the item,
the AnimatedContainer filled that constraint and laid out a
mainAxisSize.min Row at the start of the AC's content area.
The icon's pixel-X depended on the AC's padding _and_ the
constraint; even with equal padding the rendered layout
shifted across the expand animation because Flutter
re-resolved alignment under a moving constraint.

Replace the entire pattern with a fixed-width icon column
that doesn't care about the parent constraint:

  ListView (padding 0) → Item Row
    SizedBox(width: 72) → Center → icon   ← anchored to literal x
    if expanded → Expanded → label text   ← grows into remaining space

Same pattern for the brand/connection/channel rows above. The
72 px matches the rail's collapsed width exactly, so collapsed
rail = one icon column + nothing; expanded rail = same icon
column + label slot. Icon's pixel-X is now provably constant.

Removes the AC's horizontal padding entirely — the icon
column owns the left anchor. The background highlight still
spans the full row width (typical nav-rail UX), and the rail
expand animation only moves the rail's right edge, never any
icon-bearing element.

Also passes an `iconColumnWidth: 72` to _SidebarItem instead
of hard-coding the magic number in two places.

Version 0.51.3 → 0.51.4.

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
2026-05-30 16:15:03 +02:00

1252 lines
41 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// F∆I Studio — desktop GUI for the F∆I hub.
//
// Visual language: warm minimalism / deep tech with soul.
// Tokens in lib/theme/, primitives in lib/widgets/.
import 'dart:async';
import 'package:fai_studio_flow_editor/fai_studio_flow_editor.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'data/hub.dart';
import 'data/system_actions.dart';
import 'data/theme_plugin.dart';
import 'l10n/app_localizations.dart';
import 'pages/approvals.dart';
import 'pages/audit.dart';
import 'pages/doctor.dart';
import 'pages/flows.dart';
import 'pages/store.dart';
import 'pages/welcome.dart';
import 'theme/theme.dart';
import 'theme/tokens.dart';
import 'widgets/fai_search_palette.dart';
import 'widgets/widgets.dart';
/// Studio's own build version. Bump on every UI commit so the
/// running app self-identifies — visible in the sidebar header
/// and quick-glance proof that you're seeing the current build.
const String kStudioVersion = '0.51.4';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
// Restore the persisted endpoint, theme mode, locale, and
// (when installed) the operator's theme-plugin choice
// before the first frame so nothing flickers on startup.
await HubService.instance.loadPersistedEndpoint();
final themeMode = await HubService.instance.loadThemeMode();
final locale = await HubService.instance.loadLocale();
final themePlugin = await loadActiveThemePlugin();
runApp(StudioApp(
initialThemeMode: themeMode,
initialLocale: locale,
initialThemePlugin: themePlugin,
));
}
class StudioApp extends StatefulWidget {
final ThemeModeValue initialThemeMode;
final Locale initialLocale;
/// Capability name of the active theme plugin (e.g.
/// `studio.theme.solarized`) or `null` for the built-in
/// FaiTheme.
final String? initialThemePlugin;
const StudioApp({
super.key,
required this.initialThemeMode,
required this.initialLocale,
this.initialThemePlugin,
});
/// Lookup helper so descendants can flip the theme without
/// passing callbacks down through every level.
static StudioAppState? of(BuildContext context) =>
context.findAncestorStateOfType<StudioAppState>();
@override
State<StudioApp> createState() => StudioAppState();
}
class StudioAppState extends State<StudioApp> {
/// Exposed so any descendant can listen via
/// `ValueListenableBuilder` and rebuild instantly when the
/// theme flips. setState alone wouldn't propagate through
/// `MaterialApp(home: const StudioShell())` because the
/// const child's element doesn't get a `didUpdateWidget`.
late final ValueNotifier<ThemeModeValue> modeNotifier;
/// Active app locale. The sidebar's language toggle flips
/// this, MaterialApp re-renders with the new
/// AppLocalizations, and every translated string updates.
late final ValueNotifier<Locale> localeNotifier;
/// Capability name of the active `studio.theme.*` plugin
/// (e.g. `studio.theme.solarized`) or `null` for
/// FaiTheme's built-in palette. Flipping this triggers a
/// re-fetch of the plugin's ColorSchemes; the MaterialApp
/// rebuilds with the new themes.
late final ValueNotifier<String?> themePluginNotifier;
Future<void> setMode(ThemeModeValue m) async {
modeNotifier.value = m;
await HubService.instance.saveThemeMode(m);
}
Future<void> setLocale(Locale l) async {
localeNotifier.value = l;
await HubService.instance.saveLocale(l);
}
Future<void> setThemePlugin(String? capability) async {
themePluginNotifier.value = capability;
await saveActiveThemePlugin(capability);
}
@override
void initState() {
super.initState();
modeNotifier = ValueNotifier(widget.initialThemeMode);
localeNotifier = ValueNotifier(widget.initialLocale);
themePluginNotifier = ValueNotifier(widget.initialThemePlugin);
}
@override
void dispose() {
modeNotifier.dispose();
localeNotifier.dispose();
themePluginNotifier.dispose();
super.dispose();
}
ThemeMode _flutterMode(ThemeModeValue v) {
switch (v) {
case ThemeModeValue.system:
return ThemeMode.system;
case ThemeModeValue.light:
return ThemeMode.light;
case ThemeModeValue.dark:
return ThemeMode.dark;
}
}
/// Build (or return null) ThemeData pair for the active
/// theme plugin. Returns immediately with `null` when no
/// plugin is selected; otherwise awaits the plugin's
/// `theme_for` round-trip for both brightnesses.
Future<({ThemeData? light, ThemeData? dark})> _pluginThemes(
String? capability,
) async {
if (capability == null || capability.isEmpty) {
return (light: null, dark: null);
}
try {
final schemes = await loadThemePluginSchemes(capability);
return (
light: schemes.light == null ? null : themeDataFromScheme(schemes.light!),
dark: schemes.dark == null ? null : themeDataFromScheme(schemes.dark!),
);
} catch (_) {
// Plugin unreachable, hub down, etc. Fall back to
// FaiTheme defaults silently — the Settings dialog is
// where the operator surfaces / fixes the problem.
return (light: null, dark: null);
}
}
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<ThemeModeValue>(
valueListenable: modeNotifier,
builder: (_, mode, _) => ValueListenableBuilder<Locale>(
valueListenable: localeNotifier,
builder: (_, locale, _) => ValueListenableBuilder<String?>(
valueListenable: themePluginNotifier,
builder: (_, pluginCap, _) => FutureBuilder<({ThemeData? light, ThemeData? dark})>(
// Re-key on the capability so a plugin change
// discards the in-flight future for the previous
// plugin and starts fresh.
key: ValueKey('plugin_themes:$pluginCap'),
future: _pluginThemes(pluginCap),
builder: (context, snap) {
final p = snap.data ?? (light: null, dark: null);
return MaterialApp(
title: 'F∆I Studio',
debugShowCheckedModeBanner: false,
theme: p.light ?? FaiTheme.light(),
darkTheme: p.dark ?? FaiTheme.dark(),
themeMode: _flutterMode(mode),
locale: locale,
supportedLocales: AppLocalizations.supportedLocales,
localizationsDelegates: AppLocalizations.localizationsDelegates,
home: const StudioShell(),
);
},
),
),
),
);
}
}
class StudioShell extends StatefulWidget {
const StudioShell({super.key});
@override
State<StudioShell> createState() => _StudioShellState();
}
class _StudioShellState extends State<StudioShell> {
int _selectedIndex = 0;
bool? _connected; // null = unknown, polled on a timer
String? _activeChannel; // local / dev / beta / production
Timer? _healthPoll;
static const _pages = <_NavPage>[
_NavPage(
id: 'welcome',
icon: Icons.waving_hand_outlined,
selectedIcon: Icons.waving_hand,
page: WelcomePage(),
),
// Store moved to slot 2 in v0.39.1 — it's the
// operator's daily-use surface and belongs above Doctor,
// which is a diagnostics page.
_NavPage(
id: 'store',
icon: Icons.storefront_outlined,
selectedIcon: Icons.storefront,
page: StorePage(),
),
_NavPage(
id: 'doctor',
icon: Icons.health_and_safety_outlined,
selectedIcon: Icons.health_and_safety,
page: DoctorPage(),
),
// Modules tab dropped in v0.35.0 — installed-module info
// (permissions, on-disk path, uninstall) now lives inside
// the Store's detail sheet, and the Store with the
// "Installed" filter covers the listing role. Cmd+K still
// opens FaiModuleSheet for quick info.
_NavPage(
id: 'flows',
icon: Icons.account_tree_outlined,
selectedIcon: Icons.account_tree,
page: FlowsPage(),
),
_NavPage(
id: 'flow-editor',
icon: Icons.code_outlined,
selectedIcon: Icons.code,
page: _FlowEditorAdapter(),
),
_NavPage(
id: 'audit',
icon: Icons.timeline_outlined,
selectedIcon: Icons.timeline,
page: AuditPage(),
),
_NavPage(
id: 'approvals',
icon: Icons.inbox_outlined,
selectedIcon: Icons.inbox,
page: ApprovalsPage(),
),
];
@override
void initState() {
super.initState();
_checkHealth();
_healthPoll = Timer.periodic(
const Duration(seconds: 5),
(_) => _checkHealth(),
);
}
Future<void> _checkHealth() async {
final ok = await HubService.instance.healthy();
if (!mounted) return;
if (_connected != ok) setState(() => _connected = ok);
// Refresh the active channel pointer alongside the health
// probe — cheap, and keeps the sidebar pill honest if the
// operator switched channels from the CLI.
if (ok) {
try {
final snap = await HubService.instance.channelStatus();
if (!mounted) return;
if (_activeChannel != snap.active) {
setState(() => _activeChannel = snap.active);
}
} catch (_) {
// Best-effort; pill simply doesn't update this tick.
}
}
}
@override
void dispose() {
_healthPoll?.cancel();
super.dispose();
}
void _openSearchPalette() {
final l = AppLocalizations.of(context)!;
final pagesGroup = l.searchGroupPages;
final hits = <FaiSearchHit>[
for (var i = 0; i < _pages.length; i++)
FaiSearchHit(
label: _pages[i].labelOf(context),
hint: l.searchPageHint(i + 1),
icon: _pages[i].icon,
group: pagesGroup,
onSelect: () {
Navigator.of(context).pop();
setState(() => _selectedIndex = i);
},
),
FaiSearchHit(
label: l.searchSettingsLabel,
hint: l.searchSettingsHint,
icon: Icons.settings_outlined,
group: pagesGroup,
onSelect: () {
Navigator.of(context).pop();
FaiSettingsDialog.show(context);
},
),
];
FaiSearchPalette.show(context, staticHits: hits);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Shortcuts(
shortcuts: <ShortcutActivator, Intent>{
// Cmd+1..6 jumps to the matching destination. Numbered
// 1-based to match the visual order in the sidebar.
for (var i = 0; i < _pages.length; i++)
SingleActivator(
LogicalKeyboardKey(LogicalKeyboardKey.digit1.keyId + i),
meta: true,
): _GoToPageIntent(i),
// Settings dialog. macOS reserves Cmd+, for the native
// app-Preferences menu item, which Flutter Desktop
// doesn't wire up — the keystroke is captured by the OS
// before it reaches our Shortcuts widget. Cmd+; is the
// working alternative; we register Cmd+, too for
// completeness in case a future Flutter version (or a
// native-menu setup) lets it through.
const SingleActivator(LogicalKeyboardKey.semicolon, meta: true):
const _OpenSettingsIntent(),
const SingleActivator(LogicalKeyboardKey.comma, meta: true):
const _OpenSettingsIntent(),
// Universal command palette. Mirrors Cmd+K from VSCode /
// Linear / 1Password — operators jump anywhere by typing.
const SingleActivator(LogicalKeyboardKey.keyK, meta: true):
const _OpenSearchIntent(),
const SingleActivator(LogicalKeyboardKey.keyK, control: true):
const _OpenSearchIntent(),
},
child: Actions(
actions: <Type, Action<Intent>>{
_GoToPageIntent: CallbackAction<_GoToPageIntent>(
onInvoke: (intent) {
setState(() => _selectedIndex = intent.index);
return null;
},
),
_OpenSettingsIntent: CallbackAction<_OpenSettingsIntent>(
onInvoke: (_) {
FaiSettingsDialog.show(context);
return null;
},
),
_OpenSearchIntent: CallbackAction<_OpenSearchIntent>(
onInvoke: (_) {
_openSearchPalette();
return null;
},
),
},
child: Focus(
autofocus: true,
child: Scaffold(
body: Row(
children: [
_Sidebar(
selectedIndex: _selectedIndex,
onSelect: (i) => setState(() => _selectedIndex = i),
pages: _pages,
connected: _connected,
endpointLabel: HubService.instance.endpointLabel,
activeChannel: _activeChannel,
),
Container(
width: 1,
color: theme.colorScheme.outlineVariant,
),
Expanded(
child: AnimatedSwitcher(
duration: FaiMotion.base,
switchInCurve: FaiMotion.easing,
switchOutCurve: FaiMotion.easing,
transitionBuilder: (child, anim) => FadeTransition(
opacity: anim,
child: SlideTransition(
position: Tween<Offset>(
begin: const Offset(0, 0.02),
end: Offset.zero,
).animate(anim),
child: child,
),
),
child: KeyedSubtree(
key: ValueKey(_selectedIndex),
child: _pages[_selectedIndex].page,
),
),
),
],
),
),
),
),
);
}
}
/// Intents driven by the Shortcuts/Actions pair on the shell.
class _GoToPageIntent extends Intent {
final int index;
const _GoToPageIntent(this.index);
}
class _OpenSettingsIntent extends Intent {
const _OpenSettingsIntent();
}
class _OpenSearchIntent extends Intent {
const _OpenSearchIntent();
}
class _Sidebar extends StatefulWidget {
final int selectedIndex;
final ValueChanged<int> onSelect;
final List<_NavPage> pages;
final bool? connected;
final String endpointLabel;
/// Active channel name from the running daemon
/// (`local`/`dev`/`beta`/`production`). Null while the
/// initial fetch is in flight or the hub is unreachable.
final String? activeChannel;
const _Sidebar({
required this.selectedIndex,
required this.onSelect,
required this.pages,
required this.connected,
required this.endpointLabel,
required this.activeChannel,
});
@override
State<_Sidebar> createState() => _SidebarState();
}
class _SidebarState extends State<_Sidebar> {
// Default-collapsed (icons only); expands on hover. Width
// anim mirrors NavigationRail's spec: 72 collapsed, 220
// expanded.
static const double _collapsedWidth = 72;
static const double _expandedWidth = 220;
bool _hovered = false;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final mode = widget.connected == true ? FaiDeltaMode.live : FaiDeltaMode.idle;
final expanded = _hovered;
final width = expanded ? _expandedWidth : _collapsedWidth;
return MouseRegion(
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: AnimatedContainer(
duration: FaiMotion.fast,
width: width,
color: theme.colorScheme.surfaceContainerLow,
padding: const EdgeInsets.symmetric(vertical: FaiSpace.xl),
child: ClipRect(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Brand row — fixed-width icon column (kCollapsedWidth)
// on the left, label/version stacked beside it when
// expanded. The 72-px column means the triangle's
// pixel-X is identical in both states; only the
// rail's RIGHT edge moves on expand, never the
// left-anchored content.
Row(
children: [
SizedBox(
width: _collapsedWidth,
child: Center(
child: FaiDeltaMark(
color: theme.colorScheme.primary,
mode: mode,
),
),
),
if (expanded)
Expanded(
child: Padding(
padding: const EdgeInsets.only(right: FaiSpace.md),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'F∆I Studio',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 2),
Text(
'v$kStudioVersion',
style: FaiTheme.mono(
size: 10,
color: theme.colorScheme.primary,
),
),
],
),
),
),
],
),
const SizedBox(height: FaiSpace.md),
// Connection row — same pattern. Mini-dot sits
// centered in the 72-col; the full _ConnectionPill
// takes the remaining width when expanded.
Row(
children: [
SizedBox(
width: _collapsedWidth,
child: Center(
child: Tooltip(
message: widget.connected == true
? 'Connected · ${widget.endpointLabel}'
: widget.connected == false
? 'Disconnected · ${widget.endpointLabel}'
: 'Connecting · ${widget.endpointLabel}',
child: _CollapsedConnectionDot(
connected: widget.connected,
),
),
),
),
if (expanded)
Expanded(
child: Padding(
padding: const EdgeInsets.only(right: FaiSpace.md),
child: _ConnectionPill(
connected: widget.connected,
label: widget.endpointLabel,
onStartRequested: () async {
final r =
await SystemActions.faiDaemon(['start']);
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
r.ok
? 'Daemon start requested. Reconnecting…'
: 'Could not start daemon: ${r.stderr.trim().isEmpty ? r.stdout.trim() : r.stderr.trim()}',
),
),
);
},
),
),
),
],
),
if (widget.activeChannel != null && widget.activeChannel!.isNotEmpty) ...[
const SizedBox(height: FaiSpace.sm),
Row(
children: [
SizedBox(
width: _collapsedWidth,
child: Center(
child: Tooltip(
message: widget.activeChannel!,
child: _CollapsedChannelChip(
channel: widget.activeChannel!,
),
),
),
),
if (expanded)
Expanded(
child: Padding(
padding: const EdgeInsets.only(right: FaiSpace.md),
child: _ChannelPill(
channel: widget.activeChannel!,
),
),
),
],
),
],
const SizedBox(height: FaiSpace.xxl),
// Destinations — same fixed-width icon column,
// optional label slot on the right. ListView's
// horizontal padding stays zero so the SizedBox
// anchors at the rail's literal left edge.
Expanded(
child: ListView(
padding: EdgeInsets.zero,
children: [
for (var i = 0; i < widget.pages.length; i++)
_SidebarItem(
page: widget.pages[i],
selected: i == widget.selectedIndex,
expanded: expanded,
iconColumnWidth: _collapsedWidth,
onTap: () => widget.onSelect(i),
),
],
),
),
// Footer: theme toggle + clock + settings when
// expanded; just the settings icon when collapsed.
Padding(
padding: const EdgeInsets.symmetric(horizontal: FaiSpace.md),
child: expanded
? Row(
children: [
_ThemeToggle(),
_LanguageToggle(),
const Expanded(
child: Center(child: _SidebarClock()),
),
IconButton(
icon: const Icon(Icons.settings_outlined, size: 16),
tooltip: 'Settings (⌘;)',
visualDensity: VisualDensity.compact,
onPressed: () => _openSettings(context),
),
],
)
: IconButton(
icon: const Icon(Icons.settings_outlined, size: 16),
tooltip: 'Settings (⌘;)',
visualDensity: VisualDensity.compact,
onPressed: () => _openSettings(context),
),
),
],
),
),
),
);
}
Future<void> _openSettings(BuildContext context) async {
final saved = await FaiSettingsDialog.show(context);
if (saved && context.mounted) {
// Force a sidebar rebuild so the new endpoint shows in
// the connection pill.
(context as Element).markNeedsBuild();
}
}
}
/// Active-channel indicator. Click to open Settings (where the
/// operator can switch). Color-coded so production deployments
/// look visibly different from local-dev — the kind of thing
/// you want to see before clicking "Clear log".
class _ChannelPill extends StatelessWidget {
final String channel;
const _ChannelPill({required this.channel});
Color _accent(ColorScheme cs) {
switch (channel) {
case 'production':
return cs.error;
case 'beta':
return FaiColors.warning;
case 'dev':
return cs.primary;
case 'local':
default:
return cs.onSurfaceVariant;
}
}
IconData get _icon {
switch (channel) {
case 'production':
return Icons.shield_outlined;
case 'beta':
return Icons.science_outlined;
case 'dev':
return Icons.bolt_outlined;
case 'local':
default:
return Icons.home_work_outlined;
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final accent = _accent(theme.colorScheme);
return Tooltip(
message:
'Active channel — click to switch in Settings (⌘;).\n'
'production = stable · beta = pre-release · dev = rolling · local = workspace',
child: Material(
color: theme.colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(FaiRadius.sm),
child: InkWell(
borderRadius: BorderRadius.circular(FaiRadius.sm),
onTap: () => FaiSettingsDialog.show(context),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: FaiSpace.sm,
vertical: 4,
),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(FaiRadius.sm),
border: Border.all(color: accent.withValues(alpha: 0.4)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(_icon, size: 11, color: accent),
const SizedBox(width: 4),
Text(
channel,
style: FaiTheme.mono(
size: 10,
weight: FontWeight.w600,
color: accent,
),
),
],
),
),
),
),
);
}
}
/// Mini-version of [_ConnectionPill] used when the sidebar is
/// collapsed. A 10px dot in the same green/red/amber tonality
/// the FaiDeltaMark uses for live / idle / unknown.
class _CollapsedConnectionDot extends StatelessWidget {
final bool? connected;
const _CollapsedConnectionDot({required this.connected});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final color = connected == true
? FaiColors.success
: connected == false
? theme.colorScheme.error
: FaiColors.warning;
return Container(
width: 10,
height: 10,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: color.withValues(alpha: 0.35),
blurRadius: 4,
spreadRadius: 0.5,
),
],
),
);
}
}
/// Mini-version of [_ChannelPill] used when the sidebar is
/// collapsed. A 16px circular chip with the first letter of
/// the channel (P / B / D / L) in the same accent tone the
/// full pill uses, so production stays unmistakeable at a
/// glance.
class _CollapsedChannelChip extends StatelessWidget {
final String channel;
const _CollapsedChannelChip({required this.channel});
Color _accent(ColorScheme cs) {
switch (channel) {
case 'production':
return cs.error;
case 'beta':
return FaiColors.warning;
case 'dev':
return cs.primary;
case 'local':
default:
return cs.onSurfaceVariant;
}
}
String get _letter {
if (channel.isEmpty) return '·';
return channel.substring(0, 1).toUpperCase();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final accent = _accent(theme.colorScheme);
return Container(
width: 18,
height: 18,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: accent.withValues(alpha: 0.55)),
),
alignment: Alignment.center,
child: Text(
_letter,
style: FaiTheme.mono(
size: 10,
weight: FontWeight.w700,
color: accent,
),
),
);
}
}
class _ConnectionPill extends StatelessWidget {
final bool? connected;
final String label;
/// Triggered by the inline "Start hub" button when the
/// daemon is unreachable. Runs `fai daemon start` server-side
/// (well, locally — Studio shells out). The shell-level
/// health poll picks the daemon up on its next tick.
final Future<void> Function() onStartRequested;
const _ConnectionPill({
required this.connected,
required this.label,
required this.onStartRequested,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final dotColor = connected == true
? FaiColors.success
: connected == false
? FaiColors.danger
: FaiColors.muted;
final caption = connected == true
? 'connected'
: connected == false
? 'unreachable'
: 'connecting…';
return Container(
padding: const EdgeInsets.symmetric(
horizontal: FaiSpace.sm,
vertical: 6,
),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(FaiRadius.sm),
border: Border.all(color: theme.colorScheme.outlineVariant),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
mainAxisSize: MainAxisSize.min,
children: [
FaiStatusDot(color: dotColor, pulsing: connected == true),
const SizedBox(width: 6),
Text(
caption,
style: theme.textTheme.labelSmall?.copyWith(
color: dotColor == FaiColors.danger
? theme.colorScheme.error
: theme.colorScheme.onSurface,
fontWeight: FontWeight.w500,
),
),
],
),
const SizedBox(height: 2),
Text(
label,
style: FaiTheme.mono(
size: 10,
color: theme.colorScheme.onSurfaceVariant,
),
),
// Inline "Start hub" affordance when the daemon
// isn't there. Resolves the chicken-and-egg for
// Windows operators who can't run `fai daemon start`
// from a shell.
if (connected == false) ...[
const SizedBox(height: FaiSpace.sm),
SizedBox(
width: double.infinity,
child: FilledButton.tonalIcon(
onPressed: onStartRequested,
icon: const Icon(Icons.play_arrow, size: 14),
label: const Text(
'Start hub',
style: TextStyle(fontSize: 11),
),
style: FilledButton.styleFrom(
visualDensity: VisualDensity.compact,
padding: const EdgeInsets.symmetric(
horizontal: FaiSpace.sm,
vertical: 4,
),
),
),
),
],
],
),
);
}
}
class _SidebarItem extends StatefulWidget {
final _NavPage page;
final bool selected;
final bool expanded;
final double iconColumnWidth;
final VoidCallback onTap;
const _SidebarItem({
required this.page,
required this.selected,
required this.expanded,
required this.iconColumnWidth,
required this.onTap,
});
@override
State<_SidebarItem> createState() => _SidebarItemState();
}
class _SidebarItemState extends State<_SidebarItem> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final color = widget.selected
? theme.colorScheme.primary
: _hovered
? theme.colorScheme.onSurface
: theme.colorScheme.onSurfaceVariant;
final bg = widget.selected
? theme.colorScheme.primary.withValues(alpha: 0.08)
: _hovered
? theme.colorScheme.surfaceContainerHigh
: Colors.transparent;
final label = widget.page.labelOf(context);
final icon = Icon(
widget.selected ? widget.page.selectedIcon : widget.page.icon,
size: 18,
color: color,
);
// Fixed-width icon column on the left + optional label slot
// on the right. The icon's pixel-X is anchored to the rail's
// literal left edge (no ListView padding, no AC h-padding)
// and the icon column width matches the rail's collapsed
// width, so the icon sits at the same x-pixel — center of
// the 72-px slot — regardless of whether the rail is
// currently 72 or 220 wide. The label appears as an
// Expanded sibling to the icon column only when expanded.
final content = Row(
children: [
SizedBox(
width: widget.iconColumnWidth,
child: Center(child: icon),
),
if (widget.expanded)
Expanded(
child: Padding(
padding: const EdgeInsets.only(right: FaiSpace.md),
child: Text(
label,
overflow: TextOverflow.fade,
softWrap: false,
style: theme.textTheme.labelMedium?.copyWith(color: color),
),
),
),
],
);
final inner = Padding(
// Vertical breathing room between items. No horizontal
// padding — the icon column itself owns the left anchor.
padding: const EdgeInsets.symmetric(vertical: 2),
child: MouseRegion(
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
cursor: SystemMouseCursors.click,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: widget.onTap,
child: AnimatedContainer(
duration: FaiMotion.fast,
// Only vertical padding — horizontal is owned by the
// icon column. Background highlight spans the full
// rail width, which is the typical nav-rail UX.
padding: const EdgeInsets.symmetric(vertical: FaiSpace.md),
decoration: BoxDecoration(color: bg),
child: content,
),
),
),
);
// Tooltip only when collapsed — when the rail is expanded
// the label is visible inline + tooltips become noisy.
if (widget.expanded) return inner;
return Tooltip(
message: label,
preferBelow: false,
child: inner,
);
}
}
class _NavPage {
/// Stable id used for the Cmd+K palette and the sidebar
/// shortcut keys; never shown to the operator. The visible
/// label comes from [labelOf] which reads the active
/// AppLocalizations.
final String id;
final IconData icon;
final IconData selectedIcon;
final Widget page;
const _NavPage({
required this.id,
required this.icon,
required this.selectedIcon,
required this.page,
});
/// Resolve the localized label via AppLocalizations.
String labelOf(BuildContext context) {
final l = AppLocalizations.of(context);
if (l == null) return id;
switch (id) {
case 'welcome':
return l.navWelcome;
case 'doctor':
return l.navDoctor;
case 'modules':
return l.navModules;
case 'store':
return l.navStore;
case 'flows':
return l.navFlows;
case 'flow-editor':
return l.navFlowEditor;
case 'audit':
return l.navAudit;
case 'approvals':
return l.navApprovals;
default:
return id;
}
}
}
class _ThemeToggle extends StatelessWidget {
@override
Widget build(BuildContext context) {
final app = StudioApp.of(context);
if (app == null) return const SizedBox.shrink();
return ValueListenableBuilder<ThemeModeValue>(
valueListenable: app.modeNotifier,
builder: (_, mode, _) {
final icon = switch (mode) {
ThemeModeValue.system => Icons.brightness_auto_outlined,
ThemeModeValue.light => Icons.light_mode_outlined,
ThemeModeValue.dark => Icons.dark_mode_outlined,
};
final tooltip = switch (mode) {
ThemeModeValue.system => 'theme: follow system',
ThemeModeValue.light => 'theme: light',
ThemeModeValue.dark => 'theme: dark',
};
return IconButton(
icon: Icon(icon, size: 16),
tooltip: tooltip,
visualDensity: VisualDensity.compact,
onPressed: () {
final next = switch (mode) {
ThemeModeValue.system => ThemeModeValue.light,
ThemeModeValue.light => ThemeModeValue.dark,
ThemeModeValue.dark => ThemeModeValue.system,
};
app.setMode(next);
},
);
},
);
}
}
/// Small ticking wall-clock for the sidebar. Aligns the on-screen
/// time with the timestamps the audit log writes — operators
/// glance here when an event timestamp looks off and want a
/// reference point. Updates on the second; tooltip exposes the
/// full ISO + UTC offset for unambiguous comparison.
class _SidebarClock extends StatefulWidget {
const _SidebarClock();
@override
State<_SidebarClock> createState() => _SidebarClockState();
}
class _SidebarClockState extends State<_SidebarClock> {
late DateTime _now;
Timer? _ticker;
@override
void initState() {
super.initState();
_now = DateTime.now();
_scheduleNextTick();
}
void _scheduleNextTick() {
// Align ticks on the second boundary so the seconds digit
// changes when the wall-clock second changes, not 0999 ms
// late depending on when the widget mounted.
final delay = Duration(milliseconds: 1000 - _now.millisecond);
_ticker = Timer(delay, () {
if (!mounted) return;
setState(() => _now = DateTime.now());
_ticker = Timer.periodic(const Duration(seconds: 1), (_) {
if (!mounted) return;
setState(() => _now = DateTime.now());
});
});
}
@override
void dispose() {
_ticker?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final hh = _now.hour.toString().padLeft(2, '0');
final mm = _now.minute.toString().padLeft(2, '0');
final ss = _now.second.toString().padLeft(2, '0');
final yyyy = _now.year.toString().padLeft(4, '0');
final mo = _now.month.toString().padLeft(2, '0');
final dd = _now.day.toString().padLeft(2, '0');
final offsetMin = _now.timeZoneOffset.inMinutes;
final sign = offsetMin >= 0 ? '+' : '-';
final absMin = offsetMin.abs();
final offH = (absMin ~/ 60).toString().padLeft(2, '0');
final offM = (absMin % 60).toString().padLeft(2, '0');
final tooltip =
'Local: $yyyy-$mo-$dd $hh:$mm:$ss $sign$offH:$offM\n'
'UTC: ${_now.toUtc().toIso8601String()}';
return Tooltip(
message: tooltip,
child: Text(
'$hh:$mm:$ss',
style: FaiTheme.mono(
size: 11,
color: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.85),
),
),
);
}
}
/// Two-state language toggle (DE / EN) parked in the sidebar
/// footer next to the theme button. Flipping it updates the
/// app-wide locale via the parent [`StudioAppState`]; every
/// AppLocalizations consumer rebuilds with the new strings.
/// The Store-page detail sheet still renders bilingual store
/// fields off the same locale, so a single click flips
/// chrome + content together.
class _LanguageToggle extends StatelessWidget {
@override
Widget build(BuildContext context) {
final app = StudioApp.of(context);
if (app == null) return const SizedBox.shrink();
return ValueListenableBuilder<Locale>(
valueListenable: app.localeNotifier,
builder: (context, locale, _) {
final isDe = locale.languageCode == 'de';
return Tooltip(
message: isDe
? 'Sprache: Deutsch — Klick wechselt zu English'
: 'Language: English — click to switch to Deutsch',
child: TextButton(
onPressed: () => app.setLocale(Locale(isDe ? 'en' : 'de')),
style: TextButton.styleFrom(
minimumSize: const Size(36, 32),
padding: const EdgeInsets.symmetric(horizontal: 6),
visualDensity: VisualDensity.compact,
),
child: Text(
isDe ? 'DE' : 'EN',
style: FaiTheme.mono(
size: 11,
weight: FontWeight.w600,
color: Theme.of(context).colorScheme.primary,
),
),
),
);
},
);
}
}
/// Adapter that resolves the swappable FlowEditorPage's
/// runtime dependencies (locale, hub onRun callback) from
/// Studio's BuildContext. The _NavPage destination list
/// holds widgets as const-ish, so the adapter sits in
/// between to grab live data.
class _FlowEditorAdapter extends StatelessWidget {
const _FlowEditorAdapter();
@override
Widget build(BuildContext context) {
final lang = Localizations.localeOf(context).languageCode;
return FlowEditorPage(
locale: lang == 'de' ? FlowEditorLocale.de : FlowEditorLocale.en,
onRun: (name) async =>
(await HubService.instance.runSavedFlow(name: name))
.cast<String, Object>(),
);
}
}