import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../features/diagnostics/presentation/diagnostics_page.dart'; import '../features/observability/presentation/observability_controller.dart'; import '../features/overview/presentation/overview_page.dart'; import '../shared/widgets/surfaces.dart'; import 'theme_mode_controller.dart'; enum _Destination { vehicle, events } class AppShell extends ConsumerStatefulWidget { const AppShell({super.key}); @override ConsumerState createState() => _AppShellState(); } class _AppShellState extends ConsumerState { _Destination _destination = _Destination.vehicle; @override Widget build(BuildContext context) { final state = ref.watch(observabilityControllerProvider); const pages = [OverviewPage(), DiagnosticsPage()]; return Scaffold( body: AppCanvas( child: SafeArea( child: Stack( children: [ LayoutBuilder( builder: (context, constraints) { final textScale = MediaQuery.textScalerOf(context).scale(14) / 14; final compact = constraints.maxWidth < 960 || textScale > 1.25; final workspace = _Workspace( state: state, selectedIndex: _destination.index, pages: pages, compact: compact, ); return compact ? _CompactShell( selectedIndex: _destination.index, onSelected: _selectDestination, workspace: workspace, ) : _DesktopShell( selectedIndex: _destination.index, onSelected: _selectDestination, workspace: workspace, ); }, ), if (state.accessibilityAnnouncement case final announcement?) _CriticalAnnouncement(message: announcement), ], ), ), ), ); } void _selectDestination(int index) { setState(() => _destination = _Destination.values[index]); } } class _DesktopShell extends StatelessWidget { const _DesktopShell({ required this.selectedIndex, required this.onSelected, required this.workspace, }); final int selectedIndex; final ValueChanged onSelected; final Widget workspace; @override Widget build(BuildContext context) { return Row( children: [ FrostedChrome( child: SizedBox( width: 216, child: NavigationRail( extended: true, minExtendedWidth: 216, selectedIndex: selectedIndex, labelType: NavigationRailLabelType.none, onDestinationSelected: onSelected, leading: const _BrandBlock(), trailing: const Padding( padding: EdgeInsets.fromLTRB(12, 0, 12, 16), child: AppearanceMenu(showLabel: true), ), destinations: const [ NavigationRailDestination( icon: Icon(Icons.radar_outlined), selectedIcon: Icon(Icons.radar), label: Text('Vehicle observation'), ), NavigationRailDestination( icon: Icon(Icons.segment_outlined), selectedIcon: Icon(Icons.segment), label: Text('Event stream'), ), ], ), ), ), Expanded(child: workspace), ], ); } } class _BrandBlock extends StatelessWidget { const _BrandBlock(); @override Widget build(BuildContext context) { final theme = Theme.of(context); return Padding( padding: const EdgeInsets.fromLTRB(24, 24, 16, 20), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Icon(Icons.flight_takeoff_outlined, color: theme.colorScheme.primary), const SizedBox(height: 16), const ConsoleLabel('Kaptan'), Text('Ground Suite', style: theme.textTheme.titleLarge), const SizedBox(height: 4), Text('Observation console', style: theme.textTheme.bodySmall), ], ), ); } } class _CompactShell extends StatelessWidget { const _CompactShell({ required this.selectedIndex, required this.onSelected, required this.workspace, }); final int selectedIndex; final ValueChanged onSelected; final Widget workspace; @override Widget build(BuildContext context) { return Column( children: [ Expanded(child: workspace), FrostedChrome( borderRadius: BorderRadius.vertical(top: Radius.circular(12)), child: Row( key: const Key('compact-navigation'), children: [ Expanded( child: _CompactDestinationButton( selected: selectedIndex == _Destination.vehicle.index, icon: Icons.radar_outlined, selectedIcon: Icons.radar, label: 'Vehicle', onTap: () => onSelected(_Destination.vehicle.index), ), ), Expanded( child: _CompactDestinationButton( selected: selectedIndex == _Destination.events.index, icon: Icons.segment_outlined, selectedIcon: Icons.segment, label: 'Events', onTap: () => onSelected(_Destination.events.index), ), ), ], ), ), ], ); } } class _CompactDestinationButton extends StatelessWidget { const _CompactDestinationButton({ required this.selected, required this.icon, required this.selectedIcon, required this.label, required this.onTap, }); final bool selected; final IconData icon; final IconData selectedIcon; final String label; final VoidCallback onTap; @override Widget build(BuildContext context) { final scheme = Theme.of(context).colorScheme; final tone = selected ? scheme.primary : scheme.onSurfaceVariant; return Semantics( button: true, selected: selected, label: label, child: InkWell( onTap: onTap, child: Padding( padding: const EdgeInsets.symmetric(vertical: 12), child: Column( mainAxisSize: MainAxisSize.min, children: [ Icon(selected ? selectedIcon : icon, color: tone), const SizedBox(height: 4), Text( label, style: Theme.of(context).textTheme.labelMedium?.copyWith( color: tone, fontWeight: selected ? FontWeight.w700 : FontWeight.w500, ), ), ], ), ), ), ); } } class _Workspace extends StatelessWidget { const _Workspace({ required this.state, required this.selectedIndex, required this.pages, required this.compact, }); final ObservabilityState state; final int selectedIndex; final List pages; final bool compact; @override Widget build(BuildContext context) { return Column( children: [ _OperationalStatusStrip(state: state, compact: compact), Expanded(child: pages[selectedIndex]), ], ); } } class _OperationalStatusStrip extends StatelessWidget { const _OperationalStatusStrip({required this.state, required this.compact}); final ObservabilityState state; final bool compact; @override Widget build(BuildContext context) { final heartbeat = state.lastHeartbeat; final dark = Theme.of(context).brightness == Brightness.dark; final replayTone = dark ? const Color(0xff70b8ff) : const Color(0xff175cd3); final source = state.isReplay ? 'Replay · fixture' : state.sourceLabel; return LayoutBuilder( builder: (context, constraints) { final textScale = MediaQuery.textScalerOf(context).scale(14) / 14; final showFacts = !compact && constraints.maxWidth >= 900 && textScale <= 1.25; return FrostedChrome( child: Padding( padding: EdgeInsets.symmetric( horizontal: compact ? 16 : 20, vertical: 10, ), child: showFacts ? Row( children: [ StatusBadge(label: source, color: replayTone), const SizedBox(width: 8), const StatusBadge(label: 'Observation only'), const SizedBox(width: 12), Text( state.status.label, style: Theme.of(context).textTheme.bodySmall, ), const VerticalDivider(indent: 4, endIndent: 4), Text( heartbeat == null ? 'Awaiting reported heartbeat' : 'SYS ${heartbeat.systemId} · COMP ${heartbeat.componentId}', style: Theme.of(context).textTheme.bodySmall?.copyWith( fontFamily: 'monospace', ), ), const SizedBox(width: 12), Text( 'Ingress ${state.parserDropCount} / ${state.semanticDropCount}', style: Theme.of(context).textTheme.bodySmall?.copyWith( fontFamily: 'monospace', ), ), const Spacer(), Text( state.lastFrameAt == null ? 'No observations' : state.isReplay ? 'Replay ended ${_shortTimestamp(state.lastFrameAt!)}' : 'Last ${_shortTimestamp(state.lastFrameAt!)}', style: Theme.of(context).textTheme.bodySmall, ), ], ) : Wrap( spacing: 8, runSpacing: 6, crossAxisAlignment: WrapCrossAlignment.center, children: [ StatusBadge(label: source, color: replayTone), const StatusBadge(label: 'Observation only'), const AppearanceMenu(), ], ), ), ); }, ); } } class AppearanceMenu extends ConsumerWidget { const AppearanceMenu({super.key, this.showLabel = false}); final bool showLabel; @override Widget build(BuildContext context, WidgetRef ref) { final mode = ref.watch(themeModeProvider); final label = _appearanceLabel(mode); return Semantics( button: true, label: 'Appearance: $label', child: Tooltip( message: 'Appearance: $label', child: PopupMenuButton( initialValue: mode, constraints: const BoxConstraints(minWidth: 300, maxWidth: 360), onSelected: ref.read(themeModeProvider.notifier).select, child: Padding( padding: const EdgeInsets.all(8), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon(_appearanceIcon(mode), size: 20), if (showLabel) ...[ const SizedBox(width: 8), Text( 'Appearance', style: Theme.of(context).textTheme.labelLarge, ), ], ], ), ), itemBuilder: (context) => [ const PopupMenuItem( enabled: false, child: ConsoleLabel('Appearance'), ), _appearanceItem( ThemeMode.system, mode, 'System default (recommended)', ), _appearanceItem(ThemeMode.light, mode, 'Light'), _appearanceItem(ThemeMode.dark, mode, 'Dark'), ], ), ), ); } PopupMenuItem _appearanceItem( ThemeMode value, ThemeMode selected, String label, ) => PopupMenuItem( value: value, child: Row( children: [ Icon(value == selected ? Icons.check : Icons.circle_outlined, size: 18), const SizedBox(width: 10), Expanded( child: Text(label, maxLines: 2, overflow: TextOverflow.ellipsis), ), ], ), ); } class _CriticalAnnouncement extends StatelessWidget { const _CriticalAnnouncement({required this.message}); final String message; @override Widget build(BuildContext context) => Semantics( container: true, liveRegion: true, label: message, child: const SizedBox.square(dimension: 1), ); } String _appearanceLabel(ThemeMode mode) => switch (mode) { ThemeMode.system => 'System default', ThemeMode.light => 'Light', ThemeMode.dark => 'Dark', }; IconData _appearanceIcon(ThemeMode mode) => switch (mode) { ThemeMode.system => Icons.brightness_auto_outlined, ThemeMode.light => Icons.light_mode_outlined, ThemeMode.dark => Icons.dark_mode_outlined, }; String _shortTimestamp(DateTime value) => '${value.hour.toString().padLeft(2, '0')}:' '${value.minute.toString().padLeft(2, '0')}:' '${value.second.toString().padLeft(2, '0')}Z';