feat(ground-suite): add system glass themes

This commit is contained in:
Onur Özkan committed 2026-08-10 15:55:03 +03:00
1 parent 01fb9f7292
commit fd26a0bd14
9 files changed
+309 -23

No files matched your search

+5
View File
@@ -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
+7
View File
@@ -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
+3 -1
View File
@@ -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(),
);
}
+21 -4
View File
@@ -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,7 +24,8 @@ class _AppShellState extends ConsumerState<AppShell> {
const pages = <Widget>[OverviewPage(), DiagnosticsPage()];
return Scaffold(
body: SafeArea(
body: OrkavianBackdrop(
child: SafeArea(
child: Stack(
children: [
LayoutBuilder(
@@ -53,6 +55,7 @@ class _AppShellState extends ConsumerState<AppShell> {
],
),
),
),
);
}
@@ -76,7 +79,9 @@ class _WideShell extends StatelessWidget {
Widget build(BuildContext context) {
return Row(
children: [
NavigationRail(
GlassPanel(
borderRadius: BorderRadius.zero,
child: NavigationRail(
selectedIndex: selectedIndex,
labelType: NavigationRailLabelType.all,
onDestinationSelected: onSelected,
@@ -86,6 +91,7 @@ class _WideShell extends StatelessWidget {
),
destinations: _destinations,
),
),
const VerticalDivider(width: 1),
Expanded(child: content),
],
@@ -109,7 +115,9 @@ class _CompactShell extends StatelessWidget {
return Column(
children: [
Expanded(child: content),
NavigationBar(
GlassPanel(
borderRadius: const BorderRadius.vertical(top: Radius.circular(16)),
child: NavigationBar(
selectedIndex: selectedIndex,
onDestinationSelected: onSelected,
destinations: const [
@@ -125,6 +133,7 @@ class _CompactShell extends StatelessWidget {
),
],
),
),
],
);
}
@@ -194,12 +203,18 @@ class _ConnectionBar extends StatelessWidget {
horizontal: compact ? 16 : 24,
vertical: 12,
),
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),
Icon(
Icons.visibility_outlined,
color: theme.colorScheme.primary,
),
Text('Observation only', style: theme.textTheme.titleSmall),
_StatusChip(label: state.sourceLabel),
_StatusChip(label: state.status.label),
@@ -208,6 +223,8 @@ class _ConnectionBar extends StatelessWidget {
],
),
),
),
),
);
}
}
+89 -6
View File
@@ -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),
),
),
);
}
@@ -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,14 +24,23 @@ class _DiagnosticsPageState extends ConsumerState<DiagnosticsPage> {
final state = ref.watch(observabilityControllerProvider);
final events = state.events.where(_matches).toList(growable: false);
return ListView(
padding: const EdgeInsets.all(24),
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),
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.',
'Messages remain distinct by protocol class. A configuration '
'result will remain authoritative when configuration support '
'is added.',
),
const SizedBox(height: 16),
Wrap(
@@ -57,16 +67,31 @@ class _DiagnosticsPageState extends ConsumerState<DiagnosticsPage> {
setState(() => _query = value.trim().toLowerCase());
},
),
const SizedBox(height: 16),
],
),
),
),
if (events.isEmpty)
const Card(
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,8 +144,9 @@ class _DiagnosticCard extends StatelessWidget {
return Semantics(
container: true,
label: content.semanticLabel,
child: Card(
margin: const EdgeInsets.only(bottom: 12),
child: Padding(
padding: const EdgeInsets.only(bottom: 12),
child: GlassListItem(
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
@@ -153,6 +179,7 @@ class _DiagnosticCard extends StatelessWidget {
),
),
),
),
);
}
}
@@ -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(
@@ -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),
],
),
);
}
}
+23
View File
@@ -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<MaterialApp>(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);
});
}