Some checks failed
Security / Security check (push) Failing after 1s
The connection-row tooltip, the _ConnectionLabel caption, the footer settings tooltip, and the channel-pill explainer were hardcoded English even when the running app was set to German. Move all four through AppLocalizations. Adds ARB strings (de + en): - connectionTapToStart antippen zum Starten / tap to start - sidebarSettingsTooltip Einstellungen (Cmd-;) / Settings (Cmd-;) - sidebarChannelTooltip multi-line channel explainer Existing connectionConnected / connectionUnreachable / connectionConnecting are reused as the caption pieces; only those two new strings (plus the channel tooltip) needed adding. Version 0.51.7 -> 0.51.8. Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
1343 lines
43 KiB
Dart
1343 lines
43 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: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.8';
|
||
|
||
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;
|
||
// Fixed pixel heights for every header row so the layout
|
||
// is the SAME total height in collapsed and expanded
|
||
// states. Items below the header (destinations list,
|
||
// footer) sit at a mathematically constant Y — they
|
||
// cannot move when the rail expands because nothing
|
||
// above them grows. This is the structural fix; the
|
||
// earlier opacity-tied animation only made the broken
|
||
// height transition smoother to the eye.
|
||
static const double _brandRowH = 48;
|
||
static const double _connRowH = 44;
|
||
static const double _channelRowH = 28;
|
||
static const double _rowGap = 8;
|
||
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();
|
||
}
|
||
|
||
Future<void> _startDaemon(BuildContext context) 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()}',
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final theme = Theme.of(context);
|
||
final mode = widget.connected == true ? FaiDeltaMode.live : FaiDeltaMode.idle;
|
||
final hasChannel =
|
||
widget.activeChannel != null && widget.activeChannel!.isNotEmpty;
|
||
return MouseRegion(
|
||
onEnter: (_) => _ctrl.forward(),
|
||
onExit: (_) => _ctrl.reverse(),
|
||
child: AnimatedBuilder(
|
||
animation: _ctrl,
|
||
builder: (context, _) {
|
||
final t = Curves.easeInOutCubic.transform(_ctrl.value);
|
||
final width = _collapsedWidth + (_expandedWidth - _collapsedWidth) * t;
|
||
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: [
|
||
// Brand row — fixed 48px regardless of state.
|
||
SizedBox(
|
||
height: _brandRowH,
|
||
child: _sidebarRow(
|
||
leading: FaiDeltaMark(
|
||
color: theme.colorScheme.primary,
|
||
mode: mode,
|
||
),
|
||
label: _BrandLabel(),
|
||
t: t,
|
||
labelsInteractive: labelsInteractive,
|
||
),
|
||
),
|
||
const SizedBox(height: _rowGap),
|
||
// Connection row — fixed 44px. Tapping the row
|
||
// (anywhere in either icon col or label) when
|
||
// disconnected fires the daemon-start action,
|
||
// so we can drop the inline "Start hub" button
|
||
// that used to make this row's height balloon.
|
||
Builder(
|
||
builder: (context) {
|
||
final l = AppLocalizations.of(context)!;
|
||
final caption = widget.connected == true
|
||
? l.connectionConnected
|
||
: widget.connected == false
|
||
? l.connectionTapToStart
|
||
: l.connectionConnecting;
|
||
return SizedBox(
|
||
height: _connRowH,
|
||
child: _sidebarRow(
|
||
leading: Tooltip(
|
||
message: '$caption · ${widget.endpointLabel}',
|
||
child: _CollapsedConnectionDot(
|
||
connected: widget.connected,
|
||
),
|
||
),
|
||
label: _ConnectionLabel(
|
||
connected: widget.connected,
|
||
endpoint: widget.endpointLabel,
|
||
),
|
||
t: t,
|
||
labelsInteractive: labelsInteractive,
|
||
onTap: widget.connected == false
|
||
? () => _startDaemon(context)
|
||
: null,
|
||
),
|
||
);
|
||
},
|
||
),
|
||
const SizedBox(height: _rowGap),
|
||
// Channel row — fixed 28px. Always reserved
|
||
// (with empty content when there's no channel
|
||
// selected) so toggling channels at runtime
|
||
// doesn't shift the destinations list below.
|
||
SizedBox(
|
||
height: _channelRowH,
|
||
child: hasChannel
|
||
? _sidebarRow(
|
||
leading: Tooltip(
|
||
message: widget.activeChannel!,
|
||
child: _CollapsedChannelChip(
|
||
channel: widget.activeChannel!,
|
||
),
|
||
),
|
||
label: _ChannelPill(
|
||
channel: widget.activeChannel!,
|
||
),
|
||
t: t,
|
||
labelsInteractive: labelsInteractive,
|
||
)
|
||
: const SizedBox.shrink(),
|
||
),
|
||
const SizedBox(height: FaiSpace.md),
|
||
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,
|
||
VoidCallback? onTap,
|
||
}) {
|
||
final body = Row(
|
||
crossAxisAlignment: CrossAxisAlignment.center,
|
||
children: [
|
||
SizedBox(
|
||
width: _collapsedWidth,
|
||
child: Center(child: leading),
|
||
),
|
||
Expanded(
|
||
child: t > 0
|
||
? IgnorePointer(
|
||
// The whole row is interactive when onTap is set
|
||
// (we don't want the label sub-widgets eating
|
||
// the tap), so ignore pointer events on the
|
||
// label sub-tree even when the rail is expanded.
|
||
ignoring: onTap != null || !labelsInteractive,
|
||
child: Opacity(
|
||
opacity: t,
|
||
child: Padding(
|
||
padding: const EdgeInsets.only(right: FaiSpace.md),
|
||
child: label,
|
||
),
|
||
),
|
||
)
|
||
: const SizedBox.shrink(),
|
||
),
|
||
],
|
||
);
|
||
if (onTap == null) return body;
|
||
return MouseRegion(
|
||
cursor: SystemMouseCursors.click,
|
||
child: GestureDetector(
|
||
behavior: HitTestBehavior.opaque,
|
||
onTap: onTap,
|
||
child: body,
|
||
),
|
||
);
|
||
}
|
||
|
||
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);
|
||
final l = AppLocalizations.of(context)!;
|
||
return Tooltip(
|
||
message: l.sidebarChannelTooltip,
|
||
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 [_ConnectionLabel] 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,
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Two-line brand label shown in the rail's expanded state.
|
||
/// Fits inside a 48-px fixed-height row by design: 16-px title
|
||
/// + 2-px gap + 10-px monospaced version stamp = 28-px content
|
||
/// with 10 px of vertical breathing room top + bottom.
|
||
class _BrandLabel extends StatelessWidget {
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final theme = Theme.of(context);
|
||
return Column(
|
||
mainAxisAlignment: MainAxisAlignment.center,
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
const Text(
|
||
'F∆I Studio',
|
||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
const SizedBox(height: 2),
|
||
Text(
|
||
'v$kStudioVersion',
|
||
style: FaiTheme.mono(size: 10, color: theme.colorScheme.primary),
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Single-conceptual-block connection label shown in the
|
||
/// rail's expanded state. Two single-line rows: a caption
|
||
/// (`connected` / `tap to start` / `connecting…`) and the
|
||
/// endpoint label below in mono. Fits inside the 44-px fixed
|
||
/// connection-row height — see `_connRowH` in `_SidebarState`.
|
||
/// The old inline "Start hub" button is gone; the parent row
|
||
/// is tappable when `connected == false` and fires the same
|
||
/// daemon-start action.
|
||
class _ConnectionLabel extends StatelessWidget {
|
||
final bool? connected;
|
||
final String endpoint;
|
||
|
||
const _ConnectionLabel({required this.connected, required this.endpoint});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final theme = Theme.of(context);
|
||
final l = AppLocalizations.of(context)!;
|
||
final captionColor = connected == false
|
||
? theme.colorScheme.error
|
||
: theme.colorScheme.onSurface;
|
||
final caption = connected == true
|
||
? l.connectionConnected
|
||
: connected == false
|
||
? l.connectionTapToStart
|
||
: l.connectionConnecting;
|
||
return Column(
|
||
mainAxisAlignment: MainAxisAlignment.center,
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
caption,
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: theme.textTheme.labelMedium?.copyWith(
|
||
color: captionColor,
|
||
fontWeight: FontWeight.w500,
|
||
),
|
||
),
|
||
const SizedBox(height: 2),
|
||
Text(
|
||
endpoint,
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: FaiTheme.mono(
|
||
size: 10,
|
||
color: theme.colorScheme.onSurfaceVariant,
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
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) {
|
||
final l = AppLocalizations.of(context)!;
|
||
return Row(
|
||
crossAxisAlignment: CrossAxisAlignment.center,
|
||
children: [
|
||
SizedBox(
|
||
width: iconColumnWidth,
|
||
child: Center(
|
||
child: IconButton(
|
||
icon: const Icon(Icons.settings_outlined, size: 16),
|
||
tooltip: l.sidebarSettingsTooltip,
|
||
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 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,
|
||
),
|
||
),
|
||
),
|
||
);
|
||
},
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 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>(),
|
||
);
|
||
}
|
||
}
|