diff --git a/docs/src/ground-suite.md b/docs/src/ground-suite.md index d3ace47..ad012ac 100644 --- a/docs/src/ground-suite.md +++ b/docs/src/ground-suite.md @@ -14,10 +14,22 @@ Link presence is never presented as proof of safety, authority, arming state, or write capability. Ground Suite provides light and dark themes, selecting the operating system's -current preference by default. The dark theme uses near-black, muted-silver, -and restrained green accents with locally blurred translucent panels. -The observation bar also offers a current-session System, Light, or Dark -appearance selector. +current preference by default. Its dark theme uses a near-black canvas, +muted-silver text, and restrained green accents. Locally blurred glass is +limited to persistent navigation and session chrome; vehicle facts and the +event stream use opaque instrument surfaces so they remain readable at volume. +The desktop rail and compact session strip offer a current-session System, +Light, or Dark appearance selector. + +The replay shell is organised as an operator scan path: source and authority, +then last reported heartbeat and observed streams, then ingress accounting and +the event stream. It labels reported vehicle state precisely and never infers +current safety, command authority, or configuration capability from a replay +or a received diagnostic record. The event stream keeps ordinary logs, runtime +errors, and orderly shutdown reports visually and semantically distinct. Its +local history is bounded; the interface prioritises runtime errors and orderly +shutdown reports, and reports any local UI eviction independently from +vehicle-side diagnostic loss. Ground Suite uses a receive-only source/decoder/classifier boundary. The replay implementation feeds typed observations today. A future live adapter diff --git a/ground-suite/README.md b/ground-suite/README.md index 9701bd9..b0f0c13 100644 --- a/ground-suite/README.md +++ b/ground-suite/README.md @@ -6,9 +6,9 @@ identifier is `dev.kaptan.ground_suite`. ## Current scope The first vertical slice is a **read-only observability console**. It uses a -deterministic replay fixture to present a vehicle identity, heartbeat state, -telemetry presence, ordinary logs, runtime errors, and orderly-shutdown -observations. It never sends bytes to a vehicle. +deterministic replay fixture to present a reported vehicle identity and +heartbeat, telemetry presence, ordinary logs, runtime errors, and +orderly-shutdown observations. It never sends bytes to a vehicle. This is deliberate: the current F722 firmware rejects every inbound MAVLink frame, and `kaptan-sitl serve` has no MAVLink endpoint. A connected-looking UI @@ -20,7 +20,7 @@ must not suggest the vehicle is controllable, safe, armed, or writable. VehicleDataPort (replay now; MAVLink adapter later) -> typed observation-or-drop updates -> Riverpod observability state - -> Overview / Diagnostics presentation + -> Vehicle observation / Event stream presentation ``` `MavlinkByteSource` and `MavlinkEventDecoder` are receive-only seams. The @@ -48,12 +48,31 @@ will be added only with their respective CI coverage. ## Appearance Ground Suite supplies light and dark themes and follows the operating system -by default. Its dark palette uses near-black, muted-silver, and restrained -green accents; translucent, locally blurred panels keep operational content -legible without turning diagnostics into decoration. +by default. Its dark palette pairs a near-black canvas with muted-silver text +and restrained green accents. The visual system is intentionally layered: +locally blurred glass is reserved for persistent navigation and session +chrome, while vehicle facts and the event stream use opaque instrument +surfaces for reliable scanning at volume. -Use the appearance button in the observation bar to select **System default**, -**Light**, or **Dark**. The choice applies for the current session only. +Use **Appearance** in the desktop rail or compact session strip to select +**System default**, **Light**, or **Dark**. The choice applies for the current +session only. + +## Replay workspace + +The shell keeps provenance and authority visible before any detail: + +- **Vehicle observation** presents the replay source, last reported heartbeat, + observed data streams, and ingress accounting. Typed replay marks integrity + as not evaluated; reported state is not a current safety authority. +- **Event stream** is a dense, copyable diagnostic timeline. Logs, runtime + errors, and orderly shutdown reports remain distinct, and bounded delivery + flags such as truncation and coalescing remain visible. The app retains a + bounded local history, prioritising runtime errors and shutdown reports, and + discloses local UI evictions separately from vehicle-side delivery loss. +- **Vehicle setup · unavailable** is explanatory text, not a disabled control. + It accurately reflects that this firmware currently accepts no inbound + MAVLink authority. ## Deliberately deferred work diff --git a/ground-suite/lib/app/app_shell.dart b/ground-suite/lib/app/app_shell.dart index d197704..6cea746 100644 --- a/ground-suite/lib/app/app_shell.dart +++ b/ground-suite/lib/app/app_shell.dart @@ -1,13 +1,13 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'theme_mode_controller.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/glass_panel.dart'; +import '../shared/widgets/surfaces.dart'; +import 'theme_mode_controller.dart'; -enum _Destination { overview, diagnostics } +enum _Destination { vehicle, events } class AppShell extends ConsumerStatefulWidget { const AppShell({super.key}); @@ -17,7 +17,7 @@ class AppShell extends ConsumerStatefulWidget { } class _AppShellState extends ConsumerState { - _Destination _destination = _Destination.overview; + _Destination _destination = _Destination.vehicle; @override Widget build(BuildContext context) { @@ -25,14 +25,17 @@ class _AppShellState extends ConsumerState { const pages = [OverviewPage(), DiagnosticsPage()]; return Scaffold( - body: GroundSuiteBackdrop( + body: AppCanvas( child: SafeArea( child: Stack( children: [ LayoutBuilder( builder: (context, constraints) { - final compact = constraints.maxWidth < 760; - final content = _Workspace( + 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, @@ -42,12 +45,12 @@ class _AppShellState extends ConsumerState { ? _CompactShell( selectedIndex: _destination.index, onSelected: _selectDestination, - content: content, + workspace: workspace, ) - : _WideShell( + : _DesktopShell( selectedIndex: _destination.index, onSelected: _selectDestination, - content: content, + workspace: workspace, ); }, ), @@ -65,72 +68,117 @@ class _AppShellState extends ConsumerState { } } -class _WideShell extends StatelessWidget { - const _WideShell({ +class _DesktopShell extends StatelessWidget { + const _DesktopShell({ required this.selectedIndex, required this.onSelected, - required this.content, + required this.workspace, }); final int selectedIndex; final ValueChanged onSelected; - final Widget content; + final Widget workspace; @override Widget build(BuildContext context) { return Row( children: [ - GlassPanel( - borderRadius: BorderRadius.zero, - child: NavigationRail( - selectedIndex: selectedIndex, - labelType: NavigationRailLabelType.all, - onDestinationSelected: onSelected, - leading: const Padding( - padding: EdgeInsets.symmetric(vertical: 16), - child: Icon(Icons.flight_takeoff_outlined, size: 28), + 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'), + ), + ], ), - destinations: _destinations, ), ), - const VerticalDivider(width: 1), - Expanded(child: content), + 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.content, + required this.workspace, }); final int selectedIndex; final ValueChanged onSelected; - final Widget content; + final Widget workspace; @override Widget build(BuildContext context) { return Column( children: [ - Expanded(child: content), - GlassPanel( - borderRadius: const BorderRadius.vertical(top: Radius.circular(16)), - child: NavigationBar( - selectedIndex: selectedIndex, - onDestinationSelected: onSelected, - destinations: const [ - NavigationDestination( - icon: Icon(Icons.space_dashboard_outlined), - selectedIcon: Icon(Icons.space_dashboard), - label: 'Overview', + 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), + ), ), - NavigationDestination( - icon: Icon(Icons.receipt_long_outlined), - selectedIcon: Icon(Icons.receipt_long), - label: 'Diagnostics', + Expanded( + child: _CompactDestinationButton( + selected: selectedIndex == _Destination.events.index, + icon: Icons.segment_outlined, + selectedIcon: Icons.segment, + label: 'Events', + onTap: () => onSelected(_Destination.events.index), + ), ), ], ), @@ -140,18 +188,52 @@ class _CompactShell extends StatelessWidget { } } -const _destinations = [ - NavigationRailDestination( - icon: Icon(Icons.space_dashboard_outlined), - selectedIcon: Icon(Icons.space_dashboard), - label: Text('Overview'), - ), - NavigationRailDestination( - icon: Icon(Icons.receipt_long_outlined), - selectedIcon: Icon(Icons.receipt_long), - label: Text('Diagnostics'), - ), -]; +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({ @@ -170,86 +252,161 @@ class _Workspace extends StatelessWidget { Widget build(BuildContext context) { return Column( children: [ - _ConnectionBar(state: state, compact: compact), - const Divider(height: 1), - Expanded( - child: IndexedStack(index: selectedIndex, children: pages), - ), + _OperationalStatusStrip(state: state, compact: compact), + Expanded(child: pages[selectedIndex]), ], ); } } -class _ConnectionBar extends StatelessWidget { - const _ConnectionBar({required this.state, required this.compact}); +class _OperationalStatusStrip extends StatelessWidget { + const _OperationalStatusStrip({required this.state, required this.compact}); final ObservabilityState state; final bool compact; @override Widget build(BuildContext context) { - final theme = Theme.of(context); final heartbeat = state.lastHeartbeat; - final peer = heartbeat == null - ? 'No vehicle identity observed' - : 'System ${heartbeat.systemId} / component ${heartbeat.componentId}'; - final frameTime = state.lastFrameAt == null - ? 'No frames received' - : 'Last frame ${_formatTimestamp(state.lastFrameAt!)}'; + 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 Semantics( - label: '${state.status.label}; ${state.sourceLabel}; $peer; $frameTime', - child: Padding( - padding: EdgeInsets.symmetric( - horizontal: compact ? 16 : 24, - vertical: 12, - ), - child: GlassPanel( + 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: const EdgeInsets.all(12), - child: Wrap( - spacing: 8, - runSpacing: 8, - crossAxisAlignment: WrapCrossAlignment.center, - children: [ - Icon( - Icons.visibility_outlined, - color: theme.colorScheme.primary, - ), - Text('Observation only', style: theme.textTheme.titleSmall), - _StatusChip(label: state.sourceLabel), - _StatusChip(label: state.status.label), - Text(peer), - Text(frameTime), - const _ThemeMenu(), - ], + 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 _ThemeMenu extends ConsumerWidget { - const _ThemeMenu(); +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 PopupMenuButton( - tooltip: 'Choose theme', - initialValue: mode, - onSelected: ref.read(themeModeProvider.notifier).select, - icon: Icon(_iconFor(mode)), - itemBuilder: (context) => const [ - PopupMenuItem(value: ThemeMode.system, child: Text('System default')), - PopupMenuItem(value: ThemeMode.light, child: Text('Light')), - PopupMenuItem(value: ThemeMode.dark, child: Text('Dark')), - ], + 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 { @@ -258,31 +415,27 @@ class _CriticalAnnouncement extends StatelessWidget { final String message; @override - Widget build(BuildContext context) { - return Semantics( - container: true, - liveRegion: true, - label: message, - child: const SizedBox.square(dimension: 1), - ); - } + Widget build(BuildContext context) => Semantics( + container: true, + liveRegion: true, + label: message, + child: const SizedBox.square(dimension: 1), + ); } -class _StatusChip extends StatelessWidget { - const _StatusChip({required this.label}); +String _appearanceLabel(ThemeMode mode) => switch (mode) { + ThemeMode.system => 'System default', + ThemeMode.light => 'Light', + ThemeMode.dark => 'Dark', +}; - final String label; - - @override - Widget build(BuildContext context) { - return Chip(label: Text(label), visualDensity: VisualDensity.compact); - } -} - -String _formatTimestamp(DateTime value) => value.toIso8601String(); - -IconData _iconFor(ThemeMode mode) => switch (mode) { +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'; diff --git a/ground-suite/lib/app/theme.dart b/ground-suite/lib/app/theme.dart index e4a278a..fdaa9de 100644 --- a/ground-suite/lib/app/theme.dart +++ b/ground-suite/lib/app/theme.dart @@ -1,22 +1,22 @@ import 'package:flutter/material.dart'; /// A restrained green accent, used sparingly against neutral surfaces. -const accentGreen = Color(0xff00ff41); +const accentGreen = Color(0xff36d681); ThemeData groundSuiteLightTheme() => _theme( brightness: Brightness.light, - seed: const Color(0xff007a2e), - surface: const Color(0xfff6f8f5), - onSurface: const Color(0xff172019), - outline: const Color(0xffbdc7bc), + seed: const Color(0xff087a3b), + surface: const Color(0xfff3f6f3), + onSurface: const Color(0xff142019), + outline: const Color(0xffc2cec4), ); ThemeData groundSuiteDarkTheme() => _theme( brightness: Brightness.dark, - seed: accentGreen, - surface: const Color(0xff0a0a0a), - onSurface: const Color(0xffe7e7e7), - outline: const Color(0xff333333), + seed: const Color(0xff36d681), + surface: const Color(0xff080b09), + onSurface: const Color(0xffe8f0ea), + outline: const Color(0xff2a382e), ); ThemeData _theme({ @@ -34,10 +34,13 @@ ThemeData _theme({ : Colors.white, surface: surface, onSurface: onSurface, + onSurfaceVariant: brightness == Brightness.dark + ? const Color(0xffa8b5ac) + : const Color(0xff546258), outline: outline, surfaceContainer: brightness == Brightness.dark - ? const Color(0xff111111) - : const Color(0xffedf1ec), + ? const Color(0xff101712) + : const Color(0xffeaf0eb), ); return ThemeData( @@ -45,12 +48,26 @@ ThemeData _theme({ colorScheme: scheme, scaffoldBackgroundColor: Colors.transparent, visualDensity: VisualDensity.standard, - fontFamily: 'monospace', cardTheme: const CardThemeData( color: Colors.transparent, elevation: 0, margin: EdgeInsets.zero, ), + textTheme: ThemeData(brightness: brightness).textTheme.copyWith( + headlineMedium: TextStyle( + color: onSurface, + fontWeight: FontWeight.w700, + letterSpacing: -0.6, + ), + titleLarge: TextStyle(color: onSurface, fontWeight: FontWeight.w700), + titleMedium: TextStyle(color: onSurface, fontWeight: FontWeight.w600), + bodyMedium: TextStyle(color: onSurface), + bodySmall: TextStyle( + color: brightness == Brightness.dark + ? const Color(0xffa8b5ac) + : const Color(0xff546258), + ), + ), dividerTheme: DividerThemeData(color: outline.withValues(alpha: 0.72)), navigationRailTheme: NavigationRailThemeData( backgroundColor: Colors.transparent, diff --git a/ground-suite/lib/features/diagnostics/presentation/diagnostics_page.dart b/ground-suite/lib/features/diagnostics/presentation/diagnostics_page.dart index 18346e6..b7a1df9 100644 --- a/ground-suite/lib/features/diagnostics/presentation/diagnostics_page.dart +++ b/ground-suite/lib/features/diagnostics/presentation/diagnostics_page.dart @@ -3,7 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../core/observations/observed_event.dart'; import '../../../shared/widgets/severity_badge.dart'; -import '../../../shared/widgets/glass_panel.dart'; +import '../../../shared/widgets/surfaces.dart'; import '../../observability/presentation/observability_controller.dart'; enum _DiagnosticFilter { all, logs, errors, shutdowns } @@ -22,45 +22,55 @@ class _DiagnosticsPageState extends ConsumerState { @override Widget build(BuildContext context) { final state = ref.watch(observabilityControllerProvider); - final events = state.events.where(_matches).toList(growable: false); + final diagnostics = state.events + .where(_isDiagnostic) + .toList(growable: false); + final events = diagnostics.where(_matches).toList(growable: false); return CustomScrollView( slivers: [ SliverPadding( - padding: const EdgeInsets.fromLTRB(24, 24, 24, 16), + padding: const EdgeInsets.fromLTRB(28, 28, 28, 16), sliver: SliverToBoxAdapter( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + const ConsoleLabel('Event stream'), + const SizedBox(height: 8), Text( - 'Diagnostics', + 'Event timeline', style: Theme.of(context).textTheme.headlineMedium, ), - const SizedBox(height: 8), - const Text( - 'Messages remain distinct by protocol class. A configuration ' - 'result will remain authoritative when configuration support ' - 'is added.', + const SizedBox(height: 6), + Text( + 'Read-only decoded observations. Diagnostic delivery can be bounded and lossy.', + style: Theme.of(context).textTheme.bodyMedium, ), - const SizedBox(height: 16), + const SizedBox(height: 8), + Text( + 'Retained diagnostics ${state.events.length} · local records not retained ${state.localTimelineEvictionCount}', + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(fontFamily: 'monospace'), + ), + const SizedBox(height: 20), Wrap( spacing: 8, runSpacing: 8, - children: _DiagnosticFilter.values - .map( - (filter) => ChoiceChip( - label: Text(_labelFor(filter)), - selected: _filter == filter, - onSelected: (_) => setState(() => _filter = filter), - ), - ) - .toList(growable: false), + children: [ + for (final filter in _DiagnosticFilter.values) + ChoiceChip( + label: Text(_filterLabel(filter, diagnostics)), + selected: _filter == filter, + onSelected: (_) => setState(() => _filter = filter), + ), + ], ), const SizedBox(height: 12), TextField( decoration: const InputDecoration( border: OutlineInputBorder(), - labelText: 'Filter diagnostics', + labelText: 'Search message, error path, or detail', prefixIcon: Icon(Icons.search), ), onChanged: (value) { @@ -73,23 +83,23 @@ class _DiagnosticsPageState extends ConsumerState { ), if (events.isEmpty) const SliverPadding( - padding: EdgeInsets.fromLTRB(24, 0, 24, 24), + padding: EdgeInsets.fromLTRB(28, 0, 28, 28), sliver: SliverToBoxAdapter( - child: GlassPanel( + child: InstrumentPanel( child: Padding( padding: EdgeInsets.all(20), - child: Text('No diagnostic observations match this filter.'), + child: Text('No events match this view.'), ), ), ), ) else SliverPadding( - padding: const EdgeInsets.fromLTRB(24, 0, 24, 24), + padding: const EdgeInsets.fromLTRB(28, 0, 28, 28), sliver: SliverList.builder( itemCount: events.length, itemBuilder: (context, index) => - _DiagnosticCard(event: events[index]), + _DiagnosticRow(event: events[index]), ), ), ], @@ -98,79 +108,85 @@ class _DiagnosticsPageState extends ConsumerState { bool _matches(ObservedEvent event) { final typeMatches = switch (_filter) { - _DiagnosticFilter.all => - event is LogEvent || event is ErrorEvent || event is ShutdownEvent, + _DiagnosticFilter.all => true, _DiagnosticFilter.logs => event is LogEvent, _DiagnosticFilter.errors => event is ErrorEvent, _DiagnosticFilter.shutdowns => event is ShutdownEvent, }; - if (!typeMatches) { - return false; - } - if (_query.isEmpty) { - return true; - } - return _searchableText(event).toLowerCase().contains(_query); + return typeMatches && + (_query.isEmpty || + _searchableText(event).toLowerCase().contains(_query)); } } -class _DiagnosticCard extends StatelessWidget { - const _DiagnosticCard({required this.event}); +class _DiagnosticRow extends StatelessWidget { + const _DiagnosticRow({required this.event}); final ObservedEvent event; @override Widget build(BuildContext context) { - final theme = Theme.of(context); - final content = switch (event) { - LogEvent(:final level, :final message, :final flags) => _LogContent( - level: level, - message: message, - flags: flags, - ), - ErrorEvent(:final path, :final additionalInformation, :final flags) => - _ErrorContent( - path: path, - additionalInformation: additionalInformation, - flags: flags, - ), - ShutdownEvent(:final reason) => _ShutdownContent(reason: reason), - _ => null, - }; - if (content == null) { - return const SizedBox.shrink(); - } + final content = _contentFor( + event, + Theme.of(context).brightness == Brightness.dark, + ); return Semantics( container: true, label: content.semanticLabel, child: Padding( - padding: const EdgeInsets.only(bottom: 12), - child: GlassListItem( + padding: const EdgeInsets.only(bottom: 8), + child: InstrumentPanel( child: Padding( - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - content.leading, + Container(width: 3, height: 56, color: content.tone), const SizedBox(width: 12), + SizedBox( + width: 92, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ConsoleLabel(content.kind, color: content.tone), + const SizedBox(height: 6), + Text( + _timelineTimestamp(event.receivedAt), + style: Theme.of(context).textTheme.bodySmall?.copyWith( + fontFamily: 'monospace', + ), + ), + ], + ), + ), + const SizedBox(width: 8), + content.icon, + const SizedBox(width: 10), Expanded( child: SelectionArea( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(content.title, style: theme.textTheme.titleMedium), + Text( + content.title, + style: Theme.of(context).textTheme.titleMedium, + ), const SizedBox(height: 4), - Text(content.detail), + Text( + content.detail, + style: content.monoDetail + ? Theme.of(context).textTheme.bodyMedium + ?.copyWith(fontFamily: 'monospace') + : null, + ), if (content.additionalDetail case final detail?) ...[ const SizedBox(height: 8), - Text(detail), + Text( + detail, + style: Theme.of(context).textTheme.bodySmall, + ), ], - const SizedBox(height: 8), - Text( - event.receivedAt.toIso8601String(), - style: theme.textTheme.bodySmall, - ), ], ), ), @@ -186,65 +202,88 @@ class _DiagnosticCard extends StatelessWidget { class _DiagnosticContent { const _DiagnosticContent({ - required this.leading, + required this.kind, + required this.tone, + required this.icon, required this.title, required this.detail, required this.semanticLabel, this.additionalDetail, + this.monoDetail = false, }); - final Widget leading; + final String kind; + final Color tone; + final Widget icon; final String title; final String detail; final String? additionalDetail; final String semanticLabel; + final bool monoDetail; } -class _LogContent extends _DiagnosticContent { - _LogContent({ - required MavSeverity level, - required String message, - required int flags, - }) : super( - leading: SeverityBadge(level: level), - title: 'Log — ${level.label}', - detail: message, - additionalDetail: _formatLogFlags(flags), - semanticLabel: _semanticLogLabel(level, message, flags), - ); -} - -class _ErrorContent extends _DiagnosticContent { - _ErrorContent({ - required String path, - required String additionalInformation, - required int flags, - }) : super( - leading: const SeverityBadge(level: MavSeverity.error), - title: 'Error', - detail: path, - additionalDetail: _formatErrorDetail(additionalInformation, flags), - semanticLabel: _semanticErrorLabel(path, additionalInformation, flags), - ); -} - -class _ShutdownContent extends _DiagnosticContent { - _ShutdownContent({required KaptanShutdownReason reason}) - : super( - leading: const Icon(Icons.power_settings_new_outlined), - title: 'Orderly shutdown observed', - detail: 'Reason: ${reason.label}', - semanticLabel: 'Orderly shutdown observed: ${reason.label}', - ); -} - -String _labelFor(_DiagnosticFilter filter) => switch (filter) { - _DiagnosticFilter.all => 'All', - _DiagnosticFilter.logs => 'Logs', - _DiagnosticFilter.errors => 'Errors', - _DiagnosticFilter.shutdowns => 'Shutdowns', +_DiagnosticContent _contentFor( + ObservedEvent event, + bool dark, +) => switch (event) { + LogEvent(:final level, :final message, :final flags) => _DiagnosticContent( + kind: 'Log', + tone: _toneForSeverity(level, dark), + icon: SeverityBadge(level: level), + title: level.label, + detail: message, + additionalDetail: _formatLogFlags(flags), + semanticLabel: _semanticLogLabel(level, message, flags), + ), + ErrorEvent(:final path, :final additionalInformation, :final flags) => + _DiagnosticContent( + kind: 'Error', + tone: dark ? const Color(0xffff6b63) : const Color(0xffd92d20), + icon: Icon( + Icons.error_outline, + color: dark ? const Color(0xffff6b63) : const Color(0xffd92d20), + ), + title: 'Runtime error observed', + detail: path, + additionalDetail: _formatErrorDetail(additionalInformation, flags), + semanticLabel: _semanticErrorLabel(path, additionalInformation, flags), + monoDetail: true, + ), + ShutdownEvent(:final reason) => _DiagnosticContent( + kind: 'Shutdown', + tone: dark ? const Color(0xfff7b955) : const Color(0xffb54708), + icon: Icon( + Icons.power_settings_new_outlined, + color: dark ? const Color(0xfff7b955) : const Color(0xffb54708), + ), + title: 'Orderly shutdown report', + detail: 'Reason: ${reason.label}', + additionalDetail: + 'Completed orderly, output-inhibited report; not a command, panic, hard-fault, or reset.', + semanticLabel: 'Orderly shutdown report: ${reason.label}', + ), + _ => throw StateError('only diagnostic events reach the timeline'), }; +bool _isDiagnostic(ObservedEvent event) => + event is LogEvent || event is ErrorEvent || event is ShutdownEvent; + +String _filterLabel(_DiagnosticFilter filter, List events) { + final count = switch (filter) { + _DiagnosticFilter.all => events.length, + _DiagnosticFilter.logs => events.whereType().length, + _DiagnosticFilter.errors => events.whereType().length, + _DiagnosticFilter.shutdowns => events.whereType().length, + }; + final label = switch (filter) { + _DiagnosticFilter.all => 'All events', + _DiagnosticFilter.logs => 'Logs', + _DiagnosticFilter.errors => 'Errors', + _DiagnosticFilter.shutdowns => 'Shutdown reports', + }; + return '$label $count'; +} + String _searchableText(ObservedEvent event) => switch (event) { LogEvent(:final level, :final message) => '${level.label} $message', ErrorEvent(:final path, :final additionalInformation) => @@ -253,6 +292,18 @@ String _searchableText(ObservedEvent event) => switch (event) { _ => '', }; +Color _toneForSeverity(MavSeverity level, bool dark) => + switch (level.wireValue) { + 0 || + 1 || + 2 || + 3 => dark ? const Color(0xffff6b63) : const Color(0xffd92d20), + 4 => dark ? const Color(0xfff7b955) : const Color(0xffb54708), + 5 || 6 => dark ? const Color(0xff70b8ff) : const Color(0xff175cd3), + 7 => dark ? const Color(0xffa8b5ac) : const Color(0xff546258), + _ => dark ? const Color(0xffa8b5ac) : const Color(0xff546258), + }; + String? _formatLogFlags(int flags) { final labels = [ if (flags & LogEvent.traceFlag != 0) 'Trace', @@ -261,7 +312,7 @@ String? _formatLogFlags(int flags) { if (flags & ~LogEvent.knownFlags != 0) 'Unknown bits 0x${(flags & ~LogEvent.knownFlags).toRadixString(16)}', ]; - return labels.isEmpty ? null : 'Flags: ${labels.join(', ')}'; + return labels.isEmpty ? null : 'Flags: ${labels.join(' · ')}'; } String? _formatErrorDetail(String additionalInformation, int flags) { @@ -280,7 +331,7 @@ String? _formatErrorFlags(int flags) { if (flags & ~ErrorEvent.knownFlags != 0) 'Unknown bits 0x${(flags & ~ErrorEvent.knownFlags).toRadixString(16)}', ]; - return labels.isEmpty ? null : 'Flags: ${labels.join(', ')}'; + return labels.isEmpty ? null : 'Flags: ${labels.join(' · ')}'; } String _semanticLogLabel(MavSeverity level, String message, int flags) { @@ -298,3 +349,8 @@ String _semanticErrorLabel( final detail = _formatErrorDetail(additionalInformation, flags); return detail == null ? 'Error: $path' : 'Error: $path. $detail'; } + +String _timelineTimestamp(DateTime value) => + '${value.hour.toString().padLeft(2, '0')}:' + '${value.minute.toString().padLeft(2, '0')}:' + '${value.second.toString().padLeft(2, '0')}Z'; diff --git a/ground-suite/lib/features/observability/presentation/observability_controller.dart b/ground-suite/lib/features/observability/presentation/observability_controller.dart index db50f04..50133c2 100644 --- a/ground-suite/lib/features/observability/presentation/observability_controller.dart +++ b/ground-suite/lib/features/observability/presentation/observability_controller.dart @@ -20,6 +20,7 @@ final observabilityControllerProvider = class ObservabilityController extends Notifier { static const _criticalAnnouncementWindow = Duration(seconds: 2); + static const _timelineCapacity = 500; StreamSubscription? _subscription; DateTime? _lastCriticalAnnouncementAt; @@ -75,10 +76,22 @@ class ObservabilityController extends Notifier { void _record(ObservedEvent event) { final announcement = _announcementFor(event); + final retention = _isTimelineEvent(event) + ? _retainTimelineEvent(state.events, event) + : _TimelineRetention(state.events); + final heartbeat = event is HeartbeatEvent ? event : state.lastHeartbeat; + final observedStreams = switch (event) { + TelemetryEvent(:final kind) => {...state.observedTelemetryKinds, kind}, + _ => state.observedTelemetryKinds, + }; state = state.copyWith( status: ObservationStatus.receiving, lastFrameAt: event.receivedAt, - events: [...state.events, event], + events: retention.events, + lastHeartbeat: heartbeat, + observedTelemetryKinds: Set.unmodifiable(observedStreams), + localTimelineEvictionCount: + state.localTimelineEvictionCount + retention.evictionCount, accessibilityAnnouncement: announcement.message, replaceAccessibilityAnnouncement: true, coalescedAlertCount: @@ -110,6 +123,45 @@ class ObservabilityController extends Notifier { void _cancelSubscription() { _subscription?.cancel(); } + + _TimelineRetention _retainTimelineEvent( + List current, + ObservedEvent incoming, + ) { + if (current.length < _timelineCapacity) { + return _TimelineRetention([...current, incoming]); + } + + final retained = List.of(current); + final optionalIndex = retained.indexWhere((event) => !_isCritical(event)); + if (optionalIndex >= 0) { + retained.removeAt(optionalIndex); + retained.add(incoming); + return _TimelineRetention(retained, evictionCount: 1); + } + + if (_isCritical(incoming)) { + retained.removeAt(0); + retained.add(incoming); + return _TimelineRetention(retained, evictionCount: 1); + } + + return _TimelineRetention(retained, evictionCount: 1); + } +} + +bool _isCritical(ObservedEvent event) => + event is ErrorEvent || event is ShutdownEvent; + +bool _isTimelineEvent(ObservedEvent event) => + event is LogEvent || _isCritical(event); + +class _TimelineRetention { + _TimelineRetention(Iterable events, {this.evictionCount = 0}) + : events = List.unmodifiable(events); + + final List events; + final int evictionCount; } enum ObservationStatus { @@ -129,9 +181,12 @@ class ObservabilityState { required this.isReplay, required this.status, required this.events, + required this.lastHeartbeat, + required this.observedTelemetryKinds, required this.parserDropCount, required this.semanticDropCount, required this.coalescedAlertCount, + required this.localTimelineEvictionCount, this.lastFrameAt, this.accessibilityAnnouncement, }); @@ -142,9 +197,12 @@ class ObservabilityState { isReplay: source.isReplay, status: ObservationStatus.starting, events: const [], + lastHeartbeat: null, + observedTelemetryKinds: const {}, parserDropCount: 0, semanticDropCount: 0, coalescedAlertCount: 0, + localTimelineEvictionCount: 0, ); } @@ -152,32 +210,24 @@ class ObservabilityState { final bool isReplay; final ObservationStatus status; final List events; + final HeartbeatEvent? lastHeartbeat; + final Set observedTelemetryKinds; final int parserDropCount; final int semanticDropCount; final int coalescedAlertCount; + final int localTimelineEvictionCount; final DateTime? lastFrameAt; final String? accessibilityAnnouncement; - HeartbeatEvent? get lastHeartbeat { - for (final event in events.reversed) { - if (event case HeartbeatEvent()) { - return event; - } - } - return null; - } - - Set get observedTelemetryKinds => { - for (final event in events) - if (event case TelemetryEvent(:final kind)) kind, - }; - ObservabilityState copyWith({ ObservationStatus? status, List? events, + HeartbeatEvent? lastHeartbeat, + Set? observedTelemetryKinds, int? parserDropCount, int? semanticDropCount, int? coalescedAlertCount, + int? localTimelineEvictionCount, DateTime? lastFrameAt, String? accessibilityAnnouncement, bool replaceAccessibilityAnnouncement = false, @@ -187,9 +237,14 @@ class ObservabilityState { isReplay: isReplay, status: status ?? this.status, events: events ?? this.events, + lastHeartbeat: lastHeartbeat ?? this.lastHeartbeat, + observedTelemetryKinds: + observedTelemetryKinds ?? this.observedTelemetryKinds, parserDropCount: parserDropCount ?? this.parserDropCount, semanticDropCount: semanticDropCount ?? this.semanticDropCount, coalescedAlertCount: coalescedAlertCount ?? this.coalescedAlertCount, + localTimelineEvictionCount: + localTimelineEvictionCount ?? this.localTimelineEvictionCount, lastFrameAt: lastFrameAt ?? this.lastFrameAt, accessibilityAnnouncement: replaceAccessibilityAnnouncement ? accessibilityAnnouncement diff --git a/ground-suite/lib/features/overview/presentation/overview_page.dart b/ground-suite/lib/features/overview/presentation/overview_page.dart index 2a43baf..e9250a8 100644 --- a/ground-suite/lib/features/overview/presentation/overview_page.dart +++ b/ground-suite/lib/features/overview/presentation/overview_page.dart @@ -1,8 +1,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../core/observations/observed_event.dart'; +import '../../../shared/widgets/surfaces.dart'; import '../../observability/presentation/observability_controller.dart'; -import '../../../shared/widgets/glass_panel.dart'; class OverviewPage extends ConsumerWidget { const OverviewPage({super.key}); @@ -11,95 +12,103 @@ class OverviewPage extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final state = ref.watch(observabilityControllerProvider); final heartbeat = state.lastHeartbeat; - final telemetry = state.observedTelemetryKinds - .map((kind) => kind.label) - .join(', '); - final identity = heartbeat == null - ? 'Not observed' - : 'System ${heartbeat.systemId} / component ${heartbeat.componentId}'; - final identityDetail = heartbeat == null - ? 'Awaiting HEARTBEAT.' - : 'Reported by last HEARTBEAT: ' - '${heartbeat.armed ? 'Armed' : 'Disarmed'} · ${heartbeat.vehicleState}'; + final observedStreams = state.observedTelemetryKinds; return LayoutBuilder( builder: (context, constraints) { - final horizontalPadding = constraints.maxWidth < 600 ? 16.0 : 24.0; - final cardWidth = constraints.maxWidth < 640 - ? constraints.maxWidth - (horizontalPadding * 2) - : 280.0; + final padding = constraints.maxWidth < 600 ? 16.0 : 28.0; + final columns = constraints.maxWidth >= 1120 + ? 3 + : constraints.maxWidth >= 700 + ? 2 + : 1; + final panelWidth = + (constraints.maxWidth - (padding * 2) - (16 * (columns - 1))) / + columns; return ListView( - padding: EdgeInsets.all(horizontalPadding), + padding: EdgeInsets.all(padding), children: [ - Text('Overview', style: Theme.of(context).textTheme.headlineMedium), + const ConsoleLabel('Vehicle observation'), const SizedBox(height: 8), - const Text( - 'This workspace is receive-only. A link or replay never implies a ' - 'safe, armed, or command-capable vehicle.', + Text( + 'Replay session', + style: Theme.of(context).textTheme.headlineMedium, + ), + const SizedBox(height: 6), + Text( + 'Read-only observations from the selected source.', + style: Theme.of(context).textTheme.bodyMedium, ), const SizedBox(height: 24), Wrap( spacing: 16, runSpacing: 16, children: [ - _SummaryCard( - title: 'Source', + _MetricPanel( + width: panelWidth, + label: 'Replay session', value: state.sourceLabel, - width: cardWidth, - detail: state.isReplay - ? 'Deterministic replay; no physical vehicle is connected.' - : 'Receive-only transport.', - icon: Icons.visibility_outlined, + accent: Theme.of(context).colorScheme.secondary, + child: const Text( + 'Deterministic fixture. No physical vehicle is connected.', + ), ), - _SummaryCard( - title: 'Vehicle identity', - value: identity, - detail: identityDetail, - width: cardWidth, - icon: Icons.memory_outlined, + _MetricPanel( + width: panelWidth, + label: 'Last reported heartbeat', + value: heartbeat == null + ? 'Awaiting heartbeat' + : 'SYS ${heartbeat.systemId} · COMP ${heartbeat.componentId}', + monoValue: true, + child: _HeartbeatFacts(heartbeat: heartbeat), ), - _SummaryCard( - title: 'Observed streams', - value: telemetry.isEmpty ? 'None' : telemetry, - detail: state.isReplay - ? '${state.events.length} replayed observation(s).' - : '${state.events.length} observation(s).', - width: cardWidth, - icon: Icons.sensors_outlined, - ), - _SummaryCard( - title: 'Decoder accounting', + _MetricPanel( + width: panelWidth, + label: 'Ingress accounting', value: - '${state.parserDropCount} parser / ${state.semanticDropCount} semantic', - detail: 'Counters are zero in the typed replay fixture.', - width: cardWidth, - icon: Icons.rule_outlined, + '${state.parserDropCount} / ${state.semanticDropCount}', + monoValue: true, + child: Text( + state.isReplay + ? 'Parser / semantic drops. Not evaluated for typed replay.' + : 'Parser / semantic drops from received frames.', + ), ), ], ), - const SizedBox(height: 24), - GlassPanel( + const SizedBox(height: 16), + InstrumentPanel( child: Padding( padding: const EdgeInsets.all(20), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - 'Configuration is unavailable', - style: Theme.of(context).textTheme.titleLarge, - ), - const SizedBox(height: 8), - const Text( - 'Kaptan firmware does not yet accept inbound configuration ' - 'authority. Ground Suite will not show upload, commit, arm, ' - 'or shutdown controls until the firmware exposes reviewed ' - 'authenticated ingress and safe activation.', + const ConsoleLabel('Observed data streams'), + const SizedBox(height: 16), + Wrap( + spacing: 10, + runSpacing: 10, + children: [ + for (final kind in TelemetryKind.values) + _StreamIndicator( + kind: kind, + observed: observedStreams.contains(kind), + ), + ], ), ], ), ), ), + const SizedBox(height: 16), + InstrumentPanel( + accent: Theme.of(context).colorScheme.outline, + child: const Padding( + padding: EdgeInsets.all(20), + child: _UnavailableSetup(), + ), + ), ], ); }, @@ -107,42 +116,168 @@ class OverviewPage extends ConsumerWidget { } } -class _SummaryCard extends StatelessWidget { - const _SummaryCard({ - required this.title, - required this.value, - required this.detail, - required this.icon, +class _MetricPanel extends StatelessWidget { + const _MetricPanel({ required this.width, + required this.label, + required this.value, + required this.child, + this.accent, + this.monoValue = false, }); - final String title; - final String value; - final String detail; - final IconData icon; final double width; + final String label; + final String value; + final Widget child; + final Color? accent; + final bool monoValue; + + @override + Widget build(BuildContext context) => SizedBox( + width: width, + child: InstrumentPanel( + accent: accent, + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ConsoleLabel(label), + const SizedBox(height: 14), + Text( + value, + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontFamily: monoValue ? 'monospace' : null, + ), + ), + const SizedBox(height: 10), + DefaultTextStyle.merge( + style: Theme.of(context).textTheme.bodySmall, + child: child, + ), + ], + ), + ), + ), + ); +} + +class _HeartbeatFacts extends StatelessWidget { + const _HeartbeatFacts({required this.heartbeat}); + + final HeartbeatEvent? heartbeat; @override Widget build(BuildContext context) { - return SizedBox( - width: width, - child: GlassPanel( - child: Padding( - padding: const EdgeInsets.all(20), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Icon(icon), - const SizedBox(height: 16), - Text(title, style: Theme.of(context).textTheme.labelLarge), - const SizedBox(height: 4), - Text(value, style: Theme.of(context).textTheme.titleLarge), - const SizedBox(height: 8), - Text(detail), - ], + if (heartbeat == null) { + return const Text('No heartbeat has been reported by this source.'); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _FactLine( + label: 'Reported arm state', + value: heartbeat!.armed ? 'ARMED' : 'DISARMED', + ), + const SizedBox(height: 4), + _FactLine( + label: 'Reported MAV state', + value: heartbeat!.vehicleState.toUpperCase(), + ), + ], + ); + } +} + +class _FactLine extends StatelessWidget { + const _FactLine({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) => RichText( + text: TextSpan( + style: Theme.of(context).textTheme.bodySmall, + children: [ + TextSpan(text: '$label: '), + TextSpan( + text: value, + style: const TextStyle( + fontFamily: 'monospace', + fontWeight: FontWeight.w700, ), ), + ], + ), + ); +} + +class _StreamIndicator extends StatelessWidget { + const _StreamIndicator({required this.kind, required this.observed}); + + final TelemetryKind kind; + final bool observed; + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + final tone = observed ? scheme.primary : scheme.onSurfaceVariant; + return DecoratedBox( + decoration: BoxDecoration( + color: tone.withValues(alpha: observed ? 0.12 : 0.06), + border: Border.all( + color: tone.withValues(alpha: observed ? 0.5 : 0.32), + ), + borderRadius: BorderRadius.circular(8), + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 9), + child: Wrap( + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + Icon( + observed + ? Icons.check_circle_outline + : Icons.remove_circle_outline, + size: 17, + color: tone, + ), + const SizedBox(width: 7), + Text(kind.label), + const SizedBox(width: 7), + Text( + observed ? 'Observed' : 'Not observed', + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), ), ); } } + +class _UnavailableSetup extends StatelessWidget { + const _UnavailableSetup(); + + @override + Widget build(BuildContext context) => Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.lock_outline), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const ConsoleLabel('Vehicle setup · unavailable'), + const SizedBox(height: 8), + Text('This firmware accepts no inbound MAVLink authority.'), + ], + ), + ), + ], + ); +} diff --git a/ground-suite/lib/shared/widgets/glass_panel.dart b/ground-suite/lib/shared/widgets/glass_panel.dart deleted file mode 100644 index 08af392..0000000 --- a/ground-suite/lib/shared/widgets/glass_panel.dart +++ /dev/null @@ -1,121 +0,0 @@ -import 'dart:ui'; - -import 'package:flutter/material.dart'; - -import '../../app/theme.dart'; - -/// A bounded, translucent surface used for Ground Suite's data panels. -/// -/// The blur is applied only inside the clipped panel, keeping the effect local -/// to the UI rather than turning the whole desktop window into a filter. -class GlassPanel extends StatelessWidget { - const GlassPanel({ - required this.child, - super.key, - this.borderRadius = const BorderRadius.all(Radius.circular(16)), - }); - - final Widget child; - final BorderRadius borderRadius; - - @override - Widget build(BuildContext context) { - return ClipRRect( - borderRadius: borderRadius, - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 18, sigmaY: 18), - child: _GlassSurface(borderRadius: borderRadius, child: child), - ), - ); - } -} - -/// A scroll-friendly translucent panel without a backdrop filter. -/// -/// Timeline rows use this surface so diagnostic volume does not turn into a -/// growing set of expensive compositing layers. -class GlassListItem extends StatelessWidget { - const GlassListItem({ - required this.child, - super.key, - this.borderRadius = const BorderRadius.all(Radius.circular(16)), - }); - - final Widget child; - final BorderRadius borderRadius; - - @override - Widget build(BuildContext context) => - _GlassSurface(borderRadius: borderRadius, child: child); -} - -class _GlassSurface extends StatelessWidget { - const _GlassSurface({required this.child, required this.borderRadius}); - - final Widget child; - final BorderRadius borderRadius; - - @override - Widget build(BuildContext context) { - final scheme = Theme.of(context).colorScheme; - final isDark = scheme.brightness == Brightness.dark; - final fill = isDark ? const Color(0xb30a0a0a) : const Color(0xc9ffffff); - final border = isDark - ? scheme.primary.withValues(alpha: 0.24) - : scheme.outline.withValues(alpha: 0.7); - - return DecoratedBox( - decoration: BoxDecoration( - color: fill, - border: Border.all(color: border), - borderRadius: borderRadius, - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: isDark ? 0.24 : 0.08), - blurRadius: 24, - offset: const Offset(0, 10), - ), - ], - ), - child: child, - ); - } -} - -class GroundSuiteBackdrop extends StatelessWidget { - const GroundSuiteBackdrop({required this.child, super.key}); - - final Widget child; - - @override - Widget build(BuildContext context) { - final isDark = Theme.of(context).brightness == Brightness.dark; - final base = isDark ? const Color(0xff050505) : const Color(0xffeef3ee); - final haze = isDark - ? accentGreen.withValues(alpha: 0.1) - : const Color(0xff007a2e).withValues(alpha: 0.07); - - return ColoredBox( - color: base, - child: Stack( - children: [ - Positioned( - top: -260, - right: -220, - child: IgnorePointer( - child: Container( - width: 620, - height: 620, - decoration: BoxDecoration( - shape: BoxShape.circle, - gradient: RadialGradient(colors: [haze, Colors.transparent]), - ), - ), - ), - ), - Positioned.fill(child: child), - ], - ), - ); - } -} diff --git a/ground-suite/lib/shared/widgets/severity_badge.dart b/ground-suite/lib/shared/widgets/severity_badge.dart index 43816fe..b3559b7 100644 --- a/ground-suite/lib/shared/widgets/severity_badge.dart +++ b/ground-suite/lib/shared/widgets/severity_badge.dart @@ -9,12 +9,28 @@ class SeverityBadge extends StatelessWidget { @override Widget build(BuildContext context) { + final dark = Theme.of(context).brightness == Brightness.dark; final (icon, color) = switch (level.wireValue) { - 0 || 1 || 2 || 3 => (Icons.error_outline, Colors.red.shade700), - 4 => (Icons.warning_amber_outlined, Colors.orange.shade800), - 5 || 6 => (Icons.info_outline, Colors.blue.shade700), - 7 => (Icons.bug_report_outlined, Colors.grey.shade700), - _ => (Icons.help_outline, Colors.grey.shade700), + 0 || 1 || 2 || 3 => ( + Icons.error_outline, + dark ? const Color(0xffff6b63) : const Color(0xffd92d20), + ), + 4 => ( + Icons.warning_amber_outlined, + dark ? const Color(0xfff7b955) : const Color(0xffb54708), + ), + 5 || 6 => ( + Icons.info_outline, + dark ? const Color(0xff70b8ff) : const Color(0xff175cd3), + ), + 7 => ( + Icons.bug_report_outlined, + dark ? const Color(0xffa8b5ac) : const Color(0xff546258), + ), + _ => ( + Icons.help_outline, + dark ? const Color(0xffa8b5ac) : const Color(0xff546258), + ), }; return Semantics( diff --git a/ground-suite/lib/shared/widgets/surfaces.dart b/ground-suite/lib/shared/widgets/surfaces.dart new file mode 100644 index 0000000..bd3d5d3 --- /dev/null +++ b/ground-suite/lib/shared/widgets/surfaces.dart @@ -0,0 +1,216 @@ +import 'dart:ui'; + +import 'package:flutter/material.dart'; + +import '../../app/theme.dart'; + +/// A blurred surface for stable application chrome only. +/// +/// Keep this out of scrollable content. Diagnostic rows and metric panels use +/// [InstrumentPanel], which has the same visual language without creating a +/// compositing layer per item. +class FrostedChrome extends StatelessWidget { + const FrostedChrome({ + required this.child, + super.key, + this.borderRadius = BorderRadius.zero, + }); + + final Widget child; + final BorderRadius borderRadius; + + @override + Widget build(BuildContext context) { + final highContrast = MediaQuery.maybeOf(context)?.highContrast ?? false; + final surface = _SurfaceFrame( + borderRadius: borderRadius, + chrome: true, + opaque: highContrast, + child: child, + ); + if (highContrast) { + return surface; + } + + return ClipRRect( + borderRadius: borderRadius, + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20), + child: surface, + ), + ); + } +} + +/// An opaque, high-legibility panel for operational data and event rows. +class InstrumentPanel extends StatelessWidget { + const InstrumentPanel({ + required this.child, + super.key, + this.borderRadius = const BorderRadius.all(Radius.circular(12)), + this.accent, + }); + + final Widget child; + final BorderRadius borderRadius; + final Color? accent; + + @override + Widget build(BuildContext context) => _SurfaceFrame( + borderRadius: borderRadius, + accent: accent, + opaque: true, + child: child, + ); +} + +class _SurfaceFrame extends StatelessWidget { + const _SurfaceFrame({ + required this.child, + required this.borderRadius, + this.chrome = false, + this.opaque = false, + this.accent, + }); + + final Widget child; + final BorderRadius borderRadius; + final bool chrome; + final bool opaque; + final Color? accent; + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + final dark = scheme.brightness == Brightness.dark; + final opacity = opaque ? 0xf6 : (dark ? 0xbf : 0xd9); + final top = dark + ? Color.fromARGB( + opacity, + chrome ? 12 : 16, + chrome ? 17 : 23, + chrome ? 14 : 18, + ) + : Color.fromARGB(opacity, 255, 255, 255); + final bottom = dark + ? Color.fromARGB(opacity, 8, 11, 9) + : Color.fromARGB(opacity, 244, 248, 244); + final border = + accent?.withValues(alpha: 0.74) ?? + scheme.outline.withValues(alpha: dark ? 0.92 : 0.82); + + return DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [top, bottom], + ), + border: Border.all(color: border), + borderRadius: borderRadius, + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: dark ? 0.18 : 0.06), + blurRadius: 18, + offset: const Offset(0, 8), + ), + ], + ), + child: child, + ); + } +} + +/// The static canvas behind Ground Suite's chrome and instrument surfaces. +class AppCanvas extends StatelessWidget { + const AppCanvas({required this.child, super.key}); + + final Widget child; + + @override + Widget build(BuildContext context) { + final dark = Theme.of(context).brightness == Brightness.dark; + final base = dark ? const Color(0xff080b09) : const Color(0xfff3f6f3); + final primaryHaze = (dark ? accentGreen : const Color(0xff087a3b)) + .withValues(alpha: dark ? 0.075 : 0.05); + + return ColoredBox( + color: base, + child: Stack( + children: [ + Positioned( + top: -250, + right: -200, + child: IgnorePointer(child: _Haze(color: primaryHaze)), + ), + Positioned( + bottom: -330, + left: -260, + child: IgnorePointer( + child: _Haze(color: primaryHaze.withValues(alpha: 0.55)), + ), + ), + Positioned.fill(child: child), + ], + ), + ); + } +} + +class _Haze extends StatelessWidget { + const _Haze({required this.color}); + + final Color color; + + @override + Widget build(BuildContext context) => Container( + width: 600, + height: 600, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: RadialGradient(colors: [color, Colors.transparent]), + ), + ); +} + +class ConsoleLabel extends StatelessWidget { + const ConsoleLabel(this.text, {super.key, this.color}); + + final String text; + final Color? color; + + @override + Widget build(BuildContext context) => Text( + text.toUpperCase(), + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: color ?? Theme.of(context).colorScheme.onSurfaceVariant, + fontFamily: 'monospace', + fontWeight: FontWeight.w700, + letterSpacing: 1.25, + ), + ); +} + +class StatusBadge extends StatelessWidget { + const StatusBadge({required this.label, super.key, this.color}); + + final String label; + final Color? color; + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + final tone = color ?? scheme.onSurfaceVariant; + return DecoratedBox( + decoration: BoxDecoration( + color: tone.withValues(alpha: 0.11), + border: Border.all(color: tone.withValues(alpha: 0.45)), + borderRadius: BorderRadius.circular(7), + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5), + child: ConsoleLabel(label, color: tone), + ), + ); + } +} diff --git a/ground-suite/test/features/observability/observability_controller_test.dart b/ground-suite/test/features/observability/observability_controller_test.dart index 90565dc..964211f 100644 --- a/ground-suite/test/features/observability/observability_controller_test.dart +++ b/ground-suite/test/features/observability/observability_controller_test.dart @@ -93,10 +93,72 @@ void main() { expect(state.parserDropCount, 0); expect(state.semanticDropCount, 0); }); + + test( + 'bounds diagnostics while telemetry stays outside the timeline', + () async { + final port = _ControlledDataPort(); + final container = ProviderContainer( + overrides: [vehicleDataPortProvider.overrideWithValue(port)], + ); + addTearDown(container.dispose); + + container.read(observabilityControllerProvider); + await Future.delayed(Duration.zero); + + port.events.add( + ObservationReceived( + ErrorEvent( + receivedAt: DateTime.utc(2026, 8, 10), + path: 'kaptan_drivers::lidar_lite_v3::error::Error::TransferFault', + ), + ), + ); + port.events.add( + ObservationReceived( + HeartbeatEvent( + receivedAt: DateTime.utc(2026, 8, 10, 0, 0, 1), + systemId: 7, + componentId: 42, + armed: false, + vehicleState: 'Standby', + ), + ), + ); + for (var index = 0; index < 501; index++) { + port.events.add( + ObservationReceived( + TelemetryEvent( + receivedAt: DateTime.utc(2026, 8, 10, 0, 0, index % 60), + kind: TelemetryKind.rawImu, + ), + ), + ); + } + for (var index = 0; index < 500; index++) { + port.events.add( + ObservationReceived( + LogEvent( + receivedAt: DateTime.utc(2026, 8, 10, 0, 1, index % 60), + level: MavSeverity.info, + message: 'Diagnostic $index', + ), + ), + ); + } + + final state = container.read(observabilityControllerProvider); + expect(state.events, hasLength(500)); + expect(state.localTimelineEvictionCount, 1); + expect(state.events.whereType(), hasLength(1)); + expect(state.lastHeartbeat?.systemId, 7); + expect(state.observedTelemetryKinds, contains(TelemetryKind.rawImu)); + }, + ); } class _ControlledDataPort implements VehicleDataPort { - final events = StreamController(); + final events = StreamController(sync: true); @override bool get isReplay => false; diff --git a/ground-suite/test/widget_test.dart b/ground-suite/test/widget_test.dart index 35f706b..fa2c643 100644 --- a/ground-suite/test/widget_test.dart +++ b/ground-suite/test/widget_test.dart @@ -3,7 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:ground_suite/app/app.dart'; import 'package:ground_suite/app/app_shell.dart'; -import 'package:ground_suite/shared/widgets/glass_panel.dart'; +import 'package:ground_suite/shared/widgets/surfaces.dart'; void main() { testWidgets('replay workspace keeps diagnostics distinct and read-only', ( @@ -16,12 +16,12 @@ void main() { expect(app.themeMode, ThemeMode.system); expect(app.theme?.brightness, Brightness.light); expect(app.darkTheme?.brightness, Brightness.dark); - expect(find.byType(GlassPanel), findsAtLeastNWidgets(1)); - expect(find.text('Observation only'), findsOneWidget); + expect(find.byType(FrostedChrome), findsAtLeastNWidgets(1)); + expect(find.text('OBSERVATION ONLY'), findsOneWidget); expect(find.text('Replay fixture'), findsAtLeastNWidgets(1)); - expect(find.text('Vehicle identity'), findsOneWidget); + expect(find.text('LAST REPORTED HEARTBEAT'), findsOneWidget); - await tester.tap(find.byTooltip('Choose theme')); + await tester.tap(find.byTooltip('Appearance: System default')); await tester.pumpAndSettle(); await tester.tap(find.text('Dark')); await tester.pumpAndSettle(); @@ -31,21 +31,21 @@ void main() { ThemeMode.dark, ); - await tester.tap(find.byIcon(Icons.receipt_long_outlined)); + await tester.tap(find.byIcon(Icons.segment_outlined)); await tester.pumpAndSettle(); - expect(find.text('Diagnostics'), findsAtLeastNWidgets(1)); - await tester.tap(find.text('Errors')); + expect(find.text('Event timeline'), findsOneWidget); + await tester.tap(find.text('Errors 1')); await tester.pumpAndSettle(); expect( find.text('kaptan_drivers::lidar_lite_v3::error::Error::TransferFault'), findsOneWidget, ); - await tester.tap(find.text('Shutdowns')); + await tester.tap(find.text('Shutdown reports 1')); await tester.pumpAndSettle(); - expect(find.text('Orderly shutdown observed'), findsOneWidget); + expect(find.text('Orderly shutdown report'), findsOneWidget); }); testWidgets( @@ -57,7 +57,7 @@ void main() { await tester.pumpWidget(const ProviderScope(child: GroundSuiteApp())); await tester.pumpAndSettle(); - expect(find.byType(NavigationBar), findsOneWidget); + expect(find.byKey(const Key('compact-navigation')), findsOneWidget); expect(tester.takeException(), isNull); }, ); @@ -75,4 +75,22 @@ void main() { final context = tester.element(find.byType(AppShell)); expect(Theme.of(context).brightness, Brightness.dark); }); + + testWidgets('large desktop text uses the compact session strip', ( + tester, + ) async { + await tester.binding.setSurfaceSize(const Size(1280, 900)); + tester.binding.platformDispatcher.textScaleFactorTestValue = 2; + addTearDown(() { + tester.binding.setSurfaceSize(null); + tester.binding.platformDispatcher.clearTextScaleFactorTestValue(); + }); + + await tester.pumpWidget(const ProviderScope(child: GroundSuiteApp())); + await tester.pumpAndSettle(); + + expect(find.text('OBSERVATION ONLY'), findsOneWidget); + expect(find.text('Replay ended 12:00:06Z'), findsNothing); + expect(tester.takeException(), isNull); + }); }