diff --git a/CHANGELOG.md b/CHANGELOG.md index 70339d6..b0ded3d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ # Changelog +## 2.0.0 +> **Note**: This release contains breaking changes. + +**New Features:** +- **System Theme Mode Integration**: `PlayxTheme` now fully supports and tracks device theme modes natively across the app (`ThemeMode.system`, `ThemeMode.light`, `ThemeMode.dark`). +- **Reactive UI**: Added `PlayxTheme.themeModeNotifier` (`ValueNotifier`) allowing dynamic UI rebuilds anytime the app's internal theme mode state shifts. +- **Improved Platform Synchronization**: Instantiated a `WidgetsBindingObserver` in the controller to passively catch system brightness updates and apply changes dynamically when `ThemeMode.system` is activated. + +**Enhancements:** +- **Config Customization**: Added `lightThemeIndex` and `darkThemeIndex` in `PlayxThemeConfig` to explicitly point out light and dark configuration targets when toggling between themes automatically via system modes. +- **Robust Initial Booting**: Added `initialThemeMode` to flexibly specify initial defaults without forcing numeric indices. `initialThemeIndex` is now nullable to create a clean fallback hierarchy. +- **API Expansion**: Introduced the `updateMode` flag inside update mechanics (`updateTo`, `updateById`, `updateByIndex`, `next`). This gives exact control to avoid overriding mode state during temporary localized theme shifts. +- **SDK Update**: Enforced new SDK constraints: `sdk: '>=3.10.0 <4.0.0'` and `flutter: '>=3.38.0'`. +- Updated all underlying package dependencies to latest. + ## 1.1.1 - Bump `playx_core` dependency to `^0.7.4`. - Wrap `PlayxThemeBuilder`'s child in a `Theme` widget to ensure correct theme data is applied down the widget tree. diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock index dcaac4c..d3ab58f 100644 --- a/example/ios/Podfile.lock +++ b/example/ios/Podfile.lock @@ -1,7 +1,8 @@ PODS: - Flutter (1.0.0) - - flutter_secure_storage (6.0.0): + - flutter_secure_storage_darwin (10.0.0): - Flutter + - FlutterMacOS - path_provider_foundation (0.0.1): - Flutter - FlutterMacOS @@ -11,15 +12,15 @@ PODS: DEPENDENCIES: - Flutter (from `Flutter`) - - flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`) + - flutter_secure_storage_darwin (from `.symlinks/plugins/flutter_secure_storage_darwin/darwin`) - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) EXTERNAL SOURCES: Flutter: :path: Flutter - flutter_secure_storage: - :path: ".symlinks/plugins/flutter_secure_storage/ios" + flutter_secure_storage_darwin: + :path: ".symlinks/plugins/flutter_secure_storage_darwin/darwin" path_provider_foundation: :path: ".symlinks/plugins/path_provider_foundation/darwin" shared_preferences_foundation: @@ -27,7 +28,7 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 - flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13 + flutter_secure_storage_darwin: acdb3f316ed05a3e68f856e0353b133eec373a23 path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7 diff --git a/example/lib/main.dart b/example/lib/main.dart index 9a54ede..777d156 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -44,8 +44,42 @@ class HomeScreen extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.center, children: [ const SizedBox(height: 20), - const Text('Playx Theme Switcher'), + const Text('Playx Theme Mode'), + const SizedBox(height: 8), + ValueListenableBuilder( + valueListenable: PlayxTheme.themeModeNotifier, + builder: (context, mode, child) { + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _buildThemeModeCard( + context, + title: 'Light', + icon: Icons.light_mode, + isSelected: mode == ThemeMode.light, + onTap: () => PlayxTheme.updateToLightMode(), + ), + _buildThemeModeCard( + context, + title: 'Dark', + icon: Icons.dark_mode, + isSelected: mode == ThemeMode.dark, + onTap: () => PlayxTheme.updateToDarkMode(), + ), + _buildThemeModeCard( + context, + title: 'System', + icon: Icons.settings_system_daydream, + isSelected: mode == ThemeMode.system, + onTap: () => PlayxTheme.updateToDeviceMode(), + ), + ], + ); + }, + ), const SizedBox(height: 20), + const Text('Playx Theme Switcher'), + const SizedBox(height: 8), SizedBox( width: 200, child: Card( @@ -168,4 +202,47 @@ class HomeScreen extends StatelessWidget { ), ); } + + Widget _buildThemeModeCard( + BuildContext context, { + required String title, + required IconData icon, + required bool isSelected, + required VoidCallback onTap, + }) { + return GestureDetector( + onTap: onTap, + child: Card( + color: isSelected + ? context.playxColors.primary + : context.playxColors.surfaceContainerHigh, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + side: isSelected + ? BorderSide(color: context.playxColors.onPrimary, width: 2) + : BorderSide.none, + ), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 12.0, horizontal: 16.0), + child: Column( + children: [ + Icon(icon, + color: isSelected + ? context.playxColors.onPrimary + : context.playxColors.onSurface), + const SizedBox(height: 8), + Text( + title, + style: TextStyle( + color: isSelected + ? context.playxColors.onPrimary + : context.playxColors.onSurface, + ), + ), + ], + ), + ), + ), + ); + } } diff --git a/example/macos/Flutter/GeneratedPluginRegistrant.swift b/example/macos/Flutter/GeneratedPluginRegistrant.swift index 37af1fe..66f641b 100644 --- a/example/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/example/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,12 +5,12 @@ import FlutterMacOS import Foundation -import flutter_secure_storage_macos +import flutter_secure_storage_darwin import path_provider_foundation import shared_preferences_foundation func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { - FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin")) + FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) } diff --git a/example/pubspec.lock b/example/pubspec.lock index 8f439da..5510476 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -61,10 +61,10 @@ packages: dependency: transitive description: name: equatable - sha256: "567c64b3cb4cf82397aac55f4f0cbd3ca20d77c6c03bedbc4ceaddc08904aef7" + sha256: "3e0141505477fd8ad55d6eb4e7776d3fe8430be8e497ccb1521370c3f21a3e2b" url: "https://pub.dev" source: hosted - version: "2.0.7" + version: "2.0.8" fake_async: dependency: transitive description: @@ -93,10 +93,10 @@ packages: dependency: transitive description: name: flex_seed_scheme - sha256: b06d8b367b84cbf7ca5c5603c858fa5edae88486c4e4da79ac1044d73b6c62ec + sha256: a3183753bbcfc3af106224bff3ab3e1844b73f58062136b7499919f49f3667e7 url: "https://pub.dev" source: hosted - version: "3.5.1" + version: "4.0.1" flutter: dependency: "direct main" description: flutter @@ -122,50 +122,50 @@ packages: dependency: transitive description: name: flutter_secure_storage - sha256: "9cad52d75ebc511adfae3d447d5d13da15a55a92c9410e50f67335b6d21d16ea" + sha256: da922f2aab2d733db7e011a6bcc4a825b844892d4edd6df83ff156b09a9b2e40 url: "https://pub.dev" source: hosted - version: "9.2.4" - flutter_secure_storage_linux: + version: "10.0.0" + flutter_secure_storage_darwin: dependency: transitive description: - name: flutter_secure_storage_linux - sha256: be76c1d24a97d0b98f8b54bce6b481a380a6590df992d0098f868ad54dc8f688 + name: flutter_secure_storage_darwin + sha256: "8878c25136a79def1668c75985e8e193d9d7d095453ec28730da0315dc69aee3" url: "https://pub.dev" source: hosted - version: "1.2.3" - flutter_secure_storage_macos: + version: "0.2.0" + flutter_secure_storage_linux: dependency: transitive description: - name: flutter_secure_storage_macos - sha256: "6c0a2795a2d1de26ae202a0d78527d163f4acbb11cde4c75c670f3a0fc064247" + name: flutter_secure_storage_linux + sha256: "2b5c76dce569ab752d55a1cee6a2242bcc11fdba927078fb88c503f150767cda" url: "https://pub.dev" source: hosted - version: "3.1.3" + version: "3.0.0" flutter_secure_storage_platform_interface: dependency: transitive description: name: flutter_secure_storage_platform_interface - sha256: cf91ad32ce5adef6fba4d736a542baca9daf3beac4db2d04be350b87f69ac4a8 + sha256: "8ceea1223bee3c6ac1a22dabd8feefc550e4729b3675de4b5900f55afcb435d6" url: "https://pub.dev" source: hosted - version: "1.1.2" + version: "2.0.1" flutter_secure_storage_web: dependency: transitive description: name: flutter_secure_storage_web - sha256: f4ebff989b4f07b2656fb16b47852c0aab9fed9b4ec1c70103368337bc1886a9 + sha256: "6a1137df62b84b54261dca582c1c09ea72f4f9a4b2fcee21b025964132d5d0c3" url: "https://pub.dev" source: hosted - version: "1.2.1" + version: "2.1.0" flutter_secure_storage_windows: dependency: transitive description: name: flutter_secure_storage_windows - sha256: b20b07cb5ed4ed74fc567b78a72936203f587eba460af1df11281c9326cd3709 + sha256: "3b7c8e068875dfd46719ff57c90d8c459c87f2302ed6b00ff006b3c9fcad1613" url: "https://pub.dev" source: hosted - version: "3.1.2" + version: "4.1.0" flutter_test: dependency: "direct dev" description: flutter @@ -180,18 +180,10 @@ packages: dependency: transitive description: name: get_it - sha256: "84792561b731b6463d053e9761a5236da967c369da10b134b8585a5e18429956" + sha256: "568d62f0e68666fb5d95519743b3c24a34c7f19d834b0658c46e26d778461f66" url: "https://pub.dev" source: hosted - version: "9.0.5" - js: - dependency: transitive - description: - name: js - sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 - url: "https://pub.dev" - source: hosted - version: "0.6.7" + version: "9.2.1" leak_tracker: dependency: transitive description: @@ -244,10 +236,10 @@ packages: dependency: transitive description: name: meta - sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" url: "https://pub.dev" source: hosted - version: "1.16.0" + version: "1.17.0" path: dependency: transitive description: @@ -316,17 +308,17 @@ packages: dependency: transitive description: name: playx_core - sha256: b49e07caeda91353f04f68d52c751fdec0513faf46646c74ef785ca258fd1683 + sha256: "1d92e7d33d1132b2fabd779149b9a95b2dbf4b8b0f8f6ea28cad76950ef12f94" url: "https://pub.dev" source: hosted - version: "0.7.4" + version: "1.0.0" playx_theme: dependency: "direct main" description: path: ".." relative: true source: path - version: "1.1.1" + version: "1.2.0" plugin_platform_interface: dependency: transitive description: @@ -339,10 +331,10 @@ packages: dependency: transitive description: name: shared_preferences - sha256: "6e8bf70b7fef813df4e9a36f658ac46d107db4b4cfe1048b477d4e453a8159f5" + sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf url: "https://pub.dev" source: hosted - version: "2.5.3" + version: "2.5.5" shared_preferences_android: dependency: transitive description: @@ -440,10 +432,10 @@ packages: dependency: transitive description: name: talker_logger - sha256: "8218836d871ea5ab1ec616cffe3cdae84e8fb44022d5cc04c95d7b220572b8fb" + sha256: cea1b8283a28c2118a0b197057fc5beb5b0672c75e40a48725e5e452c0278ff3 url: "https://pub.dev" source: hosted - version: "5.0.2" + version: "5.1.16" term_glyph: dependency: transitive description: @@ -456,10 +448,10 @@ packages: dependency: transitive description: name: test_api - sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" + sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 url: "https://pub.dev" source: hosted - version: "0.7.6" + version: "0.7.7" vector_math: dependency: transitive description: @@ -509,5 +501,5 @@ packages: source: hosted version: "1.1.0" sdks: - dart: ">=3.8.0 <4.0.0" - flutter: ">=3.27.0" + dart: ">=3.10.0 <4.0.0" + flutter: ">=3.38.0" diff --git a/lib/src/config/config.dart b/lib/src/config/config.dart index 0fdcc86..8733e27 100644 --- a/lib/src/config/config.dart +++ b/lib/src/config/config.dart @@ -7,7 +7,18 @@ import 'package:playx_theme/playx_theme.dart'; /// the initial theme index, and whether to save the selected theme to device storage. class PlayxThemeConfig { /// Index of the initial theme to start the app with. - final int initialThemeIndex; + /// If provided, it overrides `initialThemeMode`. + final int? initialThemeIndex; + + /// Optional initial theme mode to start the app with. + /// Used if `initialThemeIndex` is null. + final ThemeMode? initialThemeMode; + + /// Optional index for the light theme to be used in theme mode switching. + final int? lightThemeIndex; + + /// Optional index for the dark theme to be used in theme mode switching. + final int? darkThemeIndex; /// Whether to save the theme index to the device storage or not. final bool saveTheme; @@ -23,15 +34,23 @@ class PlayxThemeConfig { /// Creates a [PlayxThemeConfig] instance. /// - /// The [initialThemeIndex] must be non-negative and less than the length of [themes]. + /// The [initialThemeIndex] must be non-negative and less than the length of [themes] if provided. /// The [themes] list must not be empty. PlayxThemeConfig({ - this.initialThemeIndex = 0, + this.initialThemeIndex, + this.initialThemeMode, + this.lightThemeIndex, + this.darkThemeIndex, this.saveTheme = true, this.themes = const [], this.migratePrefsToAsyncPrefs = false, this.logThemeChanges = true, - }) : assert(initialThemeIndex >= 0 && initialThemeIndex < themes.length), + }) : assert(initialThemeIndex == null || + (initialThemeIndex >= 0 && initialThemeIndex < themes.length)), + assert(lightThemeIndex == null || + (lightThemeIndex >= 0 && lightThemeIndex < themes.length)), + assert(darkThemeIndex == null || + (darkThemeIndex >= 0 && darkThemeIndex < themes.length)), assert(themes.isNotEmpty); } @@ -73,5 +92,8 @@ class XDefaultThemeConfig extends PlayxThemeConfig { isDark: true, colors: const PlayxColors(), ), - ], initialThemeIndex: PlayxTheme.isDeviceInDarkMode() ? 1 : 0); + ], + lightThemeIndex: 0, + darkThemeIndex: 1, + initialThemeMode: ThemeMode.system); } diff --git a/lib/src/controller/controller.dart b/lib/src/controller/controller.dart index 2466129..5a26daf 100644 --- a/lib/src/controller/controller.dart +++ b/lib/src/controller/controller.dart @@ -9,8 +9,10 @@ import '../utils/animation_utils.dart'; /// XThemeController is responsible for managing and updating the app's theme, /// including handling theme changes, animations, and theme persistence. -class XThemeController extends ValueNotifier { +class XThemeController extends ValueNotifier + with WidgetsBindingObserver { static const lastKnownIndexKey = 'playx.theme.last_known_index'; + static const lastKnownThemeModeKey = 'playx.theme.last_known_theme_mode'; static XThemeController? _instance; @@ -33,6 +35,15 @@ class XThemeController extends ValueNotifier { /// The current theme being used. XTheme get theme => value; + final ValueNotifier _themeModeNotifier = + ValueNotifier(ThemeMode.system); + + /// The notifier for current theme mode. + ValueNotifier get themeModeNotifier => _themeModeNotifier; + + /// The current theme mode (system, light, or dark). + ThemeMode get themeMode => _themeModeNotifier.value; + // Animation ui.Image? image; final previewContainer = GlobalKey(); @@ -46,19 +57,18 @@ class XThemeController extends ValueNotifier { /// Creates an instance of [XThemeController]. /// /// The provided [theme] will be used as the initial theme if provided; otherwise, - /// the theme at the [initialThemeIndex] from the configuration will be used. + /// the theme from [getInitialThemeByConfig] will be used. XThemeController({ XTheme? theme, required this.config, - }) : super(theme ?? config.themes[config.initialThemeIndex]) { - int initialThemeIndex = theme != null - ? availableThemes.indexOf(theme) - : config.initialThemeIndex; - if (initialThemeIndex < 0 || initialThemeIndex >= config.themes.length) { - initialThemeIndex = 0; + }) : super(theme ?? getInitialThemeByConfig(config)) { + var initialThemeIdx = + availableThemes.indexOf(theme ?? getInitialThemeByConfig(config)); + if (initialThemeIdx < 0 || initialThemeIdx >= config.themes.length) { + initialThemeIdx = 0; } - initialIndex = initialThemeIndex; - currentIndex = initialThemeIndex; + initialIndex = initialThemeIdx; + currentIndex = initialThemeIdx; } /// Current theme index. @@ -68,25 +78,143 @@ class XThemeController extends ValueNotifier { List get availableThemes => config.themes; static PlayxBaseLogger get logger => PlayxLogger.getLogger('PLAYX THEME')!; + /// Retrieves the optional explicitly set theme mode, or system by default. + ThemeMode getInitialThemeMode() { + if (config.initialThemeIndex != null && + config.initialThemeIndex! >= 0 && + config.initialThemeIndex! < config.themes.length) { + final theme = config.themes[config.initialThemeIndex!]; + return theme.isDark ? ThemeMode.dark : ThemeMode.light; + } else if (config.initialThemeMode != null) { + return config.initialThemeMode!; + } else { + return ThemeMode.system; + } + } + + /// Retrieves the initial theme based on the configuration. + XTheme getInitialTheme() => getInitialThemeByConfig(config); + + /// Helper to retrieve the initial theme using a configuration object before initialization. + static XTheme getInitialThemeByConfig(PlayxThemeConfig config) { + if (config.initialThemeIndex != null && + config.initialThemeIndex! >= 0 && + config.initialThemeIndex! < config.themes.length) { + return config.themes[config.initialThemeIndex!]; + } + + if (config.initialThemeMode != null) { + if (config.initialThemeMode == ThemeMode.system) { + return _getThemeForModeStatic(config, isDeviceInDarkMode()) ?? + config.themes.first; + } else if (config.initialThemeMode == ThemeMode.light) { + return _getThemeForModeStatic(config, false) ?? config.themes.first; + } else { + return _getThemeForModeStatic(config, true) ?? config.themes.first; + } + } + + return _getThemeForModeStatic(config, isDeviceInDarkMode()) ?? + config.themes.first; + } + /// Initializes the theme controller by loading the last saved theme index if applicable. Future boot() async { - final lastSavedIndex = await getLastSavedIndexFromPrefs( - migratePrefsToAsync: config.migratePrefsToAsyncPrefs); - final lastKnownIndex = - config.saveTheme ? lastSavedIndex ?? initialIndex : initialIndex; + final lastSavedIndex = config.saveTheme + ? await getLastSavedIndexFromPrefs( + migratePrefsToAsync: config.migratePrefsToAsyncPrefs) + : null; + + final lastSavedThemeModeString = config.saveTheme + ? await PlayxAsyncPrefs.maybeGetString(lastKnownThemeModeKey) + : null; + + ThemeMode computedMode; + if (lastSavedThemeModeString != null) { + computedMode = ThemeMode.values.firstWhere( + (e) => e.name == lastSavedThemeModeString, + orElse: () => ThemeMode.system, + ); + } else { + computedMode = getInitialThemeMode(); + } - currentIndex = lastKnownIndex; + _themeModeNotifier.value = computedMode; + + if (lastSavedThemeModeString != null && computedMode == ThemeMode.system) { + value = _getThemeForMode(isDeviceInDarkMode()) ?? availableThemes.first; + } else { + if (lastSavedThemeModeString == null) { + value = getInitialTheme(); + } else { + if (lastSavedIndex != null && + lastSavedIndex >= 0 && + lastSavedIndex < availableThemes.length) { + value = availableThemes[lastSavedIndex]; + } else { + if (computedMode == ThemeMode.light) { + value = _getThemeForMode(false) ?? availableThemes.first; + } else { + value = _getThemeForMode(true) ?? availableThemes.first; + } + } + } + } + + currentIndex = availableThemes.indexOf(value); + initialIndex = currentIndex; - value = config.themes.atOrNull( - lastKnownIndex, - ) ?? - config.themes.first; _instance = this; + WidgetsBinding.instance.addObserver(this); logger.info( - 'Initialized with theme: ${value.id} at index: $currentIndex', + 'Initialized with theme: ${value.id} at index: $currentIndex, mode: ${_themeModeNotifier.value.name}', ); } + Future _setThemeMode(ThemeMode mode) async { + if (_themeModeNotifier.value == mode) return; + _themeModeNotifier.value = mode; + if (config.saveTheme) { + await PlayxAsyncPrefs.setString(lastKnownThemeModeKey, mode.name); + } + } + + XTheme? _getThemeForMode(bool isDark) => + _getThemeForModeStatic(config, isDark); + + static XTheme? _getThemeForModeStatic(PlayxThemeConfig config, bool isDark) { + if (isDark) { + if (config.darkThemeIndex != null && + config.darkThemeIndex! >= 0 && + config.darkThemeIndex! < config.themes.length) { + return config.themes[config.darkThemeIndex!]; + } + return config.themes.firstWhereOrNull((element) => element.isDark); + } else { + if (config.lightThemeIndex != null && + config.lightThemeIndex! >= 0 && + config.lightThemeIndex! < config.themes.length) { + return config.themes[config.lightThemeIndex!]; + } + return config.themes.firstWhereOrNull((element) => !element.isDark); + } + } + + @override + void didChangePlatformBrightness() { + super.didChangePlatformBrightness(); + if (_themeModeNotifier.value == ThemeMode.system) { + final currentTheme = + _getThemeForMode(isDeviceInDarkMode()) ?? config.themes.first; + _updateTheme( + theme: currentTheme, + index: availableThemes.indexOf(currentTheme), + animate: false, + updateMode: false, + ); + } + } + /// Retrieves the last saved theme index from preferences. /// /// If [migratePrefsToAsync] is true, preferences are migrated to asynchronous storage. @@ -113,12 +241,14 @@ class XThemeController extends ValueNotifier { /// If [animate] is false, the theme change will not be animated. /// If [animation] is provided, the theme change will be animated based on the animation type. /// If [forceUpdateNonAnimatedTheme] is true, the app's widget tree will be rebuilt. + /// If [updateMode] is true, the `themeMode` is updated to light or dark based on the theme's brightness. Future _updateTheme({ required XTheme theme, required int index, PlayxThemeAnimation animation = const PlayxThemeAnimation.fade(), bool animate = true, bool forceUpdateNonAnimatedTheme = false, + bool updateMode = true, }) async { if (value == theme) { if (config.logThemeChanges) { @@ -131,6 +261,9 @@ class XThemeController extends ValueNotifier { throw RangeError( 'The provided index is out of range of available themes list'); } + if (updateMode) { + await _setThemeMode(theme.isDark ? ThemeMode.dark : ThemeMode.light); + } if (config.logThemeChanges) { logger.info( 'Updating theme from ${value.id} to ${theme.id} at index $index with animation enabled : $animate', @@ -170,11 +303,13 @@ class XThemeController extends ValueNotifier { /// If [animate] is false, the theme change will not be animated. /// If [animation] is provided, the theme change will be animated based on the animation type. /// If [forceUpdateNonAnimatedTheme] is true, the app's widget tree will be rebuilt. + /// If [updateMode] is true, the `themeMode` is updated based on the theme's brightness. Future updateTo( XTheme theme, { PlayxThemeAnimation animation = const PlayxThemeAnimation.fade(), bool animate = true, bool forceUpdateNonAnimatedTheme = false, + bool updateMode = true, }) async { if (!availableThemes.contains(theme)) { if (config.logThemeChanges) { @@ -188,34 +323,41 @@ class XThemeController extends ValueNotifier { animation: animation, animate: animate, forceUpdateNonAnimatedTheme: forceUpdateNonAnimatedTheme, + updateMode: updateMode, ); } /// Switches the theme to the next one in the list. /// /// If there is no next theme, it will switch to the first one. + /// If [updateMode] is true, the `themeMode` is updated based on the theme's brightness. Future nextTheme({ PlayxThemeAnimation animation = const PlayxThemeAnimation.fade(), bool animate = true, bool forceUpdateNonAnimatedTheme = false, + bool updateMode = true, }) async { final isLastTheme = currentIndex == config.themes.length - 1; return updateByIndex(isLastTheme ? 0 : currentIndex + 1, animation: animation, animate: animate, - forceUpdateNonAnimatedTheme: forceUpdateNonAnimatedTheme); + forceUpdateNonAnimatedTheme: forceUpdateNonAnimatedTheme, + updateMode: updateMode, + ); } /// Updates the theme to one by its index in the available themes list. /// /// The [index] should be within the range of the available themes list. /// Throws a [RangeError] if the index is out of range. + /// If [updateMode] is true, the `themeMode` is updated based on the theme's brightness. Future updateByIndex( int index, { PlayxThemeAnimation animation = const PlayxThemeAnimation.fade(), bool animate = true, bool forceUpdateNonAnimatedTheme = false, + bool updateMode = true, }) async { if (index < 0 || index >= availableThemes.length) { logger.e('Index $index is out of range for available themes list.'); @@ -227,18 +369,22 @@ class XThemeController extends ValueNotifier { index: index, animation: animation, animate: animate, - forceUpdateNonAnimatedTheme: forceUpdateNonAnimatedTheme); + forceUpdateNonAnimatedTheme: forceUpdateNonAnimatedTheme, + updateMode: updateMode, + ); } /// Updates the theme to one by its ID in the available themes list. /// /// The [id] should match the ID of a theme in the available themes list. /// Throws an exception if the ID is not found. + /// If [updateMode] is true, the `themeMode` is updated based on the theme's brightness. Future updateById( String id, { PlayxThemeAnimation animation = const PlayxThemeAnimation.fade(), bool animate = true, bool forceUpdateNonAnimatedTheme = false, + bool updateMode = true, }) async { if (!availableThemes.any((element) => element.id == id)) { if (config.logThemeChanges) { @@ -253,7 +399,9 @@ class XThemeController extends ValueNotifier { index: index, animation: animation, animate: animate, - forceUpdateNonAnimatedTheme: forceUpdateNonAnimatedTheme); + forceUpdateNonAnimatedTheme: forceUpdateNonAnimatedTheme, + updateMode: updateMode, + ); } /// Returns whether the device is currently in dark mode. @@ -263,58 +411,64 @@ class XThemeController extends ValueNotifier { return brightness == Brightness.dark; } - /// Updates the theme to the first light theme in the available themes list. + /// Updates the theme to the first light theme in the available themes list + /// (or uses `lightThemeIndex` if specified in config). /// /// Throws an exception if no light theme is found. Future updateToLightMode({ PlayxThemeAnimation animation = const PlayxThemeAnimation.fade(), bool animate = true, bool forceUpdateNonAnimatedTheme = false, - }) { - final theme = config.themes.firstWhereOrNull((element) => !element.isDark); + }) async { + final theme = _getThemeForMode(false); if (theme == null) { if (config.logThemeChanges) { logger.error('Could not find any light theme in the available themes'); } throw Exception('Could not find any light theme in the available themes'); } + await _setThemeMode(ThemeMode.light); return updateTo(theme, animation: animation, animate: animate, - forceUpdateNonAnimatedTheme: forceUpdateNonAnimatedTheme); + forceUpdateNonAnimatedTheme: forceUpdateNonAnimatedTheme, + updateMode: false); } - ///Update the theme to the first dark theme in supported themes. + ///Update the theme to the first dark theme in supported themes + /// (or uses `darkThemeIndex` if specified in config). ///If there is no dark theme, it will throw an exception. Future updateToDarkMode({ PlayxThemeAnimation animation = const PlayxThemeAnimation.fade(), bool animate = true, bool forceUpdateNonAnimatedTheme = false, - }) { - final theme = config.themes.firstWhereOrNull((element) => element.isDark); + }) async { + final theme = _getThemeForMode(true); if (theme == null) { if (config.logThemeChanges) { logger.error('Could not find any dark theme in the available themes'); } throw Exception('Could not find any dark theme in the available themes'); } + await _setThemeMode(ThemeMode.dark); return updateTo( theme, animation: animation, animate: animate, forceUpdateNonAnimatedTheme: forceUpdateNonAnimatedTheme, + updateMode: false, ); } - /// Update the theme to the first theme that matches the device mode. + /// Update the theme to the first theme that matches the device mode + /// (or uses `lightThemeIndex`/`darkThemeIndex` if specified in config). /// If there is no theme that matches the device mode, it will throw an exception. Future updateToDeviceMode({ PlayxThemeAnimation animation = const PlayxThemeAnimation.fade(), bool animate = true, bool forceUpdateNonAnimatedTheme = false, - }) { - XTheme? theme = config.themes - .firstWhereOrNull((element) => element.isDark == isDeviceInDarkMode()); + }) async { + XTheme? theme = _getThemeForMode(isDeviceInDarkMode()); if (theme == null) { if (config.logThemeChanges) { logger.error( @@ -322,10 +476,12 @@ class XThemeController extends ValueNotifier { } throw Exception('Could not find any theme that matches the device mode'); } + await _setThemeMode(ThemeMode.system); return updateTo(theme, animation: animation, animate: animate, - forceUpdateNonAnimatedTheme: forceUpdateNonAnimatedTheme); + forceUpdateNonAnimatedTheme: forceUpdateNonAnimatedTheme, + updateMode: false); } /// Update the theme to the first theme that matches the given mode. @@ -334,15 +490,18 @@ class XThemeController extends ValueNotifier { PlayxThemeAnimation animation = const PlayxThemeAnimation.fade(), bool animate = true, bool forceUpdateNonAnimatedTheme = false, - }) { + }) async { + await _setThemeMode(mode); switch (mode) { case ThemeMode.system: return updateToDeviceMode( animation: animation, + animate: animate, forceUpdateNonAnimatedTheme: forceUpdateNonAnimatedTheme); case ThemeMode.light: return updateToLightMode( animation: animation, + animate: animate, forceUpdateNonAnimatedTheme: forceUpdateNonAnimatedTheme); case ThemeMode.dark: return updateToDarkMode( @@ -352,8 +511,9 @@ class XThemeController extends ValueNotifier { } } - /// Clear the last saved theme index. - static Future clearLastSavedTheme() { + /// Clear the last saved theme index and theme mode. + static Future clearLastSavedTheme() async { + await PlayxAsyncPrefs.remove(lastKnownThemeModeKey); return PlayxAsyncPrefs.remove(lastKnownIndexKey); } @@ -393,6 +553,8 @@ class XThemeController extends ValueNotifier { @override void dispose() { + WidgetsBinding.instance.removeObserver(this); + _themeModeNotifier.dispose(); timer?.cancel(); _instance = null; super.dispose(); diff --git a/lib/src/playx_theme.dart b/lib/src/playx_theme.dart index f605227..4cf7342 100644 --- a/lib/src/playx_theme.dart +++ b/lib/src/playx_theme.dart @@ -60,6 +60,14 @@ abstract class PlayxTheme { /// This returns an object containing the color palette for the current theme. static PlayxColors get colors => currentTheme.colors; + /// Gets the current theme mode (system, light, or dark). + static ThemeMode get themeMode => _controller.themeMode; + + /// Gets the notifier for current theme mode. + /// Used to listen to theme mode changes. + static ValueNotifier get themeModeNotifier => + _controller.themeModeNotifier; + /// Gets the name of the currently active theme. /// /// This returns a human-readable name for the current theme. @@ -72,12 +80,8 @@ abstract class PlayxTheme { /// Gets the initial theme based on the configuration. /// - /// This returns the theme that was set to be the initial theme, or `null` if no initial theme is set. - static XTheme? get initialTheme => - _controller.config.initialThemeIndex >= 0 && - _controller.config.initialThemeIndex < supportedThemes.length - ? supportedThemes[_controller.config.initialThemeIndex] - : null; + /// This returns the theme that was established based on config index and mode priorities. + static XTheme get initialTheme => _controller.getInitialTheme(); /// Gets the list of all supported themes. /// @@ -89,6 +93,7 @@ abstract class PlayxTheme { /// If [animate] is `false`, the theme will change instantly. /// You can specify an animation for the theme change using the [animation] parameter. /// Use [forceUpdateNonAnimatedTheme] to force a theme update without animation if animation is disabled. + /// If [updateMode] is `true`, it updates the `themeMode` to light or dark based on the Theme's brightness. /// /// Throws an [Exception] if the provided theme is not available in the supported themes. static Future updateTo( @@ -96,12 +101,14 @@ abstract class PlayxTheme { PlayxThemeAnimation animation = const PlayxThemeAnimation.fade(), bool animate = true, bool forceUpdateNonAnimatedTheme = false, + bool updateMode = true, }) => _controller.updateTo( theme, animation: animation, animate: animate, forceUpdateNonAnimatedTheme: forceUpdateNonAnimatedTheme, + updateMode: updateMode, ); /// Updates the app's theme to the theme at the specified index. @@ -110,6 +117,7 @@ abstract class PlayxTheme { /// If [animate] is `true`, the theme change will be animated based on the specified [animation]. /// If [animate] is `false`, the theme will change instantly. /// Use [forceUpdateNonAnimatedTheme] to force a theme update without animation if animation is disabled. + /// If [updateMode] is `true`, it updates the `themeMode` to light or dark based on the Theme's brightness. /// /// Throws a [RangeError] if the index is out of range. static Future updateByIndex( @@ -117,12 +125,14 @@ abstract class PlayxTheme { PlayxThemeAnimation animation = const PlayxThemeAnimation.fade(), bool animate = true, bool forceUpdateNonAnimatedTheme = false, + bool updateMode = true, }) => _controller.updateByIndex( index, animation: animation, animate: animate, forceUpdateNonAnimatedTheme: forceUpdateNonAnimatedTheme, + updateMode: updateMode, ); /// Updates the app's theme to the theme with the specified ID. @@ -131,6 +141,7 @@ abstract class PlayxTheme { /// If [animate] is `true`, the theme change will be animated based on the specified [animation]. /// If [animate] is `false`, the theme will change instantly. /// Use [forceUpdateNonAnimatedTheme] to force a theme update without animation if animation is disabled. + /// If [updateMode] is `true`, it updates the `themeMode` to light or dark based on the Theme's brightness. /// /// Throws an [Exception] if the ID is not available in the supported themes. static Future updateById( @@ -138,12 +149,14 @@ abstract class PlayxTheme { PlayxThemeAnimation animation = const PlayxThemeAnimation.fade(), bool animate = true, bool forceUpdateNonAnimatedTheme = false, + bool updateMode = true, }) => _controller.updateById( id, animation: animation, animate: animate, forceUpdateNonAnimatedTheme: forceUpdateNonAnimatedTheme, + updateMode: updateMode, ); /// Updates the app's theme to the next theme in the list. @@ -152,15 +165,18 @@ abstract class PlayxTheme { /// If [animate] is `true`, the theme change will be animated based on the specified [animation]. /// If [animate] is `false`, the theme will change instantly. /// Use [forceUpdateNonAnimatedTheme] to force a theme update without animation if animation is disabled. + /// If [updateMode] is `true`, it updates the `themeMode` to light or dark based on the Theme's brightness. static Future next({ PlayxThemeAnimation animation = const PlayxThemeAnimation.fade(), bool animate = true, bool forceUpdateNonAnimatedTheme = false, + bool updateMode = true, }) => _controller.nextTheme( animation: animation, animate: animate, forceUpdateNonAnimatedTheme: forceUpdateNonAnimatedTheme, + updateMode: updateMode, ); /// Determines if the device is currently in dark mode. diff --git a/pubspec.yaml b/pubspec.yaml index f3eb35d..dcb8cba 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: playx_theme description: Simplify app theming in Flutter with Playx Theme. Effortlessly switch themes, enjoy smooth animations, and customize color schemes with ease. -version: 1.1.1 +version: 2.0.0 homepage: https://sourcya.io repository: https://github.com/playx-flutter/playx_theme issue_tracker: https://github.com/playx-flutter/playx_theme/issues @@ -12,14 +12,14 @@ topics: - color environment: - sdk: '>=3.5.0 <4.0.0' - flutter: '>=3.27.0' + sdk: '>=3.10.0 <4.0.0' + flutter: '>=3.38.0' dependencies: flutter: sdk: flutter - playx_core: ^0.7.4 - flex_seed_scheme: ^3.5.1 + playx_core: ^1.0.0 + flex_seed_scheme: ^4.0.1 animated_theme_switcher: ^2.0.10 @@ -27,6 +27,6 @@ dev_dependencies: flutter_test: sdk: flutter flutter_lints: ^6.0.0 - lints: ^6.0.0 - shared_preferences_platform_interface: ^2.4.1 + lints: ^6.1.0 + shared_preferences_platform_interface: ^2.4.2