diff --git a/src/OpenIPC.Viewer.App/Services/IBackNavigable.cs b/src/OpenIPC.Viewer.App/Services/IBackNavigable.cs new file mode 100644 index 0000000..ee4c66e --- /dev/null +++ b/src/OpenIPC.Viewer.App/Services/IBackNavigable.cs @@ -0,0 +1,12 @@ +namespace OpenIPC.Viewer.App.Services; + +/// +/// A page view model or overlay dialog with levels of its own (a settings +/// section over its list, a remote folder over its parent). System Back asks +/// it first and only leaves the page or closes the sheet once it says no. +/// +public interface IBackNavigable +{ + /// Steps one level up inside; false when already at the top. + bool TryGoBack(); +} diff --git a/src/OpenIPC.Viewer.App/Services/OverlayDialogPresenter.cs b/src/OpenIPC.Viewer.App/Services/OverlayDialogPresenter.cs index 1bdc899..f4c4301 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,39 @@ 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; + /// + /// System Back over the overlays: the topmost dialog first steps up its own + /// levels (, e.g. the file manager's folders), + /// then is cancelled as if the user had hit Cancel. False when none is open. + /// 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; + var top = Open[^1]; + var inner = top.Content as IBackNavigable ?? top.Content.DataContext as IBackNavigable; + if (inner?.TryGoBack() != true) + top.Dismissed.TrySetResult(); + return true; + } + + private sealed record OpenDialog(Control Content, TaskCompletionSource Dismissed); + // 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 +189,9 @@ void ApplySize(Size s) sizeSub = top.GetObservable(TopLevel.ClientSizeProperty).Subscribe(ApplySize); } + var entry = new OpenDialog(content, new TaskCompletionSource()); overlay.Children.Add(dim); - _activeCount++; + Open.Add(entry); 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 +205,17 @@ void ApplySize(Size s) try { - return await completion.ConfigureAwait(true); + var finished = await Task.WhenAny(completion, entry.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(entry); ActiveChanged?.Invoke(); } } diff --git a/src/OpenIPC.Viewer.App/ViewModels/FileManagerViewModel.cs b/src/OpenIPC.Viewer.App/ViewModels/FileManagerViewModel.cs index 697dde0..57f4f1c 100644 --- a/src/OpenIPC.Viewer.App/ViewModels/FileManagerViewModel.cs +++ b/src/OpenIPC.Viewer.App/ViewModels/FileManagerViewModel.cs @@ -21,7 +21,7 @@ namespace OpenIPC.Viewer.App.ViewModels; /// the OS file pickers (no local FS panel) so there are no desktop-only paths /// or mobile sandbox issues. Root-level deletes are refused. /// -public sealed partial class FileManagerViewModel : ViewModelBase, IAsyncDisposable +public sealed partial class FileManagerViewModel : ViewModelBase, IAsyncDisposable, IBackNavigable { private readonly Camera _camera; private readonly CameraDirectoryService _directory; @@ -93,6 +93,15 @@ private Task NavigateUpAsync() return LoadEntriesAsync(); } + // System Back climbs to the parent folder; at "/" it closes the manager. + public bool TryGoBack() + { + if ((RemotePath ?? "/").Trim('/').Length == 0) + return false; + _ = NavigateUpAsync(); + return true; + } + [RelayCommand] private async Task OpenEntryAsync(RemoteEntryViewModel? entry) { diff --git a/src/OpenIPC.Viewer.App/ViewModels/MainWindowViewModel.cs b/src/OpenIPC.Viewer.App/ViewModels/MainWindowViewModel.cs index 8fea254..d441de4 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,52 @@ 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 the page's own levels (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 IBackNavigable page when page.TryGoBack(): + 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/ViewModels/RecordingsPageViewModel.cs b/src/OpenIPC.Viewer.App/ViewModels/RecordingsPageViewModel.cs index 652e40f..07f566e 100644 --- a/src/OpenIPC.Viewer.App/ViewModels/RecordingsPageViewModel.cs +++ b/src/OpenIPC.Viewer.App/ViewModels/RecordingsPageViewModel.cs @@ -22,7 +22,7 @@ public enum MediaTab { Recordings, Snapshots } public enum RecordingPeriod { All, Today, Days7, Days30 } -public sealed partial class RecordingsPageViewModel : ViewModelBase +public sealed partial class RecordingsPageViewModel : ViewModelBase, IBackNavigable { private readonly IRecordingRepository _repo; private readonly CameraDirectoryService _cameras; @@ -39,6 +39,16 @@ public sealed partial class RecordingsPageViewModel : ViewModelBase // Phase 16.3: archive activity calendar. Selecting a day filters the list. public ArchiveCalendarViewModel Calendar { get; } + // Phone: Back folds an expanded month calendar back to its week strip + // before leaving the page. + public bool TryGoBack() + { + if (!ShowRecordings || !Calendar.IsExpanded) + return false; + Calendar.IsExpanded = false; + return true; + } + // Phase 14: the Recordings page doubles as the captured-media browser. A // segmented header flips between the recordings list and the snapshot // gallery; the snapshot tab loads lazily on first view. diff --git a/src/OpenIPC.Viewer.App/ViewModels/SettingsPageViewModel.cs b/src/OpenIPC.Viewer.App/ViewModels/SettingsPageViewModel.cs index ea80f1c..a74f54c 100644 --- a/src/OpenIPC.Viewer.App/ViewModels/SettingsPageViewModel.cs +++ b/src/OpenIPC.Viewer.App/ViewModels/SettingsPageViewModel.cs @@ -16,7 +16,7 @@ namespace OpenIPC.Viewer.App.ViewModels; -public sealed partial class SettingsPageViewModel : ViewModelBase +public sealed partial class SettingsPageViewModel : ViewModelBase, IBackNavigable { private readonly UserSettingsService _settings; private readonly IFileSystem _fs; @@ -136,6 +136,16 @@ partial void OnIsWideChanged(bool value) [RelayCommand] private void BackToList() => SelectedSectionIndex = -1; + // Phone: an open section returns to the section list. Wide shows both panes, + // so there is no level to step up. + public bool TryGoBack() + { + if (!ShowBackButton) + return false; + BackToList(); + return true; + } + // Deep-link target for the tray "About" item — keeps the section index in // one place (it shifts between editions with different section sets). public void SelectAboutSection() => SelectedSectionIndex = 6; 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 @@ - - + +