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..707069ab 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.GetOrientation", SystemFunctions.GetOrientation(context)) registry.register("System.MinimizeApp", SystemFunctions.MinimizeApp(activity)) // Dialog.* — core built-in (migrated from the nativephp/mobile-dialog 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..ac415bdb 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 @@ -78,4 +78,20 @@ object SystemFunctions { return mapOf("appearance" to if (night) "dark" else "light") } } + + /** + * Current app-window orientation (portrait / landscape), which may differ + * from the physical device orientation in multi-window mode. Backs + * `System::orientation()` / `isLandscape()` for the cold read before the + * first OrientationChanged push. + * Returns: + * - orientation: string - "portrait" or "landscape" + */ + class GetOrientation(private val context: Context) : BridgeFunction { + override fun execute(parameters: Map): Map { + val landscape = context.resources.configuration.orientation == + Configuration.ORIENTATION_LANDSCAPE + return mapOf("orientation" to if (landscape) "landscape" else "portrait") + } + } } 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..9b73c281 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 @@ -95,6 +95,7 @@ class MainActivity : FragmentActivity(), WebViewProvider { // Last appearance pushed to PHP, so onConfigurationChanged (which also fires // on rotation) only emits AppearanceChanged when the theme actually flips. private var lastAppearance: String? = null + private var showSplash by mutableStateOf(true) // Gates composition of the heavy MainScreen tree (Scaffold + WebView) // until the runtime is booted and the WebView is ready. Until then the first @@ -114,6 +115,11 @@ class MainActivity : FragmentActivity(), WebViewProvider { var instance: MainActivity? = null private set + // Survives activity recreation so a multi-window resize that Android + // does not route through onConfigurationChanged can still refresh PHP's + // process cache from the new activity's window configuration. + private var lastOrientation: String? = null + // Delay before the background queue worker boots. The worker spins up a // second full Laravel runtime; deferring it keeps that off the cold-start // critical path so it doesn't steal CPU from the first paint. @@ -129,6 +135,17 @@ class MainActivity : FragmentActivity(), WebViewProvider { lastAppearance = if ((resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES) "dark" else "light" + // Compare before reseeding: some multi-window changes recreate the + // activity rather than calling onConfigurationChanged. The companion + // tracker survives that recreation, so PHP still receives the change. + val currentOrientation = if (resources.configuration.orientation == + Configuration.ORIENTATION_LANDSCAPE) "landscape" else "portrait" + val previousOrientation = lastOrientation + lastOrientation = currentOrientation + if (previousOrientation != null && previousOrientation != currentOrientation) { + sendOrientationChanged(currentOrientation) + } + // Android 15 edge-to-edge compatibility fix WindowCompat.setDecorFitsSystemWindows(window, false) @@ -461,6 +478,24 @@ class MainActivity : FragmentActivity(), WebViewProvider { org.json.JSONObject().put("mode", mode).toString() ) } + + // Push when the app window's orientation changes. In multi-window mode + // this can differ from the physical device's orientation. + // Same guard as above: only emit when the orientation actually changed. + // Drives reactive System::orientation() / #[On(OrientationChanged)]. + val orientation = if (newConfig.orientation == + Configuration.ORIENTATION_LANDSCAPE) "landscape" else "portrait" + if (orientation != lastOrientation) { + lastOrientation = orientation + sendOrientationChanged(orientation) + } + } + + private fun sendOrientationChanged(orientation: String) { + NativeElementBridge.sendNativeEvent( + "Native\\Mobile\\Events\\System\\OrientationChanged", + org.json.JSONObject().put("orientation", orientation).toString() + ) } @Suppress("DEPRECATION") diff --git a/resources/xcode/NativePHP/Bridge/BridgeFunctionRegistration.swift b/resources/xcode/NativePHP/Bridge/BridgeFunctionRegistration.swift index 765c3237..d4369f70 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.GetOrientation", function: SystemFunctions.GetOrientation()) // 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..c118df3d 100644 --- a/resources/xcode/NativePHP/Bridge/Functions/SystemFunctions.swift +++ b/resources/xcode/NativePHP/Bridge/Functions/SystemFunctions.swift @@ -11,6 +11,17 @@ import UIKit /// Namespace: "System.*" enum SystemFunctions { + /// The current app window orientation. This intentionally describes the + /// window, not the physical device: iPad multitasking can make a window + /// landscape while the device itself is portrait (and vice versa). + static func currentWindowOrientation() -> String { + let size = UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .first?.windows.first?.bounds.size + ?? UIScreen.main.bounds.size + return size.width > size.height ? "landscape" : "portrait" + } + // MARK: - System.OpenAppSettings /// Open the app's settings screen in the device settings @@ -50,4 +61,24 @@ enum SystemFunctions { return ["appearance": mode] } } + + // MARK: - System.GetOrientation + + /// Current app window orientation (portrait / landscape), derived from the + /// window's aspect — the same signal the OrientationChanged push uses. + /// Backs `System::orientation()` / `isLandscape()` for the cold read before + /// the first OrientationChanged push. + /// Returns: + /// - orientation: string - "portrait" or "landscape" + class GetOrientation: BridgeFunction { + func execute(parameters: [String: Any]) throws -> [String: Any] { + func read() -> String { + SystemFunctions.currentWindowOrientation() + } + // Bridge functions may run off the main thread; UIKit window reads + // must be on main. + let orientation = Thread.isMainThread ? read() : DispatchQueue.main.sync { read() } + return ["orientation": orientation] + } + } } diff --git a/resources/xcode/NativePHP/ContentView.swift b/resources/xcode/NativePHP/ContentView.swift index 233e877c..c7849427 100644 --- a/resources/xcode/NativePHP/ContentView.swift +++ b/resources/xcode/NativePHP/ContentView.swift @@ -15,6 +15,9 @@ struct ContentView: View { // platform default. Shows behind screens and during transitions. @ObservedObject private var windowBackground = WindowBackgroundState.shared @Environment(\.colorScheme) private var colorScheme + // Last app-window orientation pushed to PHP (seeded from the first layout), + // so size changes only emit when the window aspect actually flips. + @State private var lastOrientation: String? /// The base color native screens render over — the PHP override when /// set, otherwise the system default. @@ -116,10 +119,31 @@ struct ContentView: View { // flips (Control Center toggle, sunset auto-switch). Drives the // reactive `System::appearance()` / `#[On(AppearanceChanged)]` path. // ContentView is always mounted, so this observes every change. - .onChange(of: colorScheme) { newScheme in + .onChange(of: colorScheme) { _, newScheme in let mode = newScheme == .dark ? "dark" : "light" LaravelBridge.shared.send?("Native\\Mobile\\Events\\System\\AppearanceChanged", ["mode": mode]) } + // Push a native OrientationChanged event to PHP when the app window's + // aspect flips. Read the actual window on both the push and query paths: + // GeometryReader's safe-area frame can become landscape-shaped when an + // iPad keyboard appears even though the window remains portrait. Seeded + // on first layout so only a real flip emits. Drives the reactive + // `System::orientation()` / `#[On(OrientationChanged)]` path. + .background( + GeometryReader { geometry in + Color.clear + .onAppear { + lastOrientation = SystemFunctions.currentWindowOrientation() + } + .onChange(of: geometry.size) { _, _ in + let orientation = SystemFunctions.currentWindowOrientation() + guard orientation != lastOrientation else { return } + lastOrientation = orientation + LaravelBridge.shared.send?("Native\\Mobile\\Events\\System\\OrientationChanged", ["orientation": orientation]) + } + } + .ignoresSafeArea(.keyboard) + ) } /// One layer of the two-layer native screen swap. `id` is the screen's diff --git a/src/Events/System/OrientationChanged.php b/src/Events/System/OrientationChanged.php new file mode 100644 index 00000000..3caa1db2 --- /dev/null +++ b/src/Events/System/OrientationChanged.php @@ -0,0 +1,37 @@ + ...); + * + * The query side (`System::orientation()` / `System::isLandscape()`) is kept + * in sync off this event, so reads stay fresh without a bridge round-trip. + */ +class OrientationChanged implements BroadcastsGlobally +{ + use Dispatchable, SerializesModels; + + public function __construct( + /** 'portrait' | 'landscape' */ + public string $orientation, + ) {} +} diff --git a/src/Facades/System.php b/src/Facades/System.php index 298f4440..378d346c 100644 --- a/src/Facades/System.php +++ b/src/Facades/System.php @@ -14,6 +14,10 @@ * @method static bool isDarkMode() * @method static bool isLightMode() * @method static void rememberAppearance(string $mode) + * @method static string orientation() + * @method static bool isPortrait() + * @method static bool isLandscape() + * @method static void rememberOrientation(string $orientation) */ class System extends Facade { diff --git a/src/NativeServiceProvider.php b/src/NativeServiceProvider.php index 58b24e79..4447e981 100644 --- a/src/NativeServiceProvider.php +++ b/src/NativeServiceProvider.php @@ -44,6 +44,7 @@ use Native\Mobile\Edge\NativeRouter; use Native\Mobile\Edge\NativeTagPrecompiler; use Native\Mobile\Events\System\AppearanceChanged; +use Native\Mobile\Events\System\OrientationChanged; use Native\Mobile\Http\Middleware\HonorsRequestedNativeScreen; use Native\Mobile\Plugins\Compilers\AndroidPluginCompiler; use Native\Mobile\Plugins\Compilers\IOSPluginCompiler; @@ -148,6 +149,11 @@ protected function registerSystemEventListeners(): void AppearanceChanged::class, fn (AppearanceChanged $e) => System::rememberAppearance($e->mode), ); + + Event::listen( + OrientationChanged::class, + fn (OrientationChanged $e) => System::rememberOrientation($e->orientation), + ); } protected function registerCoreFacades(): void diff --git a/src/System.php b/src/System.php index 14c80818..c2e43b87 100644 --- a/src/System.php +++ b/src/System.php @@ -16,6 +16,13 @@ class System */ private static ?string $appearance = null; + /** + * Process-cached current app-window orientation. Same lifecycle as $appearance: + * seeded on first read via `System.GetOrientation`, kept fresh by the + * OrientationChanged event. + */ + private static ?string $orientation = null; + public function isIos(): bool { $info = Device::getInfo(); @@ -90,6 +97,50 @@ public static function rememberAppearance(string $mode): void } } + /** + * Current app-window orientation: 'portrait' or 'landscape'. This describes + * the window aspect rather than the physical device, which matters in + * multi-window modes. Off device (tests, web preview), the bridge is absent + * and this returns 'portrait'. + */ + public function orientation(): string + { + if (self::$orientation !== null) { + return self::$orientation; + } + + if (function_exists('nativephp_call')) { + $result = nativephp_call('System.GetOrientation', '{}'); + $orientation = json_decode($result ?: '{}', true)['orientation'] ?? null; + if ($orientation === 'portrait' || $orientation === 'landscape') { + return self::$orientation = $orientation; + } + } + + return 'portrait'; + } + + public function isPortrait(): bool + { + return $this->orientation() === 'portrait'; + } + + public function isLandscape(): bool + { + return $this->orientation() === 'landscape'; + } + + /** + * Update the process-cached orientation. Called by the OrientationChanged + * listener so `orientation()` stays fresh without re-probing the bridge. + */ + public static function rememberOrientation(string $orientation): void + { + if ($orientation === 'portrait' || $orientation === 'landscape') { + self::$orientation = $orientation; + } + } + /** * Open the app's settings screen in the device settings. * diff --git a/tests/Unit/System/AppearanceTest.php b/tests/Unit/System/AppearanceTest.php index 0b31b5f4..400b1915 100644 --- a/tests/Unit/System/AppearanceTest.php +++ b/tests/Unit/System/AppearanceTest.php @@ -33,6 +33,14 @@ expect((new System)->appearance())->toBe('dark'); }); +it('updates the appearance cache through the service provider listener', function () { + System::rememberAppearance('light'); + + AppearanceChanged::dispatch('dark'); + + expect((new System)->appearance())->toBe('dark'); +}); + it('rebuilds a marked event from its native payload', function () { $comp = new class extends NativeComponent {}; $build = new ReflectionMethod(NativeComponent::class, 'buildEventInstance'); diff --git a/tests/Unit/System/OrientationTest.php b/tests/Unit/System/OrientationTest.php new file mode 100644 index 00000000..71fca89d --- /dev/null +++ b/tests/Unit/System/OrientationTest.php @@ -0,0 +1,51 @@ +toBeTrue(); +}); + +it('caches orientation and answers isPortrait/isLandscape off it', function () { + System::rememberOrientation('landscape'); + $sys = new System; + expect($sys->orientation())->toBe('landscape'); + expect($sys->isLandscape())->toBeTrue(); + expect($sys->isPortrait())->toBeFalse(); + + System::rememberOrientation('portrait'); + expect($sys->orientation())->toBe('portrait'); + expect($sys->isLandscape())->toBeFalse(); +}); + +it('ignores a bogus orientation value', function () { + System::rememberOrientation('landscape'); + System::rememberOrientation('diagonal'); // not portrait/landscape → no change + expect((new System)->orientation())->toBe('landscape'); +}); + +it('updates the orientation cache through the service provider listener', function () { + System::rememberOrientation('portrait'); + + OrientationChanged::dispatch('landscape'); + + expect((new System)->orientation())->toBe('landscape'); +}); + +it('rebuilds a marked event from its native payload', function () { + $comp = new class extends NativeComponent {}; + $build = new ReflectionMethod(NativeComponent::class, 'buildEventInstance'); + $build->setAccessible(true); + + $ev = $build->invoke($comp, OrientationChanged::class, ['orientation' => 'landscape']); + + expect($ev)->toBeInstanceOf(OrientationChanged::class); + expect($ev->orientation)->toBe('landscape'); +});