Refactor theme management to use ValueNotifier for reactive updates

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
This commit is contained in:
2025-07-01 16:35:31 +02:00
parent 1e84e8f648
commit f8dcaf3c6c
4 changed files with 99 additions and 77 deletions
+9 -10
View File
@@ -358,9 +358,9 @@ class ArcaneThemeExample extends StatelessWidget {
children: [ children: [
const Text("Color"), const Text("Color"),
Expanded( Expanded(
child: StreamBuilder( child: ValueListenableBuilder<ThemeData>(
stream: Arcane.theme.themeDataChanges, valueListenable: Arcane.theme.themeDataChanges,
builder: (context, themeData) => ListView.separated( builder: (context, themeData, _) => ListView.separated(
itemCount: colors.length, itemCount: colors.length,
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
separatorBuilder: (_, __) => const SizedBox(width: 4), separatorBuilder: (_, __) => const SizedBox(width: 4),
@@ -387,16 +387,15 @@ class ArcaneThemeExample extends StatelessWidget {
"Setting ${Arcane.theme.currentThemeMode.name} theme color to ${colors[index].name}", "Setting ${Arcane.theme.currentThemeMode.name} theme color to ${colors[index].name}",
); );
}, },
child: StreamBuilder<ThemeMode>( child: ValueListenableBuilder<ThemeMode>(
stream: Arcane.theme.themeModeChanges, valueListenable: Arcane.theme.themeModeChanges,
builder: (context, themeMode) { builder: (context, themeMode, _) {
return Container( return Container(
key: key: Key("${colors[index]}-${themeMode}"),
Key("${colors[index]}-${themeMode.data}"),
decoration: BoxDecoration( decoration: BoxDecoration(
color: colors[index], color: colors[index],
border: themeData.data?.colorScheme.primary border:
.name == themeData.colorScheme.primary.name ==
colors[index].name colors[index].name
? Border.all(width: 2) ? Border.all(width: 2)
: null, : null,
@@ -1,6 +1,5 @@
import "dart:async";
import "package:arcane_framework/arcane_framework.dart"; import "package:arcane_framework/arcane_framework.dart";
import "package:flutter/foundation.dart";
import "package:flutter/material.dart"; import "package:flutter/material.dart";
part "reactive_theme_extensions.dart"; part "reactive_theme_extensions.dart";
@@ -34,12 +33,8 @@ class ArcaneReactiveTheme extends ArcaneService {
/// Tracks the current system theme mode /// Tracks the current system theme mode
ThemeMode _currentSystemThemeMode = ThemeMode.system; ThemeMode _currentSystemThemeMode = ThemeMode.system;
final StreamController<ThemeMode> _systemStreamController = final ValueNotifier<ThemeMode> _systemThemeNotifier =
StreamController<ThemeMode>.broadcast( ValueNotifier<ThemeMode>(ThemeMode.system);
onCancel: () {
I._systemStreamController.close();
},
);
// ************************************************************************ // // ************************************************************************ //
// * MARK: ThemeMode // * MARK: ThemeMode
@@ -52,32 +47,24 @@ class ArcaneReactiveTheme extends ArcaneService {
ThemeMode get currentThemeMode => _currentThemeMode; ThemeMode get currentThemeMode => _currentThemeMode;
ThemeMode _currentThemeMode = ThemeMode.light; ThemeMode _currentThemeMode = ThemeMode.light;
/// Stream of `ThemeMode` changes that can be listened to for reactive UI updates. /// ValueListenable of `ThemeMode` changes that can be listened to for reactive UI updates.
Stream<ThemeMode> get themeModeChanges => I._themeModeStreamController.stream; ValueListenable<ThemeMode> get themeModeChanges => I._themeModeNotifier;
final StreamController<ThemeMode> _themeModeStreamController = final ValueNotifier<ThemeMode> _themeModeNotifier =
StreamController<ThemeMode>.broadcast( ValueNotifier<ThemeMode>(ThemeMode.light);
onCancel: () {
I._themeModeStreamController.close();
},
);
// ************************************************************************ // // ************************************************************************ //
// * MARK: ThemeData // * MARK: ThemeData
// ************************************************************************ // // ************************************************************************ //
/// The currently active theme style. /// The currently active theme style.
ThemeData get currentTheme => _currentTheme; ThemeData get currentTheme => _currentTheme;
ThemeData _currentTheme = ThemeData(); ThemeData _currentTheme = ThemeData.light();
/// Stream of `ThemeData` changes that can be listened to for reactive UI updates. /// ValueListenable of `ThemeData` changes that can be listened to for reactive UI updates.
Stream<ThemeData> get themeDataChanges => I._themeStreamController.stream; ValueListenable<ThemeData> get themeDataChanges => I._themeNotifier;
final StreamController<ThemeData> _themeStreamController = final ValueNotifier<ThemeData> _themeNotifier =
StreamController<ThemeData>.broadcast( ValueNotifier<ThemeData>(ThemeData.light());
onCancel: () {
I._themeStreamController.close();
},
);
// ************************************************************************ // // ************************************************************************ //
// * MARK: Light/Dark theme // * MARK: Light/Dark theme
@@ -143,13 +130,9 @@ class ArcaneReactiveTheme extends ArcaneService {
_currentSystemThemeMode = _currentSystemThemeMode =
context.isDarkMode ? ThemeMode.dark : ThemeMode.light; context.isDarkMode ? ThemeMode.dark : ThemeMode.light;
_systemStreamController.add(_currentSystemThemeMode); _systemThemeNotifier.value = _currentSystemThemeMode;
_updateTheme(_currentSystemThemeMode); _updateTheme(_currentSystemThemeMode);
final ThemeData theme = systemThemeMode == ThemeMode.dark ? dark : light;
_themeStreamController.add(theme);
_currentTheme = theme;
return I; return I;
} }
@@ -164,8 +147,12 @@ class ArcaneReactiveTheme extends ArcaneService {
/// ``` /// ```
ArcaneReactiveTheme setDarkTheme(ThemeData theme) { ArcaneReactiveTheme setDarkTheme(ThemeData theme) {
_darkTheme.value = theme; _darkTheme.value = theme;
_themeStreamController.add(theme);
// Only update current theme if we're currently in dark mode
if (_currentThemeMode == ThemeMode.dark) {
_themeNotifier.value = theme;
_currentTheme = theme; _currentTheme = theme;
}
return I; return I;
} }
@@ -181,8 +168,12 @@ class ArcaneReactiveTheme extends ArcaneService {
/// ``` /// ```
ArcaneReactiveTheme setLightTheme(ThemeData theme) { ArcaneReactiveTheme setLightTheme(ThemeData theme) {
_lightTheme.value = theme; _lightTheme.value = theme;
_themeStreamController.add(theme);
// Only update current theme if we're currently in light mode
if (_currentThemeMode == ThemeMode.light) {
_themeNotifier.value = theme;
_currentTheme = theme; _currentTheme = theme;
}
return I; return I;
} }
@@ -197,13 +188,32 @@ class ArcaneReactiveTheme extends ArcaneService {
_lightTheme.value = ThemeData.light(); _lightTheme.value = ThemeData.light();
_followingSystemTheme = false; _followingSystemTheme = false;
_updateTheme(ThemeMode.light); _updateTheme(ThemeMode.light);
_themeStreamController.add(_lightTheme.value); _themeNotifier.value = _lightTheme.value;
_currentTheme = _lightTheme.value; _currentTheme = _lightTheme.value;
} }
/// Updates the current theme mode and broadcasts the change. /// Updates the current theme mode and broadcasts the change.
void _updateTheme(ThemeMode themeMode) { void _updateTheme(ThemeMode themeMode) {
_currentThemeMode = themeMode; _currentThemeMode = themeMode;
_themeModeStreamController.add(themeMode); _themeModeNotifier.value = themeMode;
// Update the current theme data based on the theme mode
final ThemeData newTheme = themeMode == ThemeMode.dark ? dark : light;
_currentTheme = newTheme;
_themeNotifier.value = newTheme;
}
/// Disposes of the theme service resources.
///
/// This method should be called when the service is no longer needed
/// to clean up ValueNotifiers and prevent memory leaks.
@override
void dispose() {
_systemThemeNotifier.dispose();
_themeModeNotifier.dispose();
_themeNotifier.dispose();
_darkTheme.dispose();
_lightTheme.dispose();
super.dispose();
} }
} }
@@ -1,5 +1,3 @@
import "dart:async";
import "package:arcane_framework/arcane_framework.dart"; import "package:arcane_framework/arcane_framework.dart";
import "package:flutter/material.dart"; import "package:flutter/material.dart";
@@ -17,27 +15,15 @@ class ArcaneThemeSwitcher extends StatefulWidget {
class _ArcaneThemeSwitcherState extends State<ArcaneThemeSwitcher> class _ArcaneThemeSwitcherState extends State<ArcaneThemeSwitcher>
with WidgetsBindingObserver { with WidgetsBindingObserver {
late final StreamSubscription<ThemeMode> _themeModeSubscription;
late final StreamSubscription<ThemeData> _themeSubscription;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
// Register as an observer to detect system theme changes // Register as an observer to detect system theme changes
WidgetsBinding.instance.addObserver(this); WidgetsBinding.instance.addObserver(this);
_themeModeSubscription = ArcaneReactiveTheme.I.themeModeChanges.listen((_) {
setState(() {});
});
_themeSubscription = ArcaneReactiveTheme.I.themeDataChanges.listen((_) {
setState(() {});
});
} }
@override @override
void dispose() { void dispose() {
_themeModeSubscription.cancel();
_themeSubscription.cancel();
// Clean up the observer when the widget is disposed // Clean up the observer when the widget is disposed
WidgetsBinding.instance.removeObserver(this); WidgetsBinding.instance.removeObserver(this);
super.dispose(); super.dispose();
@@ -45,12 +31,22 @@ class _ArcaneThemeSwitcherState extends State<ArcaneThemeSwitcher>
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return ValueListenableBuilder<ThemeMode>(
valueListenable: ArcaneReactiveTheme.I.themeModeChanges,
builder: (BuildContext context, ThemeMode themeMode, Widget? child) {
return ValueListenableBuilder<ThemeData>(
valueListenable: ArcaneReactiveTheme.I.themeDataChanges,
builder: (BuildContext context, ThemeData themeData, Widget? child) {
return _ArcaneTheme( return _ArcaneTheme(
themeMode: ArcaneReactiveTheme.I.currentThemeMode, themeMode: themeMode,
followSystem: ArcaneReactiveTheme.I.isFollowingSystemTheme, followSystem: ArcaneReactiveTheme.I.isFollowingSystemTheme,
theme: ArcaneReactiveTheme.I.currentTheme, theme: themeData,
child: widget.child, child: widget.child,
); );
},
);
},
);
} }
@override @override
@@ -8,6 +8,11 @@ void main() {
setUp(() { setUp(() {
theme = ArcaneReactiveTheme.I; theme = ArcaneReactiveTheme.I;
theme.reset();
});
tearDown(() {
theme.reset();
}); });
test("singleton instance is consistent", () { test("singleton instance is consistent", () {
@@ -29,7 +34,7 @@ void main() {
test("switching theme notifies listeners", () { test("switching theme notifies listeners", () {
var notified = false; var notified = false;
theme.addListener(() => notified = true); theme.themeModeChanges.addListener(() => notified = true);
theme.switchTheme(); theme.switchTheme();
expect(notified, true); expect(notified, true);
}); });
@@ -55,7 +60,7 @@ void main() {
test("theme updates notify listeners", () { test("theme updates notify listeners", () {
bool darkNotified = false; bool darkNotified = false;
bool lightNotified = false; bool lightNotified = false;
ThemeMode currentTheme = ThemeMode.system; ThemeMode currentTheme = theme.currentThemeMode;
theme.darkTheme.addListener(() { theme.darkTheme.addListener(() {
darkNotified = true; darkNotified = true;
@@ -65,29 +70,41 @@ void main() {
lightNotified = true; lightNotified = true;
}); });
theme.addListener(() { theme.themeModeChanges.addListener(() {
currentTheme = theme.currentThemeMode; currentTheme = theme.currentThemeMode;
}); });
expect(currentTheme, ThemeMode.system); expect(currentTheme, ThemeMode.light);
theme.setDarkTheme(ThemeData.dark()); // Use custom themes to ensure ValueNotifier detects changes
theme.setLightTheme(ThemeData.light()); final customDarkTheme = ThemeData.dark().copyWith(
primaryColor: Colors.purple,
);
final customLightTheme = ThemeData.light().copyWith(
primaryColor: Colors.orange,
);
theme.setDarkTheme(customDarkTheme);
theme.setLightTheme(customLightTheme);
expect(darkNotified, true); expect(darkNotified, true);
expect(lightNotified, true); expect(lightNotified, true);
theme.switchTheme(); theme.switchTheme();
expect(currentTheme, ThemeMode.light); expect(currentTheme, ThemeMode.dark);
theme.switchTheme(); theme.switchTheme();
expect(currentTheme, ThemeMode.dark); expect(currentTheme, ThemeMode.light);
}); });
}); });
group("system theme following", () { group("system theme following", () {
setUp(() { setUp(() {
Arcane.theme.reset(); theme.reset();
});
tearDown(() {
theme.reset();
}); });
testWidgets("followSystemTheme updates theme based on context brightness", testWidgets("followSystemTheme updates theme based on context brightness",