From 02a6d0f80f694e9cfdd208625a158e16e2ee601e Mon Sep 17 00:00:00 2001 From: keyldev Date: Fri, 2 Oct 2026 01:25:00 +0300 Subject: [PATCH 1/2] fix(mobile): pages no longer overflow the width; system Back walks up the hierarchy - Insets move off ScrollViewer.Padding onto the content (settings panes, camera page Majestic/ISP/metrics, player event list): the padding shrank the viewport but not the content, so it hung past the right edge on a phone - Health Center sheet drops its 420px minimum, wider than most phones - Android Back / edge swipe (and the mouse back button) closes the top sheet, leaves fullscreen, returns from a camera or recording, a settings section to its list, then to the start page; only at the root does the app go to background - A sheet closed by Back completes like Cancel, so the awaiting command and the bottom nav are not left disabled --- .../Services/OverlayDialogPresenter.cs | 35 +++++++++--- .../ViewModels/MainWindowViewModel.cs | 53 ++++++++++++++++++- .../Views/Dialogs/HealthCenterContent.axaml | 2 +- .../Views/MainView.axaml.cs | 20 ++++++- .../Views/Pages/RecordingPlayerPage.axaml | 4 +- .../Views/Pages/SettingsPage.axaml | 12 +++-- .../Views/Pages/SingleCameraPage.axaml | 14 ++--- 7 files changed, 118 insertions(+), 22 deletions(-) diff --git a/src/OpenIPC.Viewer.App/Services/OverlayDialogPresenter.cs b/src/OpenIPC.Viewer.App/Services/OverlayDialogPresenter.cs index 1bdc899..3d0edcd 100644 --- a/src/OpenIPC.Viewer.App/Services/OverlayDialogPresenter.cs +++ b/src/OpenIPC.Viewer.App/Services/OverlayDialogPresenter.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Threading.Tasks; using Avalonia; using Avalonia.Animation; @@ -20,7 +21,7 @@ namespace OpenIPC.Viewer.App.Services; // returns null). This presenter adds a dim background + a bottom-sheet card // holding the dialog Content to the active TopLevel's OverlayLayer and awaits // a caller-provided TaskCompletionSource. Result is delivered when the content -// closes itself via its TCS. +// closes itself via its TCS, or when the dialog is dismissed (system Back). // // Layout is a bottom sheet: the card stretches full width and pins to the // bottom edge with rounded top corners, the native mobile pattern. Its height @@ -45,18 +46,33 @@ public static class OverlayDialogPresenter // Air between a sheet's last row and the navigation bar / gesture pill. private const double SheetBottomGap = 14; - // Number of overlay dialogs currently on screen. Mobile dialogs live in the + // Dialogs currently on screen, oldest first. Mobile dialogs live in the // TopLevel.OverlayLayer; the dim Border does not reliably intercept taps on // the bottom nav, so the shell gates navigation on this instead. Desktop // uses real modal Windows (ShowDialog) and never goes through here. - private static int _activeCount; + private static readonly List Open = new(); /// True while at least one overlay (mobile modal) dialog is open. - public static bool IsAnyOpen => _activeCount > 0; + public static bool IsAnyOpen => Open.Count > 0; /// Raised on the UI thread whenever may have changed. public static event Action? ActiveChanged; + /// + /// Cancels the topmost overlay dialog, as if the user had hit Cancel, and + /// reports whether there was one. Wired to the system Back button/gesture: + /// the caller is parked on the dialog's TaskCompletionSource, so tearing the + /// sheet down any other way would leave every command that awaits a dialog + /// (Add camera, Discover, …) disabled for good. + /// + public static bool TryDismissTopmost() + { + if (Open.Count == 0) + return false; + Open[^1].TrySetResult(); + return true; + } + // fullScreen → fill the whole TopLevel (no bottom-sheet card / scroll wrapper). // Used for the SSH terminal and file manager, which are full-screen pages on // mobile, not sheets (phase-13 §13.3). @@ -167,8 +183,9 @@ void ApplySize(Size s) sizeSub = top.GetObservable(TopLevel.ClientSizeProperty).Subscribe(ApplySize); } + var dismissed = new TaskCompletionSource(); overlay.Children.Add(dim); - _activeCount++; + Open.Add(dismissed); ActiveChanged?.Invoke(); // Kick the transitions after the first layout pass — set synchronously // the Avalonia renderer treats them as initial state and skips the @@ -182,13 +199,17 @@ void ApplySize(Size s) try { - return await completion.ConfigureAwait(true); + var finished = await Task.WhenAny(completion, dismissed.Task).ConfigureAwait(true); + // Dismissed (Back) delivers the same "no result" the Cancel button does. + return ReferenceEquals(finished, completion) + ? await completion.ConfigureAwait(true) + : default!; } finally { sizeSub?.Dispose(); overlay.Children.Remove(dim); - _activeCount--; + Open.Remove(dismissed); ActiveChanged?.Invoke(); } } diff --git a/src/OpenIPC.Viewer.App/ViewModels/MainWindowViewModel.cs b/src/OpenIPC.Viewer.App/ViewModels/MainWindowViewModel.cs index 8fea254..eff5ab5 100644 --- a/src/OpenIPC.Viewer.App/ViewModels/MainWindowViewModel.cs +++ b/src/OpenIPC.Viewer.App/ViewModels/MainWindowViewModel.cs @@ -32,6 +32,9 @@ public sealed partial class MainWindowViewModel : ViewModelBase, // Back returns there instead of always dropping to the library list. private ViewModelBase? _singleCameraOrigin; + // Settings → Start page: the root the system Back gesture unwinds to. + private readonly ViewModelBase _startPage; + public GridPageViewModel Live { get; } public CameraLibraryPageViewModel Library { get; } public RecordingsPageViewModel Recordings { get; } @@ -96,7 +99,8 @@ public MainWindowViewModel( _playerFactory = playerFactory; _logger = logger; // Settings → Start page (#70): open straight into the live grid if asked. - _currentPage = userSettings.Current.StartupPage == "live" ? live : library; + _startPage = userSettings.Current.StartupPage == "live" ? live : library; + _currentPage = _startPage; WeakReferenceMessenger.Default.Register(this); WeakReferenceMessenger.Default.Register(this); @@ -187,6 +191,53 @@ private void ExitKiosk() UpdateFullscreen(); } + /// + /// System Back (Android button / edge swipe, mouse back button) walks up one + /// level: the topmost sheet, then fullscreen, then the page a camera or + /// recording was opened from, then a settings section back to its list, + /// then the start page. False at the root, so the platform takes over + /// (Android sends the app to the background). + /// + public bool TryGoBack() + { + if (OverlayDialogPresenter.TryDismissTopmost()) + return true; + + if (_playerFullscreen && CurrentPage is RecordingPlayerPageViewModel) + { + Receive(new SetPlayerFullscreenMessage(false)); + return true; + } + + if (KioskMode) + { + KioskMode = false; + UpdateFullscreen(); + return true; + } + + switch (CurrentPage) + { + case RecordingPlayerPageViewModel: + Receive(new GoBackToRecordingsMessage()); + return true; + case SingleCameraPageViewModel: + Receive(new GoBackToLibraryMessage()); + return true; + case SettingsPageViewModel settings when settings.ShowBackButton: + settings.BackToListCommand.Execute(null); + return true; + } + + if (!ReferenceEquals(CurrentPage, _startPage)) + { + CurrentPage = _startPage; + return true; + } + + return false; + } + private static bool CanNavigate() => !OverlayDialogPresenter.IsAnyOpen; [RelayCommand(CanExecute = nameof(CanNavigate))] diff --git a/src/OpenIPC.Viewer.App/Views/Dialogs/HealthCenterContent.axaml b/src/OpenIPC.Viewer.App/Views/Dialogs/HealthCenterContent.axaml index f9d4faa..c53deb7 100644 --- a/src/OpenIPC.Viewer.App/Views/Dialogs/HealthCenterContent.axaml +++ b/src/OpenIPC.Viewer.App/Views/Dialogs/HealthCenterContent.axaml @@ -11,7 +11,7 @@ FontSize="{StaticResource FontSizeBase}" Foreground="{StaticResource TextPrimaryBrush}"> - + diff --git a/src/OpenIPC.Viewer.App/Views/MainView.axaml.cs b/src/OpenIPC.Viewer.App/Views/MainView.axaml.cs index 234645e..1d5fc52 100644 --- a/src/OpenIPC.Viewer.App/Views/MainView.axaml.cs +++ b/src/OpenIPC.Viewer.App/Views/MainView.axaml.cs @@ -3,6 +3,7 @@ using Avalonia; using Avalonia.Controls; using Avalonia.Controls.Platform; +using Avalonia.Interactivity; using OpenIPC.Viewer.App.ViewModels; namespace OpenIPC.Viewer.App.Views; @@ -49,6 +50,7 @@ public partial class MainView : UserControl private Thickness _contentPadding = WidePadding; private MainWindowViewModel? _vm; private IInsetsManager? _insets; + private TopLevel? _topLevel; public bool ShowSidebar => _showSidebar; public bool ShowBottomNav => _showBottomNav; @@ -72,7 +74,10 @@ public MainView() protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) { base.OnAttachedToVisualTree(e); - _insets = TopLevel.GetTopLevel(this)?.InsetsManager; + _topLevel = TopLevel.GetTopLevel(this); + if (_topLevel is not null) + _topLevel.BackRequested += OnBackRequested; + _insets = _topLevel?.InsetsManager; if (_insets is not null) { _insets.SafeAreaChanged += OnSafeAreaChanged; @@ -83,6 +88,11 @@ protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) { base.OnDetachedFromVisualTree(e); + if (_topLevel is not null) + { + _topLevel.BackRequested -= OnBackRequested; + _topLevel = null; + } if (_insets is not null) { _insets.SafeAreaChanged -= OnSafeAreaChanged; @@ -92,6 +102,14 @@ protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e private void OnSafeAreaChanged(object? sender, SafeAreaChangedArgs e) => ApplySafeArea(); + // Android Back (button or edge swipe) and the mouse back button arrive here. + // Left unhandled at the root, Android sends the app to the background. + private void OnBackRequested(object? sender, RoutedEventArgs e) + { + if (!e.Handled && _vm?.TryGoBack() == true) + e.Handled = true; + } + private void ApplySafeArea() => Padding = _isFullscreen ? default : _insets?.SafeAreaPadding ?? default; diff --git a/src/OpenIPC.Viewer.App/Views/Pages/RecordingPlayerPage.axaml b/src/OpenIPC.Viewer.App/Views/Pages/RecordingPlayerPage.axaml index 222042b..5e3dbd1 100644 --- a/src/OpenIPC.Viewer.App/Views/Pages/RecordingPlayerPage.axaml +++ b/src/OpenIPC.Viewer.App/Views/Pages/RecordingPlayerPage.axaml @@ -343,8 +343,8 @@ - - + +