From 671194c10529799360300293646a657a091eebcc Mon Sep 17 00:00:00 2001 From: flemming-it Date: Tue, 5 May 2026 22:19:13 +0200 Subject: [PATCH] feat(ui): theme-mode toggle (system / light / dark) with persistence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three-way switch in the sidebar footer next to the settings gear. Cycles system → light → dark → system. Choice persists via shared_preferences across app launches; restored before the first frame so there's no theme flicker on startup. The icon changes per state (auto / sun / moon) and the tooltip spells it out so the cycling behaviour is discoverable. - StudioApp is now stateful; exposes StudioApp.of(context) to let descendants flip the theme without prop-drilling. - ThemeModeValue enum lives in data/hub.dart so the persistence layer stays free of flutter/material imports. Bumps fai_studio 0.6.0 -> 0.7.0. Signed-off-by: flemming-it --- lib/data/hub.dart | 34 +++++++++++++++ lib/main.dart | 96 +++++++++++++++++++++++++++++++++++-------- test/widget_test.dart | 18 +++----- 3 files changed, 119 insertions(+), 29 deletions(-) diff --git a/lib/data/hub.dart b/lib/data/hub.dart index 34e11b1..66287e0 100644 --- a/lib/data/hub.dart +++ b/lib/data/hub.dart @@ -52,6 +52,22 @@ class HubService { await prefs.setBool(_kSecureKey, endpoint.secure); } + static const _kThemeKey = 'theme.mode'; + + /// Read the persisted theme mode (system / light / dark) for + /// initial app startup. Defaults to system. + Future loadThemeMode() async { + final prefs = await SharedPreferences.getInstance(); + final raw = prefs.getString(_kThemeKey); + return ThemeModeValue.fromWire(raw) ?? ThemeModeValue.system; + } + + /// Persist the theme mode the user chose. + Future saveThemeMode(ThemeModeValue mode) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_kThemeKey, mode.wire); + } + Future healthy() => _client.healthy(); Future> listModules() async { @@ -216,6 +232,24 @@ class ServiceEntry { }); } +/// Three-way switch persisted across launches. +enum ThemeModeValue { + system('system'), + light('light'), + dark('dark'); + + final String wire; + const ThemeModeValue(this.wire); + + static ThemeModeValue? fromWire(String? s) { + if (s == null) return null; + for (final v in values) { + if (v.wire == s) return v; + } + return null; + } +} + /// UI-side type, decoupled from the proto wire type so pages /// don't import protobuf packages. class ModuleSummary { diff --git a/lib/main.dart b/lib/main.dart index 33e0f7b..9cb5c59 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -18,15 +18,53 @@ import 'widgets/widgets.dart'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); - // Restore the persisted endpoint before the first frame so the - // sidebar pill doesn't briefly say "127.0.0.1:50051" before - // switching to the operator's actual hub. + // Restore the persisted endpoint and theme mode before the + // first frame so neither flicker on startup. await HubService.instance.loadPersistedEndpoint(); - runApp(const StudioApp()); + final themeMode = await HubService.instance.loadThemeMode(); + runApp(StudioApp(initialThemeMode: themeMode)); } -class StudioApp extends StatelessWidget { - const StudioApp({super.key}); +class StudioApp extends StatefulWidget { + final ThemeModeValue initialThemeMode; + + const StudioApp({super.key, required this.initialThemeMode}); + + /// Lookup helper so descendants can flip the theme without + /// passing callbacks down through every level. + static StudioAppState? of(BuildContext context) => + context.findAncestorStateOfType(); + + @override + State createState() => StudioAppState(); +} + +class StudioAppState extends State { + late ThemeModeValue _mode; + + ThemeModeValue get mode => _mode; + + Future setMode(ThemeModeValue m) async { + setState(() => _mode = m); + await HubService.instance.saveThemeMode(m); + } + + @override + void initState() { + super.initState(); + _mode = widget.initialThemeMode; + } + + ThemeMode _flutterMode() { + switch (_mode) { + case ThemeModeValue.system: + return ThemeMode.system; + case ThemeModeValue.light: + return ThemeMode.light; + case ThemeModeValue.dark: + return ThemeMode.dark; + } + } @override Widget build(BuildContext context) { @@ -35,7 +73,7 @@ class StudioApp extends StatelessWidget { debugShowCheckedModeBanner: false, theme: FaiTheme.light(), darkTheme: FaiTheme.dark(), - themeMode: ThemeMode.system, + themeMode: _flutterMode(), home: const StudioShell(), ); } @@ -204,20 +242,13 @@ class _Sidebar extends StatelessWidget { ], ), ), - // Footer: settings + version. + // Footer: theme toggle + settings. Padding( padding: const EdgeInsets.symmetric(horizontal: FaiSpace.md), child: Row( children: [ - Expanded( - child: Text( - 'platform target', - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - fontSize: 10, - ), - ), - ), + _ThemeToggle(), + const Spacer(), IconButton( icon: const Icon(Icons.settings_outlined, size: 16), tooltip: 'Hub endpoint…', @@ -387,3 +418,34 @@ class _NavPage { required this.page, }); } + +class _ThemeToggle extends StatelessWidget { + @override + Widget build(BuildContext context) { + final app = StudioApp.of(context); + final mode = app?.mode ?? ThemeModeValue.system; + final icon = switch (mode) { + ThemeModeValue.system => Icons.brightness_auto_outlined, + ThemeModeValue.light => Icons.light_mode_outlined, + ThemeModeValue.dark => Icons.dark_mode_outlined, + }; + final tooltip = switch (mode) { + ThemeModeValue.system => 'theme: follow system', + ThemeModeValue.light => 'theme: light', + ThemeModeValue.dark => 'theme: dark', + }; + return IconButton( + icon: Icon(icon, size: 16), + tooltip: tooltip, + visualDensity: VisualDensity.compact, + onPressed: () { + final next = switch (mode) { + ThemeModeValue.system => ThemeModeValue.light, + ThemeModeValue.light => ThemeModeValue.dark, + ThemeModeValue.dark => ThemeModeValue.system, + }; + app?.setMode(next); + }, + ); + } +} diff --git a/test/widget_test.dart b/test/widget_test.dart index 1f6c46a..a8b1655 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -1,24 +1,18 @@ -// Smoke test: app boots without crashing and shows the three -// MVP destinations in the navigation rail. -// -// Pages themselves now make live gRPC calls. We pump only one -// frame so they enter the loading state without actually -// awaiting the connection (which would fail without a running -// hub in CI). +// Smoke test: app boots without crashing and shows the four +// MVP destinations in the sidebar. import 'package:flutter_test/flutter_test.dart'; +import 'package:fai_studio/data/hub.dart'; import 'package:fai_studio/main.dart'; void main() { testWidgets('Studio shell shows the four destinations', (tester) async { - await tester.pumpWidget(const StudioApp()); + await tester.pumpWidget( + const StudioApp(initialThemeMode: ThemeModeValue.system), + ); expect(find.text('F∆I Studio'), findsOneWidget); - // "Doctor" appears twice (sidebar label + app bar title of the - // first selected page) — same for "Modules" / "Audit" / - // "Approvals" once their pages are first visited. We just check - // the labels are findable at all. expect(find.text('Doctor'), findsWidgets); expect(find.text('Modules'), findsWidgets); expect(find.text('Audit'), findsWidgets);