feat(ground-suite): add theme selector

This commit is contained in:
Onur Özkan committed 2026-08-10 16:04:07 +03:00
1 parent eb6a343983
commit c67d553076
6 files changed
+66 -3

No files matched your search

+2
View File
@@ -16,6 +16,8 @@ or write capability.
Ground Suite provides light and dark themes, selecting the operating system's Ground Suite provides light and dark themes, selecting the operating system's
current preference by default. The dark theme uses near-black, muted-silver, current preference by default. The dark theme uses near-black, muted-silver,
and restrained green accents with locally blurred translucent panels. and restrained green accents with locally blurred translucent panels.
The observation bar also offers a current-session System, Light, or Dark
appearance selector.
Ground Suite uses a receive-only source/decoder/classifier boundary. The Ground Suite uses a receive-only source/decoder/classifier boundary. The
replay implementation feeds typed observations today. A future live adapter replay implementation feeds typed observations today. A future live adapter
+3
View File
@@ -52,6 +52,9 @@ by default. Its dark palette uses near-black, muted-silver, and restrained
green accents; translucent, locally blurred panels keep operational content green accents; translucent, locally blurred panels keep operational content
legible without turning diagnostics into decoration. legible without turning diagnostics into decoration.
Use the appearance button in the observation bar to select **System default**,
**Light**, or **Dark**. The choice applies for the current session only.
## Deliberately deferred work ## Deliberately deferred work
- TODO(gcs-live-mavlink): generated MAVLink Common plus custom-dialect - TODO(gcs-live-mavlink): generated MAVLink Common plus custom-dialect
+5 -3
View File
@@ -1,19 +1,21 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'app_shell.dart'; import 'app_shell.dart';
import 'theme.dart'; import 'theme.dart';
import 'theme_mode_controller.dart';
class GroundSuiteApp extends StatelessWidget { class GroundSuiteApp extends ConsumerWidget {
const GroundSuiteApp({super.key}); const GroundSuiteApp({super.key});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context, WidgetRef ref) {
return MaterialApp( return MaterialApp(
title: 'Ground Suite', title: 'Ground Suite',
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,
theme: groundSuiteLightTheme(), theme: groundSuiteLightTheme(),
darkTheme: groundSuiteDarkTheme(), darkTheme: groundSuiteDarkTheme(),
themeMode: ThemeMode.system, themeMode: ref.watch(themeModeProvider),
home: const AppShell(), home: const AppShell(),
); );
} }
+29
View File
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'theme_mode_controller.dart';
import '../features/diagnostics/presentation/diagnostics_page.dart'; import '../features/diagnostics/presentation/diagnostics_page.dart';
import '../features/observability/presentation/observability_controller.dart'; import '../features/observability/presentation/observability_controller.dart';
import '../features/overview/presentation/overview_page.dart'; import '../features/overview/presentation/overview_page.dart';
@@ -220,6 +221,7 @@ class _ConnectionBar extends StatelessWidget {
_StatusChip(label: state.status.label), _StatusChip(label: state.status.label),
Text(peer), Text(peer),
Text(frameTime), Text(frameTime),
const _ThemeMenu(),
], ],
), ),
), ),
@@ -229,6 +231,27 @@ class _ConnectionBar extends StatelessWidget {
} }
} }
class _ThemeMenu extends ConsumerWidget {
const _ThemeMenu();
@override
Widget build(BuildContext context, WidgetRef ref) {
final mode = ref.watch(themeModeProvider);
return PopupMenuButton<ThemeMode>(
tooltip: 'Choose theme',
initialValue: mode,
onSelected: ref.read(themeModeProvider.notifier).select,
icon: Icon(_iconFor(mode)),
itemBuilder: (context) => const [
PopupMenuItem(value: ThemeMode.system, child: Text('System default')),
PopupMenuItem(value: ThemeMode.light, child: Text('Light')),
PopupMenuItem(value: ThemeMode.dark, child: Text('Dark')),
],
);
}
}
class _CriticalAnnouncement extends StatelessWidget { class _CriticalAnnouncement extends StatelessWidget {
const _CriticalAnnouncement({required this.message}); const _CriticalAnnouncement({required this.message});
@@ -257,3 +280,9 @@ class _StatusChip extends StatelessWidget {
} }
String _formatTimestamp(DateTime value) => value.toIso8601String(); String _formatTimestamp(DateTime value) => value.toIso8601String();
IconData _iconFor(ThemeMode mode) => switch (mode) {
ThemeMode.system => Icons.brightness_auto_outlined,
ThemeMode.light => Icons.light_mode_outlined,
ThemeMode.dark => Icons.dark_mode_outlined,
};
@@ -0,0 +1,17 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
final themeModeProvider = NotifierProvider<ThemeModeController, ThemeMode>(
ThemeModeController.new,
);
/// Holds the local display preference for the current application session.
///
/// It intentionally starts at the system preference. Persisting an operator's
/// visual preference can be added later without coupling it to vehicle state.
class ThemeModeController extends Notifier<ThemeMode> {
@override
ThemeMode build() => ThemeMode.system;
void select(ThemeMode mode) => state = mode;
}
+10
View File
@@ -21,6 +21,16 @@ void main() {
expect(find.text('Replay fixture'), findsAtLeastNWidgets(1)); expect(find.text('Replay fixture'), findsAtLeastNWidgets(1));
expect(find.text('Vehicle identity'), findsOneWidget); expect(find.text('Vehicle identity'), findsOneWidget);
await tester.tap(find.byTooltip('Choose theme'));
await tester.pumpAndSettle();
await tester.tap(find.text('Dark'));
await tester.pumpAndSettle();
expect(
tester.widget<MaterialApp>(find.byType(MaterialApp)).themeMode,
ThemeMode.dark,
);
await tester.tap(find.byIcon(Icons.receipt_long_outlined)); await tester.tap(find.byIcon(Icons.receipt_long_outlined));
await tester.pumpAndSettle(); await tester.pumpAndSettle();