Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions src/OpenIPC.Viewer.App/Services/IBackNavigable.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
namespace OpenIPC.Viewer.App.Services;

/// <summary>
/// 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.
/// </summary>
public interface IBackNavigable
{
/// <summary>Steps one level up inside; false when already at the top.</summary>
bool TryGoBack();
}
41 changes: 34 additions & 7 deletions src/OpenIPC.Viewer.App/Services/OverlayDialogPresenter.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Animation;
Expand All @@ -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
Expand All @@ -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<OpenDialog> Open = new();

/// <summary>True while at least one overlay (mobile modal) dialog is open.</summary>
public static bool IsAnyOpen => _activeCount > 0;
public static bool IsAnyOpen => Open.Count > 0;

/// <summary>Raised on the UI thread whenever <see cref="IsAnyOpen"/> may have changed.</summary>
public static event Action? ActiveChanged;

/// <summary>
/// System Back over the overlays: the topmost dialog first steps up its own
/// levels (<see cref="IBackNavigable"/>, 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.
/// </summary>
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();
Comment on lines +75 to +76

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

1. Discovery continues after its sheet closes 🐞 Bug ☼ Reliability

TryDismissTopmost completes only the presenter’s dismissal task, bypassing the dialog content’s
Finish method and its cancellation call. When Back closes an active discovery sheet,
DiscoveryDialogViewModel.Cancel() is never called, so its scan and fingerprint work can continue
after the sheet disappears.
Agent Prompt
## Issue description
Back removes mobile dialogs without invoking their cancellation paths, leaving background work active.
## Fix Focus Areas
- src/OpenIPC.Viewer.App/Services/OverlayDialogPresenter.cs[69-80]
- src/OpenIPC.Viewer.App/Views/Dialogs/DiscoveryDialogContent.axaml.cs[43-49]
- src/OpenIPC.Viewer.App/Views/Dialogs/CameraEditorContent.axaml.cs[75-80]
## Recommended Fix
Give the presenter a way to invoke each content’s normal cancel operation before dismissing it. Ensure dismissal still completes the awaiting command and runs cleanup exactly once.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

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).
Expand Down Expand Up @@ -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
Expand All @@ -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();
}
}
Expand Down
11 changes: 10 additions & 1 deletion src/OpenIPC.Viewer.App/ViewModels/FileManagerViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
/// </summary>
public sealed partial class FileManagerViewModel : ViewModelBase, IAsyncDisposable
public sealed partial class FileManagerViewModel : ViewModelBase, IAsyncDisposable, IBackNavigable
{
private readonly Camera _camera;
private readonly CameraDirectoryService _directory;
Expand Down Expand Up @@ -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;
Comment on lines +99 to +102

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

2. Folder back can mix directory listings 🐞 Bug ≡ Correctness

FileManagerViewModel.TryGoBack starts NavigateUpAsync without awaiting or serializing the
resulting directory load. Repeated Back presses, or Back during an existing load, let asynchronous
enumerations clear and append to the same Entries collection after RemotePath has changed.
Agent Prompt
## Issue description
Back can overlap file-manager directory loads, allowing results from different paths into one listing.
## Fix Focus Areas
- src/OpenIPC.Viewer.App/ViewModels/FileManagerViewModel.cs[96-103]
- src/OpenIPC.Viewer.App/ViewModels/FileManagerViewModel.cs[219-239]
## Recommended Fix
Cancel or version outstanding loads when navigating, and apply entries and busy state only for the current path. Prevent an older enumeration from appending results after a newer navigation starts.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

}

[RelayCommand]
private async Task OpenEntryAsync(RemoteEntryViewModel? entry)
{
Expand Down
52 changes: 51 additions & 1 deletion src/OpenIPC.Viewer.App/ViewModels/MainWindowViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand Down Expand Up @@ -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<OpenCameraMessage>(this);
WeakReferenceMessenger.Default.Register<GoBackToLibraryMessage>(this);
Expand Down Expand Up @@ -187,6 +191,52 @@ private void ExitKiosk()
UpdateFullscreen();
}

/// <summary>
/// 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).
/// </summary>
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;
Comment on lines +221 to +223

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

3. Back from an event opens the wrong page 🐞 Bug ≡ Correctness

MainWindowViewModel.TryGoBack sends GoBackToRecordingsMessage for every recording player instead
of using the page that opened it. A recording opened from Events therefore returns to Recordings on
system Back, while recordings opened from the Recordings page take the same route.
Agent Prompt
## Issue description
System Back from a recording opened in Events navigates to Recordings instead of its origin.
## Fix Focus Areas
- src/OpenIPC.Viewer.App/ViewModels/MainWindowViewModel.cs[219-225]
- src/OpenIPC.Viewer.App/ViewModels/MainWindowViewModel.cs[304-326]
## Recommended Fix
Store the originating page when opening a recording from a non-player page, preserve it across previous/next recording changes, and return to it on system Back after disposing the player.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

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))]
Expand Down
12 changes: 11 additions & 1 deletion src/OpenIPC.Viewer.App/ViewModels/RecordingsPageViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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.
Expand Down
12 changes: 11 additions & 1 deletion src/OpenIPC.Viewer.App/ViewModels/SettingsPageViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
FontSize="{StaticResource FontSizeBase}"
Foreground="{StaticResource TextPrimaryBrush}">

<Grid RowDefinitions="Auto,Auto,*,Auto" Margin="24" MinWidth="420">
<Grid RowDefinitions="Auto,Auto,*,Auto" Margin="24" MinWidth="300">

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

4. Health center still overflows narrow phones 🐞 Bug ≡ Correctness

HealthCenterContent replaces its grid’s 420-pixel minimum width with a fixed 300-pixel minimum
while retaining a 24-pixel margin on each side. On a 320-pixel-wide viewport, the grid requires at
least 348 pixels, so the sheet still cannot fit its content within the screen.
Agent Prompt
## Issue description
The Health Center grid’s minimum width plus its margins still exceeds a narrow mobile viewport.
## Fix Focus Areas
- src/OpenIPC.Viewer.App/Views/Dialogs/HealthCenterContent.axaml[14-14]
## Recommended Fix
Remove the fixed minimum width or make the grid and its margins responsive to available width. Verify that the sheet fits a 320-pixel-wide viewport.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


<!-- Header: title + refresh -->
<Grid Grid.Row="0" ColumnDefinitions="*,Auto">
Expand Down
20 changes: 19 additions & 1 deletion src/OpenIPC.Viewer.App/Views/MainView.axaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand All @@ -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;

Expand Down
4 changes: 2 additions & 2 deletions src/OpenIPC.Viewer.App/Views/Pages/RecordingPlayerPage.axaml
Original file line number Diff line number Diff line change
Expand Up @@ -343,8 +343,8 @@
</StackPanel>
</Border>

<ScrollViewer Grid.Row="2" Padding="4,0,4,6">
<ItemsControl ItemsSource="{Binding EventList}">
<ScrollViewer Grid.Row="2">
<ItemsControl ItemsSource="{Binding EventList}" Margin="4,0,4,6">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:PlayerEventRow">
<Button Classes="evrow"
Expand Down
12 changes: 8 additions & 4 deletions src/OpenIPC.Viewer.App/Views/Pages/SettingsPage.axaml
Original file line number Diff line number Diff line change
Expand Up @@ -82,9 +82,12 @@
<!-- ===== Master / list pane (column 0) ===== -->
<ScrollViewer Grid.Column="0"
IsVisible="{Binding ShowList}"
Padding="0,0,8,24"
HorizontalScrollBarVisibility="Disabled">
<StackPanel Spacing="12">
<!-- ALL inset lives on the content, never on ScrollViewer.Padding: the
padding is deducted from the viewport but never added to the extent,
so the content kept its full width and hung off the right edge, and
the last row could not be scrolled clear of the bottom. -->
<StackPanel Spacing="12" Margin="0,0,8,24">
<TextBlock Text="{Binding [Settings.Title], Source={x:Static svc:Localizer.Instance}}"
FontSize="{StaticResource FontSizeXl}"
FontWeight="SemiBold"
Expand Down Expand Up @@ -191,9 +194,10 @@
<!-- ===== Detail pane (column 1) ===== -->
<ScrollViewer Grid.Column="1"
IsVisible="{Binding ShowDetail}"
Padding="16,0,8,24"
HorizontalScrollBarVisibility="Disabled">
<StackPanel Spacing="20">
<!-- See the master pane above: every inset goes on the content, or combo
boxes and wrapped paragraphs run past the right edge on a phone. -->
<StackPanel Spacing="20" Margin="16,0,8,24">

<!-- Back link — narrow only -->
<Button Classes="back-link"
Expand Down
14 changes: 8 additions & 6 deletions src/OpenIPC.Viewer.App/Views/Pages/SingleCameraPage.axaml
Original file line number Diff line number Diff line change
Expand Up @@ -488,8 +488,10 @@
BorderThickness="1"
CornerRadius="4">
<StackPanel Spacing="6" Margin="2">
<ScrollViewer MaxHeight="260" Padding="8">
<ItemsControl ItemsSource="{Binding IspFields}">
<ScrollViewer MaxHeight="260">
<!-- Inset on the content, not ScrollViewer.Padding, which deflates the
viewport without shrinking the content and clips the right edge. -->
<ItemsControl ItemsSource="{Binding IspFields}" Margin="8">
<ItemsControl.ItemTemplate>
<DataTemplate>
<Grid ColumnDefinitions="160,*,Auto" Margin="0,1">
Expand Down Expand Up @@ -550,8 +552,8 @@
<TextBox Text="{Binding FieldFilter, Mode=TwoWay}"
PlaceholderText="{Binding [CameraPage.Majestic.Search], Source={x:Static svc:Localizer.Instance}}"
Margin="8,6,8,0" FontSize="11" />
<ScrollViewer MaxHeight="320" Padding="8">
<ItemsControl ItemsSource="{Binding ConfigSections}">
<ScrollViewer MaxHeight="320">
<ItemsControl ItemsSource="{Binding ConfigSections}" Margin="8">
<ItemsControl.ItemTemplate>
<DataTemplate>
<StackPanel Spacing="3" Margin="0,0,0,8"
Expand Down Expand Up @@ -646,8 +648,8 @@
Background="Transparent" BorderBrush="{StaticResource BorderMediumBrush}" BorderThickness="1"
CornerRadius="4" Foreground="{StaticResource TextPrimaryBrush}" />
</Grid>
<ScrollViewer MaxHeight="280" Padding="8,0,8,8">
<ItemsControl ItemsSource="{Binding Metrics}">
<ScrollViewer MaxHeight="280">
<ItemsControl ItemsSource="{Binding Metrics}" Margin="8,0,8,8">
<ItemsControl.ItemTemplate>
<DataTemplate>
<Grid ColumnDefinitions="*,Auto" Margin="0,1">
Expand Down
Loading