diff --git a/.idea/.idea.RoadCaptain.Windows/.idea/avalonia.xml b/.idea/.idea.RoadCaptain.Windows/.idea/avalonia.xml
index 2b5b0b69..ee7f6d79 100644
--- a/.idea/.idea.RoadCaptain.Windows/.idea/avalonia.xml
+++ b/.idea/.idea.RoadCaptain.Windows/.idea/avalonia.xml
@@ -4,12 +4,15 @@
diff --git a/src/RoadCaptain.Adapters/HttpRouteRepository.cs b/src/RoadCaptain.Adapters/HttpRouteRepository.cs
index 603a6ad2..2a9d220a 100644
--- a/src/RoadCaptain.Adapters/HttpRouteRepository.cs
+++ b/src/RoadCaptain.Adapters/HttpRouteRepository.cs
@@ -209,5 +209,10 @@ public async Task StoreAsync(PlannedRoute plannedRoute, string? toke
return null;
}
}
+
+ public Task DeleteAsync(Uri routeUri)
+ {
+ throw new NotImplementedException();
+ }
}
}
diff --git a/src/RoadCaptain.Adapters/LocalDirectoryRouteRepository.cs b/src/RoadCaptain.Adapters/LocalDirectoryRouteRepository.cs
index 608540df..8682ad33 100644
--- a/src/RoadCaptain.Adapters/LocalDirectoryRouteRepository.cs
+++ b/src/RoadCaptain.Adapters/LocalDirectoryRouteRepository.cs
@@ -236,5 +236,10 @@ protected virtual Task WriteAllTextAsync(string path, string serialized)
return null;
}
}
+
+ public Task DeleteAsync(Uri routeUri)
+ {
+ throw new NotImplementedException();
+ }
}
}
\ No newline at end of file
diff --git a/src/RoadCaptain.Adapters/RebelRouteRepository.cs b/src/RoadCaptain.Adapters/RebelRouteRepository.cs
index 4da0d40d..b5409907 100644
--- a/src/RoadCaptain.Adapters/RebelRouteRepository.cs
+++ b/src/RoadCaptain.Adapters/RebelRouteRepository.cs
@@ -157,6 +157,10 @@ public Task StoreAsync(PlannedRoute plannedRoute, string? token, Lis
public string Name => "Zwift Insider - Rebel Routes";
public bool IsReadOnly => true;
+ public Task DeleteAsync(Uri routeUri)
+ {
+ throw new InvalidOperationException("Rebel Routes are baked into RoadCaptain and can't be deleted.");
+ }
private PlannedRoute? UpgradeIfNecessaryAndSerialize(string? routeModelSerialized)
{
diff --git a/src/RoadCaptain.App.RouteBuilder/DelegateDecorator.cs b/src/RoadCaptain.App.RouteBuilder/DelegateDecorator.cs
index 3d67ef0d..e1a161ff 100644
--- a/src/RoadCaptain.App.RouteBuilder/DelegateDecorator.cs
+++ b/src/RoadCaptain.App.RouteBuilder/DelegateDecorator.cs
@@ -70,6 +70,11 @@ public async Task ShowWhatIsNewDialog(Release release)
return await InvokeIfNeededAsync(() => _decorated.ShowOpenRouteDialog());
}
+ public async Task ShowQuestionDialog(string title, string message)
+ {
+ return await InvokeIfNeededAsync(() => _decorated.ShowQuestionDialog(title, message));
+ }
+
public async Task ShowDefaultSportSelectionDialog(SportType sport)
{
return await InvokeIfNeededAsync(() => _decorated.ShowDefaultSportSelectionDialog(sport));
diff --git a/src/RoadCaptain.App.RouteBuilder/DesignTimeWindowService.cs b/src/RoadCaptain.App.RouteBuilder/DesignTimeWindowService.cs
index a4102531..b1041a75 100644
--- a/src/RoadCaptain.App.RouteBuilder/DesignTimeWindowService.cs
+++ b/src/RoadCaptain.App.RouteBuilder/DesignTimeWindowService.cs
@@ -59,6 +59,11 @@ public Task ShowWhatIsNewDialog(Release release)
throw new System.NotImplementedException();
}
+ public Task ShowQuestionDialog(string title, string message)
+ {
+ throw new System.NotImplementedException();
+ }
+
public Task ShowDefaultSportSelectionDialog(SportType sport)
{
throw new System.NotImplementedException();
diff --git a/src/RoadCaptain.App.RouteBuilder/IWindowService.cs b/src/RoadCaptain.App.RouteBuilder/IWindowService.cs
index 9cdccb89..4822d525 100644
--- a/src/RoadCaptain.App.RouteBuilder/IWindowService.cs
+++ b/src/RoadCaptain.App.RouteBuilder/IWindowService.cs
@@ -23,5 +23,6 @@ public interface IWindowService : RoadCaptain.App.Shared.IWindowService
Window? GetCurrentWindow();
Task ShowSaveFileDialog(string? previousLocation, string? suggestedFileName = null);
Task<(PlannedRoute? PlannedRoute, string? RouteFilePath)> ShowOpenRouteDialog();
+ Task ShowQuestionDialog(string title, string message);
}
}
\ No newline at end of file
diff --git a/src/RoadCaptain.App.RouteBuilder/ViewModels/DesignTimeManageRoutesViewModel.cs b/src/RoadCaptain.App.RouteBuilder/ViewModels/DesignTimeManageRoutesViewModel.cs
new file mode 100644
index 00000000..ce2c41b6
--- /dev/null
+++ b/src/RoadCaptain.App.RouteBuilder/ViewModels/DesignTimeManageRoutesViewModel.cs
@@ -0,0 +1,63 @@
+using System.Collections.Immutable;
+using RoadCaptain.UseCases;
+
+namespace RoadCaptain.App.RouteBuilder.ViewModels
+{
+ public class DesignTimeManageRoutesViewModel : ManageRoutesViewModel
+ {
+ public DesignTimeManageRoutesViewModel()
+ : base(new RetrieveRepositoryNamesUseCase(new[] { new StubRouteRepository() }), null!, new DeleteRouteUseCase(new []{new StubRouteRepository()}))
+ {
+ Repositories = new[]
+ {
+ "All",
+ "Local"
+ }.ToImmutableList();
+
+ Routes = new[]
+ {
+ new RoadCaptain.App.Shared.ViewModels.RouteViewModel(new RouteModel
+ {
+ Ascent = 123,
+ Descent = 75,
+ Distance = 105,
+ CreatorName = "Joe Bloegs",
+ World = "watopia",
+ Id = 1,
+ IsLoop = false,
+ Name = "Design time route 1",
+ RepositoryName = "Local",
+ ZwiftRouteName = "ZRName1"
+ }),
+
+ new RoadCaptain.App.Shared.ViewModels.RouteViewModel(new RouteModel
+ {
+ Ascent = 13,
+ Descent = 45,
+ Distance = 45,
+ CreatorName = "Joe Blogs",
+ World = "yorkshire",
+ Id = 2,
+ IsLoop = true,
+ Name = "Design time route 2",
+ RepositoryName = "Local",
+ ZwiftRouteName = "ZRName2"
+ }),
+
+ new RoadCaptain.App.Shared.ViewModels.RouteViewModel(new RouteModel
+ {
+ Ascent = 13,
+ Descent = 45,
+ Distance = 45,
+ CreatorName = "Joe Blogs",
+ World = "makuri_islands",
+ Id = 3,
+ IsLoop = true,
+ Name = "Design time route 3",
+ RepositoryName = "Local",
+ ZwiftRouteName = "ZRName3"
+ })
+ }.ToImmutableList();
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/RoadCaptain.App.RouteBuilder/ViewModels/DesignTimeSaveRouteDialogViewModel.cs b/src/RoadCaptain.App.RouteBuilder/ViewModels/DesignTimeSaveRouteDialogViewModel.cs
index dcc6bc96..2b6914f2 100644
--- a/src/RoadCaptain.App.RouteBuilder/ViewModels/DesignTimeSaveRouteDialogViewModel.cs
+++ b/src/RoadCaptain.App.RouteBuilder/ViewModels/DesignTimeSaveRouteDialogViewModel.cs
@@ -2,6 +2,7 @@
// Licensed under Artistic License 2.0
// See LICENSE or https://choosealicense.com/licenses/artistic-2.0/
+using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Threading.Tasks;
@@ -64,5 +65,9 @@ public Task StoreAsync(PlannedRoute plannedRoute, string? token, Lis
public string Name => "Local";
public bool IsReadOnly => false;
+ public Task DeleteAsync(Uri routeUri)
+ {
+ throw new NotImplementedException();
+ }
}
}
diff --git a/src/RoadCaptain.App.RouteBuilder/ViewModels/ManageRoutesViewModel.cs b/src/RoadCaptain.App.RouteBuilder/ViewModels/ManageRoutesViewModel.cs
new file mode 100644
index 00000000..389cfa52
--- /dev/null
+++ b/src/RoadCaptain.App.RouteBuilder/ViewModels/ManageRoutesViewModel.cs
@@ -0,0 +1,108 @@
+using System;
+using System.Collections.Immutable;
+using System.Threading.Tasks;
+using ReactiveUI;
+using RoadCaptain.App.Shared.Commands;
+using RoadCaptain.App.Shared.Dialogs;
+using RoadCaptain.Commands;
+using RoadCaptain.UseCases;
+
+namespace RoadCaptain.App.RouteBuilder.ViewModels
+{
+ public class ManageRoutesViewModel : ViewModelBase
+ {
+ private ImmutableList? _repositories;
+ private string? _selectedRepository;
+ private readonly RetrieveRepositoryNamesUseCase _retrieveRepositoryNamesUseCase;
+ private ImmutableList? _routes;
+ private readonly IWindowService _windowService;
+ private readonly DeleteRouteUseCase _deleteRouteUseCase;
+
+ public ManageRoutesViewModel(RetrieveRepositoryNamesUseCase retrieveRepositoryNamesUseCase, IWindowService windowService, DeleteRouteUseCase deleteRouteUseCase)
+ {
+ _retrieveRepositoryNamesUseCase = retrieveRepositoryNamesUseCase;
+ _windowService = windowService;
+ _deleteRouteUseCase = deleteRouteUseCase;
+ }
+
+ public AsyncRelayCommand DeleteRouteCommand => new AsyncRelayCommand(
+ parameter => DeleteRouteAsync((parameter as Shared.ViewModels.RouteViewModel)!),
+ parameter => parameter is Shared.ViewModels.RouteViewModel);
+
+ private async Task DeleteRouteAsync(Shared.ViewModels.RouteViewModel parameter)
+ {
+ var result = await _windowService.ShowQuestionDialog(
+ "Delete route?",
+ $"Are you sure you want to delete the route {parameter.Name}?");
+
+ if (result == MessageBoxResult.No)
+ {
+ return CommandResult.Aborted();
+ }
+
+ try
+ {
+ await _deleteRouteUseCase.ExecuteAsync(new DeleteRouteCommand(parameter.Uri, parameter.RepositoryName));
+ }
+ catch (Exception e)
+ {
+ return CommandResult.Failure($"Failed to delete route: {e.Message}");
+ }
+
+ return CommandResult.Success();
+ }
+
+ public Task InitializeAsync()
+ {
+ Repositories = _retrieveRepositoryNamesUseCase.Execute(new RetrieveRepositoryNamesCommand(RetrieveRepositoriesIntent.Manage)).ToImmutableList();
+
+ return Task.CompletedTask;
+ }
+
+ public ImmutableList Repositories
+ {
+ get => _repositories ?? ImmutableList.Empty;
+ set
+ {
+ if (value == _repositories)
+ {
+ return;
+ }
+
+ _repositories = value;
+ this.RaisePropertyChanged();
+ }
+ }
+
+ public string? SelectedRepository
+ {
+ get => _selectedRepository;
+ set
+ {
+ if (value == _selectedRepository)
+ {
+ return;
+ }
+
+ _selectedRepository = value;
+ this.RaisePropertyChanged();
+ }
+ }
+
+ public ImmutableList Routes
+ {
+ get => _routes ?? ImmutableList.Empty;
+ set
+ {
+ if (_routes != null && value == _routes)
+ {
+ return;
+ }
+
+ _routes = value;
+
+ this.RaisePropertyChanged();
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/RoadCaptain.App.RouteBuilder/Views/ManageRoutes.axaml b/src/RoadCaptain.App.RouteBuilder/Views/ManageRoutes.axaml
new file mode 100644
index 00000000..4b1360c5
--- /dev/null
+++ b/src/RoadCaptain.App.RouteBuilder/Views/ManageRoutes.axaml
@@ -0,0 +1,206 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/RoadCaptain.App.RouteBuilder/Views/ManageRoutes.axaml.cs b/src/RoadCaptain.App.RouteBuilder/Views/ManageRoutes.axaml.cs
new file mode 100644
index 00000000..1c1a64dc
--- /dev/null
+++ b/src/RoadCaptain.App.RouteBuilder/Views/ManageRoutes.axaml.cs
@@ -0,0 +1,60 @@
+using System;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Input;
+using Avalonia.Interactivity;
+using Avalonia.Markup.Xaml;
+using Avalonia.Threading;
+using RoadCaptain.App.RouteBuilder.ViewModels;
+
+namespace RoadCaptain.App.RouteBuilder.Views
+{
+ public partial class ManageRoutes : Window
+ {
+ public ManageRoutes()
+ {
+ InitializeComponent();
+
+#if DEBUG
+ this.AttachDevTools();
+#endif
+ }
+
+ private void InitializeComponent()
+ {
+ AvaloniaXamlLoader.Load(this);
+ }
+
+ private void CloseButton_Click(object? sender, RoutedEventArgs e)
+ {
+ Close();
+ }
+
+ private void InputElement_OnPointerPressed(object? sender, PointerPressedEventArgs e)
+ {
+ if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
+ {
+ BeginMoveDrag(e);
+ }
+ }
+
+ private void WindowBase_OnActivated(object? sender, EventArgs e)
+ {
+ // Remove event handler to ensure this is only called once
+ Activated -= WindowBase_OnActivated;
+
+ if (DataContext is ManageRoutesViewModel viewModel)
+ {
+ Dispatcher.UIThread.InvokeAsync(() => viewModel.InitializeAsync());
+ }
+ }
+
+ private void RoutesListBox_OnPointerPressed(object? sender, PointerPressedEventArgs e)
+ {
+ // This prevents the situation where the PointerPressed event bubbles
+ // up to the window and initiates the window drag operation.
+ // It fixes a bug where the combo box can't be opened.
+ e.Handled = true;
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/RoadCaptain.App.RouteBuilder/WindowService.cs b/src/RoadCaptain.App.RouteBuilder/WindowService.cs
index fd005ae7..fd7ec971 100644
--- a/src/RoadCaptain.App.RouteBuilder/WindowService.cs
+++ b/src/RoadCaptain.App.RouteBuilder/WindowService.cs
@@ -178,6 +178,16 @@ public async Task ShowSaveRouteDialog(string? lastUsedFolder, RouteViewModel rou
: (null, null);
}
+ public async Task ShowQuestionDialog(string title, string message)
+ {
+ return await MessageBox.ShowAsync(
+ message,
+ title,
+ MessageBoxButton.YesNo,
+ CurrentWindow!,
+ MessageBoxIcon.Question);
+ }
+
public void ShowMainWindow(IApplicationLifetime applicationLifetime)
{
var desktopMainWindow = Resolve();
diff --git a/src/RoadCaptain.App.Shared/ViewModels/DesignTimeSelectRouteWindowViewModel.cs b/src/RoadCaptain.App.Shared/ViewModels/DesignTimeSelectRouteWindowViewModel.cs
index 278cbf93..87398923 100644
--- a/src/RoadCaptain.App.Shared/ViewModels/DesignTimeSelectRouteWindowViewModel.cs
+++ b/src/RoadCaptain.App.Shared/ViewModels/DesignTimeSelectRouteWindowViewModel.cs
@@ -2,6 +2,7 @@
// Licensed under Artistic License 2.0
// See LICENSE or https://choosealicense.com/licenses/artistic-2.0/
+using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using RoadCaptain.Ports;
@@ -123,5 +124,9 @@ public Task StoreAsync(PlannedRoute plannedRoute, string? token, Lis
public string Name => "Local";
public bool IsReadOnly => false;
+ public Task DeleteAsync(Uri routeUri)
+ {
+ throw new NotImplementedException();
+ }
}
}
diff --git a/src/RoadCaptain/Commands/DeleteRouteCommand.cs b/src/RoadCaptain/Commands/DeleteRouteCommand.cs
new file mode 100644
index 00000000..c9a3a2ad
--- /dev/null
+++ b/src/RoadCaptain/Commands/DeleteRouteCommand.cs
@@ -0,0 +1,6 @@
+using System;
+
+namespace RoadCaptain.Commands
+{
+ public record DeleteRouteCommand(Uri RouteUri, string RepositoryName);
+}
\ No newline at end of file
diff --git a/src/RoadCaptain/Commands/RetrieveRepositoriesIntent.cs b/src/RoadCaptain/Commands/RetrieveRepositoriesIntent.cs
index 8130a7e2..bced7a7c 100644
--- a/src/RoadCaptain/Commands/RetrieveRepositoriesIntent.cs
+++ b/src/RoadCaptain/Commands/RetrieveRepositoriesIntent.cs
@@ -8,6 +8,7 @@ public enum RetrieveRepositoriesIntent
{
Unknown,
Retrieve,
- Store
+ Store,
+ Manage
}
}
diff --git a/src/RoadCaptain/Ports/IRouteRepository.cs b/src/RoadCaptain/Ports/IRouteRepository.cs
index 84763ef7..cf50a63a 100644
--- a/src/RoadCaptain/Ports/IRouteRepository.cs
+++ b/src/RoadCaptain/Ports/IRouteRepository.cs
@@ -2,6 +2,7 @@
// Licensed under Artistic License 2.0
// See LICENSE or https://choosealicense.com/licenses/artistic-2.0/
+using System;
using System.Collections.Generic;
using System.Threading.Tasks;
@@ -29,5 +30,6 @@ Task SearchAsync(string? world = null,
string Name { get; }
bool IsReadOnly { get; }
+ Task DeleteAsync(Uri routeUri);
}
}
\ No newline at end of file
diff --git a/src/RoadCaptain/UseCases/DeleteRouteUseCase.cs b/src/RoadCaptain/UseCases/DeleteRouteUseCase.cs
new file mode 100644
index 00000000..7eea3805
--- /dev/null
+++ b/src/RoadCaptain/UseCases/DeleteRouteUseCase.cs
@@ -0,0 +1,33 @@
+using System;
+using System.Collections.Generic;
+using System.Collections.Immutable;
+using System.Linq;
+using System.Threading.Tasks;
+using RoadCaptain.Commands;
+using RoadCaptain.Ports;
+
+namespace RoadCaptain.UseCases
+{
+ public class DeleteRouteUseCase
+ {
+ private readonly ImmutableList _routeRepositories;
+
+ public DeleteRouteUseCase(IEnumerable routeRepositories)
+ {
+ _routeRepositories = routeRepositories.ToImmutableList();
+ }
+
+ public async Task ExecuteAsync(DeleteRouteCommand deleteRouteCommand)
+ {
+ var routeRepository = _routeRepositories.SingleOrDefault(r => r.Name == deleteRouteCommand.RepositoryName);
+
+ if (routeRepository == null)
+ {
+ throw new ArgumentException(
+ "Attempted to delete a route on a repository that I don't know about. Can't delete this route.");
+ }
+
+ await routeRepository.DeleteAsync(deleteRouteCommand.RouteUri);
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/RoadCaptain/UseCases/RetrieveRepositoryNamesUseCase.cs b/src/RoadCaptain/UseCases/RetrieveRepositoryNamesUseCase.cs
index 770c5dba..bbbe54f0 100644
--- a/src/RoadCaptain/UseCases/RetrieveRepositoryNamesUseCase.cs
+++ b/src/RoadCaptain/UseCases/RetrieveRepositoryNamesUseCase.cs
@@ -24,6 +24,7 @@ public string[] Execute(RetrieveRepositoryNamesCommand command)
var repositories = command.Intent switch
{
RetrieveRepositoriesIntent.Retrieve => new[] { "All" }.Concat(_routeRepositories.Select(r => r.Name)).ToArray(),
+ RetrieveRepositoriesIntent.Manage => new [] { "All"}.Concat(_routeRepositories.Where(r => !r.IsReadOnly).Select(r => r.Name)).ToArray(),
RetrieveRepositoriesIntent.Store => _routeRepositories.Where(r => !r.IsReadOnly).Select(r => r.Name).ToArray(),
_ => throw new ArgumentException("Invalid intent")
};
diff --git a/test/RoadCaptain.Tests.Unit/StubRepository.cs b/test/RoadCaptain.Tests.Unit/StubRepository.cs
index 79b47a60..5d29033a 100644
--- a/test/RoadCaptain.Tests.Unit/StubRepository.cs
+++ b/test/RoadCaptain.Tests.Unit/StubRepository.cs
@@ -55,5 +55,9 @@ public Task StoreAsync(PlannedRoute plannedRoute, string? token, Lis
public string Name { get; }
public bool IsReadOnly => false;
+ public Task DeleteAsync(Uri routeUri)
+ {
+ throw new NotImplementedException();
+ }
}
}