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 @@
-
-
+
+