diff --git a/example/lib/main.dart b/example/lib/main.dart index cba5685..5f654c2 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -59,25 +59,10 @@ class MainApp extends StatelessWidget { debugShowCheckedModeBanner: false, theme: Arcane.theme.light, darkTheme: Arcane.theme.dark, - themeMode: - ArcaneTheme.of(context)?.themeMode ?? Arcane.theme.currentTheme, + themeMode: Arcane.theme.currentModeOf(context), home: Scaffold( appBar: AppBar( title: const Text("Arcane Framework Example"), - actions: [ - IconButton( - icon: const Icon(Icons.settings_system_daydream), - onPressed: () { - Arcane.theme.followSystemTheme(context); - }, - ), - IconButton( - icon: const Icon(Icons.contrast), - onPressed: () { - Arcane.theme.switchTheme(); - }, - ), - ], ), body: const HomeScreen(), ), @@ -101,6 +86,49 @@ class _HomeScreenState extends State { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ + Card( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + children: [ + Column( + children: [ + Checkbox( + value: Arcane.theme.isFollowingSystemTheme, + onChanged: (value) { + if (value == true) { + Arcane.theme.followSystemTheme(context); + } else { + Arcane.theme.switchTheme( + themeMode: Arcane.theme.systemTheme, + ); + } + }, + ), + const Text("Use system theme"), + ], + ), + Switch( + value: Arcane.theme.currentTheme == ThemeMode.dark, + thumbIcon: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.selected)) { + return const Icon(Icons.dark_mode); + } + return const Icon(Icons.light_mode); + }), + onChanged: (_) { + Arcane.theme.switchTheme(); + }, + ), + Text( + "The current theme mode is ${context.themeMode.name} and\n" + "is ${Arcane.theme.isFollowingSystemTheme ? "" : "not "}" + "following the system theme.", + ), + ], + ), + ), + ), Text( "Authentication status: ${Arcane.auth.status.name}", ), diff --git a/lib/src/arcane_app.dart b/lib/src/arcane_app.dart index d9daec5..9371d2e 100644 --- a/lib/src/arcane_app.dart +++ b/lib/src/arcane_app.dart @@ -1,4 +1,5 @@ import "package:arcane_framework/arcane_framework.dart"; +import "package:arcane_framework/src/services/reactive_theme/reactive_theme_switcher.dart"; import "package:flutter/material.dart"; /// A root widget for an Arcane-powered application. @@ -54,22 +55,9 @@ class _ArcaneAppState extends State with WidgetsBindingObserver { return ArcaneEnvironmentProvider( child: ArcaneServiceProvider( serviceInstances: widget.services, - child: Builder( + child: ArcaneThemeSwitcher( key: _appKey, - builder: (BuildContext currentContext) { - return StreamBuilder( - stream: ArcaneReactiveTheme.I.currentThemeStream, - initialData: ArcaneReactiveTheme.I.currentTheme, - builder: (context, AsyncSnapshot snapshot) { - final ThemeMode themeMode = snapshot.data ?? ThemeMode.light; - - return ArcaneTheme( - themeMode: themeMode, - child: widget.child, - ); - }, - ); - }, + child: widget.child, ), ), ); @@ -95,10 +83,9 @@ class _ArcaneAppState extends State with WidgetsBindingObserver { // and use it to check the system theme if (mounted && _appKey.currentContext != null) { // Use the current context from the key to check system theme - final BuildContext currentContext = _appKey.currentContext!; if (ArcaneReactiveTheme.I.isFollowingSystemTheme) { WidgetsBinding.instance.addPostFrameCallback((_) { - ArcaneReactiveTheme.I.checkSystemTheme(currentContext); + ArcaneReactiveTheme.I.followSystemTheme(context); }); } } diff --git a/lib/src/services/reactive_theme/reactive_theme_extensions.dart b/lib/src/services/reactive_theme/reactive_theme_extensions.dart index edb18f6..aebc9e4 100644 --- a/lib/src/services/reactive_theme/reactive_theme_extensions.dart +++ b/lib/src/services/reactive_theme/reactive_theme_extensions.dart @@ -19,3 +19,11 @@ extension DarkMode on BuildContext { return brightness == Brightness.dark; } } + +extension ArcaneThemeContext on BuildContext { + /// Get the current theme mode from the nearest ArcaneThemeInherited widget + ThemeMode get themeMode { + return ArcaneTheme.of(this)?.themeMode ?? + ArcaneReactiveTheme.I.currentTheme; + } +} diff --git a/lib/src/services/reactive_theme/reactive_theme_service.dart b/lib/src/services/reactive_theme/reactive_theme_service.dart index b78cdf9..2aa5760 100644 --- a/lib/src/services/reactive_theme/reactive_theme_service.dart +++ b/lib/src/services/reactive_theme/reactive_theme_service.dart @@ -27,12 +27,16 @@ class ArcaneReactiveTheme extends ArcaneService { /// Whether the theme service is currently following the system theme. /// - /// When true, the theme will automatically switch between light and dark + /// When `true`, the theme will automatically switch between light and dark /// based on the system's brightness setting. bool get isFollowingSystemTheme => _followingSystemTheme; - final ValueNotifier _systemThemeNotifier = - ValueNotifier(ThemeMode.system); + final StreamController _systemStreamController = + StreamController.broadcast( + onCancel: () { + I._systemStreamController.close(); + }, + ); final StreamController _themeStreamController = StreamController.broadcast( @@ -41,17 +45,20 @@ class ArcaneReactiveTheme extends ArcaneService { }, ); - /// Stream of theme mode changes that can be listened to for reactive UI updates. - Stream get currentThemeStream => I._themeStreamController.stream; + /// Stream of `ThemeMode` changes that can be listened to for reactive UI updates. + Stream get themeChanges => I._themeStreamController.stream; + + /// Returns the `ThemeData` corresponding to the current system theme + ThemeMode get systemTheme => _currentSystemTheme; + + /// Tracks the current system theme mode + ThemeMode _currentSystemTheme = ThemeMode.system; ThemeMode _currentTheme = ThemeMode.light; /// The currently active theme mode (light or dark). ThemeMode get currentTheme => _currentTheme; - /// A listenable that notifies listeners when the system theme mode changes. - ThemeMode get systemTheme => I._systemThemeNotifier.value; - /// The `ThemeData` for the dark theme. final ValueNotifier _darkTheme = ValueNotifier(ThemeData.dark()); @@ -70,14 +77,22 @@ class ArcaneReactiveTheme extends ArcaneService { /// ValueNotifier for the light theme that can be observed for changes. ValueNotifier get lightTheme => I._lightTheme; + /// Returns the current `ThemeMode` being used by `ArcaneReactiveTheme`. + /// Will automatically update when the theme changes. + ThemeMode currentModeOf(BuildContext context) => context.themeMode; + /// Switches the current theme between light and dark modes. /// - /// If the theme is currently light, it switches to dark, and vice versa. It also - /// notifies listeners to update the UI accordingly. + /// If the theme is currently light, it switches to dark, and vice versa. It + /// also notifies listeners to update the UI accordingly. /// /// Example: /// ```dart /// ArcaneReactiveTheme.I.switchTheme(); + /// // or + /// ArcaneReactiveTheme.I.switchTheme(themeMode: ThemeMode.dark); + /// // or + /// Arcane.theme.switchTheme(themeMode: ThemeMode.light); /// ``` ArcaneReactiveTheme switchTheme({ThemeMode? themeMode}) { _followingSystemTheme = false; @@ -103,40 +118,25 @@ class ArcaneReactiveTheme extends ArcaneService { /// Example: /// ```dart /// ArcaneReactiveTheme.I.followSystemTheme(context); + /// // or + /// Arcane.theme.followSystemTheme(context); /// ``` ArcaneReactiveTheme followSystemTheme(BuildContext context) { _followingSystemTheme = true; - // Always check the system theme when this method is called - checkSystemTheme(context); + _currentSystemTheme = context.isDarkMode ? ThemeMode.dark : ThemeMode.light; + _systemStreamController.add(_currentSystemTheme); + _updateTheme(_currentSystemTheme); + + notifyListeners(); return I; } - /// Check and apply the system theme if we're following it - /// - /// This is called automatically when the system brightness changes if - /// [followSystemTheme] has been enabled. - void checkSystemTheme(BuildContext context) { - if (!_followingSystemTheme) return; - - final Brightness systemBrightness = - MediaQuery.platformBrightnessOf(context); - - final ThemeMode systemMode = - systemBrightness == Brightness.dark ? ThemeMode.dark : ThemeMode.light; - - // Only update and notify if the theme actually changed - if (systemMode != _currentTheme) { - _updateTheme(systemMode); - notifyListeners(); - } - } - /// Sets a custom `ThemeData` for the dark theme. /// - /// This allows you to customize the dark theme and notify listeners to apply the - /// changes immediately. + /// This allows you to customize the dark theme and notify listeners to apply + /// the changes immediately. /// /// Example: /// ```dart @@ -150,8 +150,8 @@ class ArcaneReactiveTheme extends ArcaneService { /// Sets a custom `ThemeData` for the light theme. /// - /// This allows you to customize the light theme and notify listeners to apply the - /// changes immediately. + /// This allows you to customize the light theme and notify listeners to apply + /// the changes immediately. /// /// Example: /// ```dart diff --git a/lib/src/services/reactive_theme/reactive_theme_switcher.dart b/lib/src/services/reactive_theme/reactive_theme_switcher.dart new file mode 100644 index 0000000..bd50370 --- /dev/null +++ b/lib/src/services/reactive_theme/reactive_theme_switcher.dart @@ -0,0 +1,47 @@ +import "dart:async"; + +import "package:arcane_framework/arcane_framework.dart"; +import "package:flutter/material.dart"; + +class ArcaneThemeSwitcher extends StatefulWidget { + final Widget child; + + const ArcaneThemeSwitcher({ + required this.child, + Key? key, + }) : super(key: key); + + @override + State createState() => _ArcaneThemeSwitcherState(); +} + +class _ArcaneThemeSwitcherState extends State { + late final StreamSubscription _themeModeSubscription; + ThemeMode _currentThemeMode = ArcaneReactiveTheme.I.currentTheme; + + @override + void initState() { + super.initState(); + _themeModeSubscription = + ArcaneReactiveTheme.I.themeChanges.listen((ThemeMode newMode) { + if (mounted) + setState(() { + _currentThemeMode = newMode; + }); + }); + } + + @override + void dispose() { + _themeModeSubscription.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return ArcaneTheme( + themeMode: _currentThemeMode, + child: widget.child, + ); + } +} diff --git a/lib/src/services/reactive_theme/reactive_theme_wrapper.dart b/lib/src/services/reactive_theme/reactive_theme_wrapper.dart index d08dce3..f51c370 100644 --- a/lib/src/services/reactive_theme/reactive_theme_wrapper.dart +++ b/lib/src/services/reactive_theme/reactive_theme_wrapper.dart @@ -1,3 +1,5 @@ +import "dart:async"; + import "package:arcane_framework/src/services/reactive_theme/reactive_theme_service.dart"; import "package:flutter/material.dart"; @@ -16,12 +18,12 @@ class ArcaneTheme extends InheritedWidget { @override bool updateShouldNotify(ArcaneTheme oldWidget) { - return oldWidget.themeMode != themeMode; + return themeMode != oldWidget.themeMode; } static ArcaneReactiveTheme get service => ArcaneReactiveTheme.I; static bool get isFollowingSystemTheme => service.isFollowingSystemTheme; - static Stream get currentThemeStream => service.currentThemeStream; + static Stream get themeChanges => service.themeChanges; static ThemeMode get currentTheme => service.currentTheme; static ThemeMode get systemTheme => service.systemTheme; static ThemeData get dark => service.dark; @@ -33,7 +35,6 @@ class ArcaneTheme extends InheritedWidget { service.switchTheme; static ArcaneReactiveTheme Function(BuildContext context) get followSystemTheme => service.followSystemTheme; - static void get checkSystemTheme => service.checkSystemTheme; static ArcaneReactiveTheme Function(ThemeData theme) get setDarkTheme => service.setDarkTheme; static ArcaneReactiveTheme Function(ThemeData theme) get setLightTheme => diff --git a/test/services/reactive_theme/reactive_theme_service_test.dart b/test/services/reactive_theme/reactive_theme_service_test.dart index 53f9b47..92e179f 100644 --- a/test/services/reactive_theme/reactive_theme_service_test.dart +++ b/test/services/reactive_theme/reactive_theme_service_test.dart @@ -96,44 +96,33 @@ void main() { await tester.pumpWidget( const MediaQuery( data: MediaQueryData(platformBrightness: Brightness.light), - child: SizedBox(), + child: ArcaneApp( + child: SizedBox(), + ), ), ); final BuildContext lightContext = tester.element(find.byType(SizedBox)); - theme.followSystemTheme(lightContext); + Arcane.theme.followSystemTheme(lightContext); + await tester.pumpAndSettle(); expect(theme.currentTheme, equals(ThemeMode.light)); await tester.pumpWidget( const MediaQuery( data: MediaQueryData(platformBrightness: Brightness.dark), - child: SizedBox(), + child: ArcaneApp( + child: SizedBox(), + ), ), ); final BuildContext darkContext = tester.element(find.byType(SizedBox)); - theme.followSystemTheme(darkContext); + Arcane.theme.followSystemTheme(darkContext); + await tester.pumpAndSettle(); + expect(theme.currentTheme, equals(ThemeMode.dark)); }); - - testWidgets("followSystemTheme only switches when needed", - (WidgetTester tester) async { - await tester.pumpWidget( - const MediaQuery( - data: MediaQueryData(platformBrightness: Brightness.light), - child: SizedBox(), - ), - ); - final BuildContext lightContext = tester.element(find.byType(SizedBox)); - - int switchCount = 0; - theme.addListener(() => switchCount++); - - // Already light, shouldn't switch - theme.followSystemTheme(lightContext); - expect(switchCount, equals(0)); - }); }); }); }