diff --git a/README.md b/README.md index 2baeede..0f879de 100644 --- a/README.md +++ b/README.md @@ -180,6 +180,11 @@ flutter_native_splash: # The branding image dimensions must be 800x320 px. #branding: assets/dart.png #branding_dark: assets/dart_dark.png + + # app theme styles: + # Set to true if your app has its own light/dark setting. Adds the NativeSplashLight and + # NativeSplashDark styles. See "Following your app's light/dark setting" below. + #app_theme_styles: true ``` ## 2. Run the package @@ -261,6 +266,51 @@ Be aware of the following considerations regarding these elements: **_PLEASE NOTE:_** The splash screen does not appear when you launch the app from a notification. Apparently this is the intended behavior on Android 12: [core-splashscreen Icon not shown when cold launched from notification](https://issuetracker.google.com/issues/199776339?pli=1). +## Following your app's light/dark setting + +The Android 12+ splash screen follows the phone's dark mode. If your app lets people choose light or dark in the app, the splash screen can follow that choice instead: + +1. Add `app_theme_styles: true` to the `android_12` section and run the package. This adds two styles, `NativeSplashLight` and `NativeSplashDark`, which keep their colors and images whatever the phone's theme. + +2. Whenever the setting changes, pass one of them to [`SplashScreen.setSplashScreenTheme`](https://developer.android.com/reference/android/window/SplashScreen#setSplashScreenTheme(int)), or `Resources.ID_NULL` to follow the phone again. Android remembers the choice, and uses it the next time the app starts. This package has no native code, so add a method channel to your `MainActivity.kt`: + +```kotlin +import android.content.res.Resources +import android.os.Build +import io.flutter.embedding.android.FlutterActivity +import io.flutter.embedding.engine.FlutterEngine +import io.flutter.plugin.common.MethodChannel + +class MainActivity : FlutterActivity() { + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { + super.configureFlutterEngine(flutterEngine) + MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "splash_theme") + .setMethodCallHandler { call, result -> + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + splashScreen.setSplashScreenTheme( + when (call.arguments) { + "light" -> R.style.NativeSplashLight + "dark" -> R.style.NativeSplashDark + else -> Resources.ID_NULL // follow the phone + } + ) + } + result.success(null) + } + } +} +``` + +and call it from Dart when the user picks a theme: + +```dart +const splashTheme = MethodChannel('splash_theme'); + +await splashTheme.invokeMethod('set', 'dark'); // 'light', 'dark' or 'system' +``` + +`UiModeManager.setApplicationNightMode` is not enough on its own: some launchers, such as Samsung's One UI launcher, draw the splash screen in the phone's theme anyway when the app is opened from its icon. + # Flavor Support If you have a project setup that contains multiple flavors or environments, and you created more than one flavor this would be a feature for you. @@ -464,7 +514,9 @@ This is caused by an [iOS splash caching bug](https://stackoverflow.com/question ## Can I base light/dark mode on app settings? -No. This package creates a splash screen that is displayed before Flutter is loaded. Because of this, when the splash screen loads, internal app settings are not available to the splash screen. Unfortunately, this means that it is impossible to control light/dark settings of the splash from app settings. +On Android 12 and later, yes: see [Following your app's light/dark setting](#following-your-apps-lightdark-setting). + +Elsewhere, no. This package creates a splash screen that is displayed before Flutter is loaded. Because of this, when the splash screen loads, internal app settings are not available to the splash screen. Unfortunately, this means that it is impossible to control light/dark settings of the splash from app settings. # Notes diff --git a/example/pubspec.yaml b/example/pubspec.yaml index 9eb3efe..443aafd 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -166,6 +166,10 @@ flutter_native_splash: #color_dark: "#042a49" #icon_background_color_dark: "#111111" + # Set app_theme_styles to true if your app has its own light/dark setting. See "Following your + # app's light/dark setting" in the README. + #app_theme_styles: true + # The android, ios and web parameters can be used to disable generating a splash screen on a given # platform. #android: false diff --git a/lib/android.dart b/lib/android.dart index 938fff9..35d3613 100644 --- a/lib/android.dart +++ b/lib/android.dart @@ -70,6 +70,7 @@ void _createAndroidSplash({ required String? screenOrientation, String? android12BrandingImagePath, String? android12DarkBrandingImagePath, + bool android12AppThemeStyles = false, int? androidMinSdk, }) { androidMinSdk ??= 16; @@ -235,6 +236,59 @@ void _createAndroidSplash({ ); } + // A drawable with a night variant follows the phone's theme, so when the dark + // image differs, the fixed light and dark styles get copies without one. + final copyImage = + android12AppThemeStyles && android12DarkImagePath != android12ImagePath; + final copyBranding = android12AppThemeStyles && + android12DarkBrandingImagePath != android12BrandingImagePath; + _applyImageAndroid( + imagePath: copyImage ? android12ImagePath : null, + fileName: 'android12splash_light.png', + ); + _applyImageAndroid( + imagePath: copyImage ? android12DarkImagePath : null, + fileName: 'android12splash_dark.png', + ); + _applyImageAndroid( + imagePath: copyBranding ? android12BrandingImagePath : null, + android12: true, + fileName: 'android12branding_light.png', + ); + _applyImageAndroid( + imagePath: copyBranding ? android12DarkBrandingImagePath : null, + android12: true, + fileName: 'android12branding_dark.png', + ); + + if (androidV31StylesFile != null) { + String? drawable(String? path, String name, bool copy, String variant) => + path == null ? null : (copy ? '${name}_$variant' : name); + _applyAppThemeStyles( + file: androidV31StylesFile, + light: android12AppThemeStyles + ? ( + backgroundColor: android12BackgroundColor, + image: drawable( + android12ImagePath, 'android12splash', copyImage, 'light'), + iconBackgroundColor: android12IconBackgroundColor, + brandingImage: drawable(android12BrandingImagePath, + 'android12branding', copyBranding, 'light'), + ) + : null, + dark: android12AppThemeStyles + ? ( + backgroundColor: android12DarkBackgroundColor, + image: drawable( + android12DarkImagePath, 'android12splash', copyImage, 'dark'), + iconBackgroundColor: darkAndroid12IconBackgroundColor, + brandingImage: drawable(android12DarkBrandingImagePath, + 'android12branding', copyBranding, 'dark'), + ) + : null, + ); + } + if (androidStylesFile != null) { _applyStylesXml( fullScreen: fullscreen, @@ -491,58 +545,93 @@ Future _updateStylesFile({ ); // In Android 12, the color must be set directly in the styles.xml - if (android12BackgroundColor == null) { - _removeElement( - launchTheme: launchTheme, - name: 'android:windowSplashScreenBackground', - ); - } else { - _replaceElement( - launchTheme: launchTheme, - name: 'android:windowSplashScreenBackground', - value: '#$android12BackgroundColor', - ); - } + _setAndroid12SplashItems(launchTheme, ( + backgroundColor: android12BackgroundColor, + image: android12ImagePath == null ? null : 'android12splash', + iconBackgroundColor: android12IconBackgroundColor, + brandingImage: + android12BrandingImagePath == null ? null : 'android12branding', + )); - if (android12BrandingImagePath == null) { - _removeElement( - launchTheme: launchTheme, - name: 'android:windowSplashScreenBrandingImage', - ); - } else { - _replaceElement( - launchTheme: launchTheme, - name: 'android:windowSplashScreenBrandingImage', - value: '@drawable/android12branding', - ); - } + stylesFile.writeAsStringSync( + '${stylesDocument.toXmlString(pretty: true, indent: ' ')}\n', + ); +} - if (android12ImagePath == null) { - _removeElement( - launchTheme: launchTheme, - name: 'android:windowSplashScreenAnimatedIcon', - ); - } else { - _replaceElement( - launchTheme: launchTheme, - name: 'android:windowSplashScreenAnimatedIcon', - value: '@drawable/android12splash', - ); - } +/// The Android 12+ splash of a style: colors as hex without `#`, images as +/// drawable names. A null value removes its item from the style. +typedef _Android12Splash = ({ + String? backgroundColor, + String? image, + String? iconBackgroundColor, + String? brandingImage, +}); + +void _setAndroid12SplashItems(XmlElement style, _Android12Splash splash) { + final items = { + 'android:windowSplashScreenBackground': + splash.backgroundColor == null ? null : '#${splash.backgroundColor}', + 'android:windowSplashScreenBrandingImage': splash.brandingImage == null + ? null + : '@drawable/${splash.brandingImage}', + 'android:windowSplashScreenAnimatedIcon': + splash.image == null ? null : '@drawable/${splash.image}', + 'android:windowSplashScreenIconBackgroundColor': + splash.iconBackgroundColor == null + ? null + : '#${splash.iconBackgroundColor}', + }; + items.forEach((name, value) { + if (value == null) { + _removeElement(launchTheme: style, name: name); + } else { + _replaceElement(launchTheme: style, name: name, value: value); + } + }); +} - if (android12IconBackgroundColor == null) { - _removeElement( - launchTheme: launchTheme, - name: 'android:windowSplashScreenIconBackgroundColor', - ); - } else { - _replaceElement( - launchTheme: launchTheme, - name: 'android:windowSplashScreenIconBackgroundColor', - value: '#$android12IconBackgroundColor', - ); +/// Adds the NativeSplashLight and NativeSplashDark styles to [file], or +/// removes them when [light] and [dark] are null. An app passes them to +/// SplashScreen.setSplashScreenTheme so the splash follows its own light/dark +/// setting. They only go in the non-night styles file, so neither changes with +/// the phone's theme. Android stores the chosen style by name: never rename +/// them. +void _applyAppThemeStyles({ + required String file, + required _Android12Splash? light, + required _Android12Splash? dark, +}) { + final stylesFile = File(file); + if (!stylesFile.existsSync()) return; + final stylesDocument = XmlDocument.parse(stylesFile.readAsStringSync()); + final resources = stylesDocument.getElement('resources'); + if (resources == null) return; + + var changed = false; + for (final (name, parent, splash) in [ + ('NativeSplashLight', '@android:style/Theme.Light.NoTitleBar', light), + ('NativeSplashDark', '@android:style/Theme.Black.NoTitleBar', dark), + ]) { + var style = resources + .findElements('style') + .where((element) => element.getAttribute('name') == name) + .firstOrNull; + if (splash == null) { + style?.remove(); + changed |= style != null; + continue; + } + if (style == null) { + style = XmlElement.tag('style') + ..setAttribute('name', name) + ..setAttribute('parent', parent); + resources.children.add(style); + } + _setAndroid12SplashItems(style, splash); + changed = true; } + if (!changed) return; stylesFile.writeAsStringSync( '${stylesDocument.toXmlString(pretty: true, indent: ' ')}\n', ); diff --git a/lib/cli_commands.dart b/lib/cli_commands.dart index d87c1e8..4127dab 100644 --- a/lib/cli_commands.dart +++ b/lib/cli_commands.dart @@ -135,6 +135,7 @@ void _createSplashByConfig(Map config) { String? android12DarkColor; String? android12BrandingImage; String? android12DarkBrandingImage; + bool android12AppThemeStyles = false; if (config[_Parameter.android12Section] != null) { final android12Config = @@ -154,6 +155,8 @@ void _createSplashByConfig(Map config) { config: android12Config, parameter: _Parameter.brandingImage); android12DarkBrandingImage = _checkImageExists( config: android12Config, parameter: _Parameter.brandingDarkImage); + android12AppThemeStyles = + android12Config[_Parameter.appThemeStyles] as bool? ?? false; } if (!config.containsKey(_Parameter.android) || @@ -185,6 +188,7 @@ void _createSplashByConfig(Map config) { android12BrandingImagePath: android12BrandingImage, android12DarkBrandingImagePath: android12DarkBrandingImage ?? android12BrandingImage, + android12AppThemeStyles: android12AppThemeStyles, ); } else { print('Android folder not found, skipping Android splash update...'); @@ -424,6 +428,7 @@ class _Parameter { static const android12Section = 'android_12'; static const androidMinSdk = 'android_min_sdk'; static const androidScreenOrientation = 'android_screen_orientation'; + static const appThemeStyles = 'app_theme_styles'; static const backgroundImage = 'background_image'; static const backgroundImageAndroid = 'background_image_android'; static const backgroundImageIos = 'background_image_ios'; @@ -475,6 +480,7 @@ class _Parameter { android12Section, androidMinSdk, androidScreenOrientation, + appThemeStyles, backgroundImage, backgroundImageAndroid, backgroundImageIos, diff --git a/test/android_test.dart b/test/android_test.dart new file mode 100644 index 0000000..cd368a1 --- /dev/null +++ b/test/android_test.dart @@ -0,0 +1,182 @@ +import 'dart:io'; + +import 'package:flutter_native_splash/cli_commands.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:image/image.dart'; +import 'package:xml/xml.dart'; + +const _res = 'android/app/src/main/res'; +const _densities = ['mdpi', 'hdpi', 'xhdpi', 'xxhdpi', 'xxxhdpi']; + +void main() { + group('android_12 app_theme_styles', () { + late Directory originalDirectory; + late Directory project; + + setUp(() { + originalDirectory = Directory.current; + project = Directory.systemTemp.createTempSync('flutter_native_splash_'); + Directory.current = project; + Directory(_res).createSync(recursive: true); + File('android/app/src/main/AndroidManifest.xml').writeAsStringSync( + '' + '' + '', + ); + // Different sizes, so a copy shows which image it came from. + File('light.png').writeAsBytesSync(encodePng(Image(width: 8, height: 8))); + File('dark.png') + .writeAsBytesSync(encodePng(Image(width: 16, height: 16))); + }); + + tearDown(() { + Directory.current = originalDirectory; + project.deleteSync(recursive: true); + }); + + void create(String android12) { + File('flutter_native_splash.yaml').writeAsStringSync(''' +flutter_native_splash: + color: "#ffffff" + ios: false + web: false + android_12: +$android12 +'''); + createSplash(path: 'flutter_native_splash.yaml', flavor: null); + } + + test('are not added without the option', () { + create(''' + color: "#f7f8fa" + color_dark: "#0b0f16" +'''); + expect(_style('values-v31', 'LaunchTheme'), isNotNull); + expect(_style('values-v31', 'NativeSplashLight'), isNull); + expect(_style('values-v31', 'NativeSplashDark'), isNull); + }); + + test('use night-free copies when the dark image differs', () async { + create(''' + color: "#f7f8fa" + color_dark: "#0b0f16" + image: light.png + image_dark: dark.png + icon_background_color: "#111111" + app_theme_styles: true +'''); + await _waitForImages([ + for (final d in _densities) ...[ + '$_res/drawable-$d/android12splash.png', + '$_res/drawable-night-$d/android12splash.png', + '$_res/drawable-$d/android12splash_light.png', + '$_res/drawable-$d/android12splash_dark.png', + ], + ]); + + final light = _style('values-v31', 'NativeSplashLight')!; + expect( + light.getAttribute('parent'), + '@android:style/Theme.Light.NoTitleBar', + ); + expect(_items(light), { + 'android:windowSplashScreenBackground': '#f7f8fa', + 'android:windowSplashScreenAnimatedIcon': + '@drawable/android12splash_light', + 'android:windowSplashScreenIconBackgroundColor': '#111111', + }); + final dark = _style('values-v31', 'NativeSplashDark')!; + expect( + dark.getAttribute('parent'), + '@android:style/Theme.Black.NoTitleBar', + ); + expect(_items(dark), { + 'android:windowSplashScreenBackground': '#0b0f16', + 'android:windowSplashScreenAnimatedIcon': + '@drawable/android12splash_dark', + 'android:windowSplashScreenIconBackgroundColor': '#111111', + }); + expect(_style('values-night-v31', 'NativeSplashLight'), isNull); + expect(_style('values-night-v31', 'NativeSplashDark'), isNull); + expect( + _items(_style('values-v31', 'LaunchTheme')!), + containsPair( + 'android:windowSplashScreenAnimatedIcon', + '@drawable/android12splash', + ), + ); + + final darkCopy = File('$_res/drawable-xxxhdpi/android12splash_dark.png'); + expect(decodePng(darkCopy.readAsBytesSync())!.width, 16); + expect( + Directory(_res).listSync(recursive: true).where( + (f) => + f.path.contains('night') && + f.path.contains(RegExp('_(light|dark)')), + ), + isEmpty, + ); + + removeSplash(path: 'flutter_native_splash.yaml', flavor: null); + expect(_style('values-v31', 'NativeSplashLight'), isNull); + expect(_style('values-v31', 'NativeSplashDark'), isNull); + expect(darkCopy.existsSync(), isFalse); + }); + + test('share the drawable when there is one image', () async { + create(''' + color: "#f7f8fa" + color_dark: "#0b0f16" + image: light.png + app_theme_styles: true +'''); + await _waitForImages([ + for (final d in _densities) ...[ + '$_res/drawable-$d/android12splash.png', + '$_res/drawable-night-$d/android12splash.png', + ], + ]); + + for (final name in ['NativeSplashLight', 'NativeSplashDark']) { + expect( + _items(_style('values-v31', name)!), + containsPair( + 'android:windowSplashScreenAnimatedIcon', + '@drawable/android12splash', + ), + ); + } + expect( + File('$_res/drawable-xxxhdpi/android12splash_light.png').existsSync(), + isFalse, + ); + }); + }); +} + +XmlElement? _style(String values, String name) { + final file = File('$_res/$values/styles.xml'); + return XmlDocument.parse(file.readAsStringSync()) + .findAllElements('style') + .where((style) => style.getAttribute('name') == name) + .firstOrNull; +} + +Map _items(XmlElement style) => { + for (final item in style.findElements('item')) + item.getAttribute('name')!: item.innerText, + }; + +/// Android images are written by background isolates. Wait for them, so none +/// is still being written when the test changes the current directory. +Future _waitForImages(List paths) async { + bool written(String path) { + final file = File(path); + return file.existsSync() && decodePng(file.readAsBytesSync()) != null; + } + + for (var i = 0; i < 200 && !paths.every(written); i++) { + await Future.delayed(const Duration(milliseconds: 50)); + } + expect(paths.where((path) => !written(path)), isEmpty); +}