diff --git a/docs/src/ground-suite.md b/docs/src/ground-suite.md index 34b0b19..d3ace47 100644 --- a/docs/src/ground-suite.md +++ b/docs/src/ground-suite.md @@ -16,6 +16,8 @@ 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. Ground Suite uses a receive-only source/decoder/classifier boundary. The replay implementation feeds typed observations today. A future live adapter diff --git a/ground-suite/README.md b/ground-suite/README.md index 0da4afe..9701bd9 100644 --- a/ground-suite/README.md +++ b/ground-suite/README.md @@ -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 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 - 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 d58ea02..4a0cd03 100644 --- a/ground-suite/lib/app/app.dart +++ b/ground-suite/lib/app/app.dart @@ -1,19 +1,21 @@ import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'app_shell.dart'; import 'theme.dart'; +import 'theme_mode_controller.dart'; -class GroundSuiteApp extends StatelessWidget { +class GroundSuiteApp extends ConsumerWidget { const GroundSuiteApp({super.key}); @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { return MaterialApp( title: 'Ground Suite', debugShowCheckedModeBanner: false, theme: groundSuiteLightTheme(), darkTheme: groundSuiteDarkTheme(), - themeMode: ThemeMode.system, + themeMode: ref.watch(themeModeProvider), home: const AppShell(), ); } diff --git a/ground-suite/lib/app/app_shell.dart b/ground-suite/lib/app/app_shell.dart index 34728d3..d197704 100644 --- a/ground-suite/lib/app/app_shell.dart +++ b/ground-suite/lib/app/app_shell.dart @@ -1,6 +1,7 @@ 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'; @@ -220,6 +221,7 @@ class _ConnectionBar extends StatelessWidget { _StatusChip(label: state.status.label), Text(peer), 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( + 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 { const _CriticalAnnouncement({required this.message}); @@ -257,3 +280,9 @@ class _StatusChip extends StatelessWidget { } 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, +}; diff --git a/ground-suite/lib/app/theme_mode_controller.dart b/ground-suite/lib/app/theme_mode_controller.dart new file mode 100644 index 0000000..594212c --- /dev/null +++ b/ground-suite/lib/app/theme_mode_controller.dart @@ -0,0 +1,17 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +final themeModeProvider = NotifierProvider( + 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 { + @override + ThemeMode build() => ThemeMode.system; + + void select(ThemeMode mode) => state = mode; +} diff --git a/ground-suite/test/widget_test.dart b/ground-suite/test/widget_test.dart index bedaee8..35f706b 100644 --- a/ground-suite/test/widget_test.dart +++ b/ground-suite/test/widget_test.dart @@ -21,6 +21,16 @@ void main() { expect(find.text('Replay fixture'), findsAtLeastNWidgets(1)); 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(find.byType(MaterialApp)).themeMode, + ThemeMode.dark, + ); + await tester.tap(find.byIcon(Icons.receipt_long_outlined)); await tester.pumpAndSettle();