Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -61,4 +62,4 @@ fun registerBridgeFunctions(activity: FragmentActivity, context: Context) {

// Register plugin bridge functions
registerPluginBridgeFunctions(activity, context)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, Any>): Map<String, Any> {
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.
Expand All @@ -73,8 +90,9 @@ object SystemFunctions {
*/
class GetAppearance(private val context: Context) : BridgeFunction {
override fun execute(parameters: Map<String, Any>): Map<String, Any> {
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")
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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}")
Expand All @@ -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(
Expand All @@ -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

Expand All @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
28 changes: 28 additions & 0 deletions resources/xcode/NativePHP/Bridge/Functions/SystemFunctions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()` /
Expand Down
1 change: 1 addition & 0 deletions src/Facades/System.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 17 additions & 0 deletions src/System.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

namespace Native\Mobile;

use InvalidArgumentException;
use Native\Mobile\Facades\Device;

class System
Expand Down Expand Up @@ -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.
Expand Down
13 changes: 13 additions & 0 deletions tests/Unit/System/AppearanceTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down