diff --git a/docs/changelog.html b/docs/changelog.html
index a6d1b37d..8093cd88 100644
--- a/docs/changelog.html
+++ b/docs/changelog.html
@@ -27,7 +27,7 @@
- - Unreleased
+ - Unreleased
- v0.8.2
- v0.8.1
- v0.8.0
@@ -49,15 +49,20 @@
Changelog
-
+
Unreleased
-
+
Added
-
+
+ -
+ Ability to navigate back to previous pages in
+ #1236
+
+
diff --git a/docs/sitemap.xml b/docs/sitemap.xml
index edb5bfca..301fe0ad 100644
--- a/docs/sitemap.xml
+++ b/docs/sitemap.xml
@@ -14,6 +14,6 @@
https://www.gnomeshade.org/changelog
- 2024-05-15
+ 2024-05-20
diff --git a/source/Gnomeshade.Avalonia.Core/Interactivity/HotKeyBehaviour.cs b/source/Gnomeshade.Avalonia.Core/Interactivity/HotKeyBehaviour.cs
new file mode 100644
index 00000000..24eced33
--- /dev/null
+++ b/source/Gnomeshade.Avalonia.Core/Interactivity/HotKeyBehaviour.cs
@@ -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;
+
+/// Behaviour that executes a command when a hotkey has been pressed.
+public sealed class HotKeyBehaviour : Trigger
+{
+ /// Identifies the avalonia property.
+ public static readonly DirectProperty TopLevelProperty =
+ AvaloniaProperty.RegisterDirect(
+ nameof(TopLevel),
+ behaviour => behaviour.TopLevel,
+ (behaviour, topLevel) => behaviour.TopLevel = topLevel);
+
+ /// Identifies the avalonia property.
+ public static readonly DirectProperty HotKeyProperty =
+ AvaloniaProperty.RegisterDirect(
+ nameof(HotKey),
+ behaviour => behaviour.HotKey,
+ (behaviour, keyGesture) => behaviour.HotKey = keyGesture);
+
+ /// Identifies the avalonia property.
+ public static readonly DirectProperty CommandProperty =
+ AvaloniaProperty.RegisterDirect(
+ nameof(Command),
+ behaviour => behaviour.Command,
+ (behaviour, command) => behaviour.Command = command);
+
+ private KeyBinding? _gesture;
+
+ /// Gets or sets the control in which to register the .
+ public TopLevel? TopLevel { get; set; }
+
+ /// Gets or sets the which will trigger the .
+ public KeyGesture? HotKey { get; set; }
+
+ /// Gets or sets the that will be executed when is pressed.
+ public ICommand? Command { get; set; }
+
+ ///
+ protected override void OnAttachedToVisualTree()
+ {
+ if (TopLevel is { } topLevel && Command is { } command && HotKey is { } hotKey)
+ {
+ _gesture = new() { Command = command, Gesture = hotKey };
+ topLevel.KeyBindings.Add(_gesture);
+ }
+ }
+
+ ///
+ protected override void OnDetachedFromVisualTree()
+ {
+ if (TopLevel is { } topLevel && _gesture is { } gesture)
+ {
+ topLevel.KeyBindings.Remove(gesture);
+ }
+ }
+}
diff --git a/source/Gnomeshade.Avalonia.Core/Interactivity/PointerReleasedTrigger.cs b/source/Gnomeshade.Avalonia.Core/Interactivity/PointerReleasedTrigger.cs
new file mode 100644
index 00000000..2a60ee41
--- /dev/null
+++ b/source/Gnomeshade.Avalonia.Core/Interactivity/PointerReleasedTrigger.cs
@@ -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;
+
+/// Triggers actions based on routed event.
+public sealed class PointerReleasedTrigger : Trigger
+{
+ /// Identifies the avalonia property.
+ public static readonly StyledProperty SourceInteractiveProperty =
+ AvaloniaProperty.Register(nameof(SourceInteractive));
+
+ /// Identifies the avalonia property.
+ public static readonly StyledProperty MouseButtonProperty =
+ AvaloniaProperty.Register(nameof(MouseButton));
+
+ private bool _isInitialized;
+
+ ///
+ /// Gets or sets the source object from which this behavior listens for events.
+ /// If is not set, the source will default to . This is an avalonia property.
+ ///
+ public Interactive? SourceInteractive
+ {
+ get => GetValue(SourceInteractiveProperty);
+ set => SetValue(SourceInteractiveProperty, value);
+ }
+
+ /// Gets or sets the mouse button for which the trigger will list for.
+ public MouseButton MouseButton
+ {
+ get => GetValue(MouseButtonProperty);
+ set => SetValue(MouseButtonProperty, value);
+ }
+
+ private static RoutedEvent RoutedEvent => InputElement.PointerReleasedEvent;
+
+ private Interactive? Interactive => SourceInteractive ?? AssociatedObject;
+
+ ///
+ protected override void OnAttachedToVisualTree()
+ {
+ if (Interactive is { } interactive)
+ {
+ interactive.AddHandler(RoutedEvent, Handler, RoutingStrategies.Tunnel);
+ _isInitialized = true;
+ }
+ }
+
+ ///
+ protected override void OnDetachedFromVisualTree()
+ {
+ if (Interactive is { } interactive && _isInitialized)
+ {
+ interactive.RemoveHandler(RoutedEvent, Handler);
+ _isInitialized = false;
+ }
+ }
+
+ private void Handler(object? sender, PointerReleasedEventArgs e)
+ {
+ if (Interactive is { } interactive && e.InitialPressMouseButton == MouseButton)
+ {
+ Interaction.ExecuteActions(interactive, Actions, e);
+ }
+ }
+}
diff --git a/source/Gnomeshade.Avalonia.Core/MainWindowViewModel.cs b/source/Gnomeshade.Avalonia.Core/MainWindowViewModel.cs
index 2679706d..50e48a32 100644
--- a/source/Gnomeshade.Avalonia.Core/MainWindowViewModel.cs
+++ b/source/Gnomeshade.Avalonia.Core/MainWindowViewModel.cs
@@ -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;
@@ -37,6 +38,7 @@ public sealed partial class MainWindowViewModel : ViewModelBase
private readonly IServiceProvider _serviceProvider;
private readonly IDialogService _dialogService;
private readonly IOptionsMonitor _userConfigurationMonitor;
+ private readonly Stack _navigationHistory = [];
private bool _initialized;
@@ -75,6 +77,7 @@ public MainWindowViewModel(IServiceProvider serviceProvider)
Initialize = commandFactory.Create(InitializeActiveViewAsync, () => !_initialized, "Initializing");
About = commandFactory.Create(ShowAboutWindow, "Waiting for About window to be closed");
License = commandFactory.Create(ShowLicenseWindow, "Waiting for License window to be closed");
+ NavigateBack = commandFactory.Create(NavigateBackAsync, () => _navigationHistory.Count is not 0, "Navigating back");
PropertyChanging += OnPropertyChanging;
}
@@ -88,6 +91,9 @@ public MainWindowViewModel(IServiceProvider serviceProvider)
/// Gets a command for showing .
public CommandBase License { get; }
+ /// Gets a command for switching to the previous .
+ public CommandBase NavigateBack { get; }
+
/// Gets a value indicating whether it's possible to log out.
public bool CanLogOut => ActiveView is not null and not LoginViewModel and not ConfigurationWizardViewModel;
@@ -284,6 +290,11 @@ private Task SwitchTo()
return Task.CompletedTask;
}
+ if (ActiveView is { } activeView and not (LoginViewModel or ConfigurationWizardViewModel))
+ {
+ _navigationHistory.Push(activeView);
+ }
+
var viewModel = _serviceProvider.GetRequiredService();
ActiveView = viewModel;
return viewModel.RefreshAsync();
@@ -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;
+ }
+
+ ActiveView = viewModel;
+ await viewModel.RefreshAsync();
+ NavigateBack.InvokeExecuteChanged();
+ }
+
private async void OnUserLoggedIn(object? sender, EventArgs e)
{
await SwitchToTransactionOverviewAsync();
diff --git a/source/Gnomeshade.Desktop/Views/MainWindow.axaml b/source/Gnomeshade.Desktop/Views/MainWindow.axaml
index 8db2ca54..cee13e5c 100644
--- a/source/Gnomeshade.Desktop/Views/MainWindow.axaml
+++ b/source/Gnomeshade.Desktop/Views/MainWindow.axaml
@@ -124,6 +124,16 @@
+
+
+
+
+
+
+
diff --git a/tests/Gnomeshade.Avalonia.Core.Tests/MainWindowViewModelTests.cs b/tests/Gnomeshade.Avalonia.Core.Tests/MainWindowViewModelTests.cs
new file mode 100644
index 00000000..f1b72633
--- /dev/null
+++ b/tests/Gnomeshade.Avalonia.Core.Tests/MainWindowViewModelTests.cs
@@ -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("configuration should not be valid");
+ viewModel.NavigateBack.CanExecute(null).Should().BeFalse();
+ }
+
+ await viewModel.SwitchToTransactionOverviewAsync();
+ using (new AssertionScope())
+ {
+ viewModel.ActiveView.Should().BeOfType();
+ viewModel.NavigateBack.CanExecute(null).Should().BeFalse();
+ }
+
+ await viewModel.SwitchToCategoriesAsync();
+ using (new AssertionScope())
+ {
+ viewModel.ActiveView.Should().BeOfType();
+ viewModel.NavigateBack.CanExecute(null).Should().BeTrue();
+ }
+
+ await viewModel.SwitchToCounterpartiesAsync();
+ using (new AssertionScope())
+ {
+ viewModel.ActiveView.Should().BeOfType();
+ viewModel.NavigateBack.CanExecute(null).Should().BeTrue();
+ }
+
+ viewModel.NavigateBack.Execute(null);
+ while (viewModel.IsBusy)
+ {
+ await Task.Delay(100);
+ }
+
+ using (new AssertionScope())
+ {
+ viewModel.ActiveView.Should().BeOfType();
+ 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();
+ }
+ }
+}