From b0c9fe7b98f36790b54c1726e17c89832d6f4980 Mon Sep 17 00:00:00 2001 From: Hans Kokx Date: Tue, 1 Jul 2025 16:56:30 +0200 Subject: [PATCH] Refactor theme management to use ValueListenableBuilder for effective theme updates Signed-off-by: Hans Kokx --- example/lib/main.dart | 118 ++++++++++-------- .../reactive_theme_service.dart | 25 ++++ .../authentication_service_test.mocks.dart | 11 +- 3 files changed, 98 insertions(+), 56 deletions(-) diff --git a/example/lib/main.dart b/example/lib/main.dart index 14dd61d..a54d325 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -270,9 +270,6 @@ class ArcaneThemeExample extends StatelessWidget { super.key, }); - static final Listenable themeListenable = - Listenable.merge([Arcane.theme.darkTheme, Arcane.theme.lightTheme]); - @override Widget build(BuildContext context) { return Card( @@ -313,37 +310,40 @@ class ArcaneThemeExample extends StatelessWidget { Row( mainAxisSize: MainAxisSize.min, children: [ - Checkbox( - value: Arcane.theme.isFollowingSystemTheme, - onChanged: (value) { - final ThemeMode oldTheme = - Arcane.theme.currentThemeMode; - if (value == true) { - Arcane.theme.followSystemTheme(context); - Arcane.log( - "Switching theme", - metadata: { - "followingSystemTheme": - "${Arcane.theme.isFollowingSystemTheme}", - "newMode": Arcane.theme.currentThemeMode.name, - "oldMode": oldTheme.name, - }, - ); - } else { - Arcane.theme.switchTheme( - themeMode: Arcane.theme.systemThemeMode, - ); - Arcane.log( - "Switching theme", - metadata: { - "followingSystemTheme": - "${Arcane.theme.isFollowingSystemTheme}", - "newMode": Arcane.theme.currentThemeMode.name, - "oldMode": oldTheme.name, - }, - ); - } - }, + ValueListenableBuilder( + valueListenable: Arcane.theme.followingSystemThemeChanges, + builder: (context, isFollowingSystem, _) => Checkbox( + value: isFollowingSystem, + onChanged: (value) { + final ThemeMode oldTheme = + Arcane.theme.currentThemeMode; + if (value == true) { + Arcane.theme.followSystemTheme(context); + Arcane.log( + "Switching theme", + metadata: { + "followingSystemTheme": + "${Arcane.theme.isFollowingSystemTheme}", + "newMode": Arcane.theme.currentThemeMode.name, + "oldMode": oldTheme.name, + }, + ); + } else { + Arcane.theme.switchTheme( + themeMode: Arcane.theme.systemThemeMode, + ); + Arcane.log( + "Switching theme", + metadata: { + "followingSystemTheme": + "${Arcane.theme.isFollowingSystemTheme}", + "newMode": Arcane.theme.currentThemeMode.name, + "oldMode": oldTheme.name, + }, + ); + } + }, + ), ), const Text("Follow system"), ], @@ -359,8 +359,9 @@ class ArcaneThemeExample extends StatelessWidget { const Text("Color"), Expanded( child: ValueListenableBuilder( - valueListenable: Arcane.theme.themeDataChanges, - builder: (context, themeData, _) => ListView.separated( + valueListenable: Arcane.theme.effectiveThemeChanges, + builder: (context, effectiveTheme, _) => + ListView.separated( itemCount: colors.length, scrollDirection: Axis.horizontal, separatorBuilder: (_, __) => const SizedBox(width: 4), @@ -387,23 +388,34 @@ class ArcaneThemeExample extends StatelessWidget { "Setting ${Arcane.theme.currentThemeMode.name} theme color to ${colors[index].name}", ); }, - child: ValueListenableBuilder( - valueListenable: Arcane.theme.themeModeChanges, - builder: (context, themeMode, _) { - return Container( - key: Key("${colors[index]}-${themeMode}"), - decoration: BoxDecoration( - color: colors[index], - border: - themeData.colorScheme.primary.name == - colors[index].name - ? Border.all(width: 2) - : null, - ), - width: 20, - height: 20, - ); - }, + child: Container( + key: Key( + "${colors[index]}-${Arcane.theme.currentThemeMode}"), + decoration: BoxDecoration( + color: colors[index], + border: + effectiveTheme.colorScheme.primary.name == + colors[index].name + ? Border.all( + width: 2, + color: Colors.white, + ) + : null, + boxShadow: + effectiveTheme.colorScheme.primary.name == + colors[index].name + ? [ + const BoxShadow( + color: Colors.black, + spreadRadius: 1, + blurRadius: 1, + offset: Offset(0, 0), + ), + ] + : 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 fc0474d..a0f9bf0 100644 --- a/lib/src/services/reactive_theme/reactive_theme_service.dart +++ b/lib/src/services/reactive_theme/reactive_theme_service.dart @@ -66,6 +66,22 @@ class ArcaneReactiveTheme extends ArcaneService { final ValueNotifier _themeNotifier = ValueNotifier(ThemeData.light()); + /// ValueListenable that rebuilds when the effective theme changes. + /// This includes theme mode changes and active theme data changes. + /// Use this for most UI components that need to react to theme changes. + ValueListenable get effectiveThemeChanges => + I._effectiveThemeNotifier; + + final ValueNotifier _effectiveThemeNotifier = + ValueNotifier(ThemeData.light()); + + /// ValueListenable that notifies when the system theme following state changes. + ValueListenable get followingSystemThemeChanges => + I._followingSystemThemeNotifier; + + final ValueNotifier _followingSystemThemeNotifier = + ValueNotifier(false); + // ************************************************************************ // // * MARK: Light/Dark theme // ************************************************************************ // @@ -101,6 +117,7 @@ class ArcaneReactiveTheme extends ArcaneService { /// ``` ArcaneReactiveTheme switchTheme({ThemeMode? themeMode}) { _followingSystemTheme = false; + _followingSystemThemeNotifier.value = false; if (themeMode != null) { _updateTheme(themeMode); @@ -127,6 +144,7 @@ class ArcaneReactiveTheme extends ArcaneService { /// ``` ArcaneReactiveTheme followSystemTheme(BuildContext context) { _followingSystemTheme = true; + _followingSystemThemeNotifier.value = true; _currentSystemThemeMode = context.isDarkMode ? ThemeMode.dark : ThemeMode.light; @@ -152,6 +170,7 @@ class ArcaneReactiveTheme extends ArcaneService { if (_currentThemeMode == ThemeMode.dark) { _themeNotifier.value = theme; _currentTheme = theme; + _effectiveThemeNotifier.value = theme; } return I; @@ -173,6 +192,7 @@ class ArcaneReactiveTheme extends ArcaneService { if (_currentThemeMode == ThemeMode.light) { _themeNotifier.value = theme; _currentTheme = theme; + _effectiveThemeNotifier.value = theme; } return I; @@ -187,9 +207,11 @@ class ArcaneReactiveTheme extends ArcaneService { _darkTheme.value = ThemeData.dark(); _lightTheme.value = ThemeData.light(); _followingSystemTheme = false; + _followingSystemThemeNotifier.value = false; _updateTheme(ThemeMode.light); _themeNotifier.value = _lightTheme.value; _currentTheme = _lightTheme.value; + _effectiveThemeNotifier.value = _lightTheme.value; } /// Updates the current theme mode and broadcasts the change. @@ -201,6 +223,7 @@ class ArcaneReactiveTheme extends ArcaneService { final ThemeData newTheme = themeMode == ThemeMode.dark ? dark : light; _currentTheme = newTheme; _themeNotifier.value = newTheme; + _effectiveThemeNotifier.value = newTheme; } /// Disposes of the theme service resources. @@ -212,6 +235,8 @@ class ArcaneReactiveTheme extends ArcaneService { _systemThemeNotifier.dispose(); _themeModeNotifier.dispose(); _themeNotifier.dispose(); + _effectiveThemeNotifier.dispose(); + _followingSystemThemeNotifier.dispose(); _darkTheme.dispose(); _lightTheme.dispose(); super.dispose(); diff --git a/test/services/authentication/authentication_service_test.mocks.dart b/test/services/authentication/authentication_service_test.mocks.dart index f225281..1412dc3 100644 --- a/test/services/authentication/authentication_service_test.mocks.dart +++ b/test/services/authentication/authentication_service_test.mocks.dart @@ -98,11 +98,16 @@ class MockArcaneAuthInterface extends _i1.Mock as _i5.Future); @override - _i5.Future<_i2.Result> logout() => + _i5.Future<_i2.Result> logout({ + _i5.Future Function()? onLoggedOut, + }) => (super.noSuchMethod( - Invocation.method(#logout, []), + Invocation.method(#logout, [], {#onLoggedOut: onLoggedOut}), returnValue: _i5.Future<_i2.Result>.value( - _FakeResult_0(this, Invocation.method(#logout, [])), + _FakeResult_0( + this, + Invocation.method(#logout, [], {#onLoggedOut: onLoggedOut}), + ), ), ) as _i5.Future<_i2.Result>);