Skip to content
Open
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.GetOrientation", SystemFunctions.GetOrientation(context))
registry.register("System.MinimizeApp", SystemFunctions.MinimizeApp(activity))

// Dialog.* — core built-in (migrated from the nativephp/mobile-dialog
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, Any>): Map<String, Any> {
val landscape = context.resources.configuration.orientation ==
Configuration.ORIENTATION_LANDSCAPE
return mapOf("orientation" to if (landscape) "landscape" else "portrait")
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand All @@ -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)

Expand Down Expand Up @@ -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")
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.GetOrientation", function: SystemFunctions.GetOrientation())

// UI.* — core built-in. Android twin: bridge/functions/UIFunctions.kt
// (which also registers UI.SetTransition; iOS transitions ride the
Expand Down
31 changes: 31 additions & 0 deletions resources/xcode/NativePHP/Bridge/Functions/SystemFunctions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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]
}
}
}
26 changes: 25 additions & 1 deletion resources/xcode/NativePHP/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
37 changes: 37 additions & 0 deletions src/Events/System/OrientationChanged.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<?php

namespace Native\Mobile\Events\System;

use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
use Native\Mobile\Events\Concerns\BroadcastsGlobally;

/**
* The app window orientation (portrait / landscape) changed while the app was
* running. This describes the window's aspect, not the physical device, so it
* also covers iPad/Android multi-window resizing. Only fires when the app
* allows more than one orientation (`nativephp.orientation` config). Fired
* from native window/configuration changes.
*
* React in a component:
*
* #[On(OrientationChanged::class)]
* public function rotated(string $orientation): void { ... } // 'portrait' | 'landscape'
*
* …or anywhere in the app (it also dispatches globally — see
* [[BroadcastsGlobally]]):
*
* Event::listen(OrientationChanged::class, fn ($e) => ...);
*
* 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,
) {}
}
4 changes: 4 additions & 0 deletions src/Facades/System.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down
6 changes: 6 additions & 0 deletions src/NativeServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down
51 changes: 51 additions & 0 deletions src/System.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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.
*
Expand Down
8 changes: 8 additions & 0 deletions tests/Unit/System/AppearanceTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
51 changes: 51 additions & 0 deletions tests/Unit/System/OrientationTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
<?php

use Native\Mobile\Edge\NativeComponent;
use Native\Mobile\Events\Concerns\BroadcastsGlobally;
use Native\Mobile\Events\System\OrientationChanged;
use Native\Mobile\System;

/**
* Reactive orientation: the query side (System cache) + the event that keeps
* it fresh, mirroring the AppearanceChanged pattern.
*/
it('marks OrientationChanged for global dispatch', function () {
expect(is_subclass_of(OrientationChanged::class, BroadcastsGlobally::class))->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');
});
Loading