From f8dcaf3c6c51425a687df26bef65e2e8da55632a Mon Sep 17 00:00:00 2001 From: Hans Kokx Date: Tue, 1 Jul 2025 16:35:31 +0200 Subject: [PATCH] Refactor theme management to use ValueNotifier for reactive updates Signed-off-by: Hans Kokx --- example/lib/main.dart | 25 +++--- .../reactive_theme_service.dart | 82 +++++++++++-------- .../reactive_theme_switcher.dart | 34 ++++---- .../reactive_theme_service_test.dart | 35 ++++++-- 4 files changed, 99 insertions(+), 77 deletions(-) diff --git a/example/lib/main.dart b/example/lib/main.dart index 8249e30..14dd61d 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -358,9 +358,9 @@ class ArcaneThemeExample extends StatelessWidget { children: [ const Text("Color"), Expanded( - child: StreamBuilder( - stream: Arcane.theme.themeDataChanges, - builder: (context, themeData) => ListView.separated( + child: ValueListenableBuilder( + valueListenable: Arcane.theme.themeDataChanges, + builder: (context, themeData, _) => ListView.separated( itemCount: colors.length, scrollDirection: Axis.horizontal, separatorBuilder: (_, __) => const SizedBox(width: 4), @@ -387,19 +387,18 @@ class ArcaneThemeExample extends StatelessWidget { "Setting ${Arcane.theme.currentThemeMode.name} theme color to ${colors[index].name}", ); }, - child: StreamBuilder( - stream: Arcane.theme.themeModeChanges, - builder: (context, themeMode) { + child: ValueListenableBuilder( + valueListenable: Arcane.theme.themeModeChanges, + builder: (context, themeMode, _) { return Container( - key: - Key("${colors[index]}-${themeMode.data}"), + key: Key("${colors[index]}-${themeMode}"), decoration: BoxDecoration( color: colors[index], - border: themeData.data?.colorScheme.primary - .name == - colors[index].name - ? Border.all(width: 2) - : null, + border: + themeData.colorScheme.primary.name == + colors[index].name + ? Border.all(width: 2) + : null, ), width: 20, height: 20, diff --git a/lib/src/services/reactive_theme/reactive_theme_service.dart b/lib/src/services/reactive_theme/reactive_theme_service.dart index 8436689..fc0474d 100644 --- a/lib/src/services/reactive_theme/reactive_theme_service.dart +++ b/lib/src/services/reactive_theme/reactive_theme_service.dart @@ -1,6 +1,5 @@ -import "dart:async"; - import "package:arcane_framework/arcane_framework.dart"; +import "package:flutter/foundation.dart"; import "package:flutter/material.dart"; part "reactive_theme_extensions.dart"; @@ -34,12 +33,8 @@ class ArcaneReactiveTheme extends ArcaneService { /// Tracks the current system theme mode ThemeMode _currentSystemThemeMode = ThemeMode.system; - final StreamController _systemStreamController = - StreamController.broadcast( - onCancel: () { - I._systemStreamController.close(); - }, - ); + final ValueNotifier _systemThemeNotifier = + ValueNotifier(ThemeMode.system); // ************************************************************************ // // * MARK: ThemeMode @@ -52,32 +47,24 @@ class ArcaneReactiveTheme extends ArcaneService { ThemeMode get currentThemeMode => _currentThemeMode; ThemeMode _currentThemeMode = ThemeMode.light; - /// Stream of `ThemeMode` changes that can be listened to for reactive UI updates. - Stream get themeModeChanges => I._themeModeStreamController.stream; + /// ValueListenable of `ThemeMode` changes that can be listened to for reactive UI updates. + ValueListenable get themeModeChanges => I._themeModeNotifier; - final StreamController _themeModeStreamController = - StreamController.broadcast( - onCancel: () { - I._themeModeStreamController.close(); - }, - ); + final ValueNotifier _themeModeNotifier = + ValueNotifier(ThemeMode.light); // ************************************************************************ // // * MARK: ThemeData // ************************************************************************ // /// The currently active theme style. ThemeData get currentTheme => _currentTheme; - ThemeData _currentTheme = ThemeData(); + ThemeData _currentTheme = ThemeData.light(); - /// Stream of `ThemeData` changes that can be listened to for reactive UI updates. - Stream get themeDataChanges => I._themeStreamController.stream; + /// ValueListenable of `ThemeData` changes that can be listened to for reactive UI updates. + ValueListenable get themeDataChanges => I._themeNotifier; - final StreamController _themeStreamController = - StreamController.broadcast( - onCancel: () { - I._themeStreamController.close(); - }, - ); + final ValueNotifier _themeNotifier = + ValueNotifier(ThemeData.light()); // ************************************************************************ // // * MARK: Light/Dark theme @@ -143,13 +130,9 @@ class ArcaneReactiveTheme extends ArcaneService { _currentSystemThemeMode = context.isDarkMode ? ThemeMode.dark : ThemeMode.light; - _systemStreamController.add(_currentSystemThemeMode); + _systemThemeNotifier.value = _currentSystemThemeMode; _updateTheme(_currentSystemThemeMode); - final ThemeData theme = systemThemeMode == ThemeMode.dark ? dark : light; - _themeStreamController.add(theme); - _currentTheme = theme; - return I; } @@ -164,8 +147,12 @@ class ArcaneReactiveTheme extends ArcaneService { /// ``` ArcaneReactiveTheme setDarkTheme(ThemeData theme) { _darkTheme.value = theme; - _themeStreamController.add(theme); - _currentTheme = theme; + + // Only update current theme if we're currently in dark mode + if (_currentThemeMode == ThemeMode.dark) { + _themeNotifier.value = theme; + _currentTheme = theme; + } return I; } @@ -181,8 +168,12 @@ class ArcaneReactiveTheme extends ArcaneService { /// ``` ArcaneReactiveTheme setLightTheme(ThemeData theme) { _lightTheme.value = theme; - _themeStreamController.add(theme); - _currentTheme = theme; + + // Only update current theme if we're currently in light mode + if (_currentThemeMode == ThemeMode.light) { + _themeNotifier.value = theme; + _currentTheme = theme; + } return I; } @@ -197,13 +188,32 @@ class ArcaneReactiveTheme extends ArcaneService { _lightTheme.value = ThemeData.light(); _followingSystemTheme = false; _updateTheme(ThemeMode.light); - _themeStreamController.add(_lightTheme.value); + _themeNotifier.value = _lightTheme.value; _currentTheme = _lightTheme.value; } /// Updates the current theme mode and broadcasts the change. void _updateTheme(ThemeMode 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(); } } diff --git a/lib/src/services/reactive_theme/reactive_theme_switcher.dart b/lib/src/services/reactive_theme/reactive_theme_switcher.dart index 568c391..f8a238e 100644 --- a/lib/src/services/reactive_theme/reactive_theme_switcher.dart +++ b/lib/src/services/reactive_theme/reactive_theme_switcher.dart @@ -1,5 +1,3 @@ -import "dart:async"; - import "package:arcane_framework/arcane_framework.dart"; import "package:flutter/material.dart"; @@ -17,27 +15,15 @@ class ArcaneThemeSwitcher extends StatefulWidget { class _ArcaneThemeSwitcherState extends State with WidgetsBindingObserver { - late final StreamSubscription _themeModeSubscription; - late final StreamSubscription _themeSubscription; - @override void initState() { super.initState(); // Register as an observer to detect system theme changes WidgetsBinding.instance.addObserver(this); - - _themeModeSubscription = ArcaneReactiveTheme.I.themeModeChanges.listen((_) { - setState(() {}); - }); - _themeSubscription = ArcaneReactiveTheme.I.themeDataChanges.listen((_) { - setState(() {}); - }); } @override void dispose() { - _themeModeSubscription.cancel(); - _themeSubscription.cancel(); // Clean up the observer when the widget is disposed WidgetsBinding.instance.removeObserver(this); super.dispose(); @@ -45,11 +31,21 @@ class _ArcaneThemeSwitcherState extends State @override Widget build(BuildContext context) { - return _ArcaneTheme( - themeMode: ArcaneReactiveTheme.I.currentThemeMode, - followSystem: ArcaneReactiveTheme.I.isFollowingSystemTheme, - theme: ArcaneReactiveTheme.I.currentTheme, - child: widget.child, + return ValueListenableBuilder( + valueListenable: ArcaneReactiveTheme.I.themeModeChanges, + builder: (BuildContext context, ThemeMode themeMode, Widget? child) { + return ValueListenableBuilder( + valueListenable: ArcaneReactiveTheme.I.themeDataChanges, + builder: (BuildContext context, ThemeData themeData, Widget? child) { + return _ArcaneTheme( + themeMode: themeMode, + followSystem: ArcaneReactiveTheme.I.isFollowingSystemTheme, + theme: themeData, + child: widget.child, + ); + }, + ); + }, ); } diff --git a/test/services/reactive_theme/reactive_theme_service_test.dart b/test/services/reactive_theme/reactive_theme_service_test.dart index 68c6630..cae7cec 100644 --- a/test/services/reactive_theme/reactive_theme_service_test.dart +++ b/test/services/reactive_theme/reactive_theme_service_test.dart @@ -8,6 +8,11 @@ void main() { setUp(() { theme = ArcaneReactiveTheme.I; + theme.reset(); + }); + + tearDown(() { + theme.reset(); }); test("singleton instance is consistent", () { @@ -29,7 +34,7 @@ void main() { test("switching theme notifies listeners", () { var notified = false; - theme.addListener(() => notified = true); + theme.themeModeChanges.addListener(() => notified = true); theme.switchTheme(); expect(notified, true); }); @@ -55,7 +60,7 @@ void main() { test("theme updates notify listeners", () { bool darkNotified = false; bool lightNotified = false; - ThemeMode currentTheme = ThemeMode.system; + ThemeMode currentTheme = theme.currentThemeMode; theme.darkTheme.addListener(() { darkNotified = true; @@ -65,29 +70,41 @@ void main() { lightNotified = true; }); - theme.addListener(() { + theme.themeModeChanges.addListener(() { currentTheme = theme.currentThemeMode; }); - expect(currentTheme, ThemeMode.system); + expect(currentTheme, ThemeMode.light); - theme.setDarkTheme(ThemeData.dark()); - theme.setLightTheme(ThemeData.light()); + // Use custom themes to ensure ValueNotifier detects changes + 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(lightNotified, true); theme.switchTheme(); - expect(currentTheme, ThemeMode.light); + expect(currentTheme, ThemeMode.dark); theme.switchTheme(); - expect(currentTheme, ThemeMode.dark); + expect(currentTheme, ThemeMode.light); }); }); group("system theme following", () { setUp(() { - Arcane.theme.reset(); + theme.reset(); + }); + + tearDown(() { + theme.reset(); }); testWidgets("followSystemTheme updates theme based on context brightness",