feat(ui): theme-mode toggle (system / light / dark) with persistence

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 <stefan.a.flemming@googlemail.com>
This commit is contained in:
flemming-it 2026-05-05 22:19:13 +02:00
parent b3fdd69139
commit 671194c105
3 changed files with 119 additions and 29 deletions

View file

@ -52,6 +52,22 @@ class HubService {
await prefs.setBool(_kSecureKey, endpoint.secure); 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<ThemeModeValue> 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<void> saveThemeMode(ThemeModeValue mode) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_kThemeKey, mode.wire);
}
Future<bool> healthy() => _client.healthy(); Future<bool> healthy() => _client.healthy();
Future<List<ModuleSummary>> listModules() async { Future<List<ModuleSummary>> 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 /// UI-side type, decoupled from the proto wire type so pages
/// don't import protobuf packages. /// don't import protobuf packages.
class ModuleSummary { class ModuleSummary {

View file

@ -18,15 +18,53 @@ import 'widgets/widgets.dart';
Future<void> main() async { Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
// Restore the persisted endpoint before the first frame so the // Restore the persisted endpoint and theme mode before the
// sidebar pill doesn't briefly say "127.0.0.1:50051" before // first frame so neither flicker on startup.
// switching to the operator's actual hub.
await HubService.instance.loadPersistedEndpoint(); await HubService.instance.loadPersistedEndpoint();
runApp(const StudioApp()); final themeMode = await HubService.instance.loadThemeMode();
runApp(StudioApp(initialThemeMode: themeMode));
} }
class StudioApp extends StatelessWidget { class StudioApp extends StatefulWidget {
const StudioApp({super.key}); 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<StudioAppState>();
@override
State<StudioApp> createState() => StudioAppState();
}
class StudioAppState extends State<StudioApp> {
late ThemeModeValue _mode;
ThemeModeValue get mode => _mode;
Future<void> 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -35,7 +73,7 @@ class StudioApp extends StatelessWidget {
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,
theme: FaiTheme.light(), theme: FaiTheme.light(),
darkTheme: FaiTheme.dark(), darkTheme: FaiTheme.dark(),
themeMode: ThemeMode.system, themeMode: _flutterMode(),
home: const StudioShell(), home: const StudioShell(),
); );
} }
@ -204,20 +242,13 @@ class _Sidebar extends StatelessWidget {
], ],
), ),
), ),
// Footer: settings + version. // Footer: theme toggle + settings.
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: FaiSpace.md), padding: const EdgeInsets.symmetric(horizontal: FaiSpace.md),
child: Row( child: Row(
children: [ children: [
Expanded( _ThemeToggle(),
child: Text( const Spacer(),
'platform target',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
fontSize: 10,
),
),
),
IconButton( IconButton(
icon: const Icon(Icons.settings_outlined, size: 16), icon: const Icon(Icons.settings_outlined, size: 16),
tooltip: 'Hub endpoint…', tooltip: 'Hub endpoint…',
@ -387,3 +418,34 @@ class _NavPage {
required this.page, 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);
},
);
}
}

View file

@ -1,24 +1,18 @@
// Smoke test: app boots without crashing and shows the three // Smoke test: app boots without crashing and shows the four
// MVP destinations in the navigation rail. // MVP destinations in the sidebar.
//
// 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).
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:fai_studio/data/hub.dart';
import 'package:fai_studio/main.dart'; import 'package:fai_studio/main.dart';
void main() { void main() {
testWidgets('Studio shell shows the four destinations', testWidgets('Studio shell shows the four destinations',
(tester) async { (tester) async {
await tester.pumpWidget(const StudioApp()); await tester.pumpWidget(
const StudioApp(initialThemeMode: ThemeModeValue.system),
);
expect(find.text('F∆I Studio'), findsOneWidget); 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('Doctor'), findsWidgets);
expect(find.text('Modules'), findsWidgets); expect(find.text('Modules'), findsWidgets);
expect(find.text('Audit'), findsWidgets); expect(find.text('Audit'), findsWidgets);