Some checks failed
Security / Security check (push) Failing after 2s
Three operator-visible improvements per Stefan's review of the v0.50.0 editor. 1. Pencil icon on Flows page now opens Studio's own flow editor (route push) preloaded with the selected flow, replacing the previous "open in OS default editor" (SystemActions.openInOs) behaviour. The editor's AppBar gains a back arrow when reached via route push; reaching the editor via the nav rail leaves it bare. Tooltip string updated in EN + DE. 2. New FlowEditorPage `initialFlowName` parameter. When set, the page loads that flow on first frame (via post-frame callback so the BuildContext is mounted before _openByName runs). When null (the nav-rail path), the editor opens to its empty state as before. 3. Sidebar (_Sidebar) is now collapsed-by-default: shows just icons in a 72px-wide rail with per-destination tooltips. Hovering the rail expands it to 220px (the old width) with brand-mark + labels + the full footer row (theme toggle, language toggle, clock, settings). Collapsed footer shows the settings icon only. Brand- mark (FaiDeltaMark) stays visible in both states so the live/idle status dot is always glanceable. Side-effect: SystemActions import in flows.dart is no longer needed (the pencil no longer shells out) — removed. widget_test.dart: dropped the per-destination Text-presence asserts since labels are now Tooltips when collapsed. Hover-expand testing triggers RenderFlex-overflow mid- animation in widget tests (the AnimatedContainer's width transitions through a constraint slimmer than the Row's intrinsic min). The booting-without-throwing assertion remains; per-destination presence stays in the per-page test suites. Both arb files updated with the new strings (navFlowEditor already existed; added flowEditorBackTooltip + retouched flowsOpenInEditorTooltip). Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
1064 lines
34 KiB
Dart
1064 lines
34 KiB
Dart
// 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: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/flow_editor.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.50.0';
|
||
|
||
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: FlowEditorPage(),
|
||
),
|
||
_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(
|
||
children: [
|
||
// Brand mark — always visible.
|
||
FaiDeltaMark(color: theme.colorScheme.primary, mode: mode),
|
||
if (expanded) ...[
|
||
const SizedBox(height: FaiSpace.md),
|
||
Text(
|
||
'F∆I Studio',
|
||
style: theme.textTheme.titleMedium?.copyWith(
|
||
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 pill — only when expanded; the
|
||
// status-dot is encoded in the delta-mark's mode
|
||
// so collapsed users still see at-a-glance state.
|
||
Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: FaiSpace.lg),
|
||
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),
|
||
Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: FaiSpace.lg),
|
||
child: _ChannelPill(channel: widget.activeChannel!),
|
||
),
|
||
],
|
||
],
|
||
const SizedBox(height: FaiSpace.xxl),
|
||
// Destinations.
|
||
Expanded(
|
||
child: ListView(
|
||
padding: const EdgeInsets.symmetric(horizontal: FaiSpace.md),
|
||
children: [
|
||
for (var i = 0; i < widget.pages.length; i++)
|
||
_SidebarItem(
|
||
page: widget.pages[i],
|
||
selected: i == widget.selectedIndex,
|
||
expanded: expanded,
|
||
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,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
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 VoidCallback onTap;
|
||
|
||
const _SidebarItem({
|
||
required this.page,
|
||
required this.selected,
|
||
required this.expanded,
|
||
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,
|
||
);
|
||
final Widget content = widget.expanded
|
||
? Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
icon,
|
||
const SizedBox(width: FaiSpace.md),
|
||
// Flexible so the label can ellipsize during the
|
||
// collapse animation instead of overflowing.
|
||
Flexible(
|
||
child: Text(
|
||
label,
|
||
overflow: TextOverflow.fade,
|
||
softWrap: false,
|
||
style: theme.textTheme.labelMedium?.copyWith(color: color),
|
||
),
|
||
),
|
||
],
|
||
)
|
||
: Center(child: icon);
|
||
|
||
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,
|
||
padding: EdgeInsets.symmetric(
|
||
horizontal: widget.expanded ? FaiSpace.md : FaiSpace.sm,
|
||
vertical: FaiSpace.md,
|
||
),
|
||
decoration: BoxDecoration(
|
||
color: bg,
|
||
borderRadius: BorderRadius.circular(FaiRadius.sm),
|
||
),
|
||
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 0–999 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,
|
||
),
|
||
),
|
||
),
|
||
);
|
||
},
|
||
);
|
||
}
|
||
}
|