From 811085e95bafa4b3af1642d1186709bf18225452 Mon Sep 17 00:00:00 2001 From: Kobe Albright Date: Thu, 5 Nov 2026 20:02:18 -0500 Subject: [PATCH] Switch between phone and wide layouts by window size on iOS The iPhone and iPad app now pick the layout from the window size (at least 680x500 points for the wide layout, roughly iOS regular size classes) instead of the user agent, and switch live when the window crosses that line, such as when an iPhone Duo opens or closes or an iPad enters Split View. Index saves the dock layout, drops the shell for one render so the old dock is disposed, and rebuilds it in the new layout. Other hosts keep the startup check. The iOS shell also stays inside the left and right safe areas, which keeps it clear of the iPhone Duo status bar rail and the camera housing in landscape. Index now implements IDisposable so its event subscriptions are removed. --- Docs/DesignLanguage.md | 21 ++++++++++++ Valour/Client.Maui/wwwroot/app.css | 11 +++++++ Valour/Client/Device/DeviceInfo.cs | 24 ++++++++++++++ Valour/Client/Device/LayoutModeWatcher.cs | 33 +++++++++++++++++++ Valour/Client/Pages/Index.razor | 39 +++++++++++++++++++++++ Valour/Client/ValourApp.razor | 8 +++-- Valour/Client/wwwroot/js/main.js | 33 +++++++++++++++++++ 7 files changed, 167 insertions(+), 2 deletions(-) create mode 100644 Valour/Client/Device/LayoutModeWatcher.cs diff --git a/Docs/DesignLanguage.md b/Docs/DesignLanguage.md index f6a699687..b9c38c7d6 100644 --- a/Docs/DesignLanguage.md +++ b/Docs/DesignLanguage.md @@ -300,6 +300,27 @@ presence. Rings compete with the artwork and are hard to read at small sizes. Where an icon appears without a row, such as in a planet folder, a small dot at the top right marks unread activity. +## Phone and wide layouts + +Valour has two layouts. The phone layout shows one window at a time, with the +sidebar sliding in from the left. The wide layout keeps the sidebar open and +lets windows sit side by side in tabs and splits. Components read +`DeviceInfo.IsMobile` to choose between them. + +Browsers, desktop apps, and the Android app choose once at startup from the +device type. The iPhone and iPad app choose from the window size instead: the +wide layout needs a window at least 680 points wide and 500 points tall, which +roughly matches iOS regular width and height size classes. When the window +crosses that line, such as when an iPhone Duo opens or closes or an iPad enters +Split View, the page shell rebuilds itself in the new layout. The dock saves its +windows first and restores them afterward; the phone layout keeps only the +focused tab. + +On iOS, the app shell stays inside the left and right safe areas. On iPhone Duo +the status bar becomes a rail along one edge, and on other iPhones in landscape +the camera housing takes one side. The page background shows through those +insets. + ## Victor and scenes Victor is drawn as himself, the dog from the logo, in a few poses: `astronaut`, diff --git a/Valour/Client.Maui/wwwroot/app.css b/Valour/Client.Maui/wwwroot/app.css index 6222b37f6..16f254eec 100644 --- a/Valour/Client.Maui/wwwroot/app.css +++ b/Valour/Client.Maui/wwwroot/app.css @@ -61,3 +61,14 @@ h1:focus { background-color: var(--surface-canvas); } } + +/* Keeps the app clear of the side safe areas: the status bar rail that the + iPhone Duo draws along one edge, and the camera housing on iPhones in + landscape. The page background shows through the inset. */ +@supports (-webkit-touch-callout: none) { + .mobile-holder { + box-sizing: border-box; + padding-left: env(safe-area-inset-left); + padding-right: env(safe-area-inset-right); + } +} diff --git a/Valour/Client/Device/DeviceInfo.cs b/Valour/Client/Device/DeviceInfo.cs index d0ce9c427..f38555ea7 100644 --- a/Valour/Client/Device/DeviceInfo.cs +++ b/Valour/Client/Device/DeviceInfo.cs @@ -2,7 +2,31 @@ public class DeviceInfo { + /// + /// True when the app shows the phone layout: one window at a time and a + /// slide-out sidebar. The iPhone and iPad app can switch layouts while it + /// runs (see ); elsewhere it is set once. + /// public static bool IsMobile { get; set; } + + /// + /// Raised after changes while the app runs, with the + /// new value. The page shell rebuilds the layout when it fires. + /// + public static event Action LayoutModeChanged; + + /// + /// Switches between the phone and wide layouts, such as when a foldable + /// iPhone opens or closes or an iPad enters Split View. + /// + public static void SetMobileLayout(bool mobile) + { + if (IsMobile == mobile) + return; + + IsMobile = mobile; + LayoutModeChanged?.Invoke(mobile); + } public static bool IsEmbedded { get; set; } /// diff --git a/Valour/Client/Device/LayoutModeWatcher.cs b/Valour/Client/Device/LayoutModeWatcher.cs new file mode 100644 index 000000000..8c5dd0ab9 --- /dev/null +++ b/Valour/Client/Device/LayoutModeWatcher.cs @@ -0,0 +1,33 @@ +using Microsoft.JSInterop; + +namespace Valour.Client.Device; + +/// +/// Chooses the phone or wide layout from the window size in the iPhone and iPad +/// app, and switches it when the window changes size, such as when an iPhone Duo +/// opens or closes. The size rule lives in valourLayout in main.js. +/// +public sealed class LayoutModeWatcher +{ + private static DotNetObjectReference _reference; + + /// + /// Returns whether the window currently fits the phone layout, and starts + /// reporting changes to . + /// + public static async Task StartAsync(IJSRuntime js) + { + var wide = await js.InvokeAsync("valourLayout.isWide"); + + if (_reference is null) + { + _reference = DotNetObjectReference.Create(new LayoutModeWatcher()); + await js.InvokeVoidAsync("valourLayout.watch", _reference); + } + + return !wide; + } + + [JSInvokable] + public void OnWideLayoutChanged(bool wide) => DeviceInfo.SetMobileLayout(!wide); +} diff --git a/Valour/Client/Pages/Index.razor b/Valour/Client/Pages/Index.razor index 6cb0c9fda..53841e1c8 100644 --- a/Valour/Client/Pages/Index.razor +++ b/Valour/Client/Pages/Index.razor @@ -6,6 +6,7 @@ @page "/planetwiki/{StartPlanetId:long}/{StartPageId:long}" @page "/planetwiki/{StartPlanetId:long}" @using Valour.Sdk.Services +@implements IDisposable @inject ValourClient Client @inject AuthService AuthService @@ -30,6 +31,9 @@ +@* Removed for one render while the layout switches; see OnLayoutModeChanged. *@ +@if (!_rebuildingShell) +{
@@ -75,6 +79,7 @@
+} @* Ctrl+/ or Cmd+/ swaps the app for the ValourOS terminal. *@ @@ -103,6 +108,8 @@ [Parameter] public long? StartPageId { get; set; } + private bool _rebuildingShell; + private string MobileClass => DeviceInfo.IsMobile ? "mobile" : "desktop"; private string EmbeddedClass => DeviceInfo.IsEmbedded ? "embedded" : ""; @@ -115,6 +122,7 @@ WindowService.FocusedPlanetChanged += OnPlanetChange; AuthService.LoggedIn += OnLoginAsync; + DeviceInfo.LayoutModeChanged += OnLayoutModeChanged; } private async Task OnLoginAsync(User user) @@ -122,9 +130,40 @@ await InvokeAsync(StateHasChanged); } + // Folding an iPhone Duo or resizing an iPad window can switch between the + // phone and wide layouts. Components read DeviceInfo.IsMobile when they are + // created, so the shell is rebuilt instead of updated in place. The dock + // saves its layout first and restores it on load, collapsing it to the + // focused tab for the phone layout. The shell is removed for one render + // before it returns, so the old dock is disposed before the new one + // registers as the main dock. + private void OnLayoutModeChanged(bool mobile) => _ = InvokeAsync(RebuildShellAsync); + + private async Task RebuildShellAsync() + { + if (_rebuildingShell) + return; + + var dock = WindowService.MainDock; + if (dock?.Layout is not null) + await dock.SaveLayout(); + + // The launch route was already opened; reopening it would undo where the user went since. + _startRoute = null; + + _rebuildingShell = true; + StateHasChanged(); + await Task.Yield(); + + _rebuildingShell = false; + StateHasChanged(); + } + public void Dispose() { WindowService.FocusedPlanetChanged -= OnPlanetChange; + DeviceInfo.LayoutModeChanged -= OnLayoutModeChanged; + AuthService.LoggedIn -= OnLoginAsync; } private Task OnPlanetChange(Planet planet) diff --git a/Valour/Client/ValourApp.razor b/Valour/Client/ValourApp.razor index eadb9469f..ad8a13e60 100644 --- a/Valour/Client/ValourApp.razor +++ b/Valour/Client/ValourApp.razor @@ -1,4 +1,4 @@ -@using Microsoft.Extensions.DependencyInjection +@using Microsoft.Extensions.DependencyInjection @inject ValourClient Client @inject LoggingService Logger @inject IJSRuntime JsRuntime @@ -217,7 +217,11 @@ { if (firstRender) { - var isMobile = await JsRuntime.InvokeAsync("IsMobile"); + // The iPhone and iPad app pick the layout from the window size and + // switch it live; other hosts decide once from the device type. + var isMobile = DeviceInfo.IsIosApp + ? await LayoutModeWatcher.StartAsync(JsRuntime) + : await JsRuntime.InvokeAsync("IsMobile"); var isEmbedded = await JsRuntime.InvokeAsync("IsEmbedded"); DeviceInfo.IsMobile = isMobile; diff --git a/Valour/Client/wwwroot/js/main.js b/Valour/Client/wwwroot/js/main.js index 5e2444cb9..2d4502159 100644 --- a/Valour/Client/wwwroot/js/main.js +++ b/Valour/Client/wwwroot/js/main.js @@ -72,6 +72,39 @@ function IsEmbedded() { return embedded; } +// The iPhone and iPad app choose between the phone and wide layouts from the +// window size instead of the device, so an unfolded iPhone Duo or a full-screen +// iPad gets the wide layout, while a folded phone, a phone in landscape, or a +// narrow Split View window gets the phone layout. The thresholds approximate +// Apple's regular width and height size classes. +window.valourLayout = (() => { + const wideQuery = '(min-width: 680px) and (min-height: 500px)'; + // Folding animates the window through several sizes; report only where it settles. + const settleMs = 250; + let media = null; + let timer = null; + + function isWide() { + return window.matchMedia(wideQuery).matches; + } + + function watch(dotnetRef) { + if (media) + return; + + media = window.matchMedia(wideQuery); + media.addEventListener('change', () => { + clearTimeout(timer); + timer = setTimeout(() => { + dotnetRef.invokeMethodAsync('OnWideLayoutChanged', media.matches) + .catch(() => { }); + }, settleMs); + }); + } + + return { isWide, watch }; +})(); + // A readable name for this device, shown in the account's device list when // linking devices for end-to-end encryption. function getValourDeviceName(isHybrid) {