chain-studio/lib/main.dart
flemming-it 2b65cd771f
Some checks failed
Security / Security check (push) Failing after 2s
fix(studio): sidebar — single AnimationController drives width
+ label opacity in lockstep (no more glitchy expand)

The previous "fixed-width icon column" commit already locked
the icon's horizontal pixel-X, but the rail still _felt_
glitchy on expand because the rail's geometry and the rail's
content were animated by two different mechanisms:

 - Width: AnimatedContainer over FaiMotion.fast (120 ms)
 - Content: setState(_hovered = true) → conditional
   `if (expanded) Expanded(label)` snaps in one frame

So on mouse-enter the label widget appeared INSTANTLY while
the rail was still 72 px wide. The label tried to render in
0 px of available space and Flutter's layout engine clamped
it; over the next 7 frames the rail grew to 220 px and the
label visibly "settled in". That's the perceived glitch.

Replace the two-source animation with a single
SingleTickerProviderStateMixin + AnimationController whose
value `t` (0..1, eased via easeInOutCubic) drives both:

 - rail width = lerp(72, 220, t)
 - label opacity = t
 - labelsInteractive = t > 0.5 (so hidden buttons can't
   eat clicks meant for the icon column)

Labels are wrapped in `t > 0 ? IgnorePointer(Opacity(...))
: SizedBox.shrink()`. Once the animation starts, the label
joins the tree at opacity ≈ 0 (invisible — no pop) and
fades up smoothly as t grows. When fully collapsed (t == 0)
the label is removed from layout entirely, so the connection
pill's tall "Start hub" affordance doesn't inflate the
collapsed rail's height (this also fixes the widget_test
vertical-overflow that 0.51.4 introduced).

Same pattern applied to the footer (settings icon stays in
the 72-px column always; theme/lang/clock fade in beside it)
and to each _SidebarItem destination row.

End result: width AND content travel together along the same
animation curve. No more "snap then catch up" — they're
mathematically inseparable.

Version 0.51.4 → 0.51.5.

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
2026-05-31 22:24:53 +02:00

1330 lines
43 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.5';
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>
with SingleTickerProviderStateMixin {
// Default-collapsed (icons only); expands on hover. 72 / 220
// mirrors NavigationRail's spec.
static const double _collapsedWidth = 72;
static const double _expandedWidth = 220;
// Single source of truth for the entire rail's expand
// animation. width and every label's opacity are derived
// from `_ctrl.value` — no second timer, no conditional
// widget-tree mutation, so width and content move in
// lockstep instead of one snapping while the other glides.
late final AnimationController _ctrl;
@override
void initState() {
super.initState();
_ctrl = AnimationController(
vsync: this,
duration: FaiMotion.fast,
value: 0,
);
}
@override
void dispose() {
_ctrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final mode = widget.connected == true ? FaiDeltaMode.live : FaiDeltaMode.idle;
return MouseRegion(
onEnter: (_) => _ctrl.forward(),
onExit: (_) => _ctrl.reverse(),
child: AnimatedBuilder(
animation: _ctrl,
builder: (context, _) {
// eased between 0 (fully collapsed) and 1 (fully
// expanded). Opacity uses the same curve, so a label
// that starts fading in is at the same progress
// fraction as the rail's geometry — visually they
// travel together.
final t = Curves.easeInOutCubic.transform(_ctrl.value);
final width = _collapsedWidth + (_expandedWidth - _collapsedWidth) * t;
// Hold pointer hits on labels until the rail is at
// least half-open; otherwise the operator might tap
// through an almost-invisible button on the way to
// the icon they actually want.
final labelsInteractive = t > 0.5;
return Container(
width: width,
color: theme.colorScheme.surfaceContainerLow,
padding: const EdgeInsets.symmetric(vertical: FaiSpace.xl),
child: ClipRect(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_sidebarRow(
leading: FaiDeltaMark(
color: theme.colorScheme.primary,
mode: mode,
),
label: 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,
),
),
],
),
t: t,
labelsInteractive: labelsInteractive,
),
const SizedBox(height: FaiSpace.md),
_sidebarRow(
leading: Tooltip(
message: widget.connected == true
? 'Connected · ${widget.endpointLabel}'
: widget.connected == false
? 'Disconnected · ${widget.endpointLabel}'
: 'Connecting · ${widget.endpointLabel}',
child: _CollapsedConnectionDot(
connected: widget.connected,
),
),
label: _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()}',
),
),
);
},
),
t: t,
labelsInteractive: labelsInteractive,
),
if (widget.activeChannel != null && widget.activeChannel!.isNotEmpty) ...[
const SizedBox(height: FaiSpace.sm),
_sidebarRow(
leading: Tooltip(
message: widget.activeChannel!,
child: _CollapsedChannelChip(
channel: widget.activeChannel!,
),
),
label: _ChannelPill(channel: widget.activeChannel!),
t: t,
labelsInteractive: labelsInteractive,
),
],
const SizedBox(height: FaiSpace.xxl),
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,
t: t,
labelsInteractive: labelsInteractive,
iconColumnWidth: _collapsedWidth,
onTap: () => widget.onSelect(i),
),
],
),
),
_Footer(
t: t,
labelsInteractive: labelsInteractive,
iconColumnWidth: _collapsedWidth,
onOpenSettings: () => _openSettings(context),
),
],
),
),
);
},
),
);
}
/// Shared row geometry for the brand / connection / channel
/// headers. Leading widget sits in a fixed-width icon column
/// anchored to the rail's left edge. The label slot is in
/// the tree as soon as the animation kicks off (t > 0) and
/// fades in via Opacity tied to the same controller. Skipping
/// layout at t == 0 keeps the row's collapsed height = the
/// leading widget's natural height (rather than including
/// the label's tall "Start hub" affordance even when
/// invisible). The opacity at t ≈ 0.01 is low enough that
/// the layout's first-frame appearance isn't perceived as a
/// pop — the label visually grows in from invisible.
Widget _sidebarRow({
required Widget leading,
required Widget label,
required double t,
required bool labelsInteractive,
}) {
return Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
SizedBox(
width: _collapsedWidth,
child: Center(child: leading),
),
Expanded(
child: t > 0
? IgnorePointer(
ignoring: !labelsInteractive,
child: Opacity(
opacity: t,
child: Padding(
padding: const EdgeInsets.only(right: FaiSpace.md),
child: label,
),
),
)
: const SizedBox.shrink(),
),
],
);
}
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;
/// 0 = fully collapsed, 1 = fully expanded. Drives label
/// opacity in lockstep with the parent rail's width
/// animation — there is no second timer for the label, so
/// label and rail can't fall out of sync.
final double t;
final bool labelsInteractive;
final double iconColumnWidth;
final VoidCallback onTap;
const _SidebarItem({
required this.page,
required this.selected,
required this.t,
required this.labelsInteractive,
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,
);
// Label widget is ALWAYS in the tree — opacity = t makes
// it fade in/out in lockstep with the rail's width
// animation. Expanded's flex space goes from 0 (at t=0,
// rail width = 72) to (rail width 72) at t=1, so the
// label has natural space to fill without any conditional
// tree mutation.
final content = Row(
children: [
SizedBox(
width: widget.iconColumnWidth,
child: Center(child: icon),
),
Expanded(
child: widget.t > 0
? IgnorePointer(
ignoring: !widget.labelsInteractive,
child: Opacity(
opacity: widget.t,
child: Padding(
padding: const EdgeInsets.only(right: FaiSpace.md),
child: Text(
label,
overflow: TextOverflow.fade,
softWrap: false,
style: theme.textTheme.labelMedium?.copyWith(color: color),
),
),
),
)
: const SizedBox.shrink(),
),
],
);
final inner = Padding(
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,
// No horizontal padding — the icon column anchors
// the left. The bg highlight spans the rail's
// current width (typical nav-rail behaviour).
padding: const EdgeInsets.symmetric(vertical: FaiSpace.md),
decoration: BoxDecoration(color: bg),
child: content,
),
),
),
);
// Tooltip only while the label is hidden — once the label
// is visible inline the tooltip becomes redundant noise.
if (widget.t > 0.5) return inner;
return Tooltip(
message: label,
preferBelow: false,
child: inner,
);
}
}
/// Bottom-of-rail control strip. Settings icon is always the
/// last item in the icon column so its pixel-X never shifts;
/// theme + language toggles and clock fade in beside it as
/// the rail expands, sharing the same `t` as the rest of the
/// sidebar so motion stays in lockstep.
class _Footer extends StatelessWidget {
final double t;
final bool labelsInteractive;
final double iconColumnWidth;
final VoidCallback onOpenSettings;
const _Footer({
required this.t,
required this.labelsInteractive,
required this.iconColumnWidth,
required this.onOpenSettings,
});
@override
Widget build(BuildContext context) {
return Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
SizedBox(
width: iconColumnWidth,
child: Center(
child: IconButton(
icon: const Icon(Icons.settings_outlined, size: 16),
tooltip: 'Settings (⌘;)',
visualDensity: VisualDensity.compact,
onPressed: onOpenSettings,
),
),
),
Expanded(
child: t > 0
? IgnorePointer(
ignoring: !labelsInteractive,
child: Opacity(
opacity: t,
child: Padding(
padding: const EdgeInsets.only(right: FaiSpace.sm),
child: Row(
children: [
_ThemeToggle(),
_LanguageToggle(),
const Expanded(
child: Center(child: _SidebarClock()),
),
],
),
),
),
)
: const SizedBox.shrink(),
),
],
);
}
}
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>(),
);
}
}