feat(ground-suite): redesign observability console

This commit is contained in:
Onur Özkan committed 2026-08-10 16:46:36 +03:00
1 parent c67d553076
commit 153b0bf262
12 files changed
+1105 -467

No files matched your search

+16 -4
View File
@@ -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
+28 -9
View File
@@ -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
+263 -110
View File
@@ -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<AppShell> {
_Destination _destination = _Destination.overview;
_Destination _destination = _Destination.vehicle;
@override
Widget build(BuildContext context) {
@@ -25,14 +25,17 @@ class _AppShellState extends ConsumerState<AppShell> {
const pages = <Widget>[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<AppShell> {
? _CompactShell(
selectedIndex: _destination.index,
onSelected: _selectDestination,
content: content,
workspace: workspace,
)
: _WideShell(
: _DesktopShell(
selectedIndex: _destination.index,
onSelected: _selectDestination,
content: content,
workspace: workspace,
);
},
),
@@ -65,37 +68,75 @@ class _AppShellState extends ConsumerState<AppShell> {
}
}
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<int> onSelected;
final Widget content;
final Widget workspace;
@override
Widget build(BuildContext context) {
return Row(
children: [
GlassPanel(
borderRadius: BorderRadius.zero,
FrostedChrome(
child: SizedBox(
width: 216,
child: NavigationRail(
extended: true,
minExtendedWidth: 216,
selectedIndex: selectedIndex,
labelType: NavigationRailLabelType.all,
labelType: NavigationRailLabelType.none,
onDestinationSelected: onSelected,
leading: const Padding(
padding: EdgeInsets.symmetric(vertical: 16),
child: Icon(Icons.flight_takeoff_outlined, size: 28),
leading: const _BrandBlock(),
trailing: const Padding(
padding: EdgeInsets.fromLTRB(12, 0, 12, 16),
child: AppearanceMenu(showLabel: true),
),
destinations: _destinations,
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'),
),
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),
],
),
);
}
}
@@ -104,33 +145,40 @@ class _CompactShell extends StatelessWidget {
const _CompactShell({
required this.selectedIndex,
required this.onSelected,
required this.content,
required this.workspace,
});
final int selectedIndex;
final ValueChanged<int> 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),
),
),
Expanded(
child: _CompactDestinationButton(
selected: selectedIndex == _Destination.events.index,
icon: Icons.segment_outlined,
selectedIcon: Icons.segment,
label: 'Events',
onTap: () => onSelected(_Destination.events.index),
),
NavigationDestination(
icon: Icon(Icons.receipt_long_outlined),
selectedIcon: Icon(Icons.receipt_long),
label: 'Diagnostics',
),
],
),
@@ -140,18 +188,52 @@ class _CompactShell extends StatelessWidget {
}
}
const _destinations = <NavigationRailDestination>[
NavigationRailDestination(
icon: Icon(Icons.space_dashboard_outlined),
selectedIcon: Icon(Icons.space_dashboard),
label: Text('Overview'),
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,
),
NavigationRailDestination(
icon: Icon(Icons.receipt_long_outlined),
selectedIcon: Icon(Icons.receipt_long),
label: Text('Diagnostics'),
),
];
],
),
),
),
);
}
}
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',
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 : 24,
vertical: 12,
horizontal: compact ? 16 : 20,
vertical: 10,
),
child: GlassPanel(
child: Padding(
padding: const EdgeInsets.all(12),
child: Wrap(
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: 8,
runSpacing: 6,
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(),
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<ThemeMode>(
tooltip: 'Choose theme',
return Semantics(
button: true,
label: 'Appearance: $label',
child: Tooltip(
message: 'Appearance: $label',
child: PopupMenuButton<ThemeMode>(
initialValue: mode,
constraints: const BoxConstraints(minWidth: 300, maxWidth: 360),
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')),
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<ThemeMode> _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(
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';
+29 -12
View File
@@ -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,
@@ -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<DiagnosticsPage> {
@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)),
children: [
for (final filter in _DiagnosticFilter.values)
ChoiceChip(
label: Text(_filterLabel(filter, diagnostics)),
selected: _filter == filter,
onSelected: (_) => setState(() => _filter = filter),
),
)
.toList(growable: false),
],
),
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<DiagnosticsPage> {
),
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,80 +108,86 @@ class _DiagnosticsPageState extends ConsumerState<DiagnosticsPage> {
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),
],
const SizedBox(height: 8),
Text(
event.receivedAt.toIso8601String(),
style: theme.textTheme.bodySmall,
detail,
style: Theme.of(context).textTheme.bodySmall,
),
],
],
),
),
),
@@ -186,64 +202,87 @@ 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}',
_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),
);
}
class _ErrorContent extends _DiagnosticContent {
_ErrorContent({
required String path,
required String additionalInformation,
required int flags,
}) : super(
leading: const SeverityBadge(level: MavSeverity.error),
title: 'Error',
),
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),
);
}
class _ShutdownContent extends _DiagnosticContent {
_ShutdownContent({required KaptanShutdownReason reason})
: super(
leading: const Icon(Icons.power_settings_new_outlined),
title: 'Orderly shutdown observed',
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}',
semanticLabel: 'Orderly shutdown observed: ${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'),
};
String _labelFor(_DiagnosticFilter filter) => switch (filter) {
_DiagnosticFilter.all => 'All',
bool _isDiagnostic(ObservedEvent event) =>
event is LogEvent || event is ErrorEvent || event is ShutdownEvent;
String _filterLabel(_DiagnosticFilter filter, List<ObservedEvent> events) {
final count = switch (filter) {
_DiagnosticFilter.all => events.length,
_DiagnosticFilter.logs => events.whereType<LogEvent>().length,
_DiagnosticFilter.errors => events.whereType<ErrorEvent>().length,
_DiagnosticFilter.shutdowns => events.whereType<ShutdownEvent>().length,
};
final label = switch (filter) {
_DiagnosticFilter.all => 'All events',
_DiagnosticFilter.logs => 'Logs',
_DiagnosticFilter.errors => 'Errors',
_DiagnosticFilter.shutdowns => 'Shutdowns',
_DiagnosticFilter.shutdowns => 'Shutdown reports',
};
return '$label $count';
}
String _searchableText(ObservedEvent event) => switch (event) {
LogEvent(:final level, :final message) => '${level.label} $message',
@@ -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 = <String>[
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';
@@ -20,6 +20,7 @@ final observabilityControllerProvider =
class ObservabilityController extends Notifier<ObservabilityState> {
static const _criticalAnnouncementWindow = Duration(seconds: 2);
static const _timelineCapacity = 500;
StreamSubscription<VehicleDataUpdate>? _subscription;
DateTime? _lastCriticalAnnouncementAt;
@@ -75,10 +76,22 @@ class ObservabilityController extends Notifier<ObservabilityState> {
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<ObservabilityState> {
void _cancelSubscription() {
_subscription?.cancel();
}
_TimelineRetention _retainTimelineEvent(
List<ObservedEvent> current,
ObservedEvent incoming,
) {
if (current.length < _timelineCapacity) {
return _TimelineRetention([...current, incoming]);
}
final retained = List<ObservedEvent>.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<ObservedEvent> events, {this.evictionCount = 0})
: events = List.unmodifiable(events);
final List<ObservedEvent> 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<ObservedEvent> events;
final HeartbeatEvent? lastHeartbeat;
final Set<TelemetryKind> 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<TelemetryKind> get observedTelemetryKinds => {
for (final event in events)
if (event case TelemetryEvent(:final kind)) kind,
};
ObservabilityState copyWith({
ObservationStatus? status,
List<ObservedEvent>? events,
HeartbeatEvent? lastHeartbeat,
Set<TelemetryKind>? 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
@@ -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,93 +12,101 @@ 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,
),
_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,
_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: '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) {
return SizedBox(
Widget build(BuildContext context) => SizedBox(
width: width,
child: GlassPanel(
child: InstrumentPanel(
accent: accent,
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),
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) {
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.'),
],
),
),
],
);
}
@@ -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),
],
),
);
}
}
@@ -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(
@@ -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),
),
);
}
}
@@ -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<void>.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<ErrorEvent>(), hasLength(1));
expect(state.lastHeartbeat?.systemId, 7);
expect(state.observedTelemetryKinds, contains(TelemetryKind.rawImu));
},
);
}
class _ControlledDataPort implements VehicleDataPort {
final events = StreamController<VehicleDataUpdate>();
final events = StreamController<VehicleDataUpdate>(sync: true);
@override
bool get isReplay => false;
+29 -11
View File
@@ -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);
});
}