chain-studio/lib/main.dart
flemming-it 46918769ce fix(studio): keep sidebar expanded while channel menu is open
Opening the channel switcher let the hover-exit collapse the rail at the
same moment, leaving the menu floating at the pill's old (expanded)
position. The pill now signals open/close; the sidebar suppresses its
collapse (_menuOpen) while the menu is up, so it stays aligned.

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
2026-06-22 01:04:17 +02:00

1804 lines
60 KiB
Dart
Raw Permalink 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.

// Ch∆In Studio — desktop GUI for the Ch∆In hub.
//
// Visual language: warm minimalism / deep tech with soul.
// Tokens in lib/theme/, primitives in lib/widgets/.
import 'dart:async';
import 'package:chain_client_sdk/chain_client_sdk.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'data/chain_log.dart';
import 'data/error_presentation.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/federation.dart';
import 'pages/flows.dart';
import 'pages/store.dart';
import 'pages/welcome.dart';
import 'theme/theme.dart';
import 'theme/tokens.dart';
import 'widgets/chain_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.70.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();
await SystemActions.loadFaiBinaryOverride();
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
/// ChainTheme.
final String? initialThemePlugin;
/// Test-only: start the sidebar expanded (no hover). Lets a widget
/// test assert destination-icon Y stability across collapsed vs
/// expanded without a hover gesture (which trips RenderFlex overflow
/// mid-transition). Always false in production.
final bool startSidebarExpanded;
const StudioApp({
super.key,
required this.initialThemeMode,
required this.initialLocale,
this.initialThemePlugin,
this.startSidebarExpanded = false,
});
/// 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
/// ChainTheme'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.
///
/// The capability slot also carries the `custom:#RRGGBB`
/// sigil for free-color themes: when the value starts with
/// `custom:`, the suffix is parsed as a hex seed and the
/// ColorScheme is derived via Material 3's seed algorithm.
/// That single channel covers both "pick from installed
/// plugins" and "pick a seed colour" without a parallel
/// notifier.
Future<({ThemeData? light, ThemeData? dark})> _pluginThemes(
String? capability,
) async {
if (capability == null || capability.isEmpty) {
return (light: null, dark: null);
}
if (capability.startsWith('custom:')) {
final hex = capability.substring('custom:'.length).replaceAll('#', '');
if (hex.length != 6) return (light: null, dark: null);
final raw = int.tryParse(hex, radix: 16);
if (raw == null) return (light: null, dark: null);
final seed = Color(0xFF000000 | raw);
final light = ColorScheme.fromSeed(
seedColor: seed,
brightness: Brightness.light,
);
final dark = ColorScheme.fromSeed(
seedColor: seed,
brightness: Brightness.dark,
);
return (
light: themeDataFromScheme(light),
dark: themeDataFromScheme(dark),
);
}
try {
final schemes = await loadThemePluginSchemes(capability);
return (
light: schemes.light == null
? null
: themeDataFromScheme(schemes.light!),
dark: schemes.dark == null ? null : themeDataFromScheme(schemes.dark!),
);
} catch (e) {
// Plugin unreachable, hub down, manifest schema drift —
// any of these end up here. Log the failure so doctor +
// the in-app log viewer can surface it; the picker's
// own "applied" snackbar is replaced upstream by the
// friendlier failure message via [themePluginFailed].
// ignore: discarded_futures
ChainLog.instance.error(
'theme.plugin.load',
e,
context: 'capability=$capability',
);
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: 'Ch∆In Studio',
debugShowCheckedModeBanner: false,
theme: p.light ?? ChainTheme.light(),
darkTheme: p.dark ?? ChainTheme.dark(),
themeMode: _flutterMode(mode),
locale: locale,
supportedLocales: AppLocalizations.supportedLocales,
localizationsDelegates:
AppLocalizations.localizationsDelegates,
home: StudioShell(
startSidebarExpanded: widget.startSidebarExpanded,
),
);
},
),
),
),
);
}
}
class StudioShell extends StatefulWidget {
/// Test-only: forward to the sidebar so it starts expanded.
final bool startSidebarExpanded;
const StudioShell({super.key, this.startSidebarExpanded = false});
@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;
/// Consecutive failed health polls. After [_unreachableThreshold]
/// in a row we stop showing an indefinite silent "connecting…"
/// and surface a one-line "can't reach the hub" banner with a
/// jump to Settings.
int _failedPolls = 0;
static const int _unreachableThreshold = 3;
/// Whether to render the unreachable banner. Distinct from
/// `_connected == false` so a single transient miss doesn't
/// flash the banner — only sustained failure does.
bool get _hubUnreachable => _failedPolls >= _unreachableThreshold;
/// Current connection state, for descendants (e.g. WelcomePage)
/// that adapt their content to hub availability.
bool? get connected => _connected;
/// Start the local daemon via the platform binary, then surface
/// the outcome as a snackbar. Shared by the sidebar's connection
/// row and the WelcomePage hero CTA.
Future<void> startDaemon() async {
final l = AppLocalizations.of(context)!;
final r = await SystemActions.chainDaemon(['start']);
if (!mounted) return;
if (r.ok) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l.daemonStartRequested)),
);
Future.delayed(const Duration(seconds: 1), _checkHealth);
return;
}
// The start command reported failure — but very often the daemon is
// simply already running (port in use). Probe before crying error,
// so "tap to start" on an already-up hub just connects.
final alive =
await HubService.instance.healthy().catchError((_) => false);
if (!mounted) return;
if (alive) {
await _checkHealth();
return;
}
// Genuinely down: a persistent, copyable dialog — a SnackBar flashes
// away before the operator can read or copy the daemon's stderr.
await showFaiProcessErrorDialog(
context,
'daemon.start',
r.stdout,
r.stderr,
title: l.daemonStartFailed(''),
);
if (mounted) Future.delayed(const Duration(seconds: 1), _checkHealth);
}
/// Switch the sidebar's selected page by its registered id
/// (`store`, `flows`, `audit`, etc.). Used by the welcome
/// page's onboarding checklist to make each item a real
/// navigation hop instead of a static hint. Silently
/// ignores unknown ids.
void navigateTo(String pageId) {
final idx = _pages.indexWhere((p) => p.id == pageId);
if (idx < 0) return;
setState(() => _selectedIndex = idx);
}
/// Lookup helper — descendants call this to drive nav
/// without holding a controller reference.
static StudioShellState? of(BuildContext context) =>
context.findAncestorStateOfType<StudioShellState>();
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 ChainModuleSheet for quick info.
// Flows destination IS the editor as of 0.52.0 — the
// graph / text / run tabs all live behind one icon. The
// separate "flow-editor" destination is gone; the Cmd+K
// palette still navigates to flows when the operator
// wants the editor surface.
_NavPage(
id: 'flows',
icon: Icons.account_tree_outlined,
selectedIcon: Icons.account_tree,
page: FlowsPage(),
),
_NavPage(
id: 'audit',
icon: Icons.timeline_outlined,
selectedIcon: Icons.timeline,
page: AuditPage(),
),
_NavPage(
id: 'approvals',
icon: Icons.inbox_outlined,
selectedIcon: Icons.inbox,
page: ApprovalsPage(),
),
_NavPage(
id: 'federation',
icon: Icons.hub_outlined,
selectedIcon: Icons.hub,
page: FederationPage(),
),
];
/// Pending-approval count surfaced as a sidebar badge so the
/// operator notices new approvals without polling the page.
/// Refreshed alongside the health probe — same 5 s tick.
int _pendingApprovals = 0;
@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;
final wasUnreachable = _hubUnreachable;
final nextFailed = ok ? 0 : _failedPolls + 1;
final connectionChanged = _connected != ok;
final bannerChanged =
wasUnreachable != (nextFailed >= _unreachableThreshold);
if (connectionChanged || _failedPolls != nextFailed || bannerChanged) {
setState(() {
_connected = ok;
_failedPolls = nextFailed;
});
}
if (ok) {
try {
final snap = await HubService.instance.channelStatus();
if (!mounted) return;
if (_activeChannel != snap.active) {
setState(() => _activeChannel = snap.active);
}
} catch (_) {/* best-effort */}
try {
final pending = await HubService.instance.pendingApprovals();
if (!mounted) return;
if (_pendingApprovals != pending.length) {
setState(() => _pendingApprovals = pending.length);
}
} catch (_) {/* best-effort */}
}
}
@override
void dispose() {
_healthPoll?.cancel();
super.dispose();
}
void _openSearchPalette() {
final l = AppLocalizations.of(context)!;
final pagesGroup = l.searchGroupPages;
final hits = <ChainSearchHit>[
for (var i = 0; i < _pages.length; i++)
ChainSearchHit(
label: _pages[i].labelOf(context),
hint: l.searchPageHint(i + 1),
icon: _pages[i].icon,
group: pagesGroup,
onSelect: () {
Navigator.of(context).pop();
setState(() => _selectedIndex = i);
},
),
ChainSearchHit(
label: l.searchSettingsLabel,
hint: l.searchSettingsHint,
icon: Icons.settings_outlined,
group: pagesGroup,
onSelect: () {
Navigator.of(context).pop();
ChainSettingsDialog.show(context);
},
),
];
ChainSearchPalette.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: (_) {
ChainSettingsDialog.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,
pendingApprovals: _pendingApprovals,
forceExpanded: widget.startSidebarExpanded,
),
Container(width: 1, color: theme.colorScheme.outlineVariant),
Expanded(
child: Column(
children: [
if (_hubUnreachable)
_HubUnreachableBanner(
endpoint: HubService.instance.endpointLabel,
onOpenSettings: () => ChainSettingsDialog.show(context),
),
Expanded(
child: AnimatedSwitcher(
duration: ChainMotion.base,
switchInCurve: ChainMotion.easing,
switchOutCurve: ChainMotion.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,
),
),
),
],
),
),
],
),
),
),
),
);
}
}
/// One-line banner shown when the hub has been unreachable for
/// several consecutive health polls. Replaces the indefinite
/// silent "connecting…" with an explicit message + a jump to
/// Settings, where the operator can fix the endpoint.
class _HubUnreachableBanner extends StatelessWidget {
final String endpoint;
final VoidCallback onOpenSettings;
const _HubUnreachableBanner({
required this.endpoint,
required this.onOpenSettings,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l = AppLocalizations.of(context)!;
return Material(
color: theme.colorScheme.errorContainer,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: ChainSpace.lg,
vertical: ChainSpace.sm,
),
child: Row(
children: [
Icon(
Icons.cloud_off_outlined,
size: 18,
color: theme.colorScheme.onErrorContainer,
),
const SizedBox(width: ChainSpace.sm),
Expanded(
child: Text(
l.hubUnreachableBanner(endpoint),
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onErrorContainer,
),
),
),
const SizedBox(width: ChainSpace.sm),
TextButton(
onPressed: onOpenSettings,
child: Text(l.hubUnreachableOpenSettings),
),
],
),
),
);
}
}
/// 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;
/// Number of pending approvals — surfaced as a badge on the
/// matching nav item so the operator notices a new approval
/// without polling the page.
final int pendingApprovals;
/// Test-only: start fully expanded (controller at 1.0) and disable
/// hover, so a widget test can measure the expanded layout without a
/// hover gesture. Always false in production.
final bool forceExpanded;
const _Sidebar({
required this.selectedIndex,
required this.onSelect,
required this.pages,
required this.connected,
required this.endpointLabel,
required this.activeChannel,
this.pendingApprovals = 0,
this.forceExpanded = false,
});
@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;
// True while the channel-switch menu is open — suppresses the
// hover-exit collapse so the rail stays expanded and the menu stays
// aligned to the pill (otherwise it floats at the pill's old x).
bool _menuOpen = false;
@override
void initState() {
super.initState();
_ctrl = AnimationController(
vsync: this,
// Expand uses the slow (~320 ms) token: the icon→label
// reveal is a deliberate, eased gesture, not a flicker.
// The collapse runs faster (reverseDuration) so dismissing
// the rail feels crisp rather than sluggish.
duration: ChainMotion.slow,
reverseDuration: ChainMotion.base,
value: widget.forceExpanded ? 1 : 0,
);
}
@override
void dispose() {
_ctrl.dispose();
super.dispose();
}
Future<void> _startDaemon(BuildContext context) async {
final l = AppLocalizations.of(context)!;
final r = await SystemActions.chainDaemon(['start']);
if (!context.mounted) return;
if (r.ok) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l.daemonStartRequested)),
);
return;
}
// The daemon may already be running (port in use) — probe before
// showing an error, so this just connects on an already-up hub.
final alive =
await HubService.instance.healthy().catchError((_) => false);
if (!context.mounted) return;
if (alive) return;
// Genuinely down: persistent, copyable dialog (a SnackBar flashes
// away before the operator can read or copy the daemon's stderr).
await showFaiProcessErrorDialog(
context,
'daemon.start',
r.stdout,
r.stderr,
title: l.daemonStartFailed(''),
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final mode = widget.connected == true
? ChainDeltaMode.live
: ChainDeltaMode.idle;
final hasChannel =
widget.activeChannel != null && widget.activeChannel!.isNotEmpty;
return MouseRegion(
onEnter: widget.forceExpanded ? null : (_) => _ctrl.forward(),
onExit: (widget.forceExpanded || _menuOpen)
? null
: (_) => _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: ChainSpace.xl),
child: ClipRect(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Brand row — fixed 48px regardless of state.
SizedBox(
height: _brandRowH,
child: _sidebarRow(
leading: ChainDeltaMark(
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 ch = HubService.instance.connectedChannelName;
final caption = widget.connected == true
? (ch != null
? '${l.connectionConnected} · $ch'
: 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!,
onMenuOpen: () {
setState(() => _menuOpen = true);
_ctrl.forward();
},
onMenuClose: () {
if (!mounted) return;
setState(() => _menuOpen = false);
_ctrl.reverse();
},
),
t: t,
labelsInteractive: labelsInteractive,
)
: const SizedBox.shrink(),
),
const SizedBox(height: ChainSpace.md),
Expanded(
child: ListView(
padding: EdgeInsets.zero,
children: [
for (var i = 0; i < widget.pages.length; i++)
_SidebarItem(
key: ValueKey('sidebar-item-${widget.pages[i].id}'),
page: widget.pages[i],
selected: i == widget.selectedIndex,
t: t,
labelsInteractive: labelsInteractive,
iconColumnWidth: _collapsedWidth,
badge: widget.pages[i].id == 'approvals' &&
widget.pendingApprovals > 0
? widget.pendingApprovals
: null,
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: ChainSpace.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 ChainSettingsDialog.show(context);
if (saved && context.mounted) {
// Force a sidebar rebuild so the new endpoint shows in
// the connection pill.
(context as Element).markNeedsBuild();
}
}
}
/// Accent colour for a channel name. Color-coded so production
/// deployments look visibly different from local-dev — the kind of
/// thing you want to see before clicking "Clear log". Shared by the
/// pill, the collapsed chip, and the switch menu so all three stay
/// in lockstep.
Color _channelAccent(String channel, ColorScheme cs) {
switch (channel) {
case 'production':
// "Live & healthy", not an error — red here read as a fault.
return ChainColors.success;
case 'beta':
return ChainColors.warning;
case 'dev':
return cs.primary;
case 'local':
default:
return cs.onSurfaceVariant;
}
}
IconData _channelIcon(String channel) {
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;
}
}
/// Open the channel-switch menu anchored at [anchor]'s widget, then
/// act on the selection. Switching runs `chain channel switch`
/// (writes `~/.chain/current-channel` AND restarts that channel's
/// daemon), then repoints Studio's wire at the new channel so the
/// connection follows the switch immediately — the 5s health poll
/// then refreshes the active-channel label. Shared by the expanded
/// pill and the collapsed chip.
///
/// Falls back to the full Settings dialog when the hub is down (the
/// snapshot RPC fails) so the operator can still pick + start a
/// channel from there.
Future<void> _showChannelSwitchMenu(BuildContext anchor, String current) async {
// Capture everything that reads from the context up front, before
// any await — these stay valid for the rest of the flow and keep
// the analyzer happy about context-across-async-gaps.
final l = AppLocalizations.of(anchor)!;
final messenger = ScaffoldMessenger.of(anchor);
final box = anchor.findRenderObject() as RenderBox?;
final overlay = Overlay.of(anchor).context.findRenderObject() as RenderBox?;
if (box == null || overlay == null) return;
ChannelStatusSnapshot snap;
try {
snap = await HubService.instance.channelStatus();
} catch (_) {
if (anchor.mounted) ChainSettingsDialog.show(anchor);
return;
}
if (!anchor.mounted) return;
// Anchor the menu to the pill's top edge, opening downward.
final origin = box.localToGlobal(Offset.zero, ancestor: overlay);
final position = RelativeRect.fromLTRB(
origin.dx,
origin.dy + box.size.height,
overlay.size.width - origin.dx - box.size.width,
0,
);
const settingsValue = '__settings__';
final selected = await showMenu<String>(
context: anchor,
position: position,
items: [
for (final ch in snap.channels)
PopupMenuItem<String>(
value: ch.name,
child: _ChannelMenuItem(channel: ch, active: ch.name == snap.active),
),
const PopupMenuDivider(),
PopupMenuItem<String>(
value: settingsValue,
child: Row(
children: [
Icon(
Icons.settings_outlined,
size: 16,
color: Theme.of(anchor).colorScheme.onSurfaceVariant,
),
const SizedBox(width: ChainSpace.sm),
Text(l.searchSettingsLabel),
],
),
),
],
);
if (selected == null || !anchor.mounted) return;
if (selected == settingsValue) {
ChainSettingsDialog.show(anchor);
return;
}
if (selected == snap.active) return;
final r = await SystemActions.chainChannelSwitch(selected);
if (!anchor.mounted) return;
if (!r.ok) {
showFaiProcessError(
anchor,
'channel.switch',
r.stdout,
r.stderr,
title: l.channelSwitchFailed(''),
);
return;
}
// Repoint Studio at the freshly-restarted channel daemon. persist:
// false keeps launch-time auto-discovery following ~/.chain, so the
// CLI pointer remains the single source of truth.
final target = snap.channels.firstWhere(
(c) => c.name == selected,
orElse: () =>
ChannelInfo(name: selected, port: 50051, running: true, endpoint: ''),
);
try {
await HubService.instance.reconnect(
HubEndpoint(host: '127.0.0.1', port: target.port),
persist: false,
);
} catch (_) {/* health poll will retry */}
if (anchor.mounted) {
messenger.showSnackBar(
SnackBar(content: Text(l.channelSwitchOk(selected))),
);
}
}
/// One row in the channel-switch menu: accent icon, name, a
/// running/stopped dot, and a check on the active channel.
class _ChannelMenuItem extends StatelessWidget {
final ChannelInfo channel;
final bool active;
const _ChannelMenuItem({required this.channel, required this.active});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final accent = _channelAccent(channel.name, theme.colorScheme);
return Row(
children: [
Icon(_channelIcon(channel.name), size: 15, color: accent),
const SizedBox(width: ChainSpace.sm),
Text(
channel.name,
style: ChainTheme.mono(
size: 12,
weight: FontWeight.w600,
color: theme.colorScheme.onSurface,
),
),
const SizedBox(width: ChainSpace.sm),
Container(
width: 7,
height: 7,
decoration: BoxDecoration(
color: channel.running
? ChainColors.success
: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.4),
shape: BoxShape.circle,
),
),
const Spacer(),
if (active)
Icon(Icons.check, size: 16, color: theme.colorScheme.primary),
],
);
}
}
/// Active-channel indicator + switcher. Click to open the channel
/// menu and switch directly (writes `~/.chain/current-channel` and
/// restarts the daemon). Color-coded so production stays
/// unmistakeable.
class _ChannelPill extends StatelessWidget {
final String channel;
/// Fired when the switch menu opens / closes so the parent sidebar can
/// stay expanded while it's up — otherwise the hover-exit collapses the
/// rail and the menu floats at the pill's old (expanded) position.
final VoidCallback? onMenuOpen;
final VoidCallback? onMenuClose;
const _ChannelPill({
required this.channel,
this.onMenuOpen,
this.onMenuClose,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final accent = _channelAccent(channel, theme.colorScheme);
final l = AppLocalizations.of(context)!;
return Tooltip(
message: l.sidebarChannelTooltip,
child: Material(
color: theme.colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(ChainRadius.sm),
child: InkWell(
borderRadius: BorderRadius.circular(ChainRadius.sm),
onTap: () async {
onMenuOpen?.call();
await _showChannelSwitchMenu(context, channel);
onMenuClose?.call();
},
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: ChainSpace.sm,
vertical: 4,
),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(ChainRadius.sm),
border: Border.all(color: accent.withValues(alpha: 0.4)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(_channelIcon(channel), size: 11, color: accent),
const SizedBox(width: 4),
Text(
channel,
style: ChainTheme.mono(
size: 10,
weight: FontWeight.w600,
color: accent,
),
),
const SizedBox(width: 2),
Icon(Icons.arrow_drop_down, size: 12, color: accent),
],
),
),
),
),
);
}
}
/// Mini-version of [_ConnectionLabel] used when the sidebar is
/// collapsed. A 10px dot in the same green/red/amber tonality
/// the ChainDeltaMark 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
? ChainColors.success
: connected == false
? theme.colorScheme.error
: ChainColors.warning;
// Pulse only while connected — a living "this is live" signal,
// steady when down/unknown. Restores the pulse lost in the
// sidebar refactor; reuses the shared ChainStatusDot.
return ChainStatusDot(color: color, pulsing: connected == true, size: 10);
}
}
/// 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});
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 = _channelAccent(channel, theme.colorScheme);
return MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => _showChannelSwitchMenu(context, channel),
child: 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: ChainTheme.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(
'Ch∆In Studio',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 2),
Text(
'v$kStudioVersion',
style: ChainTheme.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;
// Name the channel the connection is on (production / local / …) so
// "connected" can never look like it contradicts the Diagnose page,
// which reports the *active* channel's daemon.
final ch = HubService.instance.connectedChannelName;
final caption = connected == true
? (ch != null ? '${l.connectionConnected} · $ch' : 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: ChainTheme.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;
/// Optional count badge — null hides it, non-null renders a
/// small accent pill on top-right of the icon. Used by the
/// Approvals item for pending count.
final int? badge;
const _SidebarItem({
super.key,
required this.page,
required this.selected,
required this.t,
required this.labelsInteractive,
required this.iconColumnWidth,
required this.onTap,
this.badge,
});
@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);
Widget icon = Icon(
widget.selected ? widget.page.selectedIcon : widget.page.icon,
size: 18,
color: color,
);
final badge = widget.badge;
if (badge != null) {
icon = Stack(
clipBehavior: Clip.none,
children: [
icon,
Positioned(
right: -6,
top: -4,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 5,
vertical: 1,
),
constraints: const BoxConstraints(minWidth: 16, minHeight: 14),
decoration: BoxDecoration(
color: theme.colorScheme.primary,
borderRadius: BorderRadius.circular(8),
),
child: Text(
badge > 9 ? '9+' : '$badge',
textAlign: TextAlign.center,
style: TextStyle(
color: theme.colorScheme.onPrimary,
fontSize: 9,
fontWeight: FontWeight.w700,
height: 1.1,
),
),
),
),
],
);
}
// 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: ChainSpace.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: ChainMotion.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: ChainSpace.md),
decoration: BoxDecoration(
color: bg,
borderRadius: BorderRadius.circular(ChainRadius.sm),
// Lift only the selected item — a whisper of depth
// marks "you are here" without competing with the
// accent fill.
boxShadow: widget.selected
? ChainElevation.low(theme.brightness)
: null,
),
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: ChainSpace.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;
case 'federation':
return l.navFederation;
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: ChainTheme.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: ChainTheme.mono(
size: 11,
weight: FontWeight.w600,
color: Theme.of(context).colorScheme.primary,
),
),
),
);
},
);
}
}
// _FlowEditorAdapter removed in 0.52.0 — FlowsPage now hosts
// the editor directly with the full runDriver bridge. There
// is one Flows destination; the standalone editor route is
// gone.