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) {