fix(ui): theme toggle is now actually reactive

The toggle changed _mode and called setState on StudioAppState,
but the sidebar that hosts the toggle button never rebuilt:
StudioApp returns `MaterialApp(home: const StudioShell())`, and
the const child's element doesn't get a didUpdateWidget on
parent rebuild. So the toggle's icon would only flip on the
next health-poll tick (up to 5s later) when the sidebar's own
setState fired for an unrelated reason.

Switch to a ValueNotifier-based pattern:
- StudioAppState exposes `modeNotifier` (ValueNotifier<...>)
  instead of a getter.
- StudioApp wraps MaterialApp in a ValueListenableBuilder
  bound to the notifier so themeMode flips immediately.
- _ThemeToggle is itself a ValueListenableBuilder against the
  same notifier — its icon and onPressed always reflect the
  current value without depending on parent rebuild timing.

Bumps fai_studio 0.7.2 -> 0.7.3.

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
flemming-it 2026-05-05 23:00:45 +02:00
parent 20230684a9
commit 2f32023322
2 changed files with 54 additions and 37 deletions

View file

@ -19,7 +19,7 @@ import 'widgets/widgets.dart';
/// Studio's own build version. Bump on every UI commit so the /// Studio's own build version. Bump on every UI commit so the
/// running app self-identifies visible in the sidebar header /// running app self-identifies visible in the sidebar header
/// and quick-glance proof that you're seeing the current build. /// and quick-glance proof that you're seeing the current build.
const String kStudioVersion = '0.7.2'; const String kStudioVersion = '0.7.3';
Future<void> main() async { Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
@ -45,23 +45,32 @@ class StudioApp extends StatefulWidget {
} }
class StudioAppState extends State<StudioApp> { class StudioAppState extends State<StudioApp> {
late ThemeModeValue _mode; /// Exposed so any descendant can listen via
/// `ValueListenableBuilder` and rebuild instantly when the
ThemeModeValue get mode => _mode; /// theme flips. setState alone wouldn't propagate through
/// `MaterialApp(home: const StudioShell())` because the
/// const child's element doesn't get a `didUpdateWidget`.
late final ValueNotifier<ThemeModeValue> modeNotifier;
Future<void> setMode(ThemeModeValue m) async { Future<void> setMode(ThemeModeValue m) async {
setState(() => _mode = m); modeNotifier.value = m;
await HubService.instance.saveThemeMode(m); await HubService.instance.saveThemeMode(m);
} }
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_mode = widget.initialThemeMode; modeNotifier = ValueNotifier(widget.initialThemeMode);
} }
ThemeMode _flutterMode() { @override
switch (_mode) { void dispose() {
modeNotifier.dispose();
super.dispose();
}
ThemeMode _flutterMode(ThemeModeValue v) {
switch (v) {
case ThemeModeValue.system: case ThemeModeValue.system:
return ThemeMode.system; return ThemeMode.system;
case ThemeModeValue.light: case ThemeModeValue.light:
@ -73,13 +82,16 @@ class StudioAppState extends State<StudioApp> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return MaterialApp( return ValueListenableBuilder<ThemeModeValue>(
title: 'F∆I Studio', valueListenable: modeNotifier,
debugShowCheckedModeBanner: false, builder: (_, mode, _) => MaterialApp(
theme: FaiTheme.light(), title: 'F∆I Studio',
darkTheme: FaiTheme.dark(), debugShowCheckedModeBanner: false,
themeMode: _flutterMode(), theme: FaiTheme.light(),
home: const StudioShell(), darkTheme: FaiTheme.dark(),
themeMode: _flutterMode(mode),
home: const StudioShell(),
),
); );
} }
} }
@ -436,28 +448,33 @@ class _ThemeToggle extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final app = StudioApp.of(context); final app = StudioApp.of(context);
final mode = app?.mode ?? ThemeModeValue.system; if (app == null) return const SizedBox.shrink();
final icon = switch (mode) { return ValueListenableBuilder<ThemeModeValue>(
ThemeModeValue.system => Icons.brightness_auto_outlined, valueListenable: app.modeNotifier,
ThemeModeValue.light => Icons.light_mode_outlined, builder: (_, mode, _) {
ThemeModeValue.dark => Icons.dark_mode_outlined, final icon = switch (mode) {
}; ThemeModeValue.system => Icons.brightness_auto_outlined,
final tooltip = switch (mode) { ThemeModeValue.light => Icons.light_mode_outlined,
ThemeModeValue.system => 'theme: follow system', ThemeModeValue.dark => Icons.dark_mode_outlined,
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); 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,7 +1,7 @@
name: fai_studio name: fai_studio
description: "F∆I Studio — desktop GUI for the F∆I hub" description: "F∆I Studio — desktop GUI for the F∆I hub"
publish_to: 'none' publish_to: 'none'
version: 0.7.2 version: 0.7.3
environment: environment:
sdk: ^3.11.0-200.1.beta sdk: ^3.11.0-200.1.beta