diff --git a/docs/src/ground-suite.md b/docs/src/ground-suite.md index 615b3eb..f8cd6cd 100644 --- a/docs/src/ground-suite.md +++ b/docs/src/ground-suite.md @@ -13,6 +13,11 @@ every inbound MAVLink frame, and SITL does not yet expose a MAVLink endpoint. 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 Orkavian's near-black, +muted-silver, and restrained green-accent visual language with locally blurred +translucent panels. + Ground Suite uses a receive-only source/decoder/classifier boundary. The replay implementation feeds typed observations today. A future live adapter must combine pinned MAVLink Common definitions with `kaptan.xml`, reject diff --git a/ground-suite/README.md b/ground-suite/README.md index ff8846a..ad61574 100644 --- a/ground-suite/README.md +++ b/ground-suite/README.md @@ -45,6 +45,13 @@ nix develop -c sh -c 'cd ground-suite && flutter run -d linux' Linux is the first tested desktop runner. Native macOS and Windows runners 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 follows Orkavian's near-black, muted-silver, and +restrained green-accent visual language; translucent, locally blurred panels +keep operational content legible without turning diagnostics into decoration. + ## Deliberately deferred work - TODO(gcs-live-mavlink): generated MAVLink Common plus custom-dialect diff --git a/ground-suite/lib/app/app.dart b/ground-suite/lib/app/app.dart index 56673c4..d58ea02 100644 --- a/ground-suite/lib/app/app.dart +++ b/ground-suite/lib/app/app.dart @@ -11,7 +11,9 @@ class GroundSuiteApp extends StatelessWidget { return MaterialApp( title: 'Ground Suite', debugShowCheckedModeBanner: false, - theme: groundSuiteTheme(), + theme: groundSuiteLightTheme(), + darkTheme: groundSuiteDarkTheme(), + themeMode: ThemeMode.system, home: const AppShell(), ); } diff --git a/ground-suite/lib/app/app_shell.dart b/ground-suite/lib/app/app_shell.dart index e35463b..5439612 100644 --- a/ground-suite/lib/app/app_shell.dart +++ b/ground-suite/lib/app/app_shell.dart @@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../features/diagnostics/presentation/diagnostics_page.dart'; import '../features/observability/presentation/observability_controller.dart'; import '../features/overview/presentation/overview_page.dart'; +import '../shared/widgets/glass_panel.dart'; enum _Destination { overview, diagnostics } @@ -23,34 +24,36 @@ class _AppShellState extends ConsumerState { const pages = [OverviewPage(), DiagnosticsPage()]; return Scaffold( - body: SafeArea( - child: Stack( - children: [ - LayoutBuilder( - builder: (context, constraints) { - final compact = constraints.maxWidth < 760; - final content = _Workspace( - state: state, - selectedIndex: _destination.index, - pages: pages, - compact: compact, - ); - return compact - ? _CompactShell( - selectedIndex: _destination.index, - onSelected: _selectDestination, - content: content, - ) - : _WideShell( - selectedIndex: _destination.index, - onSelected: _selectDestination, - content: content, - ); - }, - ), - if (state.accessibilityAnnouncement case final announcement?) - _CriticalAnnouncement(message: announcement), - ], + body: OrkavianBackdrop( + child: SafeArea( + child: Stack( + children: [ + LayoutBuilder( + builder: (context, constraints) { + final compact = constraints.maxWidth < 760; + final content = _Workspace( + state: state, + selectedIndex: _destination.index, + pages: pages, + compact: compact, + ); + return compact + ? _CompactShell( + selectedIndex: _destination.index, + onSelected: _selectDestination, + content: content, + ) + : _WideShell( + selectedIndex: _destination.index, + onSelected: _selectDestination, + content: content, + ); + }, + ), + if (state.accessibilityAnnouncement case final announcement?) + _CriticalAnnouncement(message: announcement), + ], + ), ), ), ); @@ -76,15 +79,18 @@ class _WideShell extends StatelessWidget { Widget build(BuildContext context) { return Row( children: [ - NavigationRail( - selectedIndex: selectedIndex, - labelType: NavigationRailLabelType.all, - onDestinationSelected: onSelected, - leading: const Padding( - padding: EdgeInsets.symmetric(vertical: 16), - child: Icon(Icons.flight_takeoff_outlined, size: 28), + 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), + ), + destinations: _destinations, ), - destinations: _destinations, ), const VerticalDivider(width: 1), Expanded(child: content), @@ -109,21 +115,24 @@ class _CompactShell extends StatelessWidget { return Column( children: [ Expanded(child: content), - NavigationBar( - selectedIndex: selectedIndex, - onDestinationSelected: onSelected, - destinations: const [ - NavigationDestination( - icon: Icon(Icons.space_dashboard_outlined), - selectedIcon: Icon(Icons.space_dashboard), - label: 'Overview', - ), - NavigationDestination( - icon: Icon(Icons.receipt_long_outlined), - selectedIcon: Icon(Icons.receipt_long), - label: 'Diagnostics', - ), - ], + 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', + ), + NavigationDestination( + icon: Icon(Icons.receipt_long_outlined), + selectedIcon: Icon(Icons.receipt_long), + label: 'Diagnostics', + ), + ], + ), ), ], ); @@ -194,18 +203,26 @@ class _ConnectionBar extends StatelessWidget { horizontal: compact ? 16 : 24, vertical: 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), - ], + child: GlassPanel( + 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), + ], + ), + ), ), ), ); diff --git a/ground-suite/lib/app/theme.dart b/ground-suite/lib/app/theme.dart index 2ee4fea..7e77d0e 100644 --- a/ground-suite/lib/app/theme.dart +++ b/ground-suite/lib/app/theme.dart @@ -1,17 +1,100 @@ import 'package:flutter/material.dart'; -ThemeData groundSuiteTheme() { - const seed = Color(0xff006c8f); - final scheme = ColorScheme.fromSeed(seedColor: seed); +/// Orkavian's restrained green accent, used sparingly against neutral surfaces. +const orkavianGreen = Color(0xff00ff41); + +ThemeData groundSuiteLightTheme() => _theme( + brightness: Brightness.light, + seed: const Color(0xff007a2e), + surface: const Color(0xfff6f8f5), + onSurface: const Color(0xff172019), + outline: const Color(0xffbdc7bc), +); + +ThemeData groundSuiteDarkTheme() => _theme( + brightness: Brightness.dark, + seed: orkavianGreen, + surface: const Color(0xff0a0a0a), + onSurface: const Color(0xffe7e7e7), + outline: const Color(0xff333333), +); + +ThemeData _theme({ + required Brightness brightness, + required Color seed, + required Color surface, + required Color onSurface, + required Color outline, +}) { + final scheme = ColorScheme.fromSeed(seedColor: seed, brightness: brightness) + .copyWith( + primary: seed, + onPrimary: brightness == Brightness.dark + ? const Color(0xff031207) + : Colors.white, + surface: surface, + onSurface: onSurface, + outline: outline, + surfaceContainer: brightness == Brightness.dark + ? const Color(0xff111111) + : const Color(0xffedf1ec), + ); return ThemeData( useMaterial3: true, colorScheme: scheme, - scaffoldBackgroundColor: scheme.surface, + scaffoldBackgroundColor: Colors.transparent, visualDensity: VisualDensity.standard, - cardTheme: CardThemeData( + fontFamily: 'monospace', + cardTheme: const CardThemeData( + color: Colors.transparent, elevation: 0, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + margin: EdgeInsets.zero, + ), + dividerTheme: DividerThemeData(color: outline.withValues(alpha: 0.72)), + navigationRailTheme: NavigationRailThemeData( + backgroundColor: Colors.transparent, + indicatorColor: seed.withValues( + alpha: brightness == Brightness.dark ? 0.2 : 0.13, + ), + selectedIconTheme: IconThemeData(color: seed), + selectedLabelTextStyle: TextStyle( + color: seed, + fontWeight: FontWeight.w700, + ), + ), + navigationBarTheme: NavigationBarThemeData( + backgroundColor: Colors.transparent, + indicatorColor: seed.withValues( + alpha: brightness == Brightness.dark ? 0.2 : 0.13, + ), + labelTextStyle: WidgetStatePropertyAll( + TextStyle(color: onSurface, fontWeight: FontWeight.w600), + ), + ), + chipTheme: ChipThemeData( + backgroundColor: scheme.surfaceContainer.withValues(alpha: 0.72), + selectedColor: seed.withValues( + alpha: brightness == Brightness.dark ? 0.22 : 0.16, + ), + side: BorderSide(color: outline.withValues(alpha: 0.75)), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + ), + inputDecorationTheme: InputDecorationTheme( + filled: true, + fillColor: scheme.surfaceContainer.withValues(alpha: 0.7), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: BorderSide(color: outline), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: BorderSide(color: outline.withValues(alpha: 0.8)), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: BorderSide(color: seed, width: 1.5), + ), ), ); } diff --git a/ground-suite/lib/features/diagnostics/presentation/diagnostics_page.dart b/ground-suite/lib/features/diagnostics/presentation/diagnostics_page.dart index cd6489e..18346e6 100644 --- a/ground-suite/lib/features/diagnostics/presentation/diagnostics_page.dart +++ b/ground-suite/lib/features/diagnostics/presentation/diagnostics_page.dart @@ -3,6 +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 '../../observability/presentation/observability_controller.dart'; enum _DiagnosticFilter { all, logs, errors, shutdowns } @@ -23,50 +24,74 @@ class _DiagnosticsPageState extends ConsumerState { final state = ref.watch(observabilityControllerProvider); final events = state.events.where(_matches).toList(growable: false); - return ListView( - padding: const EdgeInsets.all(24), - children: [ - Text('Diagnostics', 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: 16), - Wrap( - spacing: 8, - runSpacing: 8, - children: _DiagnosticFilter.values - .map( - (filter) => ChoiceChip( - label: Text(_labelFor(filter)), - selected: _filter == filter, - onSelected: (_) => setState(() => _filter = filter), + return CustomScrollView( + slivers: [ + SliverPadding( + padding: const EdgeInsets.fromLTRB(24, 24, 24, 16), + sliver: SliverToBoxAdapter( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Diagnostics', + style: Theme.of(context).textTheme.headlineMedium, ), - ) - .toList(growable: false), - ), - const SizedBox(height: 12), - TextField( - decoration: const InputDecoration( - border: OutlineInputBorder(), - labelText: 'Filter diagnostics', - prefixIcon: Icon(Icons.search), + 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: 16), + 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), + ), + const SizedBox(height: 12), + TextField( + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: 'Filter diagnostics', + prefixIcon: Icon(Icons.search), + ), + onChanged: (value) { + setState(() => _query = value.trim().toLowerCase()); + }, + ), + ], + ), ), - onChanged: (value) { - setState(() => _query = value.trim().toLowerCase()); - }, ), - const SizedBox(height: 16), if (events.isEmpty) - const Card( - child: Padding( - padding: EdgeInsets.all(20), - child: Text('No diagnostic observations match this filter.'), + const SliverPadding( + padding: EdgeInsets.fromLTRB(24, 0, 24, 24), + sliver: SliverToBoxAdapter( + child: GlassPanel( + child: Padding( + padding: EdgeInsets.all(20), + child: Text('No diagnostic observations match this filter.'), + ), + ), ), ) else - ...events.map((event) => _DiagnosticCard(event: event)), + SliverPadding( + padding: const EdgeInsets.fromLTRB(24, 0, 24, 24), + sliver: SliverList.builder( + itemCount: events.length, + itemBuilder: (context, index) => + _DiagnosticCard(event: events[index]), + ), + ), ], ); } @@ -119,37 +144,39 @@ class _DiagnosticCard extends StatelessWidget { return Semantics( container: true, label: content.semanticLabel, - child: Card( - margin: const EdgeInsets.only(bottom: 12), - child: Padding( - padding: const EdgeInsets.all(16), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - content.leading, - const SizedBox(width: 12), - Expanded( - child: SelectionArea( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(content.title, style: theme.textTheme.titleMedium), - const SizedBox(height: 4), - Text(content.detail), - if (content.additionalDetail case final detail?) ...[ + child: Padding( + padding: const EdgeInsets.only(bottom: 12), + child: GlassListItem( + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + content.leading, + const SizedBox(width: 12), + Expanded( + child: SelectionArea( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(content.title, style: theme.textTheme.titleMedium), + const SizedBox(height: 4), + Text(content.detail), + if (content.additionalDetail case final detail?) ...[ + const SizedBox(height: 8), + Text(detail), + ], const SizedBox(height: 8), - Text(detail), + Text( + event.receivedAt.toIso8601String(), + style: theme.textTheme.bodySmall, + ), ], - const SizedBox(height: 8), - Text( - event.receivedAt.toIso8601String(), - style: theme.textTheme.bodySmall, - ), - ], + ), ), ), - ), - ], + ], + ), ), ), ), diff --git a/ground-suite/lib/features/overview/presentation/overview_page.dart b/ground-suite/lib/features/overview/presentation/overview_page.dart index 1591fe2..2a43baf 100644 --- a/ground-suite/lib/features/overview/presentation/overview_page.dart +++ b/ground-suite/lib/features/overview/presentation/overview_page.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../observability/presentation/observability_controller.dart'; +import '../../../shared/widgets/glass_panel.dart'; class OverviewPage extends ConsumerWidget { const OverviewPage({super.key}); @@ -78,7 +79,7 @@ class OverviewPage extends ConsumerWidget { ], ), const SizedBox(height: 24), - Card( + GlassPanel( child: Padding( padding: const EdgeInsets.all(20), child: Column( @@ -125,7 +126,7 @@ class _SummaryCard extends StatelessWidget { Widget build(BuildContext context) { return SizedBox( width: width, - child: Card( + child: GlassPanel( child: Padding( padding: const EdgeInsets.all(20), child: Column( diff --git a/ground-suite/lib/shared/widgets/glass_panel.dart b/ground-suite/lib/shared/widgets/glass_panel.dart new file mode 100644 index 0000000..ac7d29e --- /dev/null +++ b/ground-suite/lib/shared/widgets/glass_panel.dart @@ -0,0 +1,121 @@ +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 OrkavianBackdrop extends StatelessWidget { + const OrkavianBackdrop({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 + ? orkavianGreen.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/test/widget_test.dart b/ground-suite/test/widget_test.dart index bdc967a..bedaee8 100644 --- a/ground-suite/test/widget_test.dart +++ b/ground-suite/test/widget_test.dart @@ -2,6 +2,8 @@ import 'package:flutter/material.dart'; 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'; void main() { testWidgets('replay workspace keeps diagnostics distinct and read-only', ( @@ -10,6 +12,11 @@ void main() { await tester.pumpWidget(const ProviderScope(child: GroundSuiteApp())); await tester.pumpAndSettle(); + final app = tester.widget(find.byType(MaterialApp)); + 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.text('Replay fixture'), findsAtLeastNWidgets(1)); expect(find.text('Vehicle identity'), findsOneWidget); @@ -18,6 +25,8 @@ void main() { await tester.pumpAndSettle(); expect(find.text('Diagnostics'), findsAtLeastNWidgets(1)); + await tester.tap(find.text('Errors')); + await tester.pumpAndSettle(); expect( find.text('kaptan_drivers::lidar_lite_v3::error::Error::TransferFault'), findsOneWidget, @@ -42,4 +51,18 @@ void main() { expect(tester.takeException(), isNull); }, ); + + testWidgets('follows a dark system preference', (tester) async { + tester.binding.platformDispatcher.platformBrightnessTestValue = + Brightness.dark; + addTearDown( + tester.binding.platformDispatcher.clearPlatformBrightnessTestValue, + ); + + await tester.pumpWidget(const ProviderScope(child: GroundSuiteApp())); + await tester.pumpAndSettle(); + + final context = tester.element(find.byType(AppShell)); + expect(Theme.of(context).brightness, Brightness.dark); + }); }