Skip to content

Commit

Permalink
feat(desktop): Add back navigation between views
Browse files Browse the repository at this point in the history
  • Loading branch information
VMelnalksnis committed May 20, 2024
1 parent 0e8338d commit 0d03c26
Show file tree
Hide file tree
Showing 7 changed files with 264 additions and 5 deletions.
13 changes: 9 additions & 4 deletions docs/changelog.html
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@

<label>Changelog:</label>
<ul>
<li style="display: none"><a href="#unreleased">Unreleased</a></li>
<li><a href="#unreleased">Unreleased</a></li>
<li><a href="#v0.8.2">v0.8.2</a></li>
<li><a href="#v0.8.1">v0.8.1</a></li>
<li><a href="#v0.8.0">v0.8.0</a></li>
Expand All @@ -49,15 +49,20 @@

<main id="top" class="content">
<h1>Changelog</h1>
<section id="unreleased" hidden="hidden">
<section id="unreleased">
<div>
<a style="display: inline" href="#unreleased"><img src="link.png" alt="link"/></a>
<h2 style="display: inline">Unreleased</h2>
</div>

<section id="unreleased_added" hidden="hidden">
<section id="unreleased_added">
<h3>Added</h3>
<ul></ul>
<ul>
<li>
Ability to navigate back to previous pages in
<a href="https://github.com/VMelnalksnis/Gnomeshade/pull/1236">#1236</a>
</li>
</ul>
</section>

<section id="unreleased_changed" hidden="hidden">
Expand Down
2 changes: 1 addition & 1 deletion docs/sitemap.xml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,6 @@
</url>
<url>
<loc>https://www.gnomeshade.org/changelog</loc>
<lastmod>2024-05-15</lastmod>
<lastmod>2024-05-20</lastmod>
</url>
</urlset>
67 changes: 67 additions & 0 deletions source/Gnomeshade.Avalonia.Core/Interactivity/HotKeyBehaviour.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
// Copyright 2021 Valters Melnalksnis
// Licensed under the GNU Affero General Public License v3.0 or later.
// See LICENSE.txt file in the project root for full license information.

using System.Windows.Input;

using Avalonia;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Xaml.Interactivity;

namespace Gnomeshade.Avalonia.Core.Interactivity;

/// <summary>Behaviour that executes a command when a hotkey has been pressed.</summary>
public sealed class HotKeyBehaviour : Trigger<Control>
{
/// <summary>Identifies the <seealso cref="TopLevel"/> avalonia property.</summary>
public static readonly DirectProperty<HotKeyBehaviour, TopLevel?> TopLevelProperty =
AvaloniaProperty.RegisterDirect<HotKeyBehaviour, TopLevel?>(
nameof(TopLevel),
behaviour => behaviour.TopLevel,
(behaviour, topLevel) => behaviour.TopLevel = topLevel);

Check warning on line 22 in source/Gnomeshade.Avalonia.Core/Interactivity/HotKeyBehaviour.cs

View check run for this annotation

Codecov / codecov/patch

source/Gnomeshade.Avalonia.Core/Interactivity/HotKeyBehaviour.cs#L18-L22

Added lines #L18 - L22 were not covered by tests

/// <summary>Identifies the <seealso cref="HotKey"/> avalonia property.</summary>
public static readonly DirectProperty<HotKeyBehaviour, KeyGesture?> HotKeyProperty =
AvaloniaProperty.RegisterDirect<HotKeyBehaviour, KeyGesture?>(
nameof(HotKey),
behaviour => behaviour.HotKey,
(behaviour, keyGesture) => behaviour.HotKey = keyGesture);

Check warning on line 29 in source/Gnomeshade.Avalonia.Core/Interactivity/HotKeyBehaviour.cs

View check run for this annotation

Codecov / codecov/patch

source/Gnomeshade.Avalonia.Core/Interactivity/HotKeyBehaviour.cs#L25-L29

Added lines #L25 - L29 were not covered by tests

/// <summary>Identifies the <seealso cref="Command"/> avalonia property.</summary>
public static readonly DirectProperty<HotKeyBehaviour, ICommand?> CommandProperty =
AvaloniaProperty.RegisterDirect<HotKeyBehaviour, ICommand?>(
nameof(Command),
behaviour => behaviour.Command,
(behaviour, command) => behaviour.Command = command);

Check warning on line 36 in source/Gnomeshade.Avalonia.Core/Interactivity/HotKeyBehaviour.cs

View check run for this annotation

Codecov / codecov/patch

source/Gnomeshade.Avalonia.Core/Interactivity/HotKeyBehaviour.cs#L32-L36

Added lines #L32 - L36 were not covered by tests

private KeyBinding? _gesture;

/// <summary>Gets or sets the <see cref="TopLevel"/> control in which to register the <see cref="HotKey"/>.</summary>
public TopLevel? TopLevel { get; set; }

Check warning on line 41 in source/Gnomeshade.Avalonia.Core/Interactivity/HotKeyBehaviour.cs

View check run for this annotation

Codecov / codecov/patch

source/Gnomeshade.Avalonia.Core/Interactivity/HotKeyBehaviour.cs#L41

Added line #L41 was not covered by tests

/// <summary>Gets or sets the <see cref="KeyGesture"/> which will trigger the <see cref="Command"/>.</summary>
public KeyGesture? HotKey { get; set; }

Check warning on line 44 in source/Gnomeshade.Avalonia.Core/Interactivity/HotKeyBehaviour.cs

View check run for this annotation

Codecov / codecov/patch

source/Gnomeshade.Avalonia.Core/Interactivity/HotKeyBehaviour.cs#L44

Added line #L44 was not covered by tests

/// <summary>Gets or sets the <see cref="ICommand"/> that will be executed when <see cref="HotKey"/> is pressed.</summary>
public ICommand? Command { get; set; }

Check warning on line 47 in source/Gnomeshade.Avalonia.Core/Interactivity/HotKeyBehaviour.cs

View check run for this annotation

Codecov / codecov/patch

source/Gnomeshade.Avalonia.Core/Interactivity/HotKeyBehaviour.cs#L47

Added line #L47 was not covered by tests

/// <inheritdoc />
protected override void OnAttachedToVisualTree()
{
if (TopLevel is { } topLevel && Command is { } command && HotKey is { } hotKey)
{
_gesture = new() { Command = command, Gesture = hotKey };
topLevel.KeyBindings.Add(_gesture);

Check warning on line 55 in source/Gnomeshade.Avalonia.Core/Interactivity/HotKeyBehaviour.cs

View check run for this annotation

Codecov / codecov/patch

source/Gnomeshade.Avalonia.Core/Interactivity/HotKeyBehaviour.cs#L54-L55

Added lines #L54 - L55 were not covered by tests
}
}

Check warning on line 57 in source/Gnomeshade.Avalonia.Core/Interactivity/HotKeyBehaviour.cs

View check run for this annotation

Codecov / codecov/patch

source/Gnomeshade.Avalonia.Core/Interactivity/HotKeyBehaviour.cs#L57

Added line #L57 was not covered by tests

/// <inheritdoc />
protected override void OnDetachedFromVisualTree()
{
if (TopLevel is { } topLevel && _gesture is { } gesture)
{
topLevel.KeyBindings.Remove(gesture);

Check warning on line 64 in source/Gnomeshade.Avalonia.Core/Interactivity/HotKeyBehaviour.cs

View check run for this annotation

Codecov / codecov/patch

source/Gnomeshade.Avalonia.Core/Interactivity/HotKeyBehaviour.cs#L64

Added line #L64 was not covered by tests
}
}

Check warning on line 66 in source/Gnomeshade.Avalonia.Core/Interactivity/HotKeyBehaviour.cs

View check run for this annotation

Codecov / codecov/patch

source/Gnomeshade.Avalonia.Core/Interactivity/HotKeyBehaviour.cs#L66

Added line #L66 was not covered by tests
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
// Copyright 2021 Valters Melnalksnis
// Licensed under the GNU Affero General Public License v3.0 or later.
// See LICENSE.txt file in the project root for full license information.

using Avalonia;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Xaml.Interactivity;

namespace Gnomeshade.Avalonia.Core.Interactivity;

/// <summary>Triggers actions based on <see cref="InputElement.PointerReleasedEvent"/> routed event.</summary>
public sealed class PointerReleasedTrigger : Trigger<InputElement>
{
/// <summary>Identifies the <seealso cref="SourceInteractive"/> avalonia property.</summary>
public static readonly StyledProperty<Interactive?> SourceInteractiveProperty =
AvaloniaProperty.Register<PointerReleasedTrigger, Interactive?>(nameof(SourceInteractive));

Check warning on line 17 in source/Gnomeshade.Avalonia.Core/Interactivity/PointerReleasedTrigger.cs

View check run for this annotation

Codecov / codecov/patch

source/Gnomeshade.Avalonia.Core/Interactivity/PointerReleasedTrigger.cs#L16-L17

Added lines #L16 - L17 were not covered by tests

/// <summary>Identifies the <seealso cref="MouseButton"/> avalonia property.</summary>
public static readonly StyledProperty<MouseButton> MouseButtonProperty =
AvaloniaProperty.Register<PointerReleasedTrigger, MouseButton>(nameof(MouseButton));

Check warning on line 21 in source/Gnomeshade.Avalonia.Core/Interactivity/PointerReleasedTrigger.cs

View check run for this annotation

Codecov / codecov/patch

source/Gnomeshade.Avalonia.Core/Interactivity/PointerReleasedTrigger.cs#L20-L21

Added lines #L20 - L21 were not covered by tests

private bool _isInitialized;

/// <summary>
/// Gets or sets the source object from which this behavior listens for events.
/// If <seealso cref="SourceInteractive"/> is not set, the source will default to <seealso cref="Behavior.AssociatedObject"/>. This is an avalonia property.
/// </summary>
public Interactive? SourceInteractive
{
get => GetValue(SourceInteractiveProperty);
set => SetValue(SourceInteractiveProperty, value);

Check warning on line 32 in source/Gnomeshade.Avalonia.Core/Interactivity/PointerReleasedTrigger.cs

View check run for this annotation

Codecov / codecov/patch

source/Gnomeshade.Avalonia.Core/Interactivity/PointerReleasedTrigger.cs#L31-L32

Added lines #L31 - L32 were not covered by tests
}

/// <summary>Gets or sets the mouse button for which the trigger will list for.</summary>
public MouseButton MouseButton
{
get => GetValue(MouseButtonProperty);
set => SetValue(MouseButtonProperty, value);

Check warning on line 39 in source/Gnomeshade.Avalonia.Core/Interactivity/PointerReleasedTrigger.cs

View check run for this annotation

Codecov / codecov/patch

source/Gnomeshade.Avalonia.Core/Interactivity/PointerReleasedTrigger.cs#L38-L39

Added lines #L38 - L39 were not covered by tests
}

private static RoutedEvent<PointerReleasedEventArgs> RoutedEvent => InputElement.PointerReleasedEvent;

Check warning on line 42 in source/Gnomeshade.Avalonia.Core/Interactivity/PointerReleasedTrigger.cs

View check run for this annotation

Codecov / codecov/patch

source/Gnomeshade.Avalonia.Core/Interactivity/PointerReleasedTrigger.cs#L42

Added line #L42 was not covered by tests

private Interactive? Interactive => SourceInteractive ?? AssociatedObject;

/// <inheritdoc />
protected override void OnAttachedToVisualTree()
{
if (Interactive is { } interactive)
{
interactive.AddHandler(RoutedEvent, Handler, RoutingStrategies.Tunnel);
_isInitialized = true;

Check warning on line 52 in source/Gnomeshade.Avalonia.Core/Interactivity/PointerReleasedTrigger.cs

View check run for this annotation

Codecov / codecov/patch

source/Gnomeshade.Avalonia.Core/Interactivity/PointerReleasedTrigger.cs#L51-L52

Added lines #L51 - L52 were not covered by tests
}
}

Check warning on line 54 in source/Gnomeshade.Avalonia.Core/Interactivity/PointerReleasedTrigger.cs

View check run for this annotation

Codecov / codecov/patch

source/Gnomeshade.Avalonia.Core/Interactivity/PointerReleasedTrigger.cs#L54

Added line #L54 was not covered by tests

/// <inheritdoc />
protected override void OnDetachedFromVisualTree()
{
if (Interactive is { } interactive && _isInitialized)
{
interactive.RemoveHandler(RoutedEvent, Handler);
_isInitialized = false;

Check warning on line 62 in source/Gnomeshade.Avalonia.Core/Interactivity/PointerReleasedTrigger.cs

View check run for this annotation

Codecov / codecov/patch

source/Gnomeshade.Avalonia.Core/Interactivity/PointerReleasedTrigger.cs#L61-L62

Added lines #L61 - L62 were not covered by tests
}
}

Check warning on line 64 in source/Gnomeshade.Avalonia.Core/Interactivity/PointerReleasedTrigger.cs

View check run for this annotation

Codecov / codecov/patch

source/Gnomeshade.Avalonia.Core/Interactivity/PointerReleasedTrigger.cs#L64

Added line #L64 was not covered by tests

private void Handler(object? sender, PointerReleasedEventArgs e)
{
if (Interactive is { } interactive && e.InitialPressMouseButton == MouseButton)
{
Interaction.ExecuteActions(interactive, Actions, e);

Check warning on line 70 in source/Gnomeshade.Avalonia.Core/Interactivity/PointerReleasedTrigger.cs

View check run for this annotation

Codecov / codecov/patch

source/Gnomeshade.Avalonia.Core/Interactivity/PointerReleasedTrigger.cs#L70

Added line #L70 was not covered by tests
}
}

Check warning on line 72 in source/Gnomeshade.Avalonia.Core/Interactivity/PointerReleasedTrigger.cs

View check run for this annotation

Codecov / codecov/patch

source/Gnomeshade.Avalonia.Core/Interactivity/PointerReleasedTrigger.cs#L72

Added line #L72 was not covered by tests
}
24 changes: 24 additions & 0 deletions source/Gnomeshade.Avalonia.Core/MainWindowViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
// See LICENSE.txt file in the project root for full license information.

using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Threading.Tasks;

Expand Down Expand Up @@ -37,6 +38,7 @@ public sealed partial class MainWindowViewModel : ViewModelBase
private readonly IServiceProvider _serviceProvider;
private readonly IDialogService _dialogService;
private readonly IOptionsMonitor<UserConfiguration> _userConfigurationMonitor;
private readonly Stack<ViewModelBase> _navigationHistory = [];

private bool _initialized;

Expand Down Expand Up @@ -75,6 +77,7 @@ public MainWindowViewModel(IServiceProvider serviceProvider)
Initialize = commandFactory.Create(InitializeActiveViewAsync, () => !_initialized, "Initializing");
About = commandFactory.Create<Window>(ShowAboutWindow, "Waiting for About window to be closed");
License = commandFactory.Create<Window>(ShowLicenseWindow, "Waiting for License window to be closed");
NavigateBack = commandFactory.Create(NavigateBackAsync, () => _navigationHistory.Count is not 0, "Navigating back");

PropertyChanging += OnPropertyChanging;
}
Expand All @@ -88,6 +91,9 @@ public MainWindowViewModel(IServiceProvider serviceProvider)
/// <summary>Gets a command for showing <see cref="LicensesViewModel"/>.</summary>
public CommandBase License { get; }

/// <summary>Gets a command for switching to the previous <see cref="ActiveView"/>.</summary>
public CommandBase NavigateBack { get; }

/// <summary>Gets a value indicating whether it's possible to log out.</summary>
public bool CanLogOut => ActiveView is not null and not LoginViewModel and not ConfigurationWizardViewModel;

Expand Down Expand Up @@ -284,6 +290,11 @@ private Task SwitchTo<TViewModel>()
return Task.CompletedTask;
}

if (ActiveView is { } activeView and not (LoginViewModel or ConfigurationWizardViewModel))
{
_navigationHistory.Push(activeView);
}

var viewModel = _serviceProvider.GetRequiredService<TViewModel>();
ActiveView = viewModel;
return viewModel.RefreshAsync();
Expand All @@ -305,6 +316,19 @@ private async Task ShowLicenseWindow(Window window)
await _dialogService.ShowDialog(window, viewModel, dialog => dialog.Title = "Licenses");
}

private async Task NavigateBackAsync()
{
var viewModel = _navigationHistory.Pop();
if (ActiveView == viewModel)
{
return;

Check warning on line 324 in source/Gnomeshade.Avalonia.Core/MainWindowViewModel.cs

View check run for this annotation

Codecov / codecov/patch

source/Gnomeshade.Avalonia.Core/MainWindowViewModel.cs#L324

Added line #L324 was not covered by tests
}

ActiveView = viewModel;
await viewModel.RefreshAsync();
NavigateBack.InvokeExecuteChanged();
}

private async void OnUserLoggedIn(object? sender, EventArgs e)
{
await SwitchToTransactionOverviewAsync();
Expand Down
10 changes: 10 additions & 0 deletions source/Gnomeshade.Desktop/Views/MainWindow.axaml
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,16 @@
<ProgressBar IsIndeterminate="{Binding IsBusy}" />
</StackPanel>
</Grid>

<Interaction.Behaviors>
<interactivity:PointerReleasedTrigger MouseButton="XButton1" SourceInteractive="{Binding $parent[Window]}">
<InvokeCommandAction Command="{Binding NavigateBack}" />

Check warning on line 130 in source/Gnomeshade.Desktop/Views/MainWindow.axaml

View check run for this annotation

Codecov / codecov/patch

source/Gnomeshade.Desktop/Views/MainWindow.axaml#L129-L130

Added lines #L129 - L130 were not covered by tests
</interactivity:PointerReleasedTrigger>
<interactivity:HotKeyBehaviour
TopLevel="{Binding $parent[TopLevel]}"

Check warning on line 133 in source/Gnomeshade.Desktop/Views/MainWindow.axaml

View check run for this annotation

Codecov / codecov/patch

source/Gnomeshade.Desktop/Views/MainWindow.axaml#L132-L133

Added lines #L132 - L133 were not covered by tests
HotKey="Alt+Left"
Command="{Binding NavigateBack}" />

Check warning on line 135 in source/Gnomeshade.Desktop/Views/MainWindow.axaml

View check run for this annotation

Codecov / codecov/patch

source/Gnomeshade.Desktop/Views/MainWindow.axaml#L135

Added line #L135 was not covered by tests
</Interaction.Behaviors>
</Panel>

<Interaction.Behaviors>
Expand Down
80 changes: 80 additions & 0 deletions tests/Gnomeshade.Avalonia.Core.Tests/MainWindowViewModelTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
// Copyright 2021 Valters Melnalksnis
// Licensed under the GNU Affero General Public License v3.0 or later.
// See LICENSE.txt file in the project root for full license information.

using System.Threading.Tasks;

using Gnomeshade.Avalonia.Core.Configuration;
using Gnomeshade.Avalonia.Core.Counterparties;
using Gnomeshade.Avalonia.Core.DesignTime;
using Gnomeshade.Avalonia.Core.Products;
using Gnomeshade.Avalonia.Core.Transactions;

namespace Gnomeshade.Avalonia.Core.Tests;

[TestOf(typeof(MainWindowViewModel))]
public sealed class MainWindowViewModelTests
{
[Test]
public async Task NavigateBack()
{
var viewModel = DesignTimeData.MainWindowViewModel;

viewModel.Initialize.Execute(null);
while (viewModel.IsBusy)
{
await Task.Delay(100);
}

using (new AssertionScope())
{
viewModel.ActiveView.Should().BeOfType<ConfigurationWizardViewModel>("configuration should not be valid");
viewModel.NavigateBack.CanExecute(null).Should().BeFalse();
}

await viewModel.SwitchToTransactionOverviewAsync();
using (new AssertionScope())
{
viewModel.ActiveView.Should().BeOfType<TransactionViewModel>();
viewModel.NavigateBack.CanExecute(null).Should().BeFalse();
}

await viewModel.SwitchToCategoriesAsync();
using (new AssertionScope())
{
viewModel.ActiveView.Should().BeOfType<CategoryViewModel>();
viewModel.NavigateBack.CanExecute(null).Should().BeTrue();
}

await viewModel.SwitchToCounterpartiesAsync();
using (new AssertionScope())
{
viewModel.ActiveView.Should().BeOfType<CounterpartyViewModel>();
viewModel.NavigateBack.CanExecute(null).Should().BeTrue();
}

viewModel.NavigateBack.Execute(null);
while (viewModel.IsBusy)
{
await Task.Delay(100);
}

using (new AssertionScope())
{
viewModel.ActiveView.Should().BeOfType<CategoryViewModel>();
viewModel.NavigateBack.CanExecute(null).Should().BeTrue();
}

viewModel.NavigateBack.Execute(null);
while (viewModel.IsBusy)
{
await Task.Delay(100);
}

using (new AssertionScope())
{
viewModel.NavigateBack.CanExecute(null).Should().BeFalse();
viewModel.ActiveView.Should().BeOfType<TransactionViewModel>();
}
}
}

0 comments on commit 0d03c26

Please sign in to comment.