chain-studio/lib/main.dart
flemming-it 588f437395
Some checks failed
Security / Security check (push) Failing after 2s
feat(workspace): sealed-area names are confidential by default
The switcher listed sealed areas by name ('lbs', 'stromnetz') on
any glance or screenshot — but the names themselves often carry
client/mandate identity (usertest security finding). The sealed
section now renders one aggregated row ('2 sealed areas') with a
deliberate 'Show names' reveal per menu opening; selection still
pops the regular s:<slug> value. Settings -> Security gains 'list
sealed areas with their names right away' (WorkspacePrefs,
SidebarPrefs pattern, default off).

The aggregate row wraps to two lines — popup menus cap their
width and action texts must never be truncated (the first cut
showed '1 abgeschotte…' in the proof shot). Guard: switcher tests
cover aggregated-until-reveal and the Settings toggle; the old
direct-listing test now asserts the reveal contract. DE+EN.

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
2026-07-19 03:34:05 +02:00

2189 lines
75 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

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

// 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/foundation.dart' show defaultTargetPlatform;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'data/about_info.dart';
import 'data/chain_log.dart';
import 'data/error_presentation.dart';
import 'data/hub.dart';
import 'data/sidebar_prefs.dart';
import 'data/workspace_prefs.dart';
import 'data/workspace.dart';
import 'data/system_actions.dart';
import 'data/theme_plugin.dart';
import 'l10n/app_localizations.dart';
import 'pages/approvals.dart';
import 'pages/setup_gate.dart';
import 'widgets/guided_setup_dialog.dart';
import 'pages/audit.dart';
import 'pages/doctor.dart';
import 'pages/federation.dart';
import 'pages/flows.dart';
import 'pages/runs.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 — canonical value lives in
/// data/about_info.dart (shared with Settings → About).
export 'data/about_info.dart' show kStudioVersion;
/// Run one pre-frame restore step, absorbing any failure. Everything
/// before `runApp` is a black window waiting to happen: an exception
/// here (a corrupt preference store, an unreadable file) used to kill
/// startup with no visible error at all. Each step degrades to its
/// default instead; the app MUST reach the first frame.
Future<T> _restoreOr<T>(T fallback, Future<T> Function() step) async {
try {
return await step();
} catch (e) {
debugPrint('startup restore step failed (using default): $e');
return fallback;
}
}
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.
// Every step is failure-isolated: one broken store must not
// stop the others, and nothing here may prevent runApp.
await _restoreOr(null, () async {
await HubService.instance.loadPersistedEndpoint();
return null;
});
await _restoreOr(null, () async {
await SystemActions.loadFaiBinaryOverride();
return null;
});
final themeMode = await _restoreOr(
ThemeModeValue.system,
() => HubService.instance.loadThemeMode(),
);
final locale = await _restoreOr(
const Locale('en'),
() => HubService.instance.loadLocale(),
);
final themePlugin = await _restoreOr(null, loadActiveThemePlugin);
await _restoreOr(null, () async {
await SidebarPrefs.load();
await WorkspacePrefs.load();
return null;
});
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> {
/// First-run gate: on a fresh install (no config, no recorded
/// setup plan) the app starts INSIDE the setup instead of layering
/// a dialog over a shell that isn't set up yet. Flips to true when
/// the operator finishes or skips; also true from the start on any
/// already-configured machine.
bool _setupGateDone = !GuidedSetupDialog.isFreshInstall();
/// 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: _setupGateDone
? StudioShell(
startSidebarExpanded: widget.startSidebarExpanded,
)
: SetupGateScreen(
onDone: () =>
setState(() => _setupGateDone = true),
),
);
},
),
),
),
);
}
}
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;
/// Endpoint the previous poll ran against; a change resets the
/// failure streak so stale in-flight results never blame the new
/// endpoint (see `_checkHealth`).
String _polledEndpoint = '';
/// 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;
/// True when the last failed poll was an auth rejection
/// (UNAUTHENTICATED / PERMISSION_DENIED): the hub is up, the
/// token is wrong. The banner must say so — "can't reach the
/// hub" would send the operator to fix the wrong thing.
bool _authRejected = false;
/// Test-only: replaces the hub health probe so widget tests can
/// simulate sustained unreachable / auth-rejected states
/// without a live hub.
@visibleForTesting
static Future<HubProbeResult> Function()? debugProbeOverride;
/// Test-only: replaces the token-file self-heal check (see
/// [_reloadTokenIfChanged]) so widget tests can assert the
/// auth-rejected poll re-reads the token without touching disk.
@visibleForTesting
static Future<bool> Function()? debugReloadTokenOverride;
/// Whether the hub daemon answers at all — an auth rejection or a
/// not-serving health state still means the process is alive, just
/// not usable yet; only [HubProbeResult.unreachable] is "down".
/// Used by the daemon-start paths so a wrong token doesn't
/// misreport as "daemon start failed" (the shell banner already
/// explains the token problem).
static Future<bool> daemonAnswers() async {
try {
final probe = debugProbeOverride != null
? await debugProbeOverride!()
: await HubService.instance.probeHealth();
return probe != HubProbeResult.unreachable;
} catch (_) {
return false;
}
}
/// Self-heal after an auth-rejected poll: the operator may have
/// fixed `~/.chain/hub-auth-token` outside Studio (CLI, editor) —
/// the client caches the token, so re-read the file and reconnect
/// when it changed. Returns whether a reconnect happened. Inert
/// when the probe is test-overridden, unless the reload override
/// is set too.
static Future<bool> _reloadTokenIfChanged() {
final override = debugReloadTokenOverride;
if (override != null) return override();
if (debugProbeOverride != null) return Future.value(false);
return HubService.instance
.reloadAuthTokenIfChanged()
.catchError((_) => false);
}
/// 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. Alive but
// auth-rejected counts as running — the banner explains the token.
final alive = await daemonAnswers();
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: 'runs',
icon: Icons.rocket_launch_outlined,
selectedIcon: Icons.rocket_launch,
page: RunsPage(),
),
_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({bool retriedAfterTokenReload = false}) async {
final probe = debugProbeOverride != null
? await debugProbeOverride!()
: await HubService.instance.probeHealth();
if (!mounted) return;
// A failure that raced an endpoint switch (Settings save, channel
// switch) must not inherit the old endpoint's failure streak —
// restart the count so the banner never blames the NEW endpoint
// for the OLD one's misses.
final endpoint = HubService.instance.endpointLabel;
final endpointChanged = endpoint != _polledEndpoint;
_polledEndpoint = endpoint;
final ok = probe == HubProbeResult.serving;
final authRejected = probe == HubProbeResult.authRejected;
final wasUnreachable = _hubUnreachable;
final nextFailed = ok ? 0 : (endpointChanged ? 1 : _failedPolls + 1);
final connectionChanged = _connected != ok;
final bannerChanged =
wasUnreachable != (nextFailed >= _unreachableThreshold);
if (connectionChanged ||
_failedPolls != nextFailed ||
bannerChanged ||
_authRejected != authRejected) {
setState(() {
_connected = ok;
_failedPolls = nextFailed;
_authRejected = authRejected;
});
}
if (authRejected && !retriedAfterTokenReload) {
// Self-heal: re-read the token file in case it was fixed
// outside Studio; on a real change reconnect + re-probe now
// instead of waiting out the poll interval. One retry per
// poll tick at most.
final changed = await _reloadTokenIfChanged();
if (changed && mounted) {
return _checkHealth(retriedAfterTokenReload: true);
}
}
if (ok) {
try {
final snap = await HubService.instance.channelStatus();
if (!mounted) return;
if (_activeChannel != snap.active) {
setState(() => _activeChannel = snap.active);
}
} catch (_) {/* best-effort */}
try {
// The badge counts the active workspace's pending approvals
// (empty slug = all projects), matching the Approvals page.
final pending = await HubService.instance.pendingApprovals(
project: Workspace.instance.activeSlug,
);
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..9 (Ctrl on non-mac) 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,
),
for (var i = 0; i < _pages.length; i++)
SingleActivator(
LogicalKeyboardKey(LogicalKeyboardKey.digit1.keyId + i),
control: 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: [
ValueListenableBuilder<bool>(
valueListenable: SidebarPrefs.pinned,
builder: (context, pinned, _) => _Sidebar(
selectedIndex: _selectedIndex,
onSelect: (i) => setState(() => _selectedIndex = i),
pages: _pages,
connected: _connected,
endpointLabel: HubService.instance.endpointLabel,
activeChannel: _activeChannel,
pendingApprovals: _pendingApprovals,
forceExpanded: widget.startSidebarExpanded || pinned,
onOpenSearch: _openSearchPalette,
),
),
Container(width: 1, color: theme.colorScheme.outlineVariant),
Expanded(
child: Column(
children: [
const ChainSealedIdentityBar(),
if (_hubUnreachable)
_HubUnreachableBanner(
endpoint: HubService.instance.endpointLabel,
authRejected: _authRejected,
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. When the
/// failure is an auth rejection the wording flips to "check the
/// access token" — the endpoint is fine in that case.
class _HubUnreachableBanner extends StatelessWidget {
final String endpoint;
final bool authRejected;
final VoidCallback onOpenSettings;
const _HubUnreachableBanner({
required this.endpoint,
required this.authRejected,
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(
authRejected ? Icons.key_off_outlined : Icons.cloud_off_outlined,
size: 18,
color: theme.colorScheme.onErrorContainer,
),
const SizedBox(width: ChainSpace.sm),
Expanded(
child: Text(
authRejected
? l.hubAuthRejectedBanner(endpoint)
: 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();
}
/// Platform-truthful label for the app's primary-modifier
/// shortcuts (the activators bind ⌘ on macOS and Ctrl elsewhere).
String _metaShortcut(String key) =>
defaultTargetPlatform == TargetPlatform.macOS ? '$key' : 'Ctrl+$key';
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;
/// Keep the rail fully expanded and disable hover-collapse.
/// True when the operator pins the sidebar (Settings →
/// Appearance) and in widget tests measuring the expanded
/// layout.
final bool forceExpanded;
/// Opens the Cmd+K command palette — the sidebar carries a
/// visible search row so the palette is discoverable without
/// knowing the shortcut.
final VoidCallback? onOpenSearch;
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,
this.onOpenSearch,
});
@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 didUpdateWidget(covariant _Sidebar old) {
super.didUpdateWidget(old);
// The pin toggle flips at runtime (Settings → Appearance):
// animate to the new resting state instead of jumping.
if (widget.forceExpanded != old.forceExpanded) {
widget.forceExpanded ? _ctrl.forward() : _ctrl.reverse();
}
}
@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.
// Alive but auth-rejected counts as running — the shell banner
// explains the token problem.
final alive = await StudioShellState.daemonAnswers();
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,
// Cmd+1..9 jump to the destination; the
// hint rides in tooltip + expanded label
// so the shortcut is discoverable.
shortcutHint: i < 9 ? _metaShortcut('${i + 1}') : null,
onTap: () => widget.onSelect(i),
),
],
),
),
// Visible entry point for the Cmd+K palette — the
// shortcut exists since 0.3x but nothing in the UI
// said so (usertest: "no shortcuts anywhere").
if (widget.onOpenSearch != null)
Builder(
builder: (context) {
final l = AppLocalizations.of(context)!;
return _SidebarActionRow(
icon: Icons.search,
label: l.sidebarSearchLabel,
shortcutHint: _metaShortcut('K'),
t: t,
labelsInteractive: labelsInteractive,
iconColumnWidth: _collapsedWidth,
onTap: widget.onOpenSearch!,
);
},
),
_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;
/// Keyboard shortcut ('⌘2') shown in the tooltip and, subtly,
/// next to the expanded label — discoverability for the
/// existing Cmd+1..9 navigation.
final String? shortcutHint;
const _SidebarItem({
super.key,
required this.page,
required this.selected,
required this.t,
required this.labelsInteractive,
required this.iconColumnWidth,
required this.onTap,
this.badge,
this.shortcutHint,
});
@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: Row(
children: [
Expanded(
child: Text(
label,
overflow: TextOverflow.fade,
softWrap: false,
style: theme.textTheme.labelMedium?.copyWith(
color: color,
),
),
),
if (widget.shortcutHint != null)
Flexible(
child: Text(
widget.shortcutHint!,
overflow: TextOverflow.clip,
softWrap: false,
maxLines: 1,
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant
.withValues(alpha: 0.7),
fontSize: 10,
),
),
),
],
),
),
),
)
: 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,
),
),
),
);
// Explicit button semantics: the collapsed rail is icon-only,
// so a screen reader needs the destination name + role from
// here, not from a hover-only tooltip.
final semantic = Semantics(
button: true,
selected: widget.selected,
label: widget.shortcutHint == null
? label
: '$label (${widget.shortcutHint})',
child: ExcludeSemantics(child: inner),
);
// Tooltip only while the label is hidden — once the label
// is visible inline the tooltip becomes redundant noise.
// waitDuration zero: a first-time user hunting through
// unlabeled icons should not have to linger per icon.
if (widget.t > 0.5) return semantic;
return Tooltip(
message: widget.shortcutHint == null
? label
: '$label · ${widget.shortcutHint}',
preferBelow: false,
waitDuration: Duration.zero,
child: semantic,
);
}
}
/// Sidebar utility row (search/palette) — same geometry and
/// hover behaviour as a destination item, minus selection state.
class _SidebarActionRow extends StatefulWidget {
final IconData icon;
final String label;
final String? shortcutHint;
final double t;
final bool labelsInteractive;
final double iconColumnWidth;
final VoidCallback onTap;
const _SidebarActionRow({
required this.icon,
required this.label,
required this.shortcutHint,
required this.t,
required this.labelsInteractive,
required this.iconColumnWidth,
required this.onTap,
});
@override
State<_SidebarActionRow> createState() => _SidebarActionRowState();
}
class _SidebarActionRowState extends State<_SidebarActionRow> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final color = _hovered
? theme.colorScheme.onSurface
: theme.colorScheme.onSurfaceVariant;
final content = Row(
children: [
SizedBox(
width: widget.iconColumnWidth,
child: Center(child: Icon(widget.icon, size: 18, color: color)),
),
Expanded(
child: widget.t > 0
? IgnorePointer(
ignoring: !widget.labelsInteractive,
child: Opacity(
opacity: widget.t,
child: Padding(
padding: const EdgeInsets.only(right: ChainSpace.md),
child: Row(
children: [
Expanded(
child: Text(
widget.label,
overflow: TextOverflow.fade,
softWrap: false,
style: theme.textTheme.labelMedium?.copyWith(
color: color,
),
),
),
if (widget.shortcutHint != null)
Flexible(
child: Text(
widget.shortcutHint!,
overflow: TextOverflow.clip,
softWrap: false,
maxLines: 1,
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant
.withValues(alpha: 0.7),
fontSize: 10,
),
),
),
],
),
),
),
)
: 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,
padding: const EdgeInsets.symmetric(vertical: ChainSpace.md),
decoration: BoxDecoration(
color: _hovered
? theme.colorScheme.surfaceContainerHigh
: Colors.transparent,
borderRadius: BorderRadius.circular(ChainRadius.sm),
),
child: content,
),
),
),
);
final semantic = Semantics(
button: true,
label: widget.shortcutHint == null
? widget.label
: '${widget.label} (${widget.shortcutHint})',
child: ExcludeSemantics(child: inner),
);
if (widget.t > 0.5) return semantic;
return Tooltip(
message: widget.shortcutHint == null
? widget.label
: '${widget.label} · ${widget.shortcutHint}',
preferBelow: false,
waitDuration: Duration.zero,
child: semantic,
);
}
}
/// 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),
// Flexibles + clip: the strip lives inside the
// animating rail, so a transient narrow width
// must degrade gracefully instead of throwing
// a RenderFlex overflow.
child: Row(
children: [
Flexible(child: ClipRect(child: _ThemeToggle())),
Flexible(child: ClipRect(child: _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 'runs':
return l.navRuns;
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.