diff --git a/resources/androidstudio/app/src/main/java/com/nativephp/mobile/bridge/BridgeFunctionRegistration.kt b/resources/androidstudio/app/src/main/java/com/nativephp/mobile/bridge/BridgeFunctionRegistration.kt index b0bc0041..99817b71 100644 --- a/resources/androidstudio/app/src/main/java/com/nativephp/mobile/bridge/BridgeFunctionRegistration.kt +++ b/resources/androidstudio/app/src/main/java/com/nativephp/mobile/bridge/BridgeFunctionRegistration.kt @@ -29,6 +29,7 @@ fun registerBridgeFunctions(activity: FragmentActivity, context: Context) { // plugin). iOS twin: Bridge/Functions/SystemFunctions.swift. registry.register("System.OpenAppSettings", SystemFunctions.OpenAppSettings(context)) registry.register("System.GetAppearance", SystemFunctions.GetAppearance(context)) + registry.register("System.SetAppearance", SystemFunctions.SetAppearance(activity)) registry.register("System.MinimizeApp", SystemFunctions.MinimizeApp(activity)) // Dialog.* — core built-in (migrated from the nativephp/mobile-dialog @@ -61,4 +62,4 @@ fun registerBridgeFunctions(activity: FragmentActivity, context: Context) { // Register plugin bridge functions registerPluginBridgeFunctions(activity, context) -} \ No newline at end of file +} diff --git a/resources/androidstudio/app/src/main/java/com/nativephp/mobile/bridge/functions/SystemFunctions.kt b/resources/androidstudio/app/src/main/java/com/nativephp/mobile/bridge/functions/SystemFunctions.kt index 0a085644..c77f3a24 100644 --- a/resources/androidstudio/app/src/main/java/com/nativephp/mobile/bridge/functions/SystemFunctions.kt +++ b/resources/androidstudio/app/src/main/java/com/nativephp/mobile/bridge/functions/SystemFunctions.kt @@ -65,6 +65,23 @@ object SystemFunctions { } } + /** Set an app-local light or dark appearance at runtime. */ + class SetAppearance(private val activity: Activity) : BridgeFunction { + override fun execute(parameters: Map): Map { + val appearance = parameters["appearance"] as? String + when (appearance) { + "light", "dark", "system" -> Unit + else -> throw BridgeError.InvalidParameters("appearance must be light, dark, or system") + } + + val mainActivity = activity as? com.nativephp.mobile.ui.MainActivity + ?: throw BridgeError.ExecutionFailed("System.SetAppearance requires MainActivity") + mainActivity.setAppearance(appearance) + + return mapOf("success" to true, "appearance" to appearance) + } + } + /** * Current system appearance (light / dark). Backs `System::appearance()` / * `isDark()` for the cold read before the first AppearanceChanged push. @@ -73,8 +90,9 @@ object SystemFunctions { */ class GetAppearance(private val context: Context) : BridgeFunction { override fun execute(parameters: Map): Map { - val night = (context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == + val systemNight = (context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES + val night = com.nativephp.mobile.ui.NativeAppearanceState.resolve(systemNight) return mapOf("appearance" to if (night) "dark" else "light") } } diff --git a/resources/androidstudio/app/src/main/java/com/nativephp/mobile/ui/MainActivity.kt b/resources/androidstudio/app/src/main/java/com/nativephp/mobile/ui/MainActivity.kt index b0c922f0..d7b4a7b6 100644 --- a/resources/androidstudio/app/src/main/java/com/nativephp/mobile/ui/MainActivity.kt +++ b/resources/androidstudio/app/src/main/java/com/nativephp/mobile/ui/MainActivity.kt @@ -38,7 +38,6 @@ import androidx.compose.animation.* import androidx.compose.animation.core.tween import androidx.compose.foundation.Image import androidx.compose.foundation.background -import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.* import androidx.compose.material3.ColorScheme import androidx.compose.material3.MaterialTheme @@ -166,7 +165,7 @@ class MainActivity : FragmentActivity(), WebViewProvider { // first paint. (Measured: starting the boot before first paint cost ~160ms of // uninterruptible I/O sleep on the main thread + Chromium init on the critical path.) setContent { - val isDark = isSystemInDarkTheme() + val isDark = NativeAppearanceState.isDark() MaterialTheme( colorScheme = nativeUiMaterialColorScheme(isDark), typography = NativeUIThemeProvider.resolveTypography(), @@ -435,6 +434,27 @@ class MainActivity : FragmentActivity(), WebViewProvider { }, 10_000L) } + /** Apply a theme override without recreating the activity or PHP session. */ + fun setAppearance(appearance: String) { + runOnUiThread { + NativeAppearanceState.mode = appearance.takeUnless { it == "system" } + configureStatusBar() + + val mode = if (NativeAppearanceState.resolve(systemIsDarkMode())) "dark" else "light" + if (mode != lastAppearance) { + lastAppearance = mode + NativeElementBridge.sendNativeEvent( + "Native\\Mobile\\Events\\System\\AppearanceChanged", + org.json.JSONObject().put("mode", mode).toString() + ) + } + } + } + + private fun systemIsDarkMode(): Boolean = + (resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == + Configuration.UI_MODE_NIGHT_YES + override fun onConfigurationChanged(newConfig: Configuration) { super.onConfigurationChanged(newConfig) Log.d("MainActivity", "🌀 Config changed: orientation = ${newConfig.orientation}") @@ -452,8 +472,9 @@ class MainActivity : FragmentActivity(), WebViewProvider { // Push a native AppearanceChanged event to PHP when the theme flips. // onConfigurationChanged also fires on rotation, so guard on an actual // change. Drives reactive System::appearance() / #[On(AppearanceChanged)]. - val mode = if ((newConfig.uiMode and Configuration.UI_MODE_NIGHT_MASK) == - Configuration.UI_MODE_NIGHT_YES) "dark" else "light" + val systemIsDark = (newConfig.uiMode and Configuration.UI_MODE_NIGHT_MASK) == + Configuration.UI_MODE_NIGHT_YES + val mode = if (NativeAppearanceState.resolve(systemIsDark)) "dark" else "light" if (mode != lastAppearance) { lastAppearance = mode NativeElementBridge.sendNativeEvent( @@ -473,8 +494,7 @@ class MainActivity : FragmentActivity(), WebViewProvider { when (statusBarStyle) { "auto" -> { - val isSystemDarkMode = (resources.configuration.uiMode and - Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES + val isSystemDarkMode = NativeAppearanceState.resolve(systemIsDarkMode()) windowInsetsController.isAppearanceLightStatusBars = !isSystemDarkMode windowInsetsController.isAppearanceLightNavigationBars = !isSystemDarkMode @@ -495,8 +515,7 @@ class MainActivity : FragmentActivity(), WebViewProvider { } else -> { Log.w("StatusBar", "⚠️ Unknown status bar style: $statusBarStyle, defaulting to auto") - val isSystemDarkMode = (resources.configuration.uiMode and - Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES + val isSystemDarkMode = NativeAppearanceState.resolve(systemIsDarkMode()) windowInsetsController.isAppearanceLightStatusBars = !isSystemDarkMode windowInsetsController.isAppearanceLightNavigationBars = !isSystemDarkMode } diff --git a/resources/androidstudio/app/src/main/java/com/nativephp/mobile/ui/NativeUIThemeProvider.kt b/resources/androidstudio/app/src/main/java/com/nativephp/mobile/ui/NativeUIThemeProvider.kt index 339e7bee..cde5aeff 100644 --- a/resources/androidstudio/app/src/main/java/com/nativephp/mobile/ui/NativeUIThemeProvider.kt +++ b/resources/androidstudio/app/src/main/java/com/nativephp/mobile/ui/NativeUIThemeProvider.kt @@ -1,5 +1,6 @@ package com.nativephp.mobile.ui +import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material3.ColorScheme import androidx.compose.material3.Typography import androidx.compose.material3.darkColorScheme @@ -9,6 +10,20 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue +/** App-local appearance override shared by Material chrome and native nodes. */ +object NativeAppearanceState { + var mode: String? by mutableStateOf(null) + + @Composable + fun isDark(): Boolean = resolve(isSystemInDarkTheme()) + + fun resolve(systemIsDark: Boolean): Boolean = when (mode) { + "light" -> false + "dark" -> true + else -> systemIsDark + } +} + /** * Seam that lets a UI plugin supply the app's Material3 [ColorScheme] without * core depending on the plugin. A plugin registers [colorSchemeFor] from its diff --git a/resources/androidstudio/app/src/main/java/com/nativephp/mobile/ui/nativerender/NativeRootStackRenderer.kt b/resources/androidstudio/app/src/main/java/com/nativephp/mobile/ui/nativerender/NativeRootStackRenderer.kt index a5d45734..8c46e1c6 100644 --- a/resources/androidstudio/app/src/main/java/com/nativephp/mobile/ui/nativerender/NativeRootStackRenderer.kt +++ b/resources/androidstudio/app/src/main/java/com/nativephp/mobile/ui/nativerender/NativeRootStackRenderer.kt @@ -7,7 +7,6 @@ import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.slideOutHorizontally import androidx.compose.animation.togetherWith import androidx.compose.foundation.background -import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues @@ -53,6 +52,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import com.nativephp.mobile.ui.MaterialIcon +import com.nativephp.mobile.ui.NativeAppearanceState /** * Compose port of iOS's `NativeRootStackRenderer`. Renders the @@ -317,7 +317,7 @@ fun NativeRootStackRenderer(node: NativeUINode, modifier: Modifier = Modifier) { // the keyboard covers that region would float the bar a // nav-bar height too high. bottomBarNode?.children?.firstOrNull()?.let { inner -> - val darkBg = if (isSystemInDarkTheme()) inner.props.getColor("dark_bg_color", 0) else 0 + val darkBg = if (NativeAppearanceState.isDark()) inner.props.getColor("dark_bg_color", 0) else 0 val barBg = if (darkBg != 0) darkBg else (inner.style?.bgColor ?: 0) Box( modifier = Modifier diff --git a/resources/androidstudio/app/src/main/java/com/nativephp/mobile/ui/nativerender/NodeView.kt b/resources/androidstudio/app/src/main/java/com/nativephp/mobile/ui/nativerender/NodeView.kt index 1c80b1c4..5e3f3126 100644 --- a/resources/androidstudio/app/src/main/java/com/nativephp/mobile/ui/nativerender/NodeView.kt +++ b/resources/androidstudio/app/src/main/java/com/nativephp/mobile/ui/nativerender/NodeView.kt @@ -15,7 +15,6 @@ import androidx.compose.animation.core.spring import androidx.compose.animation.core.tween import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsPressedAsState -import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxHeight @@ -32,6 +31,7 @@ import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.unit.dp +import com.nativephp.mobile.ui.NativeAppearanceState /** * Recursive composable that renders a NativeUINode and its children. @@ -46,7 +46,7 @@ import androidx.compose.ui.unit.dp fun NodeView(node: NativeUINode, overrideModifier: Modifier? = null) { key(node.id) { val renderer = NativeRendererRegistry.get(node.type) - val isDarkMode = isSystemInDarkTheme() + val isDarkMode = NativeAppearanceState.isDark() val safeAreaTop = LocalSafeAreaTop.current val safeAreaBottom = LocalSafeAreaBottom.current val availableWidth = LocalAvailableWidth.current diff --git a/resources/xcode/NativePHP/Bridge/BridgeFunctionRegistration.swift b/resources/xcode/NativePHP/Bridge/BridgeFunctionRegistration.swift index 765c3237..a0887dc9 100644 --- a/resources/xcode/NativePHP/Bridge/BridgeFunctionRegistration.swift +++ b/resources/xcode/NativePHP/Bridge/BridgeFunctionRegistration.swift @@ -18,6 +18,7 @@ func registerBridgeFunctions() { // plugin). Android twin: bridge/functions/SystemFunctions.kt. registry.register("System.OpenAppSettings", function: SystemFunctions.OpenAppSettings()) registry.register("System.GetAppearance", function: SystemFunctions.GetAppearance()) + registry.register("System.SetAppearance", function: SystemFunctions.SetAppearance()) // UI.* — core built-in. Android twin: bridge/functions/UIFunctions.kt // (which also registers UI.SetTransition; iOS transitions ride the diff --git a/resources/xcode/NativePHP/Bridge/Functions/SystemFunctions.swift b/resources/xcode/NativePHP/Bridge/Functions/SystemFunctions.swift index 483cbeec..3cd94ab5 100644 --- a/resources/xcode/NativePHP/Bridge/Functions/SystemFunctions.swift +++ b/resources/xcode/NativePHP/Bridge/Functions/SystemFunctions.swift @@ -29,6 +29,34 @@ enum SystemFunctions { } } + // MARK: - System.SetAppearance + + /// Set an app-local light or dark appearance at runtime. + class SetAppearance: BridgeFunction { + func execute(parameters: [String: Any]) throws -> [String: Any] { + guard let appearance = parameters["appearance"] as? String else { + throw BridgeError.invalidParameters("appearance is required") + } + + let style: UIUserInterfaceStyle + switch appearance { + case "light": style = .light + case "dark": style = .dark + case "system": style = .unspecified + default: throw BridgeError.invalidParameters("appearance must be light, dark, or system") + } + + DispatchQueue.main.async { + UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .flatMap(\.windows) + .forEach { $0.overrideUserInterfaceStyle = style } + } + + return ["success": true, "appearance": appearance] + } + } + // MARK: - System.GetAppearance /// Current system appearance (light / dark). Backs `System::appearance()` / diff --git a/src/Facades/System.php b/src/Facades/System.php index 298f4440..5afce139 100644 --- a/src/Facades/System.php +++ b/src/Facades/System.php @@ -13,6 +13,7 @@ * @method static string appearance() * @method static bool isDarkMode() * @method static bool isLightMode() + * @method static void setAppearance(string $mode) * @method static void rememberAppearance(string $mode) */ class System extends Facade diff --git a/src/System.php b/src/System.php index 14c80818..4e44bf4c 100644 --- a/src/System.php +++ b/src/System.php @@ -2,6 +2,7 @@ namespace Native\Mobile; +use InvalidArgumentException; use Native\Mobile\Facades\Device; class System @@ -79,6 +80,22 @@ public function isLightMode(): bool return $this->appearance() === 'light'; } + /** + * Override the app appearance without changing the device-wide setting. + */ + public function setAppearance(string $mode): void + { + if (! in_array($mode, ['light', 'dark', 'system'], true)) { + throw new InvalidArgumentException('Appearance must be light, dark, or system.'); + } + + if (function_exists('nativephp_call')) { + nativephp_call('System.SetAppearance', json_encode(['appearance' => $mode], JSON_THROW_ON_ERROR)); + } + + self::$appearance = $mode === 'system' ? null : $mode; + } + /** * Update the process-cached appearance. Called by the AppearanceChanged * listener so `appearance()` stays fresh without re-probing the bridge. diff --git a/tests/Unit/System/AppearanceTest.php b/tests/Unit/System/AppearanceTest.php index 0b31b5f4..0d603534 100644 --- a/tests/Unit/System/AppearanceTest.php +++ b/tests/Unit/System/AppearanceTest.php @@ -33,6 +33,19 @@ expect((new System)->appearance())->toBe('dark'); }); +it('sets an app-local appearance and validates the mode', function () { + $sys = new System; + + $sys->setAppearance('dark'); + expect($sys->appearance())->toBe('dark'); + + $sys->setAppearance('light'); + expect($sys->appearance())->toBe('light'); + + expect(fn () => $sys->setAppearance('chartreuse')) + ->toThrow(InvalidArgumentException::class); +}); + it('rebuilds a marked event from its native payload', function () { $comp = new class extends NativeComponent {}; $build = new ReflectionMethod(NativeComponent::class, 'buildEventInstance');