From 7eccdf079ad893f090903decc1b4b02af2b3b1a6 Mon Sep 17 00:00:00 2001 From: Robert Date: Mon, 31 Jul 2023 22:20:25 +0200 Subject: [PATCH 01/37] UI - Move notifications to the overlay layer --- .../Controls/NotificationHost.cs | 54 +++++++++++++++++++ .../Services/Builders/NotificationBuilder.cs | 46 ++++++++-------- src/Artemis.UI.Shared/Styles/InfoBar.axaml | 2 + .../Styles/Notifications.axaml | 9 ++-- src/Artemis.UI/MainWindow.axaml | 1 - .../Screens/Debugger/DebugView.axaml | 3 -- .../Screens/Device/DevicePropertiesView.axaml | 2 - .../Plugins/PluginSettingsWindowView.axaml | 2 - 8 files changed, 84 insertions(+), 35 deletions(-) create mode 100644 src/Artemis.UI.Shared/Controls/NotificationHost.cs diff --git a/src/Artemis.UI.Shared/Controls/NotificationHost.cs b/src/Artemis.UI.Shared/Controls/NotificationHost.cs new file mode 100644 index 000000000..4dce59804 --- /dev/null +++ b/src/Artemis.UI.Shared/Controls/NotificationHost.cs @@ -0,0 +1,54 @@ +using System; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Layout; + +namespace Artemis.UI.Shared; + +internal class NotificationHost : ContentControl +{ + private IDisposable? _rootBoundsWatcher; + + public NotificationHost() + { + Background = null; + HorizontalAlignment = HorizontalAlignment.Center; + VerticalAlignment = VerticalAlignment.Center; + } + + protected override Type StyleKeyOverride => typeof(OverlayPopupHost); + + protected override Size MeasureOverride(Size availableSize) + { + _ = base.MeasureOverride(availableSize); + + if (VisualRoot is TopLevel tl) + return tl.ClientSize; + if (VisualRoot is Control c) + return c.Bounds.Size; + + return default; + } + + protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnAttachedToVisualTree(e); + if (e.Root is Control wb) + // OverlayLayer is a Canvas, so we won't get a signal to resize if the window + // bounds change. Subscribe to force update + _rootBoundsWatcher = wb.GetObservable(BoundsProperty).Subscribe(_ => OnRootBoundsChanged()); + } + + protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnDetachedFromVisualTree(e); + _rootBoundsWatcher?.Dispose(); + _rootBoundsWatcher = null; + } + + private void OnRootBoundsChanged() + { + InvalidateMeasure(); + } +} \ No newline at end of file diff --git a/src/Artemis.UI.Shared/Services/Builders/NotificationBuilder.cs b/src/Artemis.UI.Shared/Services/Builders/NotificationBuilder.cs index 5dfe0ecb9..805c5cbd0 100644 --- a/src/Artemis.UI.Shared/Services/Builders/NotificationBuilder.cs +++ b/src/Artemis.UI.Shared/Services/Builders/NotificationBuilder.cs @@ -2,11 +2,11 @@ using System.Threading.Tasks; using System.Windows.Input; using Avalonia.Controls; +using Avalonia.Controls.Primitives; using Avalonia.Layout; using Avalonia.Threading; using FluentAvalonia.UI.Controls; using ReactiveUI; -using Button = Avalonia.Controls.Button; namespace Artemis.UI.Shared.Services.Builders; @@ -117,34 +117,34 @@ public class NotificationBuilder /// public Action Show() { - Panel? panel = _parent.Find("NotificationContainer"); - if (panel == null) - throw new ArtemisSharedUIException("Can't display a notification on a window without a NotificationContainer."); - Dispatcher.UIThread.Post(() => { - panel.Children.Add(_infoBar); + OverlayLayer? overlayLayer = OverlayLayer.GetOverlayLayer(_parent); + if (overlayLayer == null) + throw new ArtemisSharedUIException("Can't display a notification on a window an overlay layer."); + + NotificationHost container = new() {Content = _infoBar}; + overlayLayer.Children.Add(container); _infoBar.Closed += InfoBarOnClosed; _infoBar.IsOpen = true; - }); - Task.Run(async () => - { - await Task.Delay(_timeout); - Dispatcher.UIThread.Post(() => _infoBar.IsOpen = false); + Dispatcher.UIThread.InvokeAsync(async () => + { + await Task.Delay(_timeout); + _infoBar.IsOpen = false; + }); + + return; + + void InfoBarOnClosed(InfoBar sender, InfoBarClosedEventArgs args) + { + overlayLayer.Children.Remove(container); + _infoBar.Closed -= InfoBarOnClosed; + } }); return () => Dispatcher.UIThread.Post(() => _infoBar.IsOpen = false); } - - private void InfoBarOnClosed(InfoBar sender, InfoBarClosedEventArgs args) - { - _infoBar.Closed -= InfoBarOnClosed; - if (_parent.Content is not Panel panel) - return; - - panel.Children.Remove(_infoBar); - } } /// @@ -180,7 +180,7 @@ public class NotificationButtonBuilder _action = action; return this; } - + /// /// Changes action that is called when the button is clicked. /// @@ -222,9 +222,13 @@ public class NotificationButtonBuilder button.Classes.Add("AppBarButton"); if (_action != null) + { button.Command = ReactiveCommand.Create(() => _action()); + } else if (_asyncAction != null) + { button.Command = ReactiveCommand.CreateFromTask(() => _asyncAction()); + } else if (_command != null) { button.Command = _command; diff --git a/src/Artemis.UI.Shared/Styles/InfoBar.axaml b/src/Artemis.UI.Shared/Styles/InfoBar.axaml index 3f9b4a9bc..7b6f7f62c 100644 --- a/src/Artemis.UI.Shared/Styles/InfoBar.axaml +++ b/src/Artemis.UI.Shared/Styles/InfoBar.axaml @@ -17,6 +17,8 @@ + + diff --git a/src/Artemis.UI.Shared/Styles/Notifications.axaml b/src/Artemis.UI.Shared/Styles/Notifications.axaml index 0e9553ca2..94bc9039c 100644 --- a/src/Artemis.UI.Shared/Styles/Notifications.axaml +++ b/src/Artemis.UI.Shared/Styles/Notifications.axaml @@ -1,17 +1,14 @@  + xmlns:controls="clr-namespace:FluentAvalonia.UI.Controls;assembly=FluentAvalonia" + xmlns:shared="clr-namespace:Artemis.UI.Shared"> - - - + + + + Submission type + + + Please select the type of content you want to submit to the workshop. + + + + + Profile + A profile which others can install to enjoy new lighting and interactions. + + + + + + + Layout + A layout providing users with a visual representation of a device. + + + + + diff --git a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/EntryTypeView.axaml.cs b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/EntryTypeView.axaml.cs new file mode 100644 index 000000000..d50e2ea6e --- /dev/null +++ b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/EntryTypeView.axaml.cs @@ -0,0 +1,17 @@ +using Avalonia.Markup.Xaml; +using Avalonia.ReactiveUI; + +namespace Artemis.UI.Screens.Workshop.SubmissionWizard.Steps; + +public partial class EntryTypeView : ReactiveUserControl +{ + public EntryTypeView() + { + InitializeComponent(); + } + + private void InitializeComponent() + { + AvaloniaXamlLoader.Load(this); + } +} \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/EntryTypeViewModel.cs b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/EntryTypeViewModel.cs new file mode 100644 index 000000000..dda445838 --- /dev/null +++ b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/EntryTypeViewModel.cs @@ -0,0 +1,41 @@ +using System; +using System.Reactive; +using System.Reactive.Linq; +using Artemis.WebClient.Workshop; +using ReactiveUI; + +namespace Artemis.UI.Screens.Workshop.SubmissionWizard.Steps; + +public class EntryTypeViewModel : SubmissionViewModel +{ + private EntryType? _selectedEntryType; + + /// + public EntryTypeViewModel() + { + GoBack = ReactiveCommand.Create(() => State.ChangeScreen()); + Continue = ReactiveCommand.Create(ExecuteContinue, this.WhenAnyValue(vm => vm.SelectedEntryType).Select(e => e != null)); + } + + public EntryType? SelectedEntryType + { + get => _selectedEntryType; + set => RaiseAndSetIfChanged(ref _selectedEntryType, value); + } + + /// + public override ReactiveCommand Continue { get; } + + /// + public override ReactiveCommand GoBack { get; } + + private void ExecuteContinue() + { + if (SelectedEntryType == null) + return; + + State.EntryType = SelectedEntryType.Value; + if (State.EntryType == EntryType.Profile) + State.ChangeScreen(); + } +} \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/LoginStepView.axaml b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/LoginStepView.axaml new file mode 100644 index 000000000..019886066 --- /dev/null +++ b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/LoginStepView.axaml @@ -0,0 +1,33 @@ + + + + + + + + First things first + + In order to submit anything to the workshop you must be logged in. + + Creating an account is free and we'll not bother you with a newsletter or crap like that. + + + + + + Click continue to (create an account) and log in. + + You'll also be able to log in with Google or Discord. + + + diff --git a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/LoginStepView.axaml.cs b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/LoginStepView.axaml.cs new file mode 100644 index 000000000..c8b574eee --- /dev/null +++ b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/LoginStepView.axaml.cs @@ -0,0 +1,17 @@ +using Avalonia.Markup.Xaml; +using Avalonia.ReactiveUI; + +namespace Artemis.UI.Screens.Workshop.SubmissionWizard.Steps; + +public partial class LoginStepView : ReactiveUserControl +{ + public LoginStepView() + { + InitializeComponent(); + } + + private void InitializeComponent() + { + AvaloniaXamlLoader.Load(this); + } +} \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/LoginStepViewModel.cs b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/LoginStepViewModel.cs new file mode 100644 index 000000000..dfad8f7af --- /dev/null +++ b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/LoginStepViewModel.cs @@ -0,0 +1,50 @@ +using System; +using System.Linq; +using System.Reactive; +using System.Security.Claims; +using System.Threading; +using System.Threading.Tasks; +using Artemis.UI.Screens.Workshop.CurrentUser; +using Artemis.UI.Shared.Services; +using Artemis.WebClient.Workshop.Services; +using FluentAvalonia.UI.Controls; +using IdentityModel; +using ReactiveUI; + +namespace Artemis.UI.Screens.Workshop.SubmissionWizard.Steps; + +public class LoginStepViewModel : SubmissionViewModel +{ + private readonly IAuthenticationService _authenticationService; + private readonly IWindowService _windowService; + + public LoginStepViewModel(IAuthenticationService authenticationService, IWindowService windowService) + { + _authenticationService = authenticationService; + _windowService = windowService; + + Continue = ReactiveCommand.CreateFromTask(ExecuteLogin); + ShowGoBack = false; + ShowHeader = false; + ContinueText = "Log In"; + } + + /// + public override ReactiveCommand Continue { get; } + + /// + public override ReactiveCommand GoBack { get; } = null!; + + private async Task ExecuteLogin(CancellationToken ct) + { + ContentDialogResult result = await _windowService.CreateContentDialog().WithViewModel(out WorkshopLoginViewModel _).WithTitle("Workshop login").ShowAsync(); + if (result != ContentDialogResult.Primary) + return; + + Claim? emailVerified = _authenticationService.Claims.FirstOrDefault(c => c.Type == JwtClaimTypes.EmailVerified); + if (emailVerified?.Value == "true") + State.ChangeScreen(); + else + State.ChangeScreen(); + } +} \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/ProfileSelectionStepView.axaml b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/ProfileSelectionStepView.axaml new file mode 100644 index 000000000..ceb0a3469 --- /dev/null +++ b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/ProfileSelectionStepView.axaml @@ -0,0 +1,10 @@ + + Welcome to Avalonia! + diff --git a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/ProfileSelectionStepView.axaml.cs b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/ProfileSelectionStepView.axaml.cs new file mode 100644 index 000000000..37923b020 --- /dev/null +++ b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/ProfileSelectionStepView.axaml.cs @@ -0,0 +1,18 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Markup.Xaml; + +namespace Artemis.UI.Screens.Workshop.SubmissionWizard.Steps; + +public partial class ProfileSelectionStepView : UserControl +{ + public ProfileSelectionStepView() + { + InitializeComponent(); + } + + private void InitializeComponent() + { + AvaloniaXamlLoader.Load(this); + } +} \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/ProfileSelectionStepViewModel.cs b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/ProfileSelectionStepViewModel.cs new file mode 100644 index 000000000..3a258bb66 --- /dev/null +++ b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/ProfileSelectionStepViewModel.cs @@ -0,0 +1,13 @@ +using System.Reactive; +using ReactiveUI; + +namespace Artemis.UI.Screens.Workshop.SubmissionWizard.Steps; + +public class ProfileSelectionStepViewModel : SubmissionViewModel +{ + /// + public override ReactiveCommand Continue { get; } + + /// + public override ReactiveCommand GoBack { get; } +} \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/ValidateEmailStepView.axaml b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/ValidateEmailStepView.axaml new file mode 100644 index 000000000..4fc2fe76c --- /dev/null +++ b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/ValidateEmailStepView.axaml @@ -0,0 +1,41 @@ + + + + + + + + Confirm email address + + Before you can continue, please confirm your email address. () + + You'll find the confirmation mail in your inbox. + + + Don't see an email? Check your spam box! + + + + + + + + PS: We take this step to avoid the workshop getting spammed with low quality content. + + + diff --git a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/ValidateEmailStepView.axaml.cs b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/ValidateEmailStepView.axaml.cs new file mode 100644 index 000000000..1d9f32be8 --- /dev/null +++ b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/ValidateEmailStepView.axaml.cs @@ -0,0 +1,17 @@ +using Avalonia.Markup.Xaml; +using Avalonia.ReactiveUI; + +namespace Artemis.UI.Screens.Workshop.SubmissionWizard.Steps; + +public partial class ValidateEmailStepView : ReactiveUserControl +{ + public ValidateEmailStepView() + { + InitializeComponent(); + } + + private void InitializeComponent() + { + AvaloniaXamlLoader.Load(this); + } +} \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/ValidateEmailStepViewModel.cs b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/ValidateEmailStepViewModel.cs new file mode 100644 index 000000000..cc90adb22 --- /dev/null +++ b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/ValidateEmailStepViewModel.cs @@ -0,0 +1,78 @@ +using System; +using System.Linq; +using System.Reactive; +using System.Reactive.Disposables; +using System.Security.Claims; +using System.Threading; +using System.Threading.Tasks; +using Artemis.WebClient.Workshop.Services; +using IdentityModel; +using ReactiveUI; +using Timer = System.Timers.Timer; + +namespace Artemis.UI.Screens.Workshop.SubmissionWizard.Steps; + +public class ValidateEmailStepViewModel : SubmissionViewModel +{ + private readonly IAuthenticationService _authenticationService; + private ObservableAsPropertyHelper? _email; + + public ValidateEmailStepViewModel(IAuthenticationService authenticationService) + { + _authenticationService = authenticationService; + + Continue = ReactiveCommand.Create(ExecuteContinue); + Refresh = ReactiveCommand.CreateFromTask(ExecuteRefresh); + ShowGoBack = false; + ShowHeader = false; + + this.WhenActivated(d => + { + _email = authenticationService.GetClaim(JwtClaimTypes.Email).ToProperty(this, vm => vm.Email).DisposeWith(d); + + Timer updateTimer = new(TimeSpan.FromSeconds(15)); + updateTimer.Elapsed += (_, _) => Task.Run(Update); + updateTimer.Start(); + + updateTimer.DisposeWith(d); + }); + } + + /// + public override ReactiveCommand Continue { get; } + + /// + public override ReactiveCommand GoBack { get; } = null!; + + public ReactiveCommand Refresh { get; } + + public Claim? Email => _email?.Value; + + private async Task Update() + { + try + { + // Use the refresh token to login again, updating claims + await _authenticationService.AutoLogin(true); + + Claim? emailVerified = _authenticationService.Claims.FirstOrDefault(c => c.Type == JwtClaimTypes.EmailVerified); + if (emailVerified?.Value == "true") + ExecuteContinue(); + } + catch (Exception) + { + // ignored, meh + } + } + + private void ExecuteContinue() + { + State.ChangeScreen(); + } + + private async Task ExecuteRefresh(CancellationToken ct) + { + await Update(); + await Task.Delay(1000, ct); + } +} \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/WelcomeStepView.axaml b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/WelcomeStepView.axaml index 6e273c4af..7bf5c4dd8 100644 --- a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/WelcomeStepView.axaml +++ b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/WelcomeStepView.axaml @@ -3,50 +3,17 @@ xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:steps="clr-namespace:Artemis.UI.Screens.Workshop.SubmissionWizard.Steps" - xmlns:avalonia="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia" - mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" + mc:Ignorable="d" d:DesignWidth="970" d:DesignHeight="625" x:Class="Artemis.UI.Screens.Workshop.SubmissionWizard.Steps.WelcomeStepView" x:DataType="steps:WelcomeStepViewModel"> - - Welcome to the Workshop Submission Wizard 🧙 - Here we'll take you, step by step, through the process of uploading your submission to the workshop. + + + Welcome to the Workshop Submission Wizard 🧙 + + + Here we'll take you, step by step, through the process of uploading your submission to the workshop. + - - First things first - - In order to submit anything to the workshop you must be logged in. - - - Creating an account is free and we'll not bother you with a newsletter or crap like that. You can also log in with Google or Discord. - - - - - - - Confirm email address - - Before you can continue, please confirm your email address. () - - You'll find the confirmation mail in your inbox. - - - Don't see an email? Check your spam box! - - - - - PS: We take this step to avoid the workshop getting flooded with bogus content. - + \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/WelcomeStepViewModel.cs b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/WelcomeStepViewModel.cs index 47d59e6e0..caa55733c 100644 --- a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/WelcomeStepViewModel.cs +++ b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/WelcomeStepViewModel.cs @@ -1,50 +1,24 @@ using System; +using System.Linq; using System.Reactive; -using System.Reactive.Disposables; -using System.Security.Claims; -using System.Threading; using System.Threading.Tasks; -using Artemis.UI.Screens.Workshop.CurrentUser; -using Artemis.UI.Shared.Services; using Artemis.WebClient.Workshop.Services; using IdentityModel; using ReactiveUI; -using Timer = System.Timers.Timer; namespace Artemis.UI.Screens.Workshop.SubmissionWizard.Steps; public class WelcomeStepViewModel : SubmissionViewModel { private readonly IAuthenticationService _authenticationService; - private readonly ObservableAsPropertyHelper _showMissingVerification; - private readonly IWindowService _windowService; - private ObservableAsPropertyHelper? _email; - private ObservableAsPropertyHelper? _emailVerified; - private ObservableAsPropertyHelper? _isLoggedIn; - public WelcomeStepViewModel(IAuthenticationService authenticationService, IWindowService windowService) + public WelcomeStepViewModel(IAuthenticationService authenticationService) { _authenticationService = authenticationService; - _windowService = windowService; - _showMissingVerification = this.WhenAnyValue(vm => vm.IsLoggedIn, vm => vm.EmailVerified, (l, v) => l && (v == null || v.Value == "false")).ToProperty(this, vm => vm.ShowMissingVerification); + Continue = ReactiveCommand.CreateFromTask(ExecuteContinue); + ShowHeader = false; ShowGoBack = false; - Continue = ReactiveCommand.Create(ExecuteContinue); - Login = ReactiveCommand.CreateFromTask(ExecuteLogin); - Refresh = ReactiveCommand.CreateFromTask(ExecuteRefresh); - - this.WhenActivated(d => - { - _isLoggedIn = authenticationService.IsLoggedIn.ToProperty(this, vm => vm.IsLoggedIn).DisposeWith(d); - _emailVerified = authenticationService.GetClaim(JwtClaimTypes.EmailVerified).ToProperty(this, vm => vm.EmailVerified).DisposeWith(d); - _email = authenticationService.GetClaim(JwtClaimTypes.Email).ToProperty(this, vm => vm.Email).DisposeWith(d); - - Timer updateTimer = new(TimeSpan.FromSeconds(15)); - updateTimer.Elapsed += (_, _) => Task.Run(Update); - updateTimer.Start(); - - updateTimer.DisposeWith(d); - }); } /// @@ -53,43 +27,20 @@ public class WelcomeStepViewModel : SubmissionViewModel /// public override ReactiveCommand GoBack { get; } = null!; - public ReactiveCommand Login { get; } - public ReactiveCommand Refresh { get; } - - public bool ShowMissingVerification => _showMissingVerification.Value; - public bool IsLoggedIn => _isLoggedIn?.Value ?? false; - public Claim? EmailVerified => _emailVerified?.Value; - public Claim? Email => _email?.Value; - - private async Task Update() + private async Task ExecuteContinue() { - if (EmailVerified?.Value == "true") - return; + bool loggedIn = await _authenticationService.AutoLogin(true); - try + if (!loggedIn) { - // Use the refresh token to login again, updating claims - await _authenticationService.AutoLogin(true); + State.ChangeScreen(); } - catch (Exception) + else { - // ignored, meh + if (_authenticationService.Claims.Any(c => c.Type == JwtClaimTypes.EmailVerified && c.Value == "true")) + State.ChangeScreen(); + else + State.ChangeScreen(); } } - - private void ExecuteContinue() - { - throw new NotImplementedException(); - } - - private async Task ExecuteLogin(CancellationToken ct) - { - await _windowService.CreateContentDialog().WithViewModel(out WorkshopLoginViewModel _).WithTitle("Workshop login").ShowAsync(); - } - - private async Task ExecuteRefresh(CancellationToken ct) - { - await Update(); - await Task.Delay(1000, ct); - } } \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/SubmissionViewModel.cs b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/SubmissionViewModel.cs new file mode 100644 index 000000000..d2ba60da1 --- /dev/null +++ b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/SubmissionViewModel.cs @@ -0,0 +1,43 @@ +using System.Reactive; +using Artemis.UI.Shared; +using ReactiveUI; + +namespace Artemis.UI.Screens.Workshop.SubmissionWizard; + +public abstract class SubmissionViewModel : ActivatableViewModelBase +{ + private string _continueText = "Continue"; + private bool _showFinish; + private bool _showGoBack = true; + private bool _showHeader = true; + + public SubmissionWizardViewModel WizardViewModel { get; set; } = null!; + public SubmissionWizardState State { get; set; } = null!; + + public abstract ReactiveCommand Continue { get; } + public abstract ReactiveCommand GoBack { get; } + + public bool ShowHeader + { + get => _showHeader; + set => RaiseAndSetIfChanged(ref _showHeader, value); + } + + public bool ShowGoBack + { + get => _showGoBack; + set => RaiseAndSetIfChanged(ref _showGoBack, value); + } + + public bool ShowFinish + { + get => _showFinish; + set => RaiseAndSetIfChanged(ref _showFinish, value); + } + + public string ContinueText + { + get => _continueText; + set => RaiseAndSetIfChanged(ref _continueText, value); + } +} \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/SubmissionWizardState.cs b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/SubmissionWizardState.cs new file mode 100644 index 000000000..19256ba56 --- /dev/null +++ b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/SubmissionWizardState.cs @@ -0,0 +1,34 @@ +using System.Collections.Generic; +using System.IO; +using Artemis.WebClient.Workshop; +using DryIoc; + +namespace Artemis.UI.Screens.Workshop.SubmissionWizard; + +public class SubmissionWizardState +{ + private readonly SubmissionWizardViewModel _wizardViewModel; + private readonly IContainer _container; + + public SubmissionWizardState(SubmissionWizardViewModel wizardViewModel, IContainer container) + { + _wizardViewModel = wizardViewModel; + _container = container; + } + + public EntryType EntryType { get; set; } + + public string Name { get; set; } = string.Empty; + public Stream? Icon { get; set; } + public string Summary { get; set; } = string.Empty; + public string Description { get; set; } = string.Empty; + + public List Categories { get; set; } = new(); + public List Tags { get; set; } = new(); + public List Images { get; set; } = new(); + + public void ChangeScreen() where TSubmissionViewModel : SubmissionViewModel + { + _wizardViewModel.Screen = _container.Resolve(); + } +} \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/SubmissionWizardView.axaml b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/SubmissionWizardView.axaml index 18315e3fc..055860af1 100644 --- a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/SubmissionWizardView.axaml +++ b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/SubmissionWizardView.axaml @@ -17,7 +17,8 @@ - + + @@ -44,9 +45,7 @@ - + diff --git a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/SubmissionWizardViewModel.cs b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/SubmissionWizardViewModel.cs index 857aea286..0f3c5dc56 100644 --- a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/SubmissionWizardViewModel.cs +++ b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/SubmissionWizardViewModel.cs @@ -1,19 +1,24 @@ -using System.Reactive; -using Artemis.UI.Screens.Workshop.CurrentUser; +using Artemis.UI.Screens.Workshop.CurrentUser; using Artemis.UI.Screens.Workshop.SubmissionWizard.Steps; using Artemis.UI.Shared; -using ReactiveUI; +using DryIoc; namespace Artemis.UI.Screens.Workshop.SubmissionWizard; public class SubmissionWizardViewModel : DialogViewModelBase { + private readonly SubmissionWizardState _state; private SubmissionViewModel _screen; - public SubmissionWizardViewModel(CurrentUserViewModel currentUserViewModel, WelcomeStepViewModel welcomeStepViewModel) + public SubmissionWizardViewModel(IContainer container, CurrentUserViewModel currentUserViewModel, WelcomeStepViewModel welcomeStepViewModel) { + _state = new SubmissionWizardState(this, container); _screen = welcomeStepViewModel; + _screen.WizardViewModel = this; + _screen.State = _state; + CurrentUserViewModel = currentUserViewModel; + CurrentUserViewModel.AllowLogout = false; } public CurrentUserViewModel CurrentUserViewModel { get; } @@ -21,27 +26,11 @@ public class SubmissionWizardViewModel : DialogViewModelBase public SubmissionViewModel Screen { get => _screen; - set => RaiseAndSetIfChanged(ref _screen, value); - } -} - -public abstract class SubmissionViewModel : ActivatableViewModelBase -{ - private bool _showFinish; - private bool _showGoBack = true; - - public abstract ReactiveCommand Continue { get; } - public abstract ReactiveCommand GoBack { get; } - - public bool ShowGoBack - { - get => _showGoBack; - set => RaiseAndSetIfChanged(ref _showGoBack, value); - } - - public bool ShowFinish - { - get => _showFinish; - set => RaiseAndSetIfChanged(ref _showFinish, value); + set + { + value.WizardViewModel = this; + value.State = _state; + RaiseAndSetIfChanged(ref _screen, value); + } } } \ No newline at end of file diff --git a/src/Artemis.WebClient.Workshop/Services/AuthenticationService.cs b/src/Artemis.WebClient.Workshop/Services/AuthenticationService.cs index a9a7ac379..b159031c1 100644 --- a/src/Artemis.WebClient.Workshop/Services/AuthenticationService.cs +++ b/src/Artemis.WebClient.Workshop/Services/AuthenticationService.cs @@ -79,14 +79,14 @@ internal class AuthenticationService : CorePropertyChanged, IAuthenticationServi if (response.IsError) { - if (response.Error == OidcConstants.TokenErrors.ExpiredToken) + if (response.Error is OidcConstants.TokenErrors.ExpiredToken or OidcConstants.TokenErrors.InvalidGrant) return false; throw new ArtemisWebClientException("Failed to request refresh token: " + response.Error); } SetCurrentUser(response); - return false; + return true; } private static byte[] HashSha256(string inputString) diff --git a/src/Artemis.WebClient.Workshop/WorkshopConstants.cs b/src/Artemis.WebClient.Workshop/WorkshopConstants.cs index b514ae862..27ee76421 100644 --- a/src/Artemis.WebClient.Workshop/WorkshopConstants.cs +++ b/src/Artemis.WebClient.Workshop/WorkshopConstants.cs @@ -2,6 +2,6 @@ namespace Artemis.WebClient.Workshop; public static class WorkshopConstants { - public const string AUTHORITY_URL = "https://identity.artemis-rgb.com"; + public const string AUTHORITY_URL = "https://localhost:5001"; public const string WORKSHOP_URL = "https://workshop.artemis-rgb.com"; } \ No newline at end of file From d2b8123a307e645773bb373a2467e73aab8607e2 Mon Sep 17 00:00:00 2001 From: Robert Date: Thu, 10 Aug 2023 11:54:37 +0200 Subject: [PATCH 06/37] Core - Reworked profile render override for the editor and new previewer --- src/Artemis.Core/Models/Profile/Profile.cs | 30 +- .../ProfileConfiguration.cs | 13 +- .../Storage/Interfaces/IProfileService.cs | 19 +- .../Services/Storage/ProfileService.cs | 326 ++++++++++-------- .../Entities/Profile/ProfileEntity.cs | 1 - .../ProfileEditor/ProfileEditorService.cs | 61 ++-- src/Artemis.UI.Shared/Styles/TextBlock.axaml | 39 ++- src/Artemis.UI/Artemis.UI.csproj | 8 + .../Panels/MenuBar/MenuBarViewModel.cs | 3 +- .../VisualEditor/VisualEditorView.axaml.cs | 10 +- .../Settings/Updating/ReleaseView.axaml.cs | 7 - .../ProfileConfigurationEditViewModel.cs | 12 +- .../Sidebar/SidebarCategoryViewModel.cs | 14 +- .../SidebarProfileConfigurationViewModel.cs | 2 +- .../Categories/CategoriesView.axaml.cs | 5 - .../CurrentUser/WorkshopLoginView.axaml.cs | 6 - .../Workshop/Entries/EntryListView.axaml.cs | 8 - .../Workshop/Home/WorkshopHomeView.axaml.cs | 8 - .../Layout/LayoutDetailsView.axaml.cs | 6 - .../Workshop/Layout/LayoutListView.axaml.cs | 6 - .../Profile/ProfileDetailsView.axaml.cs | 6 - .../Workshop/Profile/ProfileListView.axaml.cs | 6 - .../Workshop/Profile/ProfilePreviewView.axaml | 58 ++++ .../Profile/ProfilePreviewView.axaml.cs | 79 +++++ .../Profile/ProfilePreviewViewModel.cs | 55 +++ .../Workshop/Search/SearchView.axaml.cs | 7 - .../Steps/EntryTypeView.axaml.cs | 6 - .../Steps/EntryTypeViewModel.cs | 2 +- .../Steps/LoginStepView.axaml.cs | 6 - .../ProfileAdaptionHintsLayerViewModel.cs | 51 +++ .../ProfileAdaptionHintsStepView.axaml | 67 ++++ .../ProfileAdaptionHintsStepView.axaml.cs | 11 + .../ProfileAdaptionHintsStepViewModel.cs | 66 ++++ .../Profile/ProfileSelectionStepView.axaml | 56 +++ .../Profile/ProfileSelectionStepView.axaml.cs | 11 + .../Profile/ProfileSelectionStepViewModel.cs | 83 +++++ .../Steps/ProfileSelectionStepView.axaml | 10 - .../Steps/ProfileSelectionStepView.axaml.cs | 18 - .../Steps/ProfileSelectionStepViewModel.cs | 13 - .../Steps/ValidateEmailStepView.axaml.cs | 6 - .../Steps/WelcomeStepView.axaml.cs | 6 - .../SubmissionWizard/SubmissionWizardState.cs | 4 +- 42 files changed, 812 insertions(+), 399 deletions(-) create mode 100644 src/Artemis.UI/Screens/Workshop/Profile/ProfilePreviewView.axaml create mode 100644 src/Artemis.UI/Screens/Workshop/Profile/ProfilePreviewView.axaml.cs create mode 100644 src/Artemis.UI/Screens/Workshop/Profile/ProfilePreviewViewModel.cs create mode 100644 src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/Profile/ProfileAdaptionHintsLayerViewModel.cs create mode 100644 src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/Profile/ProfileAdaptionHintsStepView.axaml create mode 100644 src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/Profile/ProfileAdaptionHintsStepView.axaml.cs create mode 100644 src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/Profile/ProfileAdaptionHintsStepViewModel.cs create mode 100644 src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/Profile/ProfileSelectionStepView.axaml create mode 100644 src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/Profile/ProfileSelectionStepView.axaml.cs create mode 100644 src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/Profile/ProfileSelectionStepViewModel.cs delete mode 100644 src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/ProfileSelectionStepView.axaml delete mode 100644 src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/ProfileSelectionStepView.axaml.cs delete mode 100644 src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/ProfileSelectionStepViewModel.cs diff --git a/src/Artemis.Core/Models/Profile/Profile.cs b/src/Artemis.Core/Models/Profile/Profile.cs index c511f9ec9..2a0d96a23 100644 --- a/src/Artemis.Core/Models/Profile/Profile.cs +++ b/src/Artemis.Core/Models/Profile/Profile.cs @@ -17,13 +17,12 @@ public sealed class Profile : ProfileElement private readonly ObservableCollection _scriptConfigurations; private readonly ObservableCollection _scripts; private bool _isFreshImport; - private ProfileElement? _lastSelectedProfileElement; internal Profile(ProfileConfiguration configuration, ProfileEntity profileEntity) : base(null!) { _scripts = new ObservableCollection(); _scriptConfigurations = new ObservableCollection(); - + Opacity = 0d; ShouldDisplay = true; Configuration = configuration; @@ -67,15 +66,6 @@ public sealed class Profile : ProfileElement set => SetAndNotify(ref _isFreshImport, value); } - /// - /// Gets or sets the last selected profile element of this profile - /// - public ProfileElement? LastSelectedProfileElement - { - get => _lastSelectedProfileElement; - set => SetAndNotify(ref _lastSelectedProfileElement, value); - } - /// /// Gets the profile entity this profile uses for persistent storage /// @@ -105,7 +95,7 @@ public sealed class Profile : ProfileElement profileScript.OnProfileUpdated(deltaTime); const double OPACITY_PER_SECOND = 1; - + if (ShouldDisplay && Opacity < 1) Opacity = Math.Clamp(Opacity + OPACITY_PER_SECOND * deltaTime, 0d, 1d); if (!ShouldDisplay && Opacity > 0) @@ -123,14 +113,14 @@ public sealed class Profile : ProfileElement foreach (ProfileScript profileScript in Scripts) profileScript.OnProfileRendering(canvas, canvas.LocalClipBounds); - + SKPaint? opacityPaint = null; bool applyOpacityLayer = Configuration.FadeInAndOut && Opacity < 1; - + if (applyOpacityLayer) { opacityPaint = new SKPaint(); - opacityPaint.Color = new SKColor(0, 0, 0, (byte)(255d * Easings.CubicEaseInOut(Opacity))); + opacityPaint.Color = new SKColor(0, 0, 0, (byte) (255d * Easings.CubicEaseInOut(Opacity))); canvas.SaveLayer(opacityPaint); } @@ -242,20 +232,13 @@ public sealed class Profile : ProfileElement AddChild(new Folder(this, this, rootFolder)); } - List renderElements = GetAllRenderElements(); - - if (ProfileEntity.LastSelectedProfileElement != Guid.Empty) - LastSelectedProfileElement = renderElements.FirstOrDefault(f => f.EntityId == ProfileEntity.LastSelectedProfileElement); - else - LastSelectedProfileElement = null; - while (_scriptConfigurations.Any()) RemoveScriptConfiguration(_scriptConfigurations[0]); foreach (ScriptConfiguration scriptConfiguration in ProfileEntity.ScriptConfigurations.Select(e => new ScriptConfiguration(e))) AddScriptConfiguration(scriptConfiguration); // Load node scripts last since they may rely on the profile structure being in place - foreach (RenderProfileElement renderProfileElement in renderElements) + foreach (RenderProfileElement renderProfileElement in GetAllRenderElements()) renderProfileElement.LoadNodeScript(); } @@ -312,7 +295,6 @@ public sealed class Profile : ProfileElement ProfileEntity.Id = EntityId; ProfileEntity.Name = Configuration.Name; ProfileEntity.IsFreshImport = IsFreshImport; - ProfileEntity.LastSelectedProfileElement = LastSelectedProfileElement?.EntityId ?? Guid.Empty; foreach (ProfileElement profileElement in Children) profileElement.Save(); diff --git a/src/Artemis.Core/Models/ProfileConfiguration/ProfileConfiguration.cs b/src/Artemis.Core/Models/ProfileConfiguration/ProfileConfiguration.cs index 06a2090d3..b0dec1d29 100644 --- a/src/Artemis.Core/Models/ProfileConfiguration/ProfileConfiguration.cs +++ b/src/Artemis.Core/Models/ProfileConfiguration/ProfileConfiguration.cs @@ -147,16 +147,7 @@ public class ProfileConfiguration : BreakableModel, IStorageModel, IDisposable get => _activationConditionMet; private set => SetAndNotify(ref _activationConditionMet, value); } - - /// - /// Gets or sets a boolean indicating whether this profile configuration is being edited - /// - public bool IsBeingEdited - { - get => _isBeingEdited; - set => SetAndNotify(ref _isBeingEdited, value); - } - + /// /// Gets the profile of this profile configuration /// @@ -243,8 +234,6 @@ public class ProfileConfiguration : BreakableModel, IStorageModel, IDisposable { if (_disposed) throw new ObjectDisposedException("ProfileConfiguration"); - if (IsBeingEdited) - return true; if (Category.IsSuspended || IsSuspended || IsMissingModule) return false; diff --git a/src/Artemis.Core/Services/Storage/Interfaces/IProfileService.cs b/src/Artemis.Core/Services/Storage/Interfaces/IProfileService.cs index 98e0bc322..4d2907af4 100644 --- a/src/Artemis.Core/Services/Storage/Interfaces/IProfileService.cs +++ b/src/Artemis.Core/Services/Storage/Interfaces/IProfileService.cs @@ -26,19 +26,26 @@ public interface IProfileService : IArtemisService ReadOnlyCollection ProfileConfigurations { get; } /// - /// Gets or sets a boolean indicating whether hotkeys are enabled. + /// Gets or sets the focused profile configuration which is rendered exclusively. /// - bool HotkeysEnabled { get; set; } + ProfileConfiguration? FocusProfile { get; set; } /// - /// Gets or sets a boolean indicating whether rendering should only be done for profiles being edited. + /// Gets or sets the profile element which is rendered exclusively. /// - bool RenderForEditor { get; set; } + ProfileElement? FocusProfileElement { get; set; } /// - /// Gets or sets the profile element to focus on while rendering for the editor. + /// Gets or sets a value indicating whether the currently focused profile should receive updates. /// - ProfileElement? EditorFocus { get; set; } + bool UpdateFocusProfile { get; set; } + + /// + /// Creates a copy of the provided profile configuration. + /// + /// The profile configuration to clone. + /// The resulting clone. + ProfileConfiguration CloneProfileConfiguration(ProfileConfiguration profileConfiguration); /// /// Activates the profile of the given with the currently active surface. diff --git a/src/Artemis.Core/Services/Storage/ProfileService.cs b/src/Artemis.Core/Services/Storage/ProfileService.cs index ba1f496e7..103c1c2f1 100644 --- a/src/Artemis.Core/Services/Storage/ProfileService.cs +++ b/src/Artemis.Core/Services/Storage/ProfileService.cs @@ -15,14 +15,14 @@ namespace Artemis.Core.Services; internal class ProfileService : IProfileService { private readonly ILogger _logger; + private readonly IRgbService _rgbService; + private readonly IProfileCategoryRepository _profileCategoryRepository; + private readonly IPluginManagementService _pluginManagementService; private readonly List _pendingKeyboardEvents = new(); - private readonly IPluginManagementService _pluginManagementService; private readonly List _profileCategories; - private readonly IProfileCategoryRepository _profileCategoryRepository; private readonly IProfileRepository _profileRepository; private readonly List _renderExceptions = new(); - private readonly IRgbService _rgbService; private readonly List _updateExceptions = new(); private DateTime _lastRenderExceptionLog; private DateTime _lastUpdateExceptionLog; @@ -45,7 +45,6 @@ internal class ProfileService : IProfileService _pluginManagementService.PluginFeatureEnabled += PluginManagementServiceOnPluginFeatureToggled; _pluginManagementService.PluginFeatureDisabled += PluginManagementServiceOnPluginFeatureToggled; - HotkeysEnabled = true; inputService.KeyboardKeyUp += InputServiceOnKeyboardKeyUp; if (!_profileCategories.Any()) @@ -53,140 +52,21 @@ internal class ProfileService : IProfileService UpdateModules(); } - private void InputServiceOnKeyboardKeyUp(object? sender, ArtemisKeyboardKeyEventArgs e) - { - if (!HotkeysEnabled) - return; - - lock (_profileCategories) - { - _pendingKeyboardEvents.Add(e); - } - } - - /// - /// Populates all missing LEDs on all currently active profiles - /// - private void ActiveProfilesPopulateLeds() - { - foreach (ProfileConfiguration profileConfiguration in ProfileConfigurations) - { - if (profileConfiguration.Profile == null) continue; - profileConfiguration.Profile.PopulateLeds(_rgbService.EnabledDevices); - - if (!profileConfiguration.Profile.IsFreshImport) continue; - _logger.Debug("Profile is a fresh import, adapting to surface - {profile}", profileConfiguration.Profile); - AdaptProfile(profileConfiguration.Profile); - } - } - - private void UpdateModules() - { - lock (_profileRepository) - { - List modules = _pluginManagementService.GetFeaturesOfType(); - foreach (ProfileCategory profileCategory in _profileCategories) - { - foreach (ProfileConfiguration profileConfiguration in profileCategory.ProfileConfigurations) - profileConfiguration.LoadModules(modules); - } - } - } - - private void RgbServiceOnLedsChanged(object? sender, EventArgs e) - { - ActiveProfilesPopulateLeds(); - } - - private void PluginManagementServiceOnPluginFeatureToggled(object? sender, PluginFeatureEventArgs e) - { - if (e.PluginFeature is Module) - UpdateModules(); - } - - private void ProcessPendingKeyEvents(ProfileConfiguration profileConfiguration) - { - if (profileConfiguration.HotkeyMode == ProfileConfigurationHotkeyMode.None) - return; - - bool before = profileConfiguration.IsSuspended; - foreach (ArtemisKeyboardKeyEventArgs e in _pendingKeyboardEvents) - { - if (profileConfiguration.HotkeyMode == ProfileConfigurationHotkeyMode.Toggle) - { - if (profileConfiguration.EnableHotkey != null && profileConfiguration.EnableHotkey.MatchesEventArgs(e)) - profileConfiguration.IsSuspended = !profileConfiguration.IsSuspended; - } - else - { - if (profileConfiguration.IsSuspended && profileConfiguration.EnableHotkey != null && profileConfiguration.EnableHotkey.MatchesEventArgs(e)) - profileConfiguration.IsSuspended = false; - else if (!profileConfiguration.IsSuspended && profileConfiguration.DisableHotkey != null && profileConfiguration.DisableHotkey.MatchesEventArgs(e)) - profileConfiguration.IsSuspended = true; - } - } - - // If suspension was changed, save the category - if (before != profileConfiguration.IsSuspended) - SaveProfileCategory(profileConfiguration.Category); - } - - private void CreateDefaultProfileCategories() - { - foreach (DefaultCategoryName defaultCategoryName in Enum.GetValues()) - CreateProfileCategory(defaultCategoryName.ToString()); - } - - private void LogProfileUpdateExceptions() - { - // Only log update exceptions every 10 seconds to avoid spamming the logs - if (DateTime.Now - _lastUpdateExceptionLog < TimeSpan.FromSeconds(10)) - return; - _lastUpdateExceptionLog = DateTime.Now; - - if (!_updateExceptions.Any()) - return; - - // Group by stack trace, that should gather up duplicate exceptions - foreach (IGrouping exceptions in _updateExceptions.GroupBy(e => e.StackTrace)) - { - _logger.Warning(exceptions.First(), - "Exception was thrown {count} times during profile update in the last 10 seconds", - exceptions.Count()); - } - - // When logging is finished start with a fresh slate - _updateExceptions.Clear(); - } - - private void LogProfileRenderExceptions() - { - // Only log update exceptions every 10 seconds to avoid spamming the logs - if (DateTime.Now - _lastRenderExceptionLog < TimeSpan.FromSeconds(10)) - return; - _lastRenderExceptionLog = DateTime.Now; - - if (!_renderExceptions.Any()) - return; - - // Group by stack trace, that should gather up duplicate exceptions - foreach (IGrouping exceptions in _renderExceptions.GroupBy(e => e.StackTrace)) - { - _logger.Warning(exceptions.First(), - "Exception was thrown {count} times during profile render in the last 10 seconds", - exceptions.Count()); - } - - // When logging is finished start with a fresh slate - _renderExceptions.Clear(); - } - - public bool HotkeysEnabled { get; set; } - public bool RenderForEditor { get; set; } - public ProfileElement? EditorFocus { get; set; } + public ProfileConfiguration? FocusProfile { get; set; } + public ProfileElement? FocusProfileElement { get; set; } + public bool UpdateFocusProfile { get; set; } + /// public void UpdateProfiles(double deltaTime) { + // If there is a focus profile update only that, and only if UpdateFocusProfile is true + if (FocusProfile != null) + { + if (UpdateFocusProfile) + FocusProfile.Profile?.Update(deltaTime); + return; + } + lock (_profileCategories) { // Iterate the children in reverse because the first category must be rendered last to end up on top @@ -200,16 +80,11 @@ internal class ProfileService : IProfileService // Process hotkeys that where pressed since this profile last updated ProcessPendingKeyEvents(profileConfiguration); - // Profiles being edited are updated at their own leisure - if (profileConfiguration.IsBeingEdited && RenderForEditor) - continue; - bool shouldBeActive = profileConfiguration.ShouldBeActive(false); if (shouldBeActive) { profileConfiguration.Update(); - if (!profileConfiguration.IsBeingEdited) - shouldBeActive = profileConfiguration.ActivationConditionMet; + shouldBeActive = profileConfiguration.ActivationConditionMet; } try @@ -243,20 +118,18 @@ internal class ProfileService : IProfileService } } + /// public void RenderProfiles(SKCanvas canvas) { + // If there is a focus profile, render only that + if (FocusProfile != null) + { + FocusProfile.Profile?.Render(canvas, SKPointI.Empty, FocusProfileElement); + return; + } + lock (_profileCategories) { - ProfileConfiguration? editedProfileConfiguration = _profileCategories.SelectMany(c => c.ProfileConfigurations).FirstOrDefault(p => p.IsBeingEdited); - if (editedProfileConfiguration != null) - { - editedProfileConfiguration.Profile?.Render(canvas, SKPointI.Empty, RenderForEditor ? EditorFocus : null); - return; - } - - if (RenderForEditor) - return; - // Iterate the children in reverse because the first category must be rendered last to end up on top for (int i = _profileCategories.Count - 1; i > -1; i--) { @@ -282,6 +155,7 @@ internal class ProfileService : IProfileService } } + /// public ReadOnlyCollection ProfileCategories { get @@ -293,6 +167,7 @@ internal class ProfileService : IProfileService } } + /// public ReadOnlyCollection ProfileConfigurations { get @@ -304,6 +179,7 @@ internal class ProfileService : IProfileService } } + /// public void LoadProfileConfigurationIcon(ProfileConfiguration profileConfiguration) { if (profileConfiguration.Icon.IconType == ProfileConfigurationIconType.MaterialIcon) @@ -314,6 +190,7 @@ internal class ProfileService : IProfileService profileConfiguration.Icon.SetIconByStream(profileConfiguration.Entity.IconOriginalFileName, stream); } + /// public void SaveProfileConfigurationIcon(ProfileConfiguration profileConfiguration) { if (profileConfiguration.Icon.IconType == ProfileConfigurationIconType.MaterialIcon) @@ -327,6 +204,13 @@ internal class ProfileService : IProfileService } } + /// + public ProfileConfiguration CloneProfileConfiguration(ProfileConfiguration profileConfiguration) + { + return new ProfileConfiguration(profileConfiguration.Category, profileConfiguration.Entity); + } + + /// public Profile ActivateProfile(ProfileConfiguration profileConfiguration) { if (profileConfiguration.Profile != null) @@ -364,9 +248,10 @@ internal class ProfileService : IProfileService return profile; } + /// public void DeactivateProfile(ProfileConfiguration profileConfiguration) { - if (profileConfiguration.IsBeingEdited) + if (FocusProfile == profileConfiguration) throw new ArtemisCoreException("Cannot disable a profile that is being edited, that's rude"); if (profileConfiguration.Profile == null) return; @@ -378,9 +263,10 @@ internal class ProfileService : IProfileService OnProfileDeactivated(new ProfileConfigurationEventArgs(profileConfiguration)); } + /// public void RequestDeactivation(ProfileConfiguration profileConfiguration) { - if (profileConfiguration.IsBeingEdited) + if (FocusProfile == profileConfiguration) throw new ArtemisCoreException("Cannot disable a profile that is being edited, that's rude"); if (profileConfiguration.Profile == null) return; @@ -388,6 +274,7 @@ internal class ProfileService : IProfileService profileConfiguration.Profile.ShouldDisplay = false; } + /// public void DeleteProfile(ProfileConfiguration profileConfiguration) { DeactivateProfile(profileConfiguration); @@ -401,6 +288,7 @@ internal class ProfileService : IProfileService SaveProfileCategory(profileConfiguration.Category); } + /// public ProfileCategory CreateProfileCategory(string name) { ProfileCategory profileCategory; @@ -415,6 +303,7 @@ internal class ProfileService : IProfileService return profileCategory; } + /// public void DeleteProfileCategory(ProfileCategory profileCategory) { List profileConfigurations = profileCategory.ProfileConfigurations.ToList(); @@ -430,6 +319,7 @@ internal class ProfileService : IProfileService OnProfileCategoryRemoved(new ProfileCategoryEventArgs(profileCategory)); } + /// public ProfileConfiguration CreateProfileConfiguration(ProfileCategory category, string name, string icon) { ProfileConfiguration configuration = new(category, name, icon); @@ -441,6 +331,7 @@ internal class ProfileService : IProfileService return configuration; } + /// public void RemoveProfileConfiguration(ProfileConfiguration profileConfiguration) { profileConfiguration.Category.RemoveProfileConfiguration(profileConfiguration); @@ -454,6 +345,7 @@ internal class ProfileService : IProfileService profileConfiguration.Dispose(); } + /// public void SaveProfileCategory(ProfileCategory profileCategory) { profileCategory.Save(); @@ -465,6 +357,7 @@ internal class ProfileService : IProfileService } } + /// public void SaveProfile(Profile profile, bool includeChildren) { _logger.Debug("Updating profile - Saving {Profile}", profile); @@ -480,6 +373,7 @@ internal class ProfileService : IProfileService _profileRepository.Save(profile.ProfileEntity); } + /// public ProfileConfigurationExportModel ExportProfile(ProfileConfiguration profileConfiguration) { // The profile may not be active and in that case lets activate it real quick @@ -493,6 +387,7 @@ internal class ProfileService : IProfileService }; } + /// public ProfileConfiguration ImportProfile(ProfileCategory category, ProfileConfigurationExportModel exportModel, bool makeUnique, bool markAsFreshImport, string? nameAffix) { @@ -565,6 +460,131 @@ internal class ProfileService : IProfileService _profileRepository.Save(profile.ProfileEntity); } + private void InputServiceOnKeyboardKeyUp(object? sender, ArtemisKeyboardKeyEventArgs e) + { + lock (_profileCategories) + { + _pendingKeyboardEvents.Add(e); + } + } + + /// + /// Populates all missing LEDs on all currently active profiles + /// + private void ActiveProfilesPopulateLeds() + { + foreach (ProfileConfiguration profileConfiguration in ProfileConfigurations) + { + if (profileConfiguration.Profile == null) continue; + profileConfiguration.Profile.PopulateLeds(_rgbService.EnabledDevices); + + if (!profileConfiguration.Profile.IsFreshImport) continue; + _logger.Debug("Profile is a fresh import, adapting to surface - {profile}", profileConfiguration.Profile); + AdaptProfile(profileConfiguration.Profile); + } + } + + private void UpdateModules() + { + lock (_profileRepository) + { + List modules = _pluginManagementService.GetFeaturesOfType(); + foreach (ProfileCategory profileCategory in _profileCategories) + { + foreach (ProfileConfiguration profileConfiguration in profileCategory.ProfileConfigurations) + profileConfiguration.LoadModules(modules); + } + } + } + + private void RgbServiceOnLedsChanged(object? sender, EventArgs e) + { + ActiveProfilesPopulateLeds(); + } + + private void PluginManagementServiceOnPluginFeatureToggled(object? sender, PluginFeatureEventArgs e) + { + if (e.PluginFeature is Module) + UpdateModules(); + } + + private void ProcessPendingKeyEvents(ProfileConfiguration profileConfiguration) + { + if (profileConfiguration.HotkeyMode == ProfileConfigurationHotkeyMode.None) + return; + + bool before = profileConfiguration.IsSuspended; + foreach (ArtemisKeyboardKeyEventArgs e in _pendingKeyboardEvents) + { + if (profileConfiguration.HotkeyMode == ProfileConfigurationHotkeyMode.Toggle) + { + if (profileConfiguration.EnableHotkey != null && profileConfiguration.EnableHotkey.MatchesEventArgs(e)) + profileConfiguration.IsSuspended = !profileConfiguration.IsSuspended; + } + else + { + if (profileConfiguration.IsSuspended && profileConfiguration.EnableHotkey != null && profileConfiguration.EnableHotkey.MatchesEventArgs(e)) + profileConfiguration.IsSuspended = false; + else if (!profileConfiguration.IsSuspended && profileConfiguration.DisableHotkey != null && profileConfiguration.DisableHotkey.MatchesEventArgs(e)) + profileConfiguration.IsSuspended = true; + } + } + + // If suspension was changed, save the category + if (before != profileConfiguration.IsSuspended) + SaveProfileCategory(profileConfiguration.Category); + } + + private void CreateDefaultProfileCategories() + { + foreach (DefaultCategoryName defaultCategoryName in Enum.GetValues()) + CreateProfileCategory(defaultCategoryName.ToString()); + } + + private void LogProfileUpdateExceptions() + { + // Only log update exceptions every 10 seconds to avoid spamming the logs + if (DateTime.Now - _lastUpdateExceptionLog < TimeSpan.FromSeconds(10)) + return; + _lastUpdateExceptionLog = DateTime.Now; + + if (!_updateExceptions.Any()) + return; + + // Group by stack trace, that should gather up duplicate exceptions + foreach (IGrouping exceptions in _updateExceptions.GroupBy(e => e.StackTrace)) + { + _logger.Warning(exceptions.First(), + "Exception was thrown {count} times during profile update in the last 10 seconds", + exceptions.Count()); + } + + // When logging is finished start with a fresh slate + _updateExceptions.Clear(); + } + + private void LogProfileRenderExceptions() + { + // Only log update exceptions every 10 seconds to avoid spamming the logs + if (DateTime.Now - _lastRenderExceptionLog < TimeSpan.FromSeconds(10)) + return; + _lastRenderExceptionLog = DateTime.Now; + + if (!_renderExceptions.Any()) + return; + + // Group by stack trace, that should gather up duplicate exceptions + foreach (IGrouping exceptions in _renderExceptions.GroupBy(e => e.StackTrace)) + { + _logger.Warning(exceptions.First(), + "Exception was thrown {count} times during profile render in the last 10 seconds", + exceptions.Count()); + } + + // When logging is finished start with a fresh slate + _renderExceptions.Clear(); + } + #region Events public event EventHandler? ProfileActivated; diff --git a/src/Artemis.Storage/Entities/Profile/ProfileEntity.cs b/src/Artemis.Storage/Entities/Profile/ProfileEntity.cs index 8a8616ce7..da5bbb903 100644 --- a/src/Artemis.Storage/Entities/Profile/ProfileEntity.cs +++ b/src/Artemis.Storage/Entities/Profile/ProfileEntity.cs @@ -18,7 +18,6 @@ public class ProfileEntity public string Name { get; set; } public bool IsFreshImport { get; set; } - public Guid LastSelectedProfileElement { get; set; } public List Folders { get; set; } public List Layers { get; set; } diff --git a/src/Artemis.UI.Shared/Services/ProfileEditor/ProfileEditorService.cs b/src/Artemis.UI.Shared/Services/ProfileEditor/ProfileEditorService.cs index 42390dd3e..f0c4cf085 100644 --- a/src/Artemis.UI.Shared/Services/ProfileEditor/ProfileEditorService.cs +++ b/src/Artemis.UI.Shared/Services/ProfileEditor/ProfileEditorService.cs @@ -10,7 +10,6 @@ using Artemis.Core; using Artemis.Core.Services; using Artemis.UI.Shared.Services.MainWindow; using Artemis.UI.Shared.Services.ProfileEditor.Commands; -using Avalonia.Threading; using DynamicData; using Serilog; @@ -140,14 +139,14 @@ internal class ProfileEditorService : IProfileEditorService private void ApplyFocusMode() { if (_suspendedEditingSubject.Value) - _profileService.EditorFocus = null; + _profileService.FocusProfileElement = null; - _profileService.EditorFocus = _focusModeSubject.Value switch + _profileService.FocusProfileElement = _focusModeSubject.Value switch { ProfileEditorFocusMode.None => null, ProfileEditorFocusMode.Folder => _profileElementSubject.Value?.Parent, ProfileEditorFocusMode.Selection => _profileElementSubject.Value, - _ => _profileService.EditorFocus + _ => _profileService.FocusProfileElement }; } @@ -164,52 +163,38 @@ internal class ProfileEditorService : IProfileEditorService public async Task ChangeCurrentProfileConfiguration(ProfileConfiguration? profileConfiguration) { - if (ReferenceEquals(_profileConfigurationSubject.Value, profileConfiguration)) + ProfileConfiguration? previous = _profileConfigurationSubject.Value; + if (ReferenceEquals(previous, profileConfiguration)) return; _logger.Verbose("ChangeCurrentProfileConfiguration {profile}", profileConfiguration); // Stop playing and save the current profile Pause(); - if (_profileConfigurationSubject.Value?.Profile != null) - { - _profileConfigurationSubject.Value.Profile.Reset(); - _profileConfigurationSubject.Value.Profile.LastSelectedProfileElement = _profileElementSubject.Value; - } - await SaveProfileAsync(); - // No need to deactivate the profile, if needed it will be deactivated next update - if (_profileConfigurationSubject.Value != null) - _profileConfigurationSubject.Value.IsBeingEdited = false; - // Deselect whatever profile element was active ChangeCurrentProfileElement(null); + ChangeSuspendedEditing(false); // Close the command scope if one was open _profileEditorHistoryScope?.Dispose(); - // The new profile may need activation - if (profileConfiguration != null) + // Activate the profile and it's mode off of the UI thread + await Task.Run(() => { - await Task.Run(() => - { - profileConfiguration.IsBeingEdited = true; - _moduleService.SetActivationOverride(profileConfiguration.Module); + // Activate the profile if one was provided + if (profileConfiguration != null) _profileService.ActivateProfile(profileConfiguration); - _profileService.RenderForEditor = true; - }); - if (profileConfiguration.Profile?.LastSelectedProfileElement is RenderProfileElement renderProfileElement) - ChangeCurrentProfileElement(renderProfileElement); - } - else - { - _moduleService.SetActivationOverride(null); - _profileService.RenderForEditor = false; - } + // If there is no profile configuration or module, deliberately set the override to null + _moduleService.SetActivationOverride(profileConfiguration?.Module); + }); + _profileService.FocusProfile = profileConfiguration; _profileConfigurationSubject.OnNext(profileConfiguration); + ChangeTime(TimeSpan.Zero); + previous?.Profile?.Reset(); } public void ChangeCurrentProfileElement(RenderProfileElement? renderProfileElement) @@ -238,23 +223,23 @@ internal class ProfileEditorService : IProfileEditorService if (_suspendedEditingSubject.Value == suspend) return; - _suspendedEditingSubject.OnNext(suspend); if (suspend) { Pause(); - _profileService.RenderForEditor = false; + _profileService.UpdateFocusProfile = true; _profileConfigurationSubject.Value?.Profile?.Reset(); } else { if (_profileConfigurationSubject.Value != null) - _profileService.RenderForEditor = true; + _profileService.UpdateFocusProfile = false; Tick(_timeSubject.Value); } + _suspendedEditingSubject.OnNext(suspend); ApplyFocusMode(); } - + public void ChangeFocusMode(ProfileEditorFocusMode focusMode) { if (_focusModeSubject.Value == focusMode) @@ -411,10 +396,8 @@ internal class ProfileEditorService : IProfileEditorService public void SaveProfile() { Profile? profile = _profileConfigurationSubject.Value?.Profile; - if (profile == null) - return; - - _profileService.SaveProfile(profile, true); + if (profile != null) + _profileService.SaveProfile(profile, true); } /// diff --git a/src/Artemis.UI.Shared/Styles/TextBlock.axaml b/src/Artemis.UI.Shared/Styles/TextBlock.axaml index 8a5099cde..6c28e9938 100644 --- a/src/Artemis.UI.Shared/Styles/TextBlock.axaml +++ b/src/Artemis.UI.Shared/Styles/TextBlock.axaml @@ -10,15 +10,10 @@ This is heading 5 This is heading 6 This is a subtitle - - This is heading 1 - This is heading 2 - This is heading 3 - This is heading 4 - This is heading 5 - This is heading 6 - This is a subtitle - + Danger + Warning + Success + Info @@ -81,4 +76,30 @@ + + + + + + + + + + diff --git a/src/Artemis.UI/Artemis.UI.csproj b/src/Artemis.UI/Artemis.UI.csproj index 4dea86a84..27e75baa2 100644 --- a/src/Artemis.UI/Artemis.UI.csproj +++ b/src/Artemis.UI/Artemis.UI.csproj @@ -39,9 +39,17 @@ + + + + + ProfileSelectionStepView.axaml + Code + + \ No newline at end of file diff --git a/src/Artemis.UI/Screens/ProfileEditor/Panels/MenuBar/MenuBarViewModel.cs b/src/Artemis.UI/Screens/ProfileEditor/Panels/MenuBar/MenuBarViewModel.cs index 7578952da..60a0b548d 100644 --- a/src/Artemis.UI/Screens/ProfileEditor/Panels/MenuBar/MenuBarViewModel.cs +++ b/src/Artemis.UI/Screens/ProfileEditor/Panels/MenuBar/MenuBarViewModel.cs @@ -16,7 +16,6 @@ using Artemis.UI.Shared.Services; using Artemis.UI.Shared.Services.ProfileEditor; using Newtonsoft.Json; using ReactiveUI; -using Serilog; namespace Artemis.UI.Screens.ProfileEditor.MenuBar; @@ -182,7 +181,7 @@ public class MenuBarViewModel : ActivatableViewModelBase if (!await _windowService.ShowConfirmContentDialog("Delete profile", "Are you sure you want to permanently delete this profile?")) return; - if (ProfileConfiguration.IsBeingEdited) + if (_profileService.FocusProfile == ProfileConfiguration) await _router.Navigate("home"); _profileService.RemoveProfileConfiguration(ProfileConfiguration); } diff --git a/src/Artemis.UI/Screens/ProfileEditor/Panels/VisualEditor/VisualEditorView.axaml.cs b/src/Artemis.UI/Screens/ProfileEditor/Panels/VisualEditor/VisualEditorView.axaml.cs index 3fb88d5c5..f7e4491a5 100644 --- a/src/Artemis.UI/Screens/ProfileEditor/Panels/VisualEditor/VisualEditorView.axaml.cs +++ b/src/Artemis.UI/Screens/ProfileEditor/Panels/VisualEditor/VisualEditorView.axaml.cs @@ -1,10 +1,10 @@ using System; using System.Linq; using System.Reactive.Disposables; +using System.Reactive.Linq; using Avalonia; using Avalonia.Controls.PanAndZoom; using Avalonia.Input; -using Avalonia.Markup.Xaml; using Avalonia.Media; using Avalonia.ReactiveUI; using Avalonia.Threading; @@ -19,7 +19,7 @@ public partial class VisualEditorView : ReactiveUserControl ViewModel.AutoFitRequested -= ViewModelOnAutoFitRequested).DisposeWith(d); }); - this.WhenAnyValue(v => v.Bounds).Subscribe(_ => - { - if (!_movedByUser) - AutoFit(true); - }); + this.WhenAnyValue(v => v.Bounds).Where(_ => !_movedByUser).Subscribe(_ => AutoFit(true)); } private void ZoomBorderOnPointerWheelChanged(object? sender, PointerWheelEventArgs e) diff --git a/src/Artemis.UI/Screens/Settings/Updating/ReleaseView.axaml.cs b/src/Artemis.UI/Screens/Settings/Updating/ReleaseView.axaml.cs index 29e385e8a..02cd2454c 100644 --- a/src/Artemis.UI/Screens/Settings/Updating/ReleaseView.axaml.cs +++ b/src/Artemis.UI/Screens/Settings/Updating/ReleaseView.axaml.cs @@ -1,6 +1,4 @@ -using Avalonia; using Avalonia.Controls; -using Avalonia.Markup.Xaml; namespace Artemis.UI.Screens.Settings.Updating; @@ -10,9 +8,4 @@ public partial class ReleaseView : UserControl { InitializeComponent(); } - - private void InitializeComponent() - { - AvaloniaXamlLoader.Load(this); - } } \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Sidebar/Dialogs/ProfileConfigurationEditViewModel.cs b/src/Artemis.UI/Screens/Sidebar/Dialogs/ProfileConfigurationEditViewModel.cs index c0d130ce6..2f45408ed 100644 --- a/src/Artemis.UI/Screens/Sidebar/Dialogs/ProfileConfigurationEditViewModel.cs +++ b/src/Artemis.UI/Screens/Sidebar/Dialogs/ProfileConfigurationEditViewModel.cs @@ -52,9 +52,9 @@ public class ProfileConfigurationEditViewModel : DialogViewModelBase().First().ToString()) + + _profileConfiguration = profileConfiguration == ProfileConfiguration.Empty + ? profileService.CreateProfileConfiguration(profileCategory, "New profile", Enum.GetValues().First().ToString()) : profileConfiguration; _profileName = _profileConfiguration.Name; _iconType = _profileConfiguration.Icon.IconType; @@ -140,7 +140,7 @@ public class ProfileConfigurationEditViewModel : DialogViewModelBase Confirm { get; } public ReactiveCommand Delete { get; } public ReactiveCommand Cancel { get; } - + private async Task ExecuteDelete() { if (IsNew) @@ -148,7 +148,7 @@ public class ProfileConfigurationEditViewModel : DialogViewModelBase _iconType; set => RaiseAndSetIfChanged(ref _iconType, value); } - + public ProfileIconViewModel? SelectedMaterialIcon { get => _selectedMaterialIcon; diff --git a/src/Artemis.UI/Screens/Sidebar/SidebarCategoryViewModel.cs b/src/Artemis.UI/Screens/Sidebar/SidebarCategoryViewModel.cs index 188faa33a..5fd46c94c 100644 --- a/src/Artemis.UI/Screens/Sidebar/SidebarCategoryViewModel.cs +++ b/src/Artemis.UI/Screens/Sidebar/SidebarCategoryViewModel.cs @@ -24,9 +24,9 @@ namespace Artemis.UI.Screens.Sidebar; public class SidebarCategoryViewModel : ActivatableViewModelBase { private readonly IProfileService _profileService; - private readonly IWindowService _windowService; - private readonly ISidebarVmFactory _vmFactory; private readonly IRouter _router; + private readonly ISidebarVmFactory _vmFactory; + private readonly IWindowService _windowService; private ObservableAsPropertyHelper? _isCollapsed; private ObservableAsPropertyHelper? _isSuspended; private SidebarProfileConfigurationViewModel? _selectedProfileConfiguration; @@ -67,9 +67,9 @@ public class SidebarCategoryViewModel : ActivatableViewModelBase .WhereNotNull() .Subscribe(s => _router.Navigate($"profile-editor/{s.ProfileConfiguration.ProfileId}", new RouterNavigationOptions {IgnoreOnPartialMatch = true, RecycleScreens = false})) .DisposeWith(d); - + _router.CurrentPath.WhereNotNull().Subscribe(r => SelectedProfileConfiguration = ProfileConfigurations.FirstOrDefault(c => c.Matches(r))).DisposeWith(d); - + // Update the list of profiles whenever the category fires events Observable.FromEventPattern(x => profileCategory.ProfileConfigurationAdded += x, x => profileCategory.ProfileConfigurationAdded -= x) .Subscribe(e => profileConfigurations.Add(e.EventArgs.ProfileConfiguration)) @@ -77,7 +77,7 @@ public class SidebarCategoryViewModel : ActivatableViewModelBase Observable.FromEventPattern(x => profileCategory.ProfileConfigurationRemoved += x, x => profileCategory.ProfileConfigurationRemoved -= x) .Subscribe(e => profileConfigurations.RemoveMany(profileConfigurations.Items.Where(c => c == e.EventArgs.ProfileConfiguration))) .DisposeWith(d); - + _isCollapsed = ProfileCategory.WhenAnyValue(vm => vm.IsCollapsed).ToProperty(this, vm => vm.IsCollapsed).DisposeWith(d); _isSuspended = ProfileCategory.WhenAnyValue(vm => vm.IsSuspended).ToProperty(this, vm => vm.IsSuspended).DisposeWith(d); }); @@ -136,7 +136,7 @@ public class SidebarCategoryViewModel : ActivatableViewModelBase { if (await _windowService.ShowConfirmContentDialog($"Delete {ProfileCategory.Name}", "Do you want to delete this category and all its profiles?")) { - if (ProfileCategory.ProfileConfigurations.Any(c => c.IsBeingEdited)) + if (ProfileCategory.ProfileConfigurations.Any(c => _profileService.FocusProfile == c)) await _router.Navigate("home"); _profileService.DeleteProfileCategory(ProfileCategory); } @@ -153,7 +153,7 @@ public class SidebarCategoryViewModel : ActivatableViewModelBase } private async Task ExecuteImportProfile() - { + { string[]? result = await _windowService.CreateOpenFileDialog() .HavingFilter(f => f.WithExtension("json").WithName("Artemis profile")) .ShowAsync(); diff --git a/src/Artemis.UI/Screens/Sidebar/SidebarProfileConfigurationViewModel.cs b/src/Artemis.UI/Screens/Sidebar/SidebarProfileConfigurationViewModel.cs index 98749cb51..aafa9ca52 100644 --- a/src/Artemis.UI/Screens/Sidebar/SidebarProfileConfigurationViewModel.cs +++ b/src/Artemis.UI/Screens/Sidebar/SidebarProfileConfigurationViewModel.cs @@ -98,7 +98,7 @@ public class SidebarProfileConfigurationViewModel : ActivatableViewModelBase if (!await _windowService.ShowConfirmContentDialog("Delete profile", "Are you sure you want to permanently delete this profile?")) return; - if (ProfileConfiguration.IsBeingEdited) + if (_profileService.FocusProfile == ProfileConfiguration) await _router.Navigate("home"); _profileService.RemoveProfileConfiguration(ProfileConfiguration); } diff --git a/src/Artemis.UI/Screens/Workshop/Categories/CategoriesView.axaml.cs b/src/Artemis.UI/Screens/Workshop/Categories/CategoriesView.axaml.cs index 15ac17ab5..8cb505ab9 100644 --- a/src/Artemis.UI/Screens/Workshop/Categories/CategoriesView.axaml.cs +++ b/src/Artemis.UI/Screens/Workshop/Categories/CategoriesView.axaml.cs @@ -1,6 +1,5 @@ using Avalonia; using Avalonia.Input; -using Avalonia.Markup.Xaml; using Avalonia.ReactiveUI; namespace Artemis.UI.Screens.Workshop.Categories; @@ -12,10 +11,6 @@ public partial class CategoriesView : ReactiveUserControl InitializeComponent(); } - private void InitializeComponent() - { - AvaloniaXamlLoader.Load(this); - } private void InputElement_OnPointerReleased(object? sender, PointerReleasedEventArgs e) { diff --git a/src/Artemis.UI/Screens/Workshop/CurrentUser/WorkshopLoginView.axaml.cs b/src/Artemis.UI/Screens/Workshop/CurrentUser/WorkshopLoginView.axaml.cs index 064ae6691..9d6092a7f 100644 --- a/src/Artemis.UI/Screens/Workshop/CurrentUser/WorkshopLoginView.axaml.cs +++ b/src/Artemis.UI/Screens/Workshop/CurrentUser/WorkshopLoginView.axaml.cs @@ -1,4 +1,3 @@ -using Avalonia.Markup.Xaml; using Avalonia.ReactiveUI; namespace Artemis.UI.Screens.Workshop.CurrentUser; @@ -9,9 +8,4 @@ public partial class WorkshopLoginView : ReactiveUserControl { InitializeComponent(); } - - private void InitializeComponent() - { - AvaloniaXamlLoader.Load(this); - } } \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/Home/WorkshopHomeView.axaml.cs b/src/Artemis.UI/Screens/Workshop/Home/WorkshopHomeView.axaml.cs index e28f6659b..4a143850a 100644 --- a/src/Artemis.UI/Screens/Workshop/Home/WorkshopHomeView.axaml.cs +++ b/src/Artemis.UI/Screens/Workshop/Home/WorkshopHomeView.axaml.cs @@ -1,6 +1,3 @@ -using Avalonia; -using Avalonia.Controls; -using Avalonia.Markup.Xaml; using Avalonia.ReactiveUI; namespace Artemis.UI.Screens.Workshop.Home; @@ -11,9 +8,4 @@ public partial class WorkshopHomeView : ReactiveUserControl { InitializeComponent(); } - - private void InitializeComponent() - { - AvaloniaXamlLoader.Load(this); - } } \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/Profile/ProfileDetailsView.axaml.cs b/src/Artemis.UI/Screens/Workshop/Profile/ProfileDetailsView.axaml.cs index 25ca88e9f..1150bd94c 100644 --- a/src/Artemis.UI/Screens/Workshop/Profile/ProfileDetailsView.axaml.cs +++ b/src/Artemis.UI/Screens/Workshop/Profile/ProfileDetailsView.axaml.cs @@ -1,4 +1,3 @@ -using Avalonia.Markup.Xaml; using Avalonia.ReactiveUI; namespace Artemis.UI.Screens.Workshop.Profile; @@ -9,9 +8,4 @@ public partial class ProfileDetailsView : ReactiveUserControl { InitializeComponent(); } - - private void InitializeComponent() - { - AvaloniaXamlLoader.Load(this); - } } \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/Profile/ProfilePreviewView.axaml b/src/Artemis.UI/Screens/Workshop/Profile/ProfilePreviewView.axaml new file mode 100644 index 000000000..0b30e7d07 --- /dev/null +++ b/src/Artemis.UI/Screens/Workshop/Profile/ProfilePreviewView.axaml @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Artemis.UI/Screens/Workshop/Profile/ProfilePreviewView.axaml.cs b/src/Artemis.UI/Screens/Workshop/Profile/ProfilePreviewView.axaml.cs new file mode 100644 index 000000000..b956e6ecf --- /dev/null +++ b/src/Artemis.UI/Screens/Workshop/Profile/ProfilePreviewView.axaml.cs @@ -0,0 +1,79 @@ +using System; +using System.Linq; +using System.Reactive.Linq; +using Avalonia; +using Avalonia.Controls.PanAndZoom; +using Avalonia.Input; +using Avalonia.Media; +using Avalonia.ReactiveUI; +using ReactiveUI; + +namespace Artemis.UI.Screens.Workshop.Profile; + +public partial class ProfilePreviewView : ReactiveUserControl +{ + private bool _movedByUser; + + public ProfilePreviewView() + { + InitializeComponent(); + + ZoomBorder.PropertyChanged += ZoomBorderOnPropertyChanged; + ZoomBorder.PointerMoved += ZoomBorderOnPointerMoved; + ZoomBorder.PointerWheelChanged += ZoomBorderOnPointerWheelChanged; + UpdateZoomBorderBackground(); + + this.WhenAnyValue(v => v.Bounds).Where(_ => !_movedByUser).Subscribe(_ => AutoFit()); + } + + private void ZoomBorderOnPointerWheelChanged(object? sender, PointerWheelEventArgs e) + { + _movedByUser = true; + } + + private void ZoomBorderOnPointerMoved(object? sender, PointerEventArgs e) + { + if (e.GetCurrentPoint(ZoomBorder).Properties.IsMiddleButtonPressed) + _movedByUser = true; + } + + private void ZoomBorderOnPropertyChanged(object? sender, AvaloniaPropertyChangedEventArgs e) + { + if (e.Property.Name == nameof(ZoomBorder.Background)) + UpdateZoomBorderBackground(); + } + + private void UpdateZoomBorderBackground() + { + if (ZoomBorder.Background is VisualBrush visualBrush) + visualBrush.DestinationRect = new RelativeRect(ZoomBorder.OffsetX * -1, ZoomBorder.OffsetY * -1, 20, 20, RelativeUnit.Absolute); + } + + private void ZoomBorder_OnZoomChanged(object sender, ZoomChangedEventArgs e) + { + UpdateZoomBorderBackground(); + } + + private void AutoFit() + { + if (ViewModel == null || !ViewModel.Devices.Any()) + return; + + double left = ViewModel.Devices.Select(d => d.Rectangle.Left).Min(); + double top = ViewModel.Devices.Select(d => d.Rectangle.Top).Min(); + double bottom = ViewModel.Devices.Select(d => d.Rectangle.Bottom).Max(); + double right = ViewModel.Devices.Select(d => d.Rectangle.Right).Max(); + + // Add a 10 pixel margin around the rect + Rect scriptRect = new(new Point(left - 10, top - 10), new Point(right + 10, bottom + 10)); + + // The scale depends on the available space + double scale = Math.Min(3, Math.Min(Bounds.Width / scriptRect.Width, Bounds.Height / scriptRect.Height)); + + // Pan and zoom to make the script fit + ZoomBorder.Zoom(scale, 0, 0, true); + ZoomBorder.Pan(Bounds.Center.X - scriptRect.Center.X * scale, Bounds.Center.Y - scriptRect.Center.Y * scale, true); + + _movedByUser = false; + } +} \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/Profile/ProfilePreviewViewModel.cs b/src/Artemis.UI/Screens/Workshop/Profile/ProfilePreviewViewModel.cs new file mode 100644 index 000000000..a874ff268 --- /dev/null +++ b/src/Artemis.UI/Screens/Workshop/Profile/ProfilePreviewViewModel.cs @@ -0,0 +1,55 @@ +using System; +using System.Collections.ObjectModel; +using System.Linq; +using System.Reactive.Disposables; +using Artemis.Core; +using Artemis.Core.Services; +using Artemis.UI.Shared; +using Artemis.UI.Shared.Services; +using ReactiveUI; + +namespace Artemis.UI.Screens.Workshop.Profile; + +public class ProfilePreviewViewModel : ActivatableViewModelBase +{ + private readonly IProfileService _profileService; + private readonly IWindowService _windowService; + private ProfileConfiguration? _profileConfiguration; + + public ProfilePreviewViewModel(IProfileService profileService, IRgbService rgbService, IWindowService windowService) + { + _profileService = profileService; + _windowService = windowService; + + Devices = new ObservableCollection(rgbService.EnabledDevices.OrderBy(d => d.ZIndex)); + + this.WhenAnyValue(vm => vm.ProfileConfiguration).Subscribe(_ => Update()); + this.WhenActivated(d => Disposable.Create(() => PreviewProfile(null)).DisposeWith(d)); + } + + public ObservableCollection Devices { get; } + + public ProfileConfiguration? ProfileConfiguration + { + get => _profileConfiguration; + set => RaiseAndSetIfChanged(ref _profileConfiguration, value); + } + + private void Update() + { + try + { + PreviewProfile(ProfileConfiguration); + } + catch (Exception e) + { + _windowService.ShowExceptionDialog("Failed to load preview", e); + } + } + + private void PreviewProfile(ProfileConfiguration? profileConfiguration) + { + _profileService.FocusProfile = profileConfiguration; + _profileService.UpdateFocusProfile = profileConfiguration != null; + } +} \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/Search/SearchView.axaml.cs b/src/Artemis.UI/Screens/Workshop/Search/SearchView.axaml.cs index e9da03bdb..c819fce33 100644 --- a/src/Artemis.UI/Screens/Workshop/Search/SearchView.axaml.cs +++ b/src/Artemis.UI/Screens/Workshop/Search/SearchView.axaml.cs @@ -1,6 +1,4 @@ -using System; using Avalonia.Controls; -using Avalonia.Markup.Xaml; namespace Artemis.UI.Screens.Workshop.Search; @@ -10,9 +8,4 @@ public partial class SearchView : UserControl { InitializeComponent(); } - - private void InitializeComponent() - { - AvaloniaXamlLoader.Load(this); - } } \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/EntryTypeView.axaml.cs b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/EntryTypeView.axaml.cs index d50e2ea6e..3e4df145c 100644 --- a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/EntryTypeView.axaml.cs +++ b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/EntryTypeView.axaml.cs @@ -1,4 +1,3 @@ -using Avalonia.Markup.Xaml; using Avalonia.ReactiveUI; namespace Artemis.UI.Screens.Workshop.SubmissionWizard.Steps; @@ -9,9 +8,4 @@ public partial class EntryTypeView : ReactiveUserControl { InitializeComponent(); } - - private void InitializeComponent() - { - AvaloniaXamlLoader.Load(this); - } } \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/EntryTypeViewModel.cs b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/EntryTypeViewModel.cs index dda445838..9d3c9ea5d 100644 --- a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/EntryTypeViewModel.cs +++ b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/EntryTypeViewModel.cs @@ -1,6 +1,6 @@ -using System; using System.Reactive; using System.Reactive.Linq; +using Artemis.UI.Screens.Workshop.SubmissionWizard.Steps.Profile; using Artemis.WebClient.Workshop; using ReactiveUI; diff --git a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/LoginStepView.axaml.cs b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/LoginStepView.axaml.cs index c8b574eee..3b4ec4b2b 100644 --- a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/LoginStepView.axaml.cs +++ b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/LoginStepView.axaml.cs @@ -1,4 +1,3 @@ -using Avalonia.Markup.Xaml; using Avalonia.ReactiveUI; namespace Artemis.UI.Screens.Workshop.SubmissionWizard.Steps; @@ -9,9 +8,4 @@ public partial class LoginStepView : ReactiveUserControl { InitializeComponent(); } - - private void InitializeComponent() - { - AvaloniaXamlLoader.Load(this); - } } \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/Profile/ProfileAdaptionHintsLayerViewModel.cs b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/Profile/ProfileAdaptionHintsLayerViewModel.cs new file mode 100644 index 000000000..ade952508 --- /dev/null +++ b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/Profile/ProfileAdaptionHintsLayerViewModel.cs @@ -0,0 +1,51 @@ +using System.Reactive; +using System.Reactive.Linq; +using System.Threading.Tasks; +using Artemis.Core; +using Artemis.Core.Services; +using Artemis.UI.Screens.ProfileEditor.ProfileTree.Dialogs; +using Artemis.UI.Shared; +using Artemis.UI.Shared.Services; +using FluentAvalonia.Core; +using ReactiveUI; + +namespace Artemis.UI.Screens.Workshop.SubmissionWizard.Steps.Profile; + +public class ProfileAdaptionHintsLayerViewModel : ViewModelBase +{ + private readonly IWindowService _windowService; + private readonly IProfileService _profileService; + private readonly ObservableAsPropertyHelper _adaptionHintText; + private int _adaptionHintCount; + + public Layer Layer { get; } + + public ProfileAdaptionHintsLayerViewModel(Layer layer, IWindowService windowService, IProfileService profileService) + { + _windowService = windowService; + _profileService = profileService; + _adaptionHintText = this.WhenAnyValue(vm => vm.AdaptionHintCount).Select(c => c == 1 ? "1 adaption hint" : $"{c} adaption hints").ToProperty(this, vm => vm.AdaptionHintText); + + Layer = layer; + EditAdaptionHints = ReactiveCommand.CreateFromTask(ExecuteEditAdaptionHints); + AdaptionHintCount = layer.Adapter.AdaptionHints.Count; + } + + public ReactiveCommand EditAdaptionHints { get; } + + public int AdaptionHintCount + { + get => _adaptionHintCount; + private set => RaiseAndSetIfChanged(ref _adaptionHintCount, value); + } + + public string AdaptionHintText => _adaptionHintText.Value; + + private async Task ExecuteEditAdaptionHints() + { + await _windowService.ShowDialogAsync(Layer); + _profileService.SaveProfile(Layer.Profile, true); + + AdaptionHintCount = Layer.Adapter.AdaptionHints.Count; + } +} \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/Profile/ProfileAdaptionHintsStepView.axaml b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/Profile/ProfileAdaptionHintsStepView.axaml new file mode 100644 index 000000000..b85ffdb0f --- /dev/null +++ b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/Profile/ProfileAdaptionHintsStepView.axaml @@ -0,0 +1,67 @@ + + + + + + + + + Set up profile adaption hints + + + Add hints below to help decide where to place this each layer when the profile is imported by another user. + + + + + Learn more about adaption hints + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/Profile/ProfileAdaptionHintsStepView.axaml.cs b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/Profile/ProfileAdaptionHintsStepView.axaml.cs new file mode 100644 index 000000000..a5f7815d7 --- /dev/null +++ b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/Profile/ProfileAdaptionHintsStepView.axaml.cs @@ -0,0 +1,11 @@ +using Avalonia.ReactiveUI; + +namespace Artemis.UI.Screens.Workshop.SubmissionWizard.Steps.Profile; + +public partial class ProfileAdaptionHintsStepView : ReactiveUserControl +{ + public ProfileAdaptionHintsStepView() + { + InitializeComponent(); + } +} \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/Profile/ProfileAdaptionHintsStepViewModel.cs b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/Profile/ProfileAdaptionHintsStepViewModel.cs new file mode 100644 index 000000000..fd27ecccb --- /dev/null +++ b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/Profile/ProfileAdaptionHintsStepViewModel.cs @@ -0,0 +1,66 @@ +using System; +using System.Collections.ObjectModel; +using System.Linq; +using System.Reactive; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using System.Threading.Tasks; +using Artemis.Core; +using Artemis.Core.Services; +using Artemis.UI.Screens.ProfileEditor.ProfileTree.Dialogs; +using Artemis.UI.Shared.Services; +using DynamicData; +using ReactiveUI; +using DynamicData.Aggregation; + +namespace Artemis.UI.Screens.Workshop.SubmissionWizard.Steps.Profile; + +public class ProfileAdaptionHintsStepViewModel : SubmissionViewModel +{ + private readonly IWindowService _windowService; + private readonly IProfileService _profileService; + private readonly SourceList _layers; + + public ProfileAdaptionHintsStepViewModel(IWindowService windowService, IProfileService profileService, Func getLayerViewModel) + { + _windowService = windowService; + _profileService = profileService; + _layers = new SourceList(); + _layers.Connect().Bind(out ReadOnlyObservableCollection layers).Subscribe(); + + GoBack = ReactiveCommand.Create(() => State.ChangeScreen()); + Continue = ReactiveCommand.Create(ExecuteContinue, _layers.Connect().AutoRefresh(l => l.AdaptionHintCount).Filter(l => l.AdaptionHintCount == 0).IsEmpty()); + EditAdaptionHints = ReactiveCommand.CreateFromTask(ExecuteEditAdaptionHints); + Layers = layers; + + this.WhenActivated((CompositeDisposable _) => + { + if (State.EntrySource is ProfileConfiguration profileConfiguration && profileConfiguration.Profile != null) + { + _layers.Edit(l => + { + l.Clear(); + l.AddRange(profileConfiguration.Profile.GetAllLayers().Select(getLayerViewModel)); + }); + } + }); + } + + public override ReactiveCommand Continue { get; } + public override ReactiveCommand GoBack { get; } + public ReactiveCommand EditAdaptionHints { get; } + public ReadOnlyObservableCollection Layers { get; } + + private async Task ExecuteEditAdaptionHints(Layer layer) + { + await _windowService.ShowDialogAsync(layer); + _profileService.SaveProfile(layer.Profile, true); + } + + private void ExecuteContinue() + { + if (Layers.Any(l => l.AdaptionHintCount == 0)) + return; + + } +} \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/Profile/ProfileSelectionStepView.axaml b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/Profile/ProfileSelectionStepView.axaml new file mode 100644 index 000000000..8fd0eb216 --- /dev/null +++ b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/Profile/ProfileSelectionStepView.axaml @@ -0,0 +1,56 @@ + + + + + + + + + + Profile selection + + + Please select the profile you want to share, a preview will be shown below. + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/Profile/ProfileSelectionStepView.axaml.cs b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/Profile/ProfileSelectionStepView.axaml.cs new file mode 100644 index 000000000..84cb5bf27 --- /dev/null +++ b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/Profile/ProfileSelectionStepView.axaml.cs @@ -0,0 +1,11 @@ +using Avalonia.ReactiveUI; + +namespace Artemis.UI.Screens.Workshop.SubmissionWizard.Steps.Profile; + +public partial class ProfileSelectionStepView : ReactiveUserControl +{ + public ProfileSelectionStepView() + { + InitializeComponent(); + } +} \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/Profile/ProfileSelectionStepViewModel.cs b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/Profile/ProfileSelectionStepViewModel.cs new file mode 100644 index 000000000..08f9aafd0 --- /dev/null +++ b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/Profile/ProfileSelectionStepViewModel.cs @@ -0,0 +1,83 @@ +using System; +using System.Collections.ObjectModel; +using System.Linq; +using System.Reactive; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using Artemis.Core; +using Artemis.Core.Services; +using Artemis.Storage.Entities.Profile; +using Artemis.Storage.Repositories.Interfaces; +using Artemis.UI.Screens.Workshop.Profile; +using ReactiveUI; + +namespace Artemis.UI.Screens.Workshop.SubmissionWizard.Steps.Profile; + +public class ProfileSelectionStepViewModel : SubmissionViewModel +{ + private readonly IProfileService _profileService; + private readonly IProfileCategoryRepository _profileCategoryRepository; + private ProfileConfiguration? _selectedProfile; + + /// + public ProfileSelectionStepViewModel(IProfileService profileService, ProfilePreviewViewModel profilePreviewViewModel) + { + _profileService = profileService; + + // Use copies of the profiles, the originals are used by the core and could be disposed + Profiles = new ObservableCollection(_profileService.ProfileConfigurations.Select(_profileService.CloneProfileConfiguration)); + ProfilePreview = profilePreviewViewModel; + + GoBack = ReactiveCommand.Create(() => State.ChangeScreen()); + Continue = ReactiveCommand.Create(ExecuteContinue, this.WhenAnyValue(vm => vm.SelectedProfile).Select(p => p != null)); + + this.WhenAnyValue(vm => vm.SelectedProfile).Subscribe(p => Update(p)); + this.WhenActivated((CompositeDisposable _) => + { + if (State.EntrySource is ProfileConfiguration profileConfiguration) + SelectedProfile = Profiles.FirstOrDefault(p => p.ProfileId == profileConfiguration.ProfileId); + }); + } + + private void Update(ProfileConfiguration? profileConfiguration) + { + ProfilePreview.ProfileConfiguration = null; + + foreach (ProfileConfiguration configuration in Profiles) + { + if (configuration == profileConfiguration) + _profileService.ActivateProfile(configuration); + else + _profileService.DeactivateProfile(configuration); + } + + ProfilePreview.ProfileConfiguration = profileConfiguration; + } + + public ObservableCollection Profiles { get; } + public ProfilePreviewViewModel ProfilePreview { get; } + + public ProfileConfiguration? SelectedProfile + { + get => _selectedProfile; + set => RaiseAndSetIfChanged(ref _selectedProfile, value); + } + + /// + public override ReactiveCommand Continue { get; } + + /// + public override ReactiveCommand GoBack { get; } + + private void ExecuteContinue() + { + if (SelectedProfile == null) + return; + + State.EntrySource = SelectedProfile; + State.Name = SelectedProfile.Name; + State.Icon = SelectedProfile.Icon.GetIconStream(); + + State.ChangeScreen(); + } +} \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/ProfileSelectionStepView.axaml b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/ProfileSelectionStepView.axaml deleted file mode 100644 index ceb0a3469..000000000 --- a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/ProfileSelectionStepView.axaml +++ /dev/null @@ -1,10 +0,0 @@ - - Welcome to Avalonia! - diff --git a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/ProfileSelectionStepView.axaml.cs b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/ProfileSelectionStepView.axaml.cs deleted file mode 100644 index 37923b020..000000000 --- a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/ProfileSelectionStepView.axaml.cs +++ /dev/null @@ -1,18 +0,0 @@ -using Avalonia; -using Avalonia.Controls; -using Avalonia.Markup.Xaml; - -namespace Artemis.UI.Screens.Workshop.SubmissionWizard.Steps; - -public partial class ProfileSelectionStepView : UserControl -{ - public ProfileSelectionStepView() - { - InitializeComponent(); - } - - private void InitializeComponent() - { - AvaloniaXamlLoader.Load(this); - } -} \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/ProfileSelectionStepViewModel.cs b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/ProfileSelectionStepViewModel.cs deleted file mode 100644 index 3a258bb66..000000000 --- a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/ProfileSelectionStepViewModel.cs +++ /dev/null @@ -1,13 +0,0 @@ -using System.Reactive; -using ReactiveUI; - -namespace Artemis.UI.Screens.Workshop.SubmissionWizard.Steps; - -public class ProfileSelectionStepViewModel : SubmissionViewModel -{ - /// - public override ReactiveCommand Continue { get; } - - /// - public override ReactiveCommand GoBack { get; } -} \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/ValidateEmailStepView.axaml.cs b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/ValidateEmailStepView.axaml.cs index 1d9f32be8..a5899731e 100644 --- a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/ValidateEmailStepView.axaml.cs +++ b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/ValidateEmailStepView.axaml.cs @@ -1,4 +1,3 @@ -using Avalonia.Markup.Xaml; using Avalonia.ReactiveUI; namespace Artemis.UI.Screens.Workshop.SubmissionWizard.Steps; @@ -9,9 +8,4 @@ public partial class ValidateEmailStepView : ReactiveUserControl { InitializeComponent(); } - - private void InitializeComponent() - { - AvaloniaXamlLoader.Load(this); - } } \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/SubmissionWizardState.cs b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/SubmissionWizardState.cs index 19256ba56..3525d9ed2 100644 --- a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/SubmissionWizardState.cs +++ b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/SubmissionWizardState.cs @@ -7,8 +7,8 @@ namespace Artemis.UI.Screens.Workshop.SubmissionWizard; public class SubmissionWizardState { - private readonly SubmissionWizardViewModel _wizardViewModel; private readonly IContainer _container; + private readonly SubmissionWizardViewModel _wizardViewModel; public SubmissionWizardState(SubmissionWizardViewModel wizardViewModel, IContainer container) { @@ -27,6 +27,8 @@ public class SubmissionWizardState public List Tags { get; set; } = new(); public List Images { get; set; } = new(); + public object? EntrySource { get; set; } + public void ChangeScreen() where TSubmissionViewModel : SubmissionViewModel { _wizardViewModel.Screen = _container.Resolve(); From e1f0ccbcc1cf8fc7d92a0628ccffb87fc32652b2 Mon Sep 17 00:00:00 2001 From: Robert Date: Fri, 11 Aug 2023 14:43:35 +0200 Subject: [PATCH 07/37] Submission wizard - Added adaption hint and entry details steps --- .../Services/Storage/ProfileService.cs | 10 +++ .../Artemis.UI.Shared.csproj | 1 + src/Artemis.UI/Artemis.UI.csproj | 8 +- .../Steps/EntrySpecificationsStepView.axaml | 33 ++++++++ .../EntrySpecificationsStepView.axaml.cs | 19 +++++ .../Steps/EntrySpecificationsStepViewModel.cs | 83 +++++++++++++++++++ .../ProfileAdaptionHintsStepViewModel.cs | 1 + .../Profile/ProfileSelectionStepViewModel.cs | 3 + .../Steps/ValidateEmailStepView.axaml | 21 +++-- .../Steps/ValidateEmailStepViewModel.cs | 6 ++ .../SubmissionWizard/SubmissionViewModel.cs | 2 +- .../SubmissionWizard/SubmissionWizardState.cs | 15 +++- .../SubmissionWizardView.axaml.cs | 4 +- .../SubmissionWizardViewModel.cs | 9 +- .../Artemis.VisualScripting.csproj | 8 -- .../WorkshopConstants.cs | 2 +- 16 files changed, 195 insertions(+), 30 deletions(-) create mode 100644 src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/EntrySpecificationsStepView.axaml create mode 100644 src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/EntrySpecificationsStepView.axaml.cs create mode 100644 src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/EntrySpecificationsStepViewModel.cs diff --git a/src/Artemis.Core/Services/Storage/ProfileService.cs b/src/Artemis.Core/Services/Storage/ProfileService.cs index 103c1c2f1..2ecf653f3 100644 --- a/src/Artemis.Core/Services/Storage/ProfileService.cs +++ b/src/Artemis.Core/Services/Storage/ProfileService.cs @@ -363,14 +363,24 @@ internal class ProfileService : IProfileService _logger.Debug("Updating profile - Saving {Profile}", profile); profile.Save(); if (includeChildren) + { foreach (RenderProfileElement child in profile.GetAllRenderElements()) child.Save(); + } // At this point the user made actual changes, save that profile.IsFreshImport = false; profile.ProfileEntity.IsFreshImport = false; _profileRepository.Save(profile.ProfileEntity); + + // If the provided profile is external (cloned or from the workshop?) but it is loaded locally too, reload the local instance + // A bit dodge but it ensures local instances always represent the latest stored version + ProfileConfiguration? localInstance = ProfileConfigurations.FirstOrDefault(p => p.Profile != null && p.Profile != profile && p.ProfileId == profile.ProfileEntity.Id); + if (localInstance == null) + return; + DeactivateProfile(localInstance); + ActivateProfile(localInstance); } /// diff --git a/src/Artemis.UI.Shared/Artemis.UI.Shared.csproj b/src/Artemis.UI.Shared/Artemis.UI.Shared.csproj index 15b723fd5..eabfc3924 100644 --- a/src/Artemis.UI.Shared/Artemis.UI.Shared.csproj +++ b/src/Artemis.UI.Shared/Artemis.UI.Shared.csproj @@ -15,6 +15,7 @@ + diff --git a/src/Artemis.UI/Artemis.UI.csproj b/src/Artemis.UI/Artemis.UI.csproj index 27e75baa2..d69d57955 100644 --- a/src/Artemis.UI/Artemis.UI.csproj +++ b/src/Artemis.UI/Artemis.UI.csproj @@ -23,6 +23,7 @@ + @@ -45,11 +46,4 @@ - - - - ProfileSelectionStepView.axaml - Code - - \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/EntrySpecificationsStepView.axaml b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/EntrySpecificationsStepView.axaml new file mode 100644 index 000000000..b105493c9 --- /dev/null +++ b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/EntrySpecificationsStepView.axaml @@ -0,0 +1,33 @@ + + + + + + + + + + Provide some general information on your submission below. + + + + + + + + A short summary of your submission's description + + + + The main description, Markdown supported. (A better editor planned) + + diff --git a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/EntrySpecificationsStepView.axaml.cs b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/EntrySpecificationsStepView.axaml.cs new file mode 100644 index 000000000..b57435eb7 --- /dev/null +++ b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/EntrySpecificationsStepView.axaml.cs @@ -0,0 +1,19 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Markup.Xaml; +using Avalonia.ReactiveUI; + +namespace Artemis.UI.Screens.Workshop.SubmissionWizard.Steps; + +public partial class EntrySpecificationsStepView : ReactiveUserControl +{ + public EntrySpecificationsStepView() + { + InitializeComponent(); + } + + private void InitializeComponent() + { + AvaloniaXamlLoader.Load(this); + } +} \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/EntrySpecificationsStepViewModel.cs b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/EntrySpecificationsStepViewModel.cs new file mode 100644 index 000000000..9cd5799fe --- /dev/null +++ b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/EntrySpecificationsStepViewModel.cs @@ -0,0 +1,83 @@ +using System; +using System.Reactive; +using System.Reactive.Disposables; +using Artemis.UI.Screens.Workshop.SubmissionWizard.Steps.Profile; +using Artemis.WebClient.Workshop; +using ReactiveUI; +using ReactiveUI.Validation.Extensions; + +namespace Artemis.UI.Screens.Workshop.SubmissionWizard.Steps; + +public class EntrySpecificationsStepViewModel : SubmissionViewModel +{ + private string _name = string.Empty; + private string _summary = string.Empty; + private string _description = string.Empty; + + public EntrySpecificationsStepViewModel() + { + GoBack = ReactiveCommand.Create(ExecuteGoBack); + Continue = ReactiveCommand.Create(ExecuteContinue, ValidationContext.Valid); + + this.WhenActivated((CompositeDisposable _) => + { + this.ClearValidationRules(); + + DisplayName = $"{State.EntryType} Information"; + Name = State.Name; + Summary = State.Summary; + Description = State.Description; + }); + } + + public override ReactiveCommand Continue { get; } + public override ReactiveCommand GoBack { get; } + + public string Name + { + get => _name; + set => RaiseAndSetIfChanged(ref _name, value); + } + + public string Summary + { + get => _summary; + set => RaiseAndSetIfChanged(ref _summary, value); + } + + public string Description + { + get => _description; + set => RaiseAndSetIfChanged(ref _description, value); + } + + private void ExecuteGoBack() + { + switch (State.EntryType) + { + case EntryType.Layout: + break; + case EntryType.Plugin: + break; + case EntryType.Profile: + State.ChangeScreen(); + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + private void ExecuteContinue() + { + this.ValidationRule(vm => vm.Name, s => !string.IsNullOrWhiteSpace(s), "Name cannot be empty."); + this.ValidationRule(vm => vm.Summary, s => !string.IsNullOrWhiteSpace(s), "Summary cannot be empty."); + this.ValidationRule(vm => vm.Description, s => !string.IsNullOrWhiteSpace(s), "Description cannot be empty."); + + if (!ValidationContext.IsValid) + return; + + State.Name = Name; + State.Summary = Summary; + State.Description = Description; + } +} \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/Profile/ProfileAdaptionHintsStepViewModel.cs b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/Profile/ProfileAdaptionHintsStepViewModel.cs index fd27ecccb..757d6f0c0 100644 --- a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/Profile/ProfileAdaptionHintsStepViewModel.cs +++ b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/Profile/ProfileAdaptionHintsStepViewModel.cs @@ -62,5 +62,6 @@ public class ProfileAdaptionHintsStepViewModel : SubmissionViewModel if (Layers.Any(l => l.AdaptionHintCount == 0)) return; + State.ChangeScreen(); } } \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/Profile/ProfileSelectionStepViewModel.cs b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/Profile/ProfileSelectionStepViewModel.cs index 08f9aafd0..85409e33c 100644 --- a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/Profile/ProfileSelectionStepViewModel.cs +++ b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/Profile/ProfileSelectionStepViewModel.cs @@ -26,6 +26,9 @@ public class ProfileSelectionStepViewModel : SubmissionViewModel // Use copies of the profiles, the originals are used by the core and could be disposed Profiles = new ObservableCollection(_profileService.ProfileConfigurations.Select(_profileService.CloneProfileConfiguration)); + foreach (ProfileConfiguration profileConfiguration in Profiles) + _profileService.LoadProfileConfigurationIcon(profileConfiguration); + ProfilePreview = profilePreviewViewModel; GoBack = ReactiveCommand.Create(() => State.ChangeScreen()); diff --git a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/ValidateEmailStepView.axaml b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/ValidateEmailStepView.axaml index 4fc2fe76c..d6a339ccd 100644 --- a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/ValidateEmailStepView.axaml +++ b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/ValidateEmailStepView.axaml @@ -27,12 +27,21 @@ - + + + + + PS: We take this step to avoid the workshop getting spammed with low quality content. diff --git a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/ValidateEmailStepViewModel.cs b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/ValidateEmailStepViewModel.cs index cc90adb22..a6576ee88 100644 --- a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/ValidateEmailStepViewModel.cs +++ b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/ValidateEmailStepViewModel.cs @@ -1,10 +1,13 @@ using System; +using System.Diagnostics; using System.Linq; using System.Reactive; using System.Reactive.Disposables; using System.Security.Claims; using System.Threading; using System.Threading.Tasks; +using Artemis.Core; +using Artemis.WebClient.Workshop; using Artemis.WebClient.Workshop.Services; using IdentityModel; using ReactiveUI; @@ -23,6 +26,8 @@ public class ValidateEmailStepViewModel : SubmissionViewModel Continue = ReactiveCommand.Create(ExecuteContinue); Refresh = ReactiveCommand.CreateFromTask(ExecuteRefresh); + Resend = ReactiveCommand.Create(() => Utilities.OpenUrl(WorkshopConstants.AUTHORITY_URL + "/account/confirm/resend")); + ShowGoBack = false; ShowHeader = false; @@ -45,6 +50,7 @@ public class ValidateEmailStepViewModel : SubmissionViewModel public override ReactiveCommand GoBack { get; } = null!; public ReactiveCommand Refresh { get; } + public ReactiveCommand Resend { get; } public Claim? Email => _email?.Value; diff --git a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/SubmissionViewModel.cs b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/SubmissionViewModel.cs index d2ba60da1..3cd79cb65 100644 --- a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/SubmissionViewModel.cs +++ b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/SubmissionViewModel.cs @@ -4,7 +4,7 @@ using ReactiveUI; namespace Artemis.UI.Screens.Workshop.SubmissionWizard; -public abstract class SubmissionViewModel : ActivatableViewModelBase +public abstract class SubmissionViewModel : ValidatableViewModelBase { private string _continueText = "Continue"; private bool _showFinish; diff --git a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/SubmissionWizardState.cs b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/SubmissionWizardState.cs index 3525d9ed2..fefb06891 100644 --- a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/SubmissionWizardState.cs +++ b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/SubmissionWizardState.cs @@ -1,5 +1,7 @@ +using System; using System.Collections.Generic; using System.IO; +using Artemis.UI.Shared.Services; using Artemis.WebClient.Workshop; using DryIoc; @@ -8,12 +10,14 @@ namespace Artemis.UI.Screens.Workshop.SubmissionWizard; public class SubmissionWizardState { private readonly IContainer _container; + private readonly IWindowService _windowService; private readonly SubmissionWizardViewModel _wizardViewModel; - public SubmissionWizardState(SubmissionWizardViewModel wizardViewModel, IContainer container) + public SubmissionWizardState(SubmissionWizardViewModel wizardViewModel, IContainer container, IWindowService windowService) { _wizardViewModel = wizardViewModel; _container = container; + _windowService = windowService; } public EntryType EntryType { get; set; } @@ -31,6 +35,13 @@ public class SubmissionWizardState public void ChangeScreen() where TSubmissionViewModel : SubmissionViewModel { - _wizardViewModel.Screen = _container.Resolve(); + try + { + _wizardViewModel.Screen = _container.Resolve(); + } + catch (Exception e) + { + _windowService.ShowExceptionDialog("Wizard screen failed to activate", e); + } } } \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/SubmissionWizardView.axaml.cs b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/SubmissionWizardView.axaml.cs index 58dade5ef..cb510d01e 100644 --- a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/SubmissionWizardView.axaml.cs +++ b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/SubmissionWizardView.axaml.cs @@ -25,9 +25,9 @@ public partial class SubmissionWizardView : ReactiveAppWindow Frame.NavigateFromObject(viewModel)); } - catch (Exception) + catch (Exception e) { - // ignored + ViewModel?.WindowService.ShowExceptionDialog("Wizard screen failed to activate", e); } } } \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/SubmissionWizardViewModel.cs b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/SubmissionWizardViewModel.cs index 0f3c5dc56..f2361b8ed 100644 --- a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/SubmissionWizardViewModel.cs +++ b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/SubmissionWizardViewModel.cs @@ -1,6 +1,7 @@ using Artemis.UI.Screens.Workshop.CurrentUser; using Artemis.UI.Screens.Workshop.SubmissionWizard.Steps; using Artemis.UI.Shared; +using Artemis.UI.Shared.Services; using DryIoc; namespace Artemis.UI.Screens.Workshop.SubmissionWizard; @@ -10,17 +11,19 @@ public class SubmissionWizardViewModel : DialogViewModelBase private readonly SubmissionWizardState _state; private SubmissionViewModel _screen; - public SubmissionWizardViewModel(IContainer container, CurrentUserViewModel currentUserViewModel, WelcomeStepViewModel welcomeStepViewModel) + public SubmissionWizardViewModel(IContainer container, IWindowService windowService, CurrentUserViewModel currentUserViewModel, WelcomeStepViewModel welcomeStepViewModel) { - _state = new SubmissionWizardState(this, container); + _state = new SubmissionWizardState(this, container, windowService); _screen = welcomeStepViewModel; _screen.WizardViewModel = this; _screen.State = _state; - + + WindowService = windowService; CurrentUserViewModel = currentUserViewModel; CurrentUserViewModel.AllowLogout = false; } + public IWindowService WindowService { get; } public CurrentUserViewModel CurrentUserViewModel { get; } public SubmissionViewModel Screen diff --git a/src/Artemis.VisualScripting/Artemis.VisualScripting.csproj b/src/Artemis.VisualScripting/Artemis.VisualScripting.csproj index aa97126e1..10b888b33 100644 --- a/src/Artemis.VisualScripting/Artemis.VisualScripting.csproj +++ b/src/Artemis.VisualScripting/Artemis.VisualScripting.csproj @@ -23,12 +23,4 @@ - - - - HotkeyPressNodeCustomView.axaml - Code - - - diff --git a/src/Artemis.WebClient.Workshop/WorkshopConstants.cs b/src/Artemis.WebClient.Workshop/WorkshopConstants.cs index 27ee76421..a500fa970 100644 --- a/src/Artemis.WebClient.Workshop/WorkshopConstants.cs +++ b/src/Artemis.WebClient.Workshop/WorkshopConstants.cs @@ -3,5 +3,5 @@ namespace Artemis.WebClient.Workshop; public static class WorkshopConstants { public const string AUTHORITY_URL = "https://localhost:5001"; - public const string WORKSHOP_URL = "https://workshop.artemis-rgb.com"; + public const string WORKSHOP_URL = "https://localhost:7281"; } \ No newline at end of file From 3a6171726c8c29281baf6fdd2ed8a8c37a9d2156 Mon Sep 17 00:00:00 2001 From: Robert Date: Sun, 13 Aug 2023 10:41:12 +0200 Subject: [PATCH 08/37] Core - Removed OriginalFileName from icon since it's irrelevant --- .../ProfileConfigurationIcon.cs | 16 +--------------- .../Services/Storage/ProfileService.cs | 16 ++++++++-------- .../Profile/ProfileConfigurationEntity.cs | 1 - .../Repositories/ProfileCategoryRepository.cs | 2 +- 4 files changed, 10 insertions(+), 25 deletions(-) diff --git a/src/Artemis.Core/Models/ProfileConfiguration/ProfileConfigurationIcon.cs b/src/Artemis.Core/Models/ProfileConfiguration/ProfileConfigurationIcon.cs index 058e95687..10a500cba 100644 --- a/src/Artemis.Core/Models/ProfileConfiguration/ProfileConfigurationIcon.cs +++ b/src/Artemis.Core/Models/ProfileConfiguration/ProfileConfigurationIcon.cs @@ -15,7 +15,6 @@ public class ProfileConfigurationIcon : CorePropertyChanged, IStorageModel private string? _iconName; private Stream? _iconStream; private ProfileConfigurationIconType _iconType; - private string? _originalFileName; internal ProfileConfigurationIcon(ProfileConfigurationEntity entity) { @@ -40,15 +39,6 @@ public class ProfileConfigurationIcon : CorePropertyChanged, IStorageModel private set => SetAndNotify(ref _iconName, value); } - /// - /// Gets the original file name of the icon (if applicable) - /// - public string? OriginalFileName - { - get => _originalFileName; - private set => SetAndNotify(ref _originalFileName, value); - } - /// /// Gets or sets a boolean indicating whether or not this icon should be filled. /// @@ -69,7 +59,6 @@ public class ProfileConfigurationIcon : CorePropertyChanged, IStorageModel _iconStream?.Dispose(); IconName = iconName; - OriginalFileName = null; IconType = ProfileConfigurationIconType.MaterialIcon; OnIconUpdated(); @@ -78,11 +67,9 @@ public class ProfileConfigurationIcon : CorePropertyChanged, IStorageModel /// /// Updates the stream returned by to the provided stream /// - /// The original file name backing the stream, should include the extension /// The stream to copy - public void SetIconByStream(string originalFileName, Stream stream) + public void SetIconByStream(Stream stream) { - if (originalFileName == null) throw new ArgumentNullException(nameof(originalFileName)); if (stream == null) throw new ArgumentNullException(nameof(stream)); _iconStream?.Dispose(); @@ -92,7 +79,6 @@ public class ProfileConfigurationIcon : CorePropertyChanged, IStorageModel _iconStream.Seek(0, SeekOrigin.Begin); IconName = null; - OriginalFileName = originalFileName; IconType = ProfileConfigurationIconType.BitmapImage; OnIconUpdated(); } diff --git a/src/Artemis.Core/Services/Storage/ProfileService.cs b/src/Artemis.Core/Services/Storage/ProfileService.cs index 2ecf653f3..159e2ebf5 100644 --- a/src/Artemis.Core/Services/Storage/ProfileService.cs +++ b/src/Artemis.Core/Services/Storage/ProfileService.cs @@ -187,7 +187,7 @@ internal class ProfileService : IProfileService using Stream? stream = _profileCategoryRepository.GetProfileIconStream(profileConfiguration.Entity.FileIconId); if (stream != null) - profileConfiguration.Icon.SetIconByStream(profileConfiguration.Entity.IconOriginalFileName, stream); + profileConfiguration.Icon.SetIconByStream(stream); } /// @@ -197,11 +197,8 @@ internal class ProfileService : IProfileService return; using Stream? stream = profileConfiguration.Icon.GetIconStream(); - if (stream != null && profileConfiguration.Icon.OriginalFileName != null) - { - profileConfiguration.Entity.IconOriginalFileName = profileConfiguration.Icon.OriginalFileName; + if (stream != null) _profileCategoryRepository.SaveProfileIconStream(profileConfiguration.Entity, stream); - } } /// @@ -441,8 +438,11 @@ internal class ProfileService : IProfileService profileConfiguration = new ProfileConfiguration(category, profileEntity.Name, "Import"); } - if (exportModel.ProfileImage != null && exportModel.ProfileConfigurationEntity?.IconOriginalFileName != null) - profileConfiguration.Icon.SetIconByStream(exportModel.ProfileConfigurationEntity.IconOriginalFileName, exportModel.ProfileImage); + if (exportModel.ProfileImage != null) + { + profileConfiguration.Icon.SetIconByStream(exportModel.ProfileImage); + SaveProfileConfigurationIcon(profileConfiguration); + } profileConfiguration.Entity.ProfileId = profileEntity.Id; category.AddProfileConfiguration(profileConfiguration, 0); @@ -450,7 +450,7 @@ internal class ProfileService : IProfileService List modules = _pluginManagementService.GetFeaturesOfType(); profileConfiguration.LoadModules(modules); SaveProfileCategory(category); - + return profileConfiguration; } diff --git a/src/Artemis.Storage/Entities/Profile/ProfileConfigurationEntity.cs b/src/Artemis.Storage/Entities/Profile/ProfileConfigurationEntity.cs index e91ddcc96..276b6ec8d 100644 --- a/src/Artemis.Storage/Entities/Profile/ProfileConfigurationEntity.cs +++ b/src/Artemis.Storage/Entities/Profile/ProfileConfigurationEntity.cs @@ -7,7 +7,6 @@ public class ProfileConfigurationEntity { public string Name { get; set; } public string MaterialIcon { get; set; } - public string IconOriginalFileName { get; set; } public Guid FileIconId { get; set; } public int IconType { get; set; } public bool IconFill { get; set; } diff --git a/src/Artemis.Storage/Repositories/ProfileCategoryRepository.cs b/src/Artemis.Storage/Repositories/ProfileCategoryRepository.cs index 573b50a19..71517929f 100644 --- a/src/Artemis.Storage/Repositories/ProfileCategoryRepository.cs +++ b/src/Artemis.Storage/Repositories/ProfileCategoryRepository.cs @@ -70,6 +70,6 @@ internal class ProfileCategoryRepository : IProfileCategoryRepository if (stream == null && _profileIcons.Exists(profileConfigurationEntity.FileIconId)) _profileIcons.Delete(profileConfigurationEntity.FileIconId); - _profileIcons.Upload(profileConfigurationEntity.FileIconId, profileConfigurationEntity.IconOriginalFileName, stream); + _profileIcons.Upload(profileConfigurationEntity.FileIconId, profileConfigurationEntity.FileIconId + ".png", stream); } } \ No newline at end of file From e9f2b77fd63808d8efc00f3d55a98d53930d62e3 Mon Sep 17 00:00:00 2001 From: Robert Date: Sun, 13 Aug 2023 10:41:29 +0200 Subject: [PATCH 09/37] Shared UI - Added tags input control --- .../Controls/TagsInput/TagsInput.cs | 78 +++++++++++++++++++ .../TagsInput/TagsInput.properties.cs | 54 +++++++++++++ .../Controls/TagsInput/TagsInputStyles.axaml | 49 ++++++++++++ 3 files changed, 181 insertions(+) create mode 100644 src/Artemis.UI.Shared/Controls/TagsInput/TagsInput.cs create mode 100644 src/Artemis.UI.Shared/Controls/TagsInput/TagsInput.properties.cs create mode 100644 src/Artemis.UI.Shared/Controls/TagsInput/TagsInputStyles.axaml diff --git a/src/Artemis.UI.Shared/Controls/TagsInput/TagsInput.cs b/src/Artemis.UI.Shared/Controls/TagsInput/TagsInput.cs new file mode 100644 index 000000000..3b94d3766 --- /dev/null +++ b/src/Artemis.UI.Shared/Controls/TagsInput/TagsInput.cs @@ -0,0 +1,78 @@ +using System.Text.RegularExpressions; +using System.Windows.Input; +using Avalonia.Controls; +using Avalonia.Controls.Metadata; +using Avalonia.Controls.Primitives; +using Avalonia.Input; +using ReactiveUI; + +namespace Artemis.UI.Shared.TagsInput; + +[TemplatePart("PART_TagInputBox", typeof(TextBox))] +public partial class TagsInput : TemplatedControl +{ + public TextBox? TagInputBox { get; set; } + public ICommand RemoveTag { get; } + + /// + public TagsInput() + { + RemoveTag = ReactiveCommand.Create(ExecuteRemoveTag); + } + + /// + protected override void OnApplyTemplate(TemplateAppliedEventArgs e) + { + if (TagInputBox != null) + { + TagInputBox.KeyDown -= TagInputBoxOnKeyDown; + TagInputBox.TextChanging -= TagInputBoxOnTextChanging; + } + + TagInputBox = e.NameScope.Find("PART_TagInputBox"); + + if (TagInputBox != null) + { + TagInputBox.KeyDown += TagInputBoxOnKeyDown; + TagInputBox.TextChanging += TagInputBoxOnTextChanging; + } + } + + private void ExecuteRemoveTag(string t) + { + Tags.Remove(t); + + if (TagInputBox != null) + TagInputBox.IsEnabled = Tags.Count < MaxLength; + } + + private void TagInputBoxOnTextChanging(object? sender, TextChangingEventArgs e) + { + if (TagInputBox?.Text == null) + return; + + TagInputBox.Text = CleanTagRegex().Replace(TagInputBox.Text.ToLower(), ""); + } + + private void TagInputBoxOnKeyDown(object? sender, KeyEventArgs e) + { + if (TagInputBox == null) + return; + + if (e.Key == Key.Space) + e.Handled = true; + if (e.Key != Key.Enter) + return; + + if (string.IsNullOrWhiteSpace(TagInputBox.Text) || Tags.Contains(TagInputBox.Text) || Tags.Count >= MaxLength) + return; + + Tags.Add(CleanTagRegex().Replace(TagInputBox.Text.ToLower(), "")); + + TagInputBox.Text = ""; + TagInputBox.IsEnabled = Tags.Count < MaxLength; + } + + [GeneratedRegex("[\\s\\-]+")] + private static partial Regex CleanTagRegex(); +} \ No newline at end of file diff --git a/src/Artemis.UI.Shared/Controls/TagsInput/TagsInput.properties.cs b/src/Artemis.UI.Shared/Controls/TagsInput/TagsInput.properties.cs new file mode 100644 index 000000000..2660e7bb8 --- /dev/null +++ b/src/Artemis.UI.Shared/Controls/TagsInput/TagsInput.properties.cs @@ -0,0 +1,54 @@ +using System.Collections.ObjectModel; +using Avalonia; +using Avalonia.Controls.Primitives; +using Avalonia.Data; + +namespace Artemis.UI.Shared.TagsInput; + +public partial class TagsInput : TemplatedControl +{ + /// + /// Defines the property + /// + public static readonly StyledProperty> TagsProperty = + AvaloniaProperty.Register>(nameof(Tags), new ObservableCollection()); + + /// + /// Gets or sets the selected tags. + /// + public ObservableCollection Tags + { + get => GetValue(TagsProperty); + set => SetValue(TagsProperty, value); + } + + /// + /// Defines the property + /// + public static readonly StyledProperty MaxLengthProperty = + AvaloniaProperty.Register(nameof(MaxLength), 20); + + /// + /// Gets or sets the max length of each tag + /// + public int MaxLength + { + get => GetValue(MaxLengthProperty); + set => SetValue(MaxLengthProperty, value); + } + + /// + /// Defines the property + /// + public static readonly StyledProperty MaxTagsProperty = + AvaloniaProperty.Register(nameof(MaxTags), 20); + + /// + /// Gets or sets the max amount of tags to be added + /// + public int MaxTags + { + get => GetValue(MaxTagsProperty); + set => SetValue(MaxTagsProperty, value); + } +} \ No newline at end of file diff --git a/src/Artemis.UI.Shared/Controls/TagsInput/TagsInputStyles.axaml b/src/Artemis.UI.Shared/Controls/TagsInput/TagsInputStyles.axaml new file mode 100644 index 000000000..e2a0ff8e5 --- /dev/null +++ b/src/Artemis.UI.Shared/Controls/TagsInput/TagsInputStyles.axaml @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file From 56abc48ab3bf2a8c7d31b1a5a920c2fb16a9b697 Mon Sep 17 00:00:00 2001 From: Robert Date: Sun, 13 Aug 2023 10:42:28 +0200 Subject: [PATCH 10/37] Profiles - Resize profile images to 128x128 --- src/Artemis.UI/Extensions/Bitmap.cs | 40 +++++++++++++++++++ .../ProfileConfigurationEditViewModel.cs | 5 ++- 2 files changed, 43 insertions(+), 2 deletions(-) create mode 100644 src/Artemis.UI/Extensions/Bitmap.cs diff --git a/src/Artemis.UI/Extensions/Bitmap.cs b/src/Artemis.UI/Extensions/Bitmap.cs new file mode 100644 index 000000000..ce8cba4d2 --- /dev/null +++ b/src/Artemis.UI/Extensions/Bitmap.cs @@ -0,0 +1,40 @@ +using System.IO; +using Avalonia.Media.Imaging; +using SkiaSharp; + +namespace Artemis.UI.Extensions; + +public class BitmapExtensions +{ + public static Bitmap LoadAndResize(string file, int size) + { + using SKBitmap source = SKBitmap.Decode(file); + return Resize(source, size); + } + + public static Bitmap LoadAndResize(Stream stream, int size) + { + using SKBitmap source = SKBitmap.Decode(stream); + return Resize(source, size); + } + + private static Bitmap Resize(SKBitmap source, int size) + { + int newWidth, newHeight; + float aspectRatio = (float) source.Width / source.Height; + + if (aspectRatio > 1) + { + newWidth = size; + newHeight = (int) (size / aspectRatio); + } + else + { + newWidth = (int) (size * aspectRatio); + newHeight = size; + } + + using SKBitmap resizedBitmap = source.Resize(new SKImageInfo(newWidth, newHeight), SKFilterQuality.High); + return new Bitmap(resizedBitmap.Encode(SKEncodedImageFormat.Png, 100).AsStream()); + } +} \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Sidebar/Dialogs/ProfileConfigurationEditViewModel.cs b/src/Artemis.UI/Screens/Sidebar/Dialogs/ProfileConfigurationEditViewModel.cs index 2f45408ed..032f93cbe 100644 --- a/src/Artemis.UI/Screens/Sidebar/Dialogs/ProfileConfigurationEditViewModel.cs +++ b/src/Artemis.UI/Screens/Sidebar/Dialogs/ProfileConfigurationEditViewModel.cs @@ -9,6 +9,7 @@ using Artemis.Core; using Artemis.Core.Modules; using Artemis.Core.Services; using Artemis.UI.DryIoc.Factories; +using Artemis.UI.Extensions; using Artemis.UI.Screens.VisualScripting; using Artemis.UI.Shared; using Artemis.UI.Shared.Services; @@ -231,7 +232,7 @@ public class ProfileConfigurationEditViewModel : DialogViewModelBase Date: Sun, 13 Aug 2023 10:42:51 +0200 Subject: [PATCH 11/37] Submission wizard - Added entry specifications step --- src/Artemis.UI.Shared/Styles/Artemis.axaml | 1 + src/Artemis.UI.Shared/Styles/Button.axaml | 29 --- .../Extensions/MaterialIconKindExtensions.cs | 35 ++++ .../Steps/EntrySpecificationsStepView.axaml | 144 ++++++++++++--- .../Steps/EntrySpecificationsStepViewModel.cs | 165 ++++++++++++++++-- .../Profile/ProfileSelectionStepViewModel.cs | 22 ++- .../SubmissionWizard/SubmissionWizardState.cs | 2 +- .../WorkshopConstants.cs | 4 +- 8 files changed, 328 insertions(+), 74 deletions(-) create mode 100644 src/Artemis.UI/Extensions/MaterialIconKindExtensions.cs diff --git a/src/Artemis.UI.Shared/Styles/Artemis.axaml b/src/Artemis.UI.Shared/Styles/Artemis.axaml index 870b5f242..69759ddc7 100644 --- a/src/Artemis.UI.Shared/Styles/Artemis.axaml +++ b/src/Artemis.UI.Shared/Styles/Artemis.axaml @@ -15,6 +15,7 @@ + diff --git a/src/Artemis.UI.Shared/Styles/Button.axaml b/src/Artemis.UI.Shared/Styles/Button.axaml index 2bd8aea06..dbecb197c 100644 --- a/src/Artemis.UI.Shared/Styles/Button.axaml +++ b/src/Artemis.UI.Shared/Styles/Button.axaml @@ -39,11 +39,6 @@ - - ToggleButton.window-button - - - + + + + + + + + + Icon required + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + At least one category is required + + + + + + + + + + Markdown supported, a better editor planned + + + + + + \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/EntrySpecificationsStepViewModel.cs b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/EntrySpecificationsStepViewModel.cs index 9cd5799fe..a95f335cf 100644 --- a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/EntrySpecificationsStepViewModel.cs +++ b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/EntrySpecificationsStepViewModel.cs @@ -1,37 +1,103 @@ using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Drawing; +using System.IO; +using System.Linq; using System.Reactive; using System.Reactive.Disposables; +using System.Reactive.Linq; +using System.Threading.Tasks; +using Artemis.UI.Extensions; +using Artemis.UI.Screens.Workshop.Categories; using Artemis.UI.Screens.Workshop.SubmissionWizard.Steps.Profile; +using Artemis.UI.Shared.Services; using Artemis.WebClient.Workshop; +using Artemis.WebClient.Workshop.Extensions; +using Avalonia.Threading; +using DynamicData; +using DynamicData.Aggregation; +using DynamicData.Binding; using ReactiveUI; using ReactiveUI.Validation.Extensions; +using ReactiveUI.Validation.Helpers; +using StrawberryShake; +using Bitmap = Avalonia.Media.Imaging.Bitmap; namespace Artemis.UI.Screens.Workshop.SubmissionWizard.Steps; public class EntrySpecificationsStepViewModel : SubmissionViewModel { + private readonly IWindowService _windowService; + private ObservableAsPropertyHelper? _categoriesValid; + private ObservableAsPropertyHelper? _iconValid; + private string _description = string.Empty; + private Bitmap? _iconBitmap; + private bool _isDirty; private string _name = string.Empty; private string _summary = string.Empty; - private string _description = string.Empty; - public EntrySpecificationsStepViewModel() + public EntrySpecificationsStepViewModel(IWorkshopClient workshopClient, IWindowService windowService) { + _windowService = windowService; GoBack = ReactiveCommand.Create(ExecuteGoBack); Continue = ReactiveCommand.Create(ExecuteContinue, ValidationContext.Valid); - - this.WhenActivated((CompositeDisposable _) => + SelectIcon = ReactiveCommand.CreateFromTask(ExecuteSelectIcon); + ClearIcon = ReactiveCommand.Create(ExecuteClearIcon); + + workshopClient.GetCategories + .Watch(ExecutionStrategy.CacheFirst) + .SelectOperationResult(c => c.Categories) + .ToObservableChangeSet(c => c.Id) + .Transform(c => new CategoryViewModel(c)) + .Bind(out ReadOnlyObservableCollection categoryViewModels) + .Subscribe(); + Categories = categoryViewModels; + + this.WhenActivated(d => { - this.ClearValidationRules(); - DisplayName = $"{State.EntryType} Information"; + + // Basic fields Name = State.Name; Summary = State.Summary; Description = State.Description; + + // Categories + foreach (CategoryViewModel categoryViewModel in Categories) + categoryViewModel.IsSelected = State.Categories.Contains(categoryViewModel.Id); + + // Tags + Tags.Clear(); + Tags.AddRange(State.Tags); + + // Icon + if (State.Icon != null) + { + State.Icon.Seek(0, SeekOrigin.Begin); + IconBitmap = BitmapExtensions.LoadAndResize(State.Icon, 128); + } + + IsDirty = false; + this.ClearValidationRules(); + + Disposable.Create(() => + { + IconBitmap?.Dispose(); + IconBitmap = null; + }).DisposeWith(d); }); } - + public override ReactiveCommand Continue { get; } public override ReactiveCommand GoBack { get; } + public ReactiveCommand SelectIcon { get; } + public ReactiveCommand ClearIcon { get; } + + public ReadOnlyObservableCollection Categories { get; } + public ObservableCollection Tags { get; } = new(); + public bool CategoriesValid => _categoriesValid?.Value ?? true; + public bool IconValid => _iconValid?.Value ?? true; public string Name { @@ -51,6 +117,18 @@ public class EntrySpecificationsStepViewModel : SubmissionViewModel set => RaiseAndSetIfChanged(ref _description, value); } + public bool IsDirty + { + get => _isDirty; + set => RaiseAndSetIfChanged(ref _isDirty, value); + } + + public Bitmap? IconBitmap + { + get => _iconBitmap; + set => RaiseAndSetIfChanged(ref _iconBitmap, value); + } + private void ExecuteGoBack() { switch (State.EntryType) @@ -66,18 +144,77 @@ public class EntrySpecificationsStepViewModel : SubmissionViewModel throw new ArgumentOutOfRangeException(); } } - + private void ExecuteContinue() { - this.ValidationRule(vm => vm.Name, s => !string.IsNullOrWhiteSpace(s), "Name cannot be empty."); - this.ValidationRule(vm => vm.Summary, s => !string.IsNullOrWhiteSpace(s), "Summary cannot be empty."); - this.ValidationRule(vm => vm.Description, s => !string.IsNullOrWhiteSpace(s), "Description cannot be empty."); - - if (!ValidationContext.IsValid) + if (!IsDirty) + { + SetupDataValidation(); + IsDirty = true; + + // The ValidationContext seems to update asynchronously, so stop and schedule a retry + Dispatcher.UIThread.Post(ExecuteContinue); return; - + } + + if (!ValidationContext.GetIsValid()) + return; + State.Name = Name; State.Summary = Summary; State.Description = Description; + State.Categories = Categories.Where(c => c.IsSelected).Select(c => c.Id).ToList(); + State.Tags = new List(Tags); + + State.Icon?.Dispose(); + if (IconBitmap != null) + { + State.Icon = new MemoryStream(); + IconBitmap.Save(State.Icon); + } + else + { + State.Icon = null; + } + } + + private async Task ExecuteSelectIcon() + { + string[]? result = await _windowService.CreateOpenFileDialog() + .HavingFilter(f => f.WithExtension("png").WithExtension("jpg").WithExtension("bmp").WithName("Bitmap image")) + .ShowAsync(); + + if (result == null) + return; + + IconBitmap?.Dispose(); + IconBitmap = BitmapExtensions.LoadAndResize(result[0], 128); + } + + private void ExecuteClearIcon() + { + IconBitmap?.Dispose(); + IconBitmap = null; + } + + private void SetupDataValidation() + { + // Hopefully this can be avoided in the future + // https://github.com/reactiveui/ReactiveUI.Validation/discussions/558 + this.ValidationRule(vm => vm.Name, s => !string.IsNullOrWhiteSpace(s), "Name is required"); + this.ValidationRule(vm => vm.Summary, s => !string.IsNullOrWhiteSpace(s), "Summary is required"); + this.ValidationRule(vm => vm.Description, s => !string.IsNullOrWhiteSpace(s), "Description is required"); + + // These don't use inputs that support validation messages, do so manually + ValidationHelper iconRule = this.ValidationRule(vm => vm.IconBitmap, s => s != null, "Icon required"); + ValidationHelper categoriesRule = this.ValidationRule(vm => vm.Categories, Categories.ToObservableChangeSet() + .AutoRefresh(c => c.IsSelected) + .Filter(c => c.IsSelected) + .IsEmpty() + .CombineLatest(this.WhenAnyValue(vm => vm.IsDirty), (empty, dirty) => !dirty || !empty), + "At least one category must be selected" + ); + _iconValid = iconRule.ValidationChanged.Select(c => c.IsValid).ToProperty(this, vm => vm.IconValid); + _categoriesValid = categoriesRule.ValidationChanged.Select(c => c.IsValid).ToProperty(this, vm => vm.CategoriesValid); } } \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/Profile/ProfileSelectionStepViewModel.cs b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/Profile/ProfileSelectionStepViewModel.cs index 85409e33c..219fd6910 100644 --- a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/Profile/ProfileSelectionStepViewModel.cs +++ b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/Profile/ProfileSelectionStepViewModel.cs @@ -1,5 +1,6 @@ using System; using System.Collections.ObjectModel; +using System.IO; using System.Linq; using System.Reactive; using System.Reactive.Disposables; @@ -8,8 +9,19 @@ using Artemis.Core; using Artemis.Core.Services; using Artemis.Storage.Entities.Profile; using Artemis.Storage.Repositories.Interfaces; +using Artemis.UI.Extensions; using Artemis.UI.Screens.Workshop.Profile; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Shapes; +using Avalonia.Layout; +using Avalonia.Media; +using Avalonia.Media.Imaging; +using Material.Icons; +using Material.Icons.Avalonia; using ReactiveUI; +using SkiaSharp; +using Path = Avalonia.Controls.Shapes.Path; namespace Artemis.UI.Screens.Workshop.SubmissionWizard.Steps.Profile; @@ -23,12 +35,12 @@ public class ProfileSelectionStepViewModel : SubmissionViewModel public ProfileSelectionStepViewModel(IProfileService profileService, ProfilePreviewViewModel profilePreviewViewModel) { _profileService = profileService; - + // Use copies of the profiles, the originals are used by the core and could be disposed Profiles = new ObservableCollection(_profileService.ProfileConfigurations.Select(_profileService.CloneProfileConfiguration)); foreach (ProfileConfiguration profileConfiguration in Profiles) _profileService.LoadProfileConfigurationIcon(profileConfiguration); - + ProfilePreview = profilePreviewViewModel; GoBack = ReactiveCommand.Create(() => State.ChangeScreen()); @@ -45,7 +57,7 @@ public class ProfileSelectionStepViewModel : SubmissionViewModel private void Update(ProfileConfiguration? profileConfiguration) { ProfilePreview.ProfileConfiguration = null; - + foreach (ProfileConfiguration configuration in Profiles) { if (configuration == profileConfiguration) @@ -81,6 +93,10 @@ public class ProfileSelectionStepViewModel : SubmissionViewModel State.Name = SelectedProfile.Name; State.Icon = SelectedProfile.Icon.GetIconStream(); + // Render the material icon of the profile + if (State.Icon == null && SelectedProfile.Icon.IconName != null) + State.Icon = Enum.Parse(SelectedProfile.Icon.IconName).EncodeToBitmap(128, 14, SKColors.White); + State.ChangeScreen(); } } \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/SubmissionWizardState.cs b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/SubmissionWizardState.cs index fefb06891..996496e03 100644 --- a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/SubmissionWizardState.cs +++ b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/SubmissionWizardState.cs @@ -28,7 +28,7 @@ public class SubmissionWizardState public string Description { get; set; } = string.Empty; public List Categories { get; set; } = new(); - public List Tags { get; set; } = new(); + public List Tags { get; set; } = new(); public List Images { get; set; } = new(); public object? EntrySource { get; set; } diff --git a/src/Artemis.WebClient.Workshop/WorkshopConstants.cs b/src/Artemis.WebClient.Workshop/WorkshopConstants.cs index a500fa970..b514ae862 100644 --- a/src/Artemis.WebClient.Workshop/WorkshopConstants.cs +++ b/src/Artemis.WebClient.Workshop/WorkshopConstants.cs @@ -2,6 +2,6 @@ namespace Artemis.WebClient.Workshop; public static class WorkshopConstants { - public const string AUTHORITY_URL = "https://localhost:5001"; - public const string WORKSHOP_URL = "https://localhost:7281"; + public const string AUTHORITY_URL = "https://identity.artemis-rgb.com"; + public const string WORKSHOP_URL = "https://workshop.artemis-rgb.com"; } \ No newline at end of file From ad4da3032d44d58fa4a1e9b7cbb9457011ebe763 Mon Sep 17 00:00:00 2001 From: Robert Date: Sun, 13 Aug 2023 21:11:05 +0200 Subject: [PATCH 12/37] Workshop - Added profile uploading Submission wizard - Final upload steps --- src/Artemis.UI/Assets/Animations/success.json | 1 + src/Artemis.UI/Extensions/Bitmap.cs | 6 +- ...TypeView.axaml => EntryTypeStepView.axaml} | 4 +- ...ew.axaml.cs => EntryTypeStepView.axaml.cs} | 4 +- ...ViewModel.cs => EntryTypeStepViewModel.cs} | 4 +- .../Steps/LoginStepViewModel.cs | 2 +- .../ProfileAdaptionHintsStepViewModel.cs | 2 +- .../Profile/ProfileSelectionStepViewModel.cs | 2 +- ...iew.axaml => SpecificationsStepView.axaml} | 4 +- ...aml.cs => SpecificationsStepView.axaml.cs} | 4 +- ...odel.cs => SpecificationsStepViewModel.cs} | 144 +++++++++--------- .../Steps/SubmitStepView.axaml | 71 +++++++++ .../Steps/SubmitStepView.axaml.cs | 19 +++ .../Steps/SubmitStepViewModel.cs | 73 +++++++++ .../Steps/UploadStepView.axaml | 31 ++++ .../Steps/UploadStepView.axaml.cs | 19 +++ .../Steps/UploadStepViewModel.cs | 101 ++++++++++++ .../Steps/ValidateEmailStepViewModel.cs | 2 +- .../Steps/WelcomeStepView.axaml | 4 +- .../Steps/WelcomeStepViewModel.cs | 2 +- .../SubmissionWizardView.axaml | 2 +- .../DryIoc/ContainerExtensions.cs | 13 ++ .../Entities/Release.cs | 22 +++ .../Extensions/ClientBuilderExtensions.cs | 18 +++ .../Queries/CreateEntry.graphql | 5 + .../AuthenticationDelegatingHandler.cs | 22 +++ .../EntryUploadHandlerFactory.cs | 24 +++ .../UploadHandlers/EntryUploadResult.cs | 28 ++++ .../UploadHandlers/IEntryUploadHandler.cs | 7 + .../LayoutEntryUploadHandler.cs | 12 ++ .../ProfileEntryUploadHandler.cs | 61 ++++++++ .../WorkshopConstants.cs | 1 + 32 files changed, 621 insertions(+), 93 deletions(-) create mode 100644 src/Artemis.UI/Assets/Animations/success.json rename src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/{EntryTypeView.axaml => EntryTypeStepView.axaml} (96%) rename src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/{EntryTypeView.axaml.cs => EntryTypeStepView.axaml.cs} (54%) rename src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/{EntryTypeViewModel.cs => EntryTypeStepViewModel.cs} (92%) rename src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/{EntrySpecificationsStepView.axaml => SpecificationsStepView.axaml} (98%) rename src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/{EntrySpecificationsStepView.axaml.cs => SpecificationsStepView.axaml.cs} (67%) rename src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/{EntrySpecificationsStepViewModel.cs => SpecificationsStepViewModel.cs} (72%) create mode 100644 src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/SubmitStepView.axaml create mode 100644 src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/SubmitStepView.axaml.cs create mode 100644 src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/SubmitStepViewModel.cs create mode 100644 src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/UploadStepView.axaml create mode 100644 src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/UploadStepView.axaml.cs create mode 100644 src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/UploadStepViewModel.cs create mode 100644 src/Artemis.WebClient.Workshop/Entities/Release.cs create mode 100644 src/Artemis.WebClient.Workshop/Extensions/ClientBuilderExtensions.cs create mode 100644 src/Artemis.WebClient.Workshop/Queries/CreateEntry.graphql create mode 100644 src/Artemis.WebClient.Workshop/Services/AuthenticationDelegatingHandler.cs create mode 100644 src/Artemis.WebClient.Workshop/UploadHandlers/EntryUploadHandlerFactory.cs create mode 100644 src/Artemis.WebClient.Workshop/UploadHandlers/EntryUploadResult.cs create mode 100644 src/Artemis.WebClient.Workshop/UploadHandlers/IEntryUploadHandler.cs create mode 100644 src/Artemis.WebClient.Workshop/UploadHandlers/Implementations/LayoutEntryUploadHandler.cs create mode 100644 src/Artemis.WebClient.Workshop/UploadHandlers/Implementations/ProfileEntryUploadHandler.cs diff --git a/src/Artemis.UI/Assets/Animations/success.json b/src/Artemis.UI/Assets/Animations/success.json new file mode 100644 index 000000000..f274c145e --- /dev/null +++ b/src/Artemis.UI/Assets/Animations/success.json @@ -0,0 +1 @@ +{"v":"5.0.1","fr":29.9700012207031,"ip":0,"op":45.0000018328876,"w":512,"h":512,"nm":"Comp 1","ddd":0,"assets":[{"id":"comp_0","layers":[{"ddd":0,"ind":1,"ty":4,"nm":"Shape Layer 1","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.572],"y":[0.556]},"o":{"x":[0.167],"y":[0.167]},"n":["0p572_0p556_0p167_0p167"],"t":7,"s":[100],"e":[92.154]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.641],"y":[0.056]},"n":["0p833_0p833_0p641_0p056"],"t":13,"s":[92.154],"e":[30]},{"t":17.0000006924242}],"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[256,256,0],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-230,4],[214,4]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0.23137254902,0.741176470588,0.36862745098,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":70,"ix":5},"lc":2,"lj":1,"ml":4,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Shape 1","np":2,"cix":2,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.588],"y":[-51709.363]},"o":{"x":[0.167],"y":[0.167]},"n":["0p588_-51709p363_0p167_0p167"],"t":7,"s":[0],"e":[0]},{"i":{"x":[0.696],"y":[0.999]},"o":{"x":[0.509],"y":[0.003]},"n":["0p696_0p999_0p509_0p003"],"t":10,"s":[0],"e":[100]},{"t":16.0000006516934}],"ix":1},"e":{"a":1,"k":[{"i":{"x":[0.566],"y":[0.999]},"o":{"x":[0.457],"y":[0.063]},"n":["0p566_0p999_0p457_0p063"],"t":7,"s":[0],"e":[100]},{"t":16.0000006516934}],"ix":2},"o":{"a":0,"k":0,"ix":3},"m":1,"ix":2,"nm":"Trim Paths 1","mn":"ADBE Vector Filter - Trim","hd":false}],"ip":0,"op":150.000006109625,"st":0,"bm":0}]},{"id":"comp_1","layers":[{"ddd":0,"ind":1,"ty":4,"nm":"Shape Layer 1","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[256,256,0],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":1,"k":[{"i":{"x":[0,0],"y":[0.997,0.997]},"o":{"x":[0.167,0.167],"y":[0.167,0.167]},"n":["0_0p997_0p167_0p167","0_0p997_0p167_0p167"],"t":24,"s":[40,40],"e":[90,90]},{"i":{"x":[0.833,0.833],"y":[0.833,0.833]},"o":{"x":[0.574,0.574],"y":[-0.004,-0.004]},"n":["0p833_0p833_0p574_-0p004","0p833_0p833_0p574_-0p004"],"t":27,"s":[90,90],"e":[18.394,18.394]},{"t":38.0000015477717}],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"fl","c":{"a":0,"k":[1,1,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.167,"y":0.167},"n":"0p667_1_0p167_0p167","t":24,"s":[-181.074,-5.414],"e":[200,-5.414],"to":[34.0465698242188,0],"ti":[-26.72825050354,0]},{"t":38.0000015477717}],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"n":["0p833_0p833_0p167_0p167"],"t":24,"s":[0],"e":[100]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"n":["0p833_0p833_0p167_0p167"],"t":25,"s":[100],"e":[100]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"n":["0p833_0p833_0p167_0p167"],"t":29,"s":[100],"e":[0]},{"t":38.0000015477717}],"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":2,"cix":2,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":150.000006109625,"st":0,"bm":0}]},{"id":"comp_2","layers":[{"ddd":0,"ind":1,"ty":4,"nm":"Shape Layer 1","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.572],"y":[0.556]},"o":{"x":[0.167],"y":[0.167]},"n":["0p572_0p556_0p167_0p167"],"t":10,"s":[100],"e":[92.154]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.641],"y":[0.056]},"n":["0p833_0p833_0p641_0p056"],"t":16,"s":[92.154],"e":[30]},{"t":20.0000008146167}],"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[256,256,0],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-230,4],[214,4]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0.23137254902,0.741176470588,0.36862745098,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":70,"ix":5},"lc":2,"lj":1,"ml":4,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Shape 1","np":2,"cix":2,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.588],"y":[-51709.363]},"o":{"x":[0.167],"y":[0.167]},"n":["0p588_-51709p363_0p167_0p167"],"t":10,"s":[0],"e":[0]},{"i":{"x":[0.696],"y":[0.999]},"o":{"x":[0.509],"y":[0.003]},"n":["0p696_0p999_0p509_0p003"],"t":13,"s":[0],"e":[100]},{"t":19.0000007738859}],"ix":1},"e":{"a":1,"k":[{"i":{"x":[0.566],"y":[0.999]},"o":{"x":[0.457],"y":[0.063]},"n":["0p566_0p999_0p457_0p063"],"t":10,"s":[0],"e":[100]},{"t":19.0000007738859}],"ix":2},"o":{"a":0,"k":0,"ix":3},"m":1,"ix":2,"nm":"Trim Paths 1","mn":"ADBE Vector Filter - Trim","hd":false}],"ip":0,"op":150.000006109625,"st":0,"bm":0}]}],"layers":[{"ddd":0,"ind":1,"ty":0,"nm":"trait","refId":"comp_0","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":90,"ix":10},"p":{"a":0,"k":[263.334,471.109,0],"ix":2},"a":{"a":0,"k":[256,256,0],"ix":1},"s":{"a":0,"k":[15,15,100],"ix":6}},"ao":0,"w":512,"h":512,"ip":0,"op":150.000006109625,"st":0,"bm":0},{"ddd":0,"ind":2,"ty":0,"nm":"trait","refId":"comp_0","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":-180,"ix":10},"p":{"a":0,"k":[51.641,253.275,0],"ix":2},"a":{"a":0,"k":[256,256,0],"ix":1},"s":{"a":0,"k":[15,15,100],"ix":6}},"ao":0,"w":512,"h":512,"ip":0,"op":150.000006109625,"st":0,"bm":0},{"ddd":0,"ind":3,"ty":0,"nm":"trait","refId":"comp_0","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":-90,"ix":10},"p":{"a":0,"k":[266.322,44.315,0],"ix":2},"a":{"a":0,"k":[256,256,0],"ix":1},"s":{"a":0,"k":[15,15,100],"ix":6}},"ao":0,"w":512,"h":512,"ip":0,"op":150.000006109625,"st":0,"bm":0},{"ddd":0,"ind":4,"ty":0,"nm":"trait","refId":"comp_0","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[469.91,258.792,0],"ix":2},"a":{"a":0,"k":[256,256,0],"ix":1},"s":{"a":0,"k":[15,15,100],"ix":6}},"ao":0,"w":512,"h":512,"ip":0,"op":150.000006109625,"st":0,"bm":0},{"ddd":0,"ind":5,"ty":0,"nm":"firefly","refId":"comp_1","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":-18.097,"ix":10},"p":{"a":0,"k":[400.635,189.708,0],"ix":2},"a":{"a":0,"k":[256,256,0],"ix":1},"s":{"a":0,"k":[20,20,100],"ix":6}},"ao":0,"w":512,"h":512,"ip":0,"op":150.000006109625,"st":0,"bm":0},{"ddd":0,"ind":6,"ty":0,"nm":"firefly","refId":"comp_1","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":-72.471,"ix":10},"p":{"a":0,"k":[359.413,150.912,0],"ix":2},"a":{"a":0,"k":[256,256,0],"ix":1},"s":{"a":0,"k":[20,20,100],"ix":6}},"ao":0,"w":512,"h":512,"ip":0,"op":150.000006109625,"st":0,"bm":0},{"ddd":0,"ind":7,"ty":0,"nm":"firefly","refId":"comp_1","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":-45.707,"ix":10},"p":{"a":0,"k":[396.894,150.961,0],"ix":2},"a":{"a":0,"k":[256,256,0],"ix":1},"s":{"a":0,"k":[30,30,100],"ix":6}},"ao":0,"w":512,"h":512,"ip":0,"op":150.000006109625,"st":0,"bm":0},{"ddd":0,"ind":8,"ty":0,"nm":"trait 2","refId":"comp_2","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":-135.205,"ix":10},"p":{"a":0,"k":[410.865,406.53,0],"ix":2},"a":{"a":0,"k":[256,256,0],"ix":1},"s":{"a":0,"k":[-19.512,19.512,100],"ix":6}},"ao":0,"w":512,"h":512,"ip":0,"op":150.000006109625,"st":0,"bm":0},{"ddd":0,"ind":9,"ty":0,"nm":"trait 2","refId":"comp_2","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":-45.606,"ix":10},"p":{"a":0,"k":[105.535,402.598,0],"ix":2},"a":{"a":0,"k":[256,256,0],"ix":1},"s":{"a":0,"k":[-19.512,19.512,100],"ix":6}},"ao":0,"w":512,"h":512,"ip":0,"op":150.000006109625,"st":0,"bm":0},{"ddd":0,"ind":10,"ty":0,"nm":"trait 2","refId":"comp_2","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":-135.205,"ix":10},"p":{"a":0,"k":[104.864,111.71,0],"ix":2},"a":{"a":0,"k":[256,256,0],"ix":1},"s":{"a":0,"k":[19.512,19.512,100],"ix":6}},"ao":0,"w":512,"h":512,"ip":0,"op":150.000006109625,"st":0,"bm":0},{"ddd":0,"ind":11,"ty":0,"nm":"trait 2","refId":"comp_2","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":-45.606,"ix":10},"p":{"a":0,"k":[416.722,113.206,0],"ix":2},"a":{"a":0,"k":[256,256,0],"ix":1},"s":{"a":0,"k":[19.512,19.512,100],"ix":6}},"ao":0,"w":512,"h":512,"ip":0,"op":150.000006109625,"st":0,"bm":0},{"ddd":0,"ind":12,"ty":4,"nm":"Shape Layer 2","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[236.888,240.258,0],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[69.59,69.59,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0]],"v":[[-76.426,37.999],[12.056,114.074],[169.991,-68.635]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":35,"ix":5},"lc":2,"lj":1,"ml":4,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[-7,11],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Shape 1","np":2,"cix":2,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":0,"k":0,"ix":1},"e":{"a":1,"k":[{"i":{"x":[0.833],"y":[-2.986]},"o":{"x":[0.167],"y":[0]},"n":["0p833_-2p986_0p167_0"],"t":0,"s":[0],"e":[0]},{"i":{"x":[0],"y":[0.973]},"o":{"x":[0.167],"y":[0.042]},"n":["0_0p973_0p167_0p042"],"t":14.791,"s":[0],"e":[32]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.828],"y":[0.011]},"n":["0p833_0p833_0p828_0p011"],"t":19.791,"s":[32],"e":[100]},{"t":24.7912510097683}],"ix":2},"o":{"a":0,"k":0,"ix":3},"m":1,"ix":2,"nm":"Trim Paths 1","mn":"ADBE Vector Filter - Trim","hd":false}],"ip":0,"op":150.000006109625,"st":0,"bm":0},{"ddd":0,"ind":13,"ty":4,"nm":"Shape Layer 1","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[256,256,0],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[80,80,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":1,"k":[{"i":{"x":[0.976,0.976],"y":[0.968,0.968]},"o":{"x":[0.654,0.654],"y":[0.007,0.007]},"n":["0p976_0p968_0p654_0p007","0p976_0p968_0p654_0p007"],"t":0,"s":[0,0],"e":[401.025,401.025]},{"i":{"x":[0.468,0.468],"y":[1.057,1.057]},"o":{"x":[0.346,0.346],"y":[-4.83,-4.83]},"n":["0p468_1p057_0p346_-4p83","0p468_1p057_0p346_-4p83"],"t":7,"s":[401.025,401.025],"e":[372.7,372.7]},{"i":{"x":[0.375,0.375],"y":[1.543,1.543]},"o":{"x":[0.364,0.364],"y":[0.031,0.031]},"n":["0p375_1p543_0p364_0p031","0p375_1p543_0p364_0p031"],"t":12,"s":[372.7,372.7],"e":[401.025,401.025]},{"i":{"x":[0.833,0.833],"y":[1,1]},"o":{"x":[0.327,0.327],"y":[-8.038,-8.038]},"n":["0p833_1_0p327_-8p038","0p833_1_0p327_-8p038"],"t":16,"s":[401.025,401.025],"e":[401.025,401.025]},{"t":20.0000008146167}],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"fl","c":{"a":0,"k":[0.229886716955,0.739552696078,0.369435897528,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[5.992,3.49],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":2,"cix":2,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":150.000006109625,"st":0,"bm":0}]} \ No newline at end of file diff --git a/src/Artemis.UI/Extensions/Bitmap.cs b/src/Artemis.UI/Extensions/Bitmap.cs index ce8cba4d2..8c14ee4da 100644 --- a/src/Artemis.UI/Extensions/Bitmap.cs +++ b/src/Artemis.UI/Extensions/Bitmap.cs @@ -14,7 +14,11 @@ public class BitmapExtensions public static Bitmap LoadAndResize(Stream stream, int size) { - using SKBitmap source = SKBitmap.Decode(stream); + stream.Seek(0, SeekOrigin.Begin); + using MemoryStream copy = new(); + stream.CopyTo(copy); + copy.Seek(0, SeekOrigin.Begin); + using SKBitmap source = SKBitmap.Decode(copy); return Resize(source, size); } diff --git a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/EntryTypeView.axaml b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/EntryTypeStepView.axaml similarity index 96% rename from src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/EntryTypeView.axaml rename to src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/EntryTypeStepView.axaml index d94509c6e..c1720ccfa 100644 --- a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/EntryTypeView.axaml +++ b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/EntryTypeStepView.axaml @@ -6,8 +6,8 @@ xmlns:workshop="clr-namespace:Artemis.WebClient.Workshop;assembly=Artemis.WebClient.Workshop" xmlns:converters="clr-namespace:Artemis.UI.Shared.Converters;assembly=Artemis.UI.Shared" mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" - x:Class="Artemis.UI.Screens.Workshop.SubmissionWizard.Steps.EntryTypeView" - x:DataType="steps:EntryTypeViewModel"> + x:Class="Artemis.UI.Screens.Workshop.SubmissionWizard.Steps.EntryTypeStepView" + x:DataType="steps:EntryTypeStepViewModel"> diff --git a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/EntryTypeView.axaml.cs b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/EntryTypeStepView.axaml.cs similarity index 54% rename from src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/EntryTypeView.axaml.cs rename to src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/EntryTypeStepView.axaml.cs index 3e4df145c..087084ca5 100644 --- a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/EntryTypeView.axaml.cs +++ b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/EntryTypeStepView.axaml.cs @@ -2,9 +2,9 @@ using Avalonia.ReactiveUI; namespace Artemis.UI.Screens.Workshop.SubmissionWizard.Steps; -public partial class EntryTypeView : ReactiveUserControl +public partial class EntryTypeStepView : ReactiveUserControl { - public EntryTypeView() + public EntryTypeStepView() { InitializeComponent(); } diff --git a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/EntryTypeViewModel.cs b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/EntryTypeStepViewModel.cs similarity index 92% rename from src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/EntryTypeViewModel.cs rename to src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/EntryTypeStepViewModel.cs index 9d3c9ea5d..cce78ea9d 100644 --- a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/EntryTypeViewModel.cs +++ b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/EntryTypeStepViewModel.cs @@ -6,12 +6,12 @@ using ReactiveUI; namespace Artemis.UI.Screens.Workshop.SubmissionWizard.Steps; -public class EntryTypeViewModel : SubmissionViewModel +public class EntryTypeStepViewModel : SubmissionViewModel { private EntryType? _selectedEntryType; /// - public EntryTypeViewModel() + public EntryTypeStepViewModel() { GoBack = ReactiveCommand.Create(() => State.ChangeScreen()); Continue = ReactiveCommand.Create(ExecuteContinue, this.WhenAnyValue(vm => vm.SelectedEntryType).Select(e => e != null)); diff --git a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/LoginStepViewModel.cs b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/LoginStepViewModel.cs index dfad8f7af..83b7d3ca0 100644 --- a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/LoginStepViewModel.cs +++ b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/LoginStepViewModel.cs @@ -43,7 +43,7 @@ public class LoginStepViewModel : SubmissionViewModel Claim? emailVerified = _authenticationService.Claims.FirstOrDefault(c => c.Type == JwtClaimTypes.EmailVerified); if (emailVerified?.Value == "true") - State.ChangeScreen(); + State.ChangeScreen(); else State.ChangeScreen(); } diff --git a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/Profile/ProfileAdaptionHintsStepViewModel.cs b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/Profile/ProfileAdaptionHintsStepViewModel.cs index 757d6f0c0..768ca5405 100644 --- a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/Profile/ProfileAdaptionHintsStepViewModel.cs +++ b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/Profile/ProfileAdaptionHintsStepViewModel.cs @@ -62,6 +62,6 @@ public class ProfileAdaptionHintsStepViewModel : SubmissionViewModel if (Layers.Any(l => l.AdaptionHintCount == 0)) return; - State.ChangeScreen(); + State.ChangeScreen(); } } \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/Profile/ProfileSelectionStepViewModel.cs b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/Profile/ProfileSelectionStepViewModel.cs index 219fd6910..3cc2b409a 100644 --- a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/Profile/ProfileSelectionStepViewModel.cs +++ b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/Profile/ProfileSelectionStepViewModel.cs @@ -43,7 +43,7 @@ public class ProfileSelectionStepViewModel : SubmissionViewModel ProfilePreview = profilePreviewViewModel; - GoBack = ReactiveCommand.Create(() => State.ChangeScreen()); + GoBack = ReactiveCommand.Create(() => State.ChangeScreen()); Continue = ReactiveCommand.Create(ExecuteContinue, this.WhenAnyValue(vm => vm.SelectedProfile).Select(p => p != null)); this.WhenAnyValue(vm => vm.SelectedProfile).Subscribe(p => Update(p)); diff --git a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/EntrySpecificationsStepView.axaml b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/SpecificationsStepView.axaml similarity index 98% rename from src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/EntrySpecificationsStepView.axaml rename to src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/SpecificationsStepView.axaml index fdf3c6f38..197f2ef06 100644 --- a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/EntrySpecificationsStepView.axaml +++ b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/SpecificationsStepView.axaml @@ -7,8 +7,8 @@ xmlns:categories="clr-namespace:Artemis.UI.Screens.Workshop.Categories" xmlns:tagsInput="clr-namespace:Artemis.UI.Shared.TagsInput;assembly=Artemis.UI.Shared" mc:Ignorable="d" d:DesignWidth="970" d:DesignHeight="625" - x:Class="Artemis.UI.Screens.Workshop.SubmissionWizard.Steps.EntrySpecificationsStepView" - x:DataType="steps:EntrySpecificationsStepViewModel"> + x:Class="Artemis.UI.Screens.Workshop.SubmissionWizard.Steps.SpecificationsStepView" + x:DataType="steps:SpecificationsStepViewModel"> diff --git a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/EntrySpecificationsStepView.axaml.cs b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/SpecificationsStepView.axaml.cs similarity index 67% rename from src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/EntrySpecificationsStepView.axaml.cs rename to src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/SpecificationsStepView.axaml.cs index b57435eb7..f7d256121 100644 --- a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/EntrySpecificationsStepView.axaml.cs +++ b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/SpecificationsStepView.axaml.cs @@ -5,9 +5,9 @@ using Avalonia.ReactiveUI; namespace Artemis.UI.Screens.Workshop.SubmissionWizard.Steps; -public partial class EntrySpecificationsStepView : ReactiveUserControl +public partial class SpecificationsStepView : ReactiveUserControl { - public EntrySpecificationsStepView() + public SpecificationsStepView() { InitializeComponent(); } diff --git a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/EntrySpecificationsStepViewModel.cs b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/SpecificationsStepViewModel.cs similarity index 72% rename from src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/EntrySpecificationsStepViewModel.cs rename to src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/SpecificationsStepViewModel.cs index a95f335cf..fcacc0d58 100644 --- a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/EntrySpecificationsStepViewModel.cs +++ b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/SpecificationsStepViewModel.cs @@ -1,7 +1,6 @@ using System; using System.Collections.Generic; using System.Collections.ObjectModel; -using System.Drawing; using System.IO; using System.Linq; using System.Reactive; @@ -13,7 +12,6 @@ using Artemis.UI.Screens.Workshop.Categories; using Artemis.UI.Screens.Workshop.SubmissionWizard.Steps.Profile; using Artemis.UI.Shared.Services; using Artemis.WebClient.Workshop; -using Artemis.WebClient.Workshop.Extensions; using Avalonia.Threading; using DynamicData; using DynamicData.Aggregation; @@ -26,18 +24,17 @@ using Bitmap = Avalonia.Media.Imaging.Bitmap; namespace Artemis.UI.Screens.Workshop.SubmissionWizard.Steps; -public class EntrySpecificationsStepViewModel : SubmissionViewModel +public class SpecificationsStepViewModel : SubmissionViewModel { private readonly IWindowService _windowService; private ObservableAsPropertyHelper? _categoriesValid; private ObservableAsPropertyHelper? _iconValid; private string _description = string.Empty; - private Bitmap? _iconBitmap; - private bool _isDirty; private string _name = string.Empty; private string _summary = string.Empty; + private Bitmap? _iconBitmap; - public EntrySpecificationsStepViewModel(IWorkshopClient workshopClient, IWindowService windowService) + public SpecificationsStepViewModel(IWorkshopClient workshopClient, IWindowService windowService) { _windowService = windowService; GoBack = ReactiveCommand.Create(ExecuteGoBack); @@ -45,47 +42,18 @@ public class EntrySpecificationsStepViewModel : SubmissionViewModel SelectIcon = ReactiveCommand.CreateFromTask(ExecuteSelectIcon); ClearIcon = ReactiveCommand.Create(ExecuteClearIcon); - workshopClient.GetCategories - .Watch(ExecutionStrategy.CacheFirst) - .SelectOperationResult(c => c.Categories) - .ToObservableChangeSet(c => c.Id) - .Transform(c => new CategoryViewModel(c)) - .Bind(out ReadOnlyObservableCollection categoryViewModels) - .Subscribe(); - Categories = categoryViewModels; - this.WhenActivated(d => { DisplayName = $"{State.EntryType} Information"; - - // Basic fields - Name = State.Name; - Summary = State.Summary; - Description = State.Description; - // Categories - foreach (CategoryViewModel categoryViewModel in Categories) - categoryViewModel.IsSelected = State.Categories.Contains(categoryViewModel.Id); + // Load categories + Observable.FromAsync(workshopClient.GetCategories.ExecuteAsync).Subscribe(PopulateCategories).DisposeWith(d); - // Tags - Tags.Clear(); - Tags.AddRange(State.Tags); + // Apply the state + ApplyFromState(); - // Icon - if (State.Icon != null) - { - State.Icon.Seek(0, SeekOrigin.Begin); - IconBitmap = BitmapExtensions.LoadAndResize(State.Icon, 128); - } - - IsDirty = false; this.ClearValidationRules(); - - Disposable.Create(() => - { - IconBitmap?.Dispose(); - IconBitmap = null; - }).DisposeWith(d); + Disposable.Create(ExecuteClearIcon).DisposeWith(d); }); } @@ -94,7 +62,7 @@ public class EntrySpecificationsStepViewModel : SubmissionViewModel public ReactiveCommand SelectIcon { get; } public ReactiveCommand ClearIcon { get; } - public ReadOnlyObservableCollection Categories { get; } + public ObservableCollection Categories { get; } = new(); public ObservableCollection Tags { get; } = new(); public bool CategoriesValid => _categoriesValid?.Value ?? true; public bool IconValid => _iconValid?.Value ?? true; @@ -117,12 +85,6 @@ public class EntrySpecificationsStepViewModel : SubmissionViewModel set => RaiseAndSetIfChanged(ref _description, value); } - public bool IsDirty - { - get => _isDirty; - set => RaiseAndSetIfChanged(ref _isDirty, value); - } - public Bitmap? IconBitmap { get => _iconBitmap; @@ -131,6 +93,9 @@ public class EntrySpecificationsStepViewModel : SubmissionViewModel private void ExecuteGoBack() { + // Apply what's there so far + ApplyToState(); + switch (State.EntryType) { case EntryType.Layout: @@ -147,35 +112,20 @@ public class EntrySpecificationsStepViewModel : SubmissionViewModel private void ExecuteContinue() { - if (!IsDirty) + if (!ValidationContext.Validations.Any()) { - SetupDataValidation(); - IsDirty = true; - // The ValidationContext seems to update asynchronously, so stop and schedule a retry + SetupDataValidation(); Dispatcher.UIThread.Post(ExecuteContinue); return; } + ApplyToState(); + if (!ValidationContext.GetIsValid()) return; - - State.Name = Name; - State.Summary = Summary; - State.Description = Description; - State.Categories = Categories.Where(c => c.IsSelected).Select(c => c.Id).ToList(); - State.Tags = new List(Tags); - - State.Icon?.Dispose(); - if (IconBitmap != null) - { - State.Icon = new MemoryStream(); - IconBitmap.Save(State.Icon); - } - else - { - State.Icon = null; - } + + State.ChangeScreen(); } private async Task ExecuteSelectIcon() @@ -197,6 +147,13 @@ public class EntrySpecificationsStepViewModel : SubmissionViewModel IconBitmap = null; } + private void PopulateCategories(IOperationResult result) + { + Categories.Clear(); + if (result.Data != null) + Categories.AddRange(result.Data.Categories.Select(c => new CategoryViewModel(c) {IsSelected = State.Categories.Contains(c.Id)})); + } + private void SetupDataValidation() { // Hopefully this can be avoided in the future @@ -204,17 +161,56 @@ public class EntrySpecificationsStepViewModel : SubmissionViewModel this.ValidationRule(vm => vm.Name, s => !string.IsNullOrWhiteSpace(s), "Name is required"); this.ValidationRule(vm => vm.Summary, s => !string.IsNullOrWhiteSpace(s), "Summary is required"); this.ValidationRule(vm => vm.Description, s => !string.IsNullOrWhiteSpace(s), "Description is required"); - + // These don't use inputs that support validation messages, do so manually ValidationHelper iconRule = this.ValidationRule(vm => vm.IconBitmap, s => s != null, "Icon required"); - ValidationHelper categoriesRule = this.ValidationRule(vm => vm.Categories, Categories.ToObservableChangeSet() - .AutoRefresh(c => c.IsSelected) - .Filter(c => c.IsSelected) - .IsEmpty() - .CombineLatest(this.WhenAnyValue(vm => vm.IsDirty), (empty, dirty) => !dirty || !empty), + ValidationHelper categoriesRule = this.ValidationRule(vm => vm.Categories, Categories.ToObservableChangeSet().AutoRefresh(c => c.IsSelected).Filter(c => c.IsSelected).IsNotEmpty(), "At least one category must be selected" ); _iconValid = iconRule.ValidationChanged.Select(c => c.IsValid).ToProperty(this, vm => vm.IconValid); _categoriesValid = categoriesRule.ValidationChanged.Select(c => c.IsValid).ToProperty(this, vm => vm.CategoriesValid); } + + private void ApplyFromState() + { + // Basic fields + Name = State.Name; + Summary = State.Summary; + Description = State.Description; + + // Tags + Tags.Clear(); + Tags.AddRange(State.Tags); + + // Icon + if (State.Icon != null) + { + State.Icon.Seek(0, SeekOrigin.Begin); + IconBitmap = BitmapExtensions.LoadAndResize(State.Icon, 128); + } + } + + private void ApplyToState() + { + // Basic fields + State.Name = Name; + State.Summary = Summary; + State.Description = Description; + + // Categories and tasks + State.Categories = Categories.Where(c => c.IsSelected).Select(c => c.Id).ToList(); + State.Tags = new List(Tags); + + // Icon + State.Icon?.Dispose(); + if (IconBitmap != null) + { + State.Icon = new MemoryStream(); + IconBitmap.Save(State.Icon); + } + else + { + State.Icon = null; + } + } } \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/SubmitStepView.axaml b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/SubmitStepView.axaml new file mode 100644 index 000000000..381f0ffb9 --- /dev/null +++ b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/SubmitStepView.axaml @@ -0,0 +1,71 @@ + + + + + + + + + Ready to submit? + + + We have all the information we need, are you ready to submit the following to the workshop? + + + + + + + + + + + + + + by + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/SubmitStepView.axaml.cs b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/SubmitStepView.axaml.cs new file mode 100644 index 000000000..49f0af7ff --- /dev/null +++ b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/SubmitStepView.axaml.cs @@ -0,0 +1,19 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Markup.Xaml; +using Avalonia.ReactiveUI; + +namespace Artemis.UI.Screens.Workshop.SubmissionWizard.Steps; + +public partial class SubmitStepView : ReactiveUserControl +{ + public SubmitStepView() + { + InitializeComponent(); + } + + private void InitializeComponent() + { + AvaloniaXamlLoader.Load(this); + } +} \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/SubmitStepViewModel.cs b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/SubmitStepViewModel.cs new file mode 100644 index 000000000..0752ccc0d --- /dev/null +++ b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/SubmitStepViewModel.cs @@ -0,0 +1,73 @@ +using System.Collections.ObjectModel; +using System.Linq; +using System.Reactive; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using Artemis.UI.Screens.Workshop.Categories; +using Artemis.WebClient.Workshop; +using Artemis.WebClient.Workshop.Services; +using IdentityModel; +using ReactiveUI; +using StrawberryShake; +using System; +using System.IO; +using Avalonia.Media.Imaging; + +namespace Artemis.UI.Screens.Workshop.SubmissionWizard.Steps; + +public class SubmitStepViewModel : SubmissionViewModel +{ + private ReadOnlyObservableCollection? _categories; + private Bitmap? _iconBitmap; + + /// + public SubmitStepViewModel(IAuthenticationService authenticationService, IWorkshopClient workshopClient) + { + CurrentUser = authenticationService.Claims.FirstOrDefault(c => c.Type == JwtClaimTypes.Name)?.Value; + GoBack = ReactiveCommand.Create(() => State.ChangeScreen()); + Continue = ReactiveCommand.Create(() => State.ChangeScreen()); + + ContinueText = "Submit"; + + this.WhenActivated(d => + { + if (State.Icon != null) + { + State.Icon.Seek(0, SeekOrigin.Begin); + IconBitmap = new Bitmap(State.Icon); + IconBitmap.DisposeWith(d); + } + Observable.FromAsync(workshopClient.GetCategories.ExecuteAsync).Subscribe(PopulateCategories).DisposeWith(d); + }); + } + + public Bitmap? IconBitmap + { + get => _iconBitmap; + set => RaiseAndSetIfChanged(ref _iconBitmap, value); + } + + public string? CurrentUser { get; } + + public ReadOnlyObservableCollection? Categories + { + get => _categories; + set => RaiseAndSetIfChanged(ref _categories, value); + } + + public override ReactiveCommand Continue { get; } + + public override ReactiveCommand GoBack { get; } + + private void PopulateCategories(IOperationResult result) + { + if (result.Data == null) + Categories = null; + else + { + Categories = new ReadOnlyObservableCollection( + new ObservableCollection(result.Data.Categories.Where(c => State.Categories.Contains(c.Id)).Select(c => new CategoryViewModel(c))) + ); + } + } +} \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/UploadStepView.axaml b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/UploadStepView.axaml new file mode 100644 index 000000000..bd215cce5 --- /dev/null +++ b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/UploadStepView.axaml @@ -0,0 +1,31 @@ + + + + + + + + + Uploading your submission... + + + Wooo, the final step, that was pretty easy, right!? + + + + + All done! Hit finish to view your submission. + + + + diff --git a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/UploadStepView.axaml.cs b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/UploadStepView.axaml.cs new file mode 100644 index 000000000..7dc094d0c --- /dev/null +++ b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/UploadStepView.axaml.cs @@ -0,0 +1,19 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Markup.Xaml; +using Avalonia.ReactiveUI; + +namespace Artemis.UI.Screens.Workshop.SubmissionWizard.Steps; + +public partial class UploadStepView : ReactiveUserControl +{ + public UploadStepView() + { + InitializeComponent(); + } + + private void InitializeComponent() + { + AvaloniaXamlLoader.Load(this); + } +} \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/UploadStepViewModel.cs b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/UploadStepViewModel.cs new file mode 100644 index 000000000..8cec0dba1 --- /dev/null +++ b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/UploadStepViewModel.cs @@ -0,0 +1,101 @@ +using System; +using System.Reactive; +using System.Reactive.Linq; +using System.Threading; +using System.Threading.Tasks; +using Artemis.UI.Shared.Services; +using Artemis.WebClient.Workshop; +using Artemis.WebClient.Workshop.UploadHandlers; +using ReactiveUI; +using StrawberryShake; +using System.Reactive.Disposables; +using Artemis.Core; + +namespace Artemis.UI.Screens.Workshop.SubmissionWizard.Steps; + +public class UploadStepViewModel : SubmissionViewModel +{ + private readonly IWorkshopClient _workshopClient; + private readonly EntryUploadHandlerFactory _entryUploadHandlerFactory; + private readonly IWindowService _windowService; + private bool _finished; + + /// + public UploadStepViewModel(IWorkshopClient workshopClient, EntryUploadHandlerFactory entryUploadHandlerFactory, IWindowService windowService) + { + _workshopClient = workshopClient; + _entryUploadHandlerFactory = entryUploadHandlerFactory; + _windowService = windowService; + + ShowGoBack = false; + ContinueText = "Finish"; + Continue = ReactiveCommand.Create(ExecuteContinue, this.WhenAnyValue(vm => vm.Finished)); + + this.WhenActivated(d => Observable.FromAsync(ExecuteUpload).Subscribe().DisposeWith(d)); + } + + /// + public override ReactiveCommand Continue { get; } + + /// + public override ReactiveCommand GoBack { get; } = null!; + + public bool Finished + { + get => _finished; + set => RaiseAndSetIfChanged(ref _finished, value); + } + + public async Task ExecuteUpload(CancellationToken cancellationToken) + { + IOperationResult result = await _workshopClient.AddEntry.ExecuteAsync(new CreateEntryInput + { + EntryType = State.EntryType, + Name = State.Name, + Summary = State.Summary, + Description = State.Description, + Categories = State.Categories, + Tags = State.Tags + }, cancellationToken); + + Guid? entryId = result.Data?.AddEntry?.Id; + if (result.IsErrorResult() || entryId == null) + { + await _windowService.ShowConfirmContentDialog("Failed to create workshop entry", result.Errors.ToString() ?? "Not even an error message", "Close", null); + return; + } + + if (cancellationToken.IsCancellationRequested) + return; + + // Create the workshop entry + try + { + IEntryUploadHandler uploadHandler = _entryUploadHandlerFactory.CreateHandler(State.EntryType); + EntryUploadResult uploadResult = await uploadHandler.CreateReleaseAsync(entryId.Value, State.EntrySource!, cancellationToken); + if (!uploadResult.IsSuccess) + { + string? message = uploadResult.Message; + if (message != null) + message += "\r\n\r\n"; + else + message = ""; + message += "Your submission has still been saved, you may try to upload a new release"; + await _windowService.ShowConfirmContentDialog("Failed to upload workshop entry", message, "Close", null); + return; + } + + Finished = true; + } + catch (Exception e) + { + // Something went wrong when creating a release :c + // We'll keep the workshop entry so that the user can make changes and try again + } + } + + private void ExecuteContinue() + { + throw new NotImplementedException(); + } +} \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/ValidateEmailStepViewModel.cs b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/ValidateEmailStepViewModel.cs index a6576ee88..3778c2673 100644 --- a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/ValidateEmailStepViewModel.cs +++ b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/ValidateEmailStepViewModel.cs @@ -73,7 +73,7 @@ public class ValidateEmailStepViewModel : SubmissionViewModel private void ExecuteContinue() { - State.ChangeScreen(); + State.ChangeScreen(); } private async Task ExecuteRefresh(CancellationToken ct) diff --git a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/WelcomeStepView.axaml b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/WelcomeStepView.axaml index 7bf5c4dd8..9f9538c38 100644 --- a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/WelcomeStepView.axaml +++ b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/WelcomeStepView.axaml @@ -3,7 +3,7 @@ xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:steps="clr-namespace:Artemis.UI.Screens.Workshop.SubmissionWizard.Steps" - mc:Ignorable="d" d:DesignWidth="970" d:DesignHeight="625" + mc:Ignorable="d" d:DesignWidth="970" d:DesignHeight="900" x:Class="Artemis.UI.Screens.Workshop.SubmissionWizard.Steps.WelcomeStepView" x:DataType="steps:WelcomeStepViewModel"> @@ -14,6 +14,6 @@ Here we'll take you, step by step, through the process of uploading your submission to the workshop. - + \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/WelcomeStepViewModel.cs b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/WelcomeStepViewModel.cs index caa55733c..3160fbb1e 100644 --- a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/WelcomeStepViewModel.cs +++ b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/WelcomeStepViewModel.cs @@ -38,7 +38,7 @@ public class WelcomeStepViewModel : SubmissionViewModel else { if (_authenticationService.Claims.Any(c => c.Type == JwtClaimTypes.EmailVerified && c.Value == "true")) - State.ChangeScreen(); + State.ChangeScreen(); else State.ChangeScreen(); } diff --git a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/SubmissionWizardView.axaml b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/SubmissionWizardView.axaml index 055860af1..e313f0f9f 100644 --- a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/SubmissionWizardView.axaml +++ b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/SubmissionWizardView.axaml @@ -12,7 +12,7 @@ Icon="/Assets/Images/Logo/application.ico" Title="Artemis | Workshop submission wizard" Width="1000" - Height="735" + Height="950" WindowStartupLocation="CenterOwner"> diff --git a/src/Artemis.WebClient.Workshop/DryIoc/ContainerExtensions.cs b/src/Artemis.WebClient.Workshop/DryIoc/ContainerExtensions.cs index bdcdbede6..46c4060fc 100644 --- a/src/Artemis.WebClient.Workshop/DryIoc/ContainerExtensions.cs +++ b/src/Artemis.WebClient.Workshop/DryIoc/ContainerExtensions.cs @@ -1,5 +1,9 @@ +using System.Reflection; +using Artemis.WebClient.Workshop.Extensions; using Artemis.WebClient.Workshop.Repositories; using Artemis.WebClient.Workshop.Services; +using Artemis.WebClient.Workshop.State; +using Artemis.WebClient.Workshop.UploadHandlers; using DryIoc; using DryIoc.Microsoft.DependencyInjection; using IdentityModel.Client; @@ -18,11 +22,17 @@ public static class ContainerExtensions /// The builder building the current container public static void RegisterWorkshopClient(this IContainer container) { + Assembly[] workshopAssembly = {typeof(WorkshopConstants).Assembly}; + ServiceCollection serviceCollection = new(); serviceCollection .AddHttpClient() .AddWorkshopClient() + .AddHttpMessageHandler() .ConfigureHttpClient(client => client.BaseAddress = new Uri(WorkshopConstants.WORKSHOP_URL + "/graphql")); + serviceCollection.AddHttpClient(WorkshopConstants.WORKSHOP_CLIENT_NAME) + .AddHttpMessageHandler() + .ConfigureHttpClient(client => client.BaseAddress = new Uri(WorkshopConstants.WORKSHOP_URL)); serviceCollection.AddSingleton(r => { @@ -34,5 +44,8 @@ public static class ContainerExtensions container.Register(Reuse.Singleton); container.Register(Reuse.Singleton); + + container.Register(Reuse.Transient); + container.RegisterMany(workshopAssembly, type => type.IsAssignableTo(), Reuse.Transient); } } \ No newline at end of file diff --git a/src/Artemis.WebClient.Workshop/Entities/Release.cs b/src/Artemis.WebClient.Workshop/Entities/Release.cs new file mode 100644 index 000000000..e2c2d1146 --- /dev/null +++ b/src/Artemis.WebClient.Workshop/Entities/Release.cs @@ -0,0 +1,22 @@ +using System.ComponentModel.DataAnnotations; + +namespace Artemis.Web.Workshop.Entities; + +public class Release +{ + public Guid Id { get; set; } + + [MaxLength(64)] + public string Version { get; set; } = string.Empty; + + public DateTimeOffset CreatedAt { get; set; } + + public long Downloads { get; set; } + + public long DownloadSize { get; set; } + + [MaxLength(32)] + public string? Md5Hash { get; set; } + + public Guid EntryId { get; set; } +} \ No newline at end of file diff --git a/src/Artemis.WebClient.Workshop/Extensions/ClientBuilderExtensions.cs b/src/Artemis.WebClient.Workshop/Extensions/ClientBuilderExtensions.cs new file mode 100644 index 000000000..6db06795f --- /dev/null +++ b/src/Artemis.WebClient.Workshop/Extensions/ClientBuilderExtensions.cs @@ -0,0 +1,18 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Http; +using StrawberryShake; + +namespace Artemis.WebClient.Workshop.Extensions; + +public static class ClientBuilderExtensions +{ + public static IClientBuilder AddHttpMessageHandler(this IClientBuilder builder) where THandler : DelegatingHandler where T : IStoreAccessor + { + builder.Services.Configure( + builder.ClientName, + options => options.HttpMessageHandlerBuilderActions.Add(b => b.AdditionalHandlers.Add(b.Services.GetRequiredService())) + ); + + return builder; + } +} \ No newline at end of file diff --git a/src/Artemis.WebClient.Workshop/Queries/CreateEntry.graphql b/src/Artemis.WebClient.Workshop/Queries/CreateEntry.graphql new file mode 100644 index 000000000..7191bc704 --- /dev/null +++ b/src/Artemis.WebClient.Workshop/Queries/CreateEntry.graphql @@ -0,0 +1,5 @@ +mutation AddEntry ($input: CreateEntryInput!) { + addEntry(input: $input) { + id + } +} diff --git a/src/Artemis.WebClient.Workshop/Services/AuthenticationDelegatingHandler.cs b/src/Artemis.WebClient.Workshop/Services/AuthenticationDelegatingHandler.cs new file mode 100644 index 000000000..3adda3145 --- /dev/null +++ b/src/Artemis.WebClient.Workshop/Services/AuthenticationDelegatingHandler.cs @@ -0,0 +1,22 @@ +using System.Net.Http.Headers; + +namespace Artemis.WebClient.Workshop.Services; + +public class AuthenticationDelegatingHandler : DelegatingHandler +{ + private readonly IAuthenticationService _authenticationService; + + /// + public AuthenticationDelegatingHandler(IAuthenticationService authenticationService) + { + _authenticationService = authenticationService; + } + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + string? token = await _authenticationService.GetBearer(); + if (token != null) + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + return await base.SendAsync(request, cancellationToken); + } +} \ No newline at end of file diff --git a/src/Artemis.WebClient.Workshop/UploadHandlers/EntryUploadHandlerFactory.cs b/src/Artemis.WebClient.Workshop/UploadHandlers/EntryUploadHandlerFactory.cs new file mode 100644 index 000000000..ea0155e4f --- /dev/null +++ b/src/Artemis.WebClient.Workshop/UploadHandlers/EntryUploadHandlerFactory.cs @@ -0,0 +1,24 @@ +using Artemis.WebClient.Workshop.UploadHandlers.Implementations; +using DryIoc; + +namespace Artemis.WebClient.Workshop.UploadHandlers; + +public class EntryUploadHandlerFactory +{ + private readonly IContainer _container; + + public EntryUploadHandlerFactory(IContainer container) + { + _container = container; + } + + public IEntryUploadHandler CreateHandler(EntryType entryType) + { + return entryType switch + { + EntryType.Profile => _container.Resolve(), + EntryType.Layout => _container.Resolve(), + _ => throw new NotSupportedException($"EntryType '{entryType}' is not supported.") + }; + } +} \ No newline at end of file diff --git a/src/Artemis.WebClient.Workshop/UploadHandlers/EntryUploadResult.cs b/src/Artemis.WebClient.Workshop/UploadHandlers/EntryUploadResult.cs new file mode 100644 index 000000000..040b99413 --- /dev/null +++ b/src/Artemis.WebClient.Workshop/UploadHandlers/EntryUploadResult.cs @@ -0,0 +1,28 @@ +using Artemis.Web.Workshop.Entities; + +namespace Artemis.WebClient.Workshop.UploadHandlers; + +public class EntryUploadResult +{ + public bool IsSuccess { get; set; } + public string? Message { get; set; } + public Release? Release { get; set; } + + public static EntryUploadResult FromFailure(string? message) + { + return new EntryUploadResult + { + IsSuccess = false, + Message = message + }; + } + + public static EntryUploadResult FromSuccess(Release release) + { + return new EntryUploadResult + { + IsSuccess = true, + Release = release + }; + } +} \ No newline at end of file diff --git a/src/Artemis.WebClient.Workshop/UploadHandlers/IEntryUploadHandler.cs b/src/Artemis.WebClient.Workshop/UploadHandlers/IEntryUploadHandler.cs new file mode 100644 index 000000000..a80787acb --- /dev/null +++ b/src/Artemis.WebClient.Workshop/UploadHandlers/IEntryUploadHandler.cs @@ -0,0 +1,7 @@ + +namespace Artemis.WebClient.Workshop.UploadHandlers; + +public interface IEntryUploadHandler +{ + Task CreateReleaseAsync(Guid entryId, object file, CancellationToken cancellationToken); +} \ No newline at end of file diff --git a/src/Artemis.WebClient.Workshop/UploadHandlers/Implementations/LayoutEntryUploadHandler.cs b/src/Artemis.WebClient.Workshop/UploadHandlers/Implementations/LayoutEntryUploadHandler.cs new file mode 100644 index 000000000..1b3318940 --- /dev/null +++ b/src/Artemis.WebClient.Workshop/UploadHandlers/Implementations/LayoutEntryUploadHandler.cs @@ -0,0 +1,12 @@ +using RGB.NET.Layout; + +namespace Artemis.WebClient.Workshop.UploadHandlers.Implementations; + +public class LayoutEntryUploadHandler : IEntryUploadHandler +{ + /// + public async Task CreateReleaseAsync(Guid entryId, object file, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } +} \ No newline at end of file diff --git a/src/Artemis.WebClient.Workshop/UploadHandlers/Implementations/ProfileEntryUploadHandler.cs b/src/Artemis.WebClient.Workshop/UploadHandlers/Implementations/ProfileEntryUploadHandler.cs new file mode 100644 index 000000000..4582e4f9b --- /dev/null +++ b/src/Artemis.WebClient.Workshop/UploadHandlers/Implementations/ProfileEntryUploadHandler.cs @@ -0,0 +1,61 @@ +using System.IO.Compression; +using System.Net.Http.Headers; +using System.Text; +using Artemis.Core; +using Artemis.Core.Services; +using Artemis.Web.Workshop.Entities; +using Newtonsoft.Json; + +namespace Artemis.WebClient.Workshop.UploadHandlers.Implementations; + +public class ProfileEntryUploadHandler : IEntryUploadHandler +{ + private readonly IHttpClientFactory _httpClientFactory; + private readonly IProfileService _profileService; + + public ProfileEntryUploadHandler(IHttpClientFactory httpClientFactory, IProfileService profileService) + { + _httpClientFactory = httpClientFactory; + _profileService = profileService; + } + + /// + public async Task CreateReleaseAsync(Guid entryId, object file, CancellationToken cancellationToken) + { + if (file is not ProfileConfiguration profileConfiguration) + throw new InvalidOperationException("Can only create releases for profile configurations"); + + ProfileConfigurationExportModel export = _profileService.ExportProfile(profileConfiguration); + string json = JsonConvert.SerializeObject(export, IProfileService.ExportSettings); + + using MemoryStream archiveStream = new(); + + // Create a ZIP archive with a single entry on the archive stream + using (ZipArchive archive = new(archiveStream, ZipArchiveMode.Create, true)) + { + ZipArchiveEntry entry = archive.CreateEntry("profile.json"); + await using (Stream entryStream = entry.Open()) + { + await entryStream.WriteAsync(Encoding.Default.GetBytes(json), cancellationToken); + } + } + + // Submit the archive + HttpClient client = _httpClientFactory.CreateClient(WorkshopConstants.WORKSHOP_CLIENT_NAME); + + // Construct the request + archiveStream.Seek(0, SeekOrigin.Begin); + MultipartFormDataContent content = new(); + StreamContent streamContent = new(archiveStream); + streamContent.Headers.ContentType = new MediaTypeHeaderValue("application/zip"); + content.Add(streamContent, "file", "file.zip"); + + // Submit + HttpResponseMessage response = await client.PostAsync("releases/upload/" + entryId, content, cancellationToken); + if (!response.IsSuccessStatusCode) + return EntryUploadResult.FromFailure($"{response.StatusCode} - {await response.Content.ReadAsStringAsync(cancellationToken)}"); + + Release? release = JsonConvert.DeserializeObject(await response.Content.ReadAsStringAsync(cancellationToken)); + return release != null ? EntryUploadResult.FromSuccess(release) : EntryUploadResult.FromFailure("Failed to deserialize response"); + } +} \ No newline at end of file diff --git a/src/Artemis.WebClient.Workshop/WorkshopConstants.cs b/src/Artemis.WebClient.Workshop/WorkshopConstants.cs index b514ae862..192a92b56 100644 --- a/src/Artemis.WebClient.Workshop/WorkshopConstants.cs +++ b/src/Artemis.WebClient.Workshop/WorkshopConstants.cs @@ -4,4 +4,5 @@ public static class WorkshopConstants { public const string AUTHORITY_URL = "https://identity.artemis-rgb.com"; public const string WORKSHOP_URL = "https://workshop.artemis-rgb.com"; + public const string WORKSHOP_CLIENT_NAME = "WorkshopApiClient"; } \ No newline at end of file From a1fd8d50997bc7e4dd6f99ad5c3db8ba7030d412 Mon Sep 17 00:00:00 2001 From: Robert Date: Mon, 14 Aug 2023 20:10:05 +0200 Subject: [PATCH 13/37] Shared UI - Added HttpClientUtilities offering progressable stream content Submission wizard - Added upload progress Submission wizard - Close on finish --- .../Utilities/ProgressableStreamContent.cs | 119 ++++++++++++++++++ .../Utilities/StreamProgress.cs | 57 +++++++++ .../Workshop/Home/WorkshopHomeViewModel.cs | 2 +- .../Steps/UploadStepView.axaml | 6 + .../Steps/UploadStepViewModel.cs | 50 +++++++- .../SubmissionWizard/SubmissionWizardState.cs | 10 ++ .../Artemis.WebClient.Workshop.csproj | 1 + .../UploadHandlers/IEntryUploadHandler.cs | 5 +- .../LayoutEntryUploadHandler.cs | 4 +- .../ProfileEntryUploadHandler.cs | 11 +- 10 files changed, 249 insertions(+), 16 deletions(-) create mode 100644 src/Artemis.UI.Shared/Utilities/ProgressableStreamContent.cs create mode 100644 src/Artemis.UI.Shared/Utilities/StreamProgress.cs diff --git a/src/Artemis.UI.Shared/Utilities/ProgressableStreamContent.cs b/src/Artemis.UI.Shared/Utilities/ProgressableStreamContent.cs new file mode 100644 index 000000000..bac5bb9d1 --- /dev/null +++ b/src/Artemis.UI.Shared/Utilities/ProgressableStreamContent.cs @@ -0,0 +1,119 @@ +// Heavily based on: +// SkyClip +// - ProgressableStreamContent.cs +// -------------------------------------------------------------------- +// Author: Jeff Hansen +// Copyright (C) Jeff Hansen 2015. All rights reserved. + +using System; +using System.IO; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; + +namespace Artemis.UI.Shared.Utilities; + +/// +/// Provides HTTP content based on a stream with support for IProgress. +/// +public class ProgressableStreamContent : StreamContent +{ + private const int DEFAULT_BUFFER_SIZE = 4096; + + private readonly int _bufferSize; + private readonly IProgress _progress; + private readonly Stream _streamToWrite; + private bool _contentConsumed; + + /// + /// Initializes a new instance of the class. + /// + /// The stream to write. + /// The downloader. + public ProgressableStreamContent(Stream streamToWrite, IProgress progress) : this(streamToWrite, DEFAULT_BUFFER_SIZE, progress) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The stream to write. + /// The buffer size. + /// The downloader. + public ProgressableStreamContent(Stream streamToWrite, int bufferSize, IProgress progress) : base(streamToWrite, bufferSize) + { + if (bufferSize <= 0) + throw new ArgumentOutOfRangeException(nameof(bufferSize)); + + _streamToWrite = streamToWrite; + _bufferSize = bufferSize; + _progress = progress; + } + + /// + protected override void Dispose(bool disposing) + { + if (disposing) + _streamToWrite.Dispose(); + + base.Dispose(disposing); + } + + /// + protected override async Task SerializeToStreamAsync(Stream stream, TransportContext? context) + { + await SerializeToStreamAsync(stream, context, CancellationToken.None); + } + + /// + protected override async Task SerializeToStreamAsync(Stream stream, TransportContext? context, CancellationToken cancellationToken) + { + PrepareContent(); + + byte[] buffer = new byte[_bufferSize]; + long size = _streamToWrite.Length; + int uploaded = 0; + + await using (_streamToWrite) + { + while (!cancellationToken.IsCancellationRequested) + { + int length = await _streamToWrite.ReadAsync(buffer, cancellationToken); + if (length <= 0) + break; + + uploaded += length; + _progress.Report(new StreamProgress(uploaded, size)); + await stream.WriteAsync(buffer, 0, length, cancellationToken); + } + } + } + + /// + protected override bool TryComputeLength(out long length) + { + length = _streamToWrite.Length; + return true; + } + + /// + /// Prepares the content. + /// + /// The stream has already been read. + private void PrepareContent() + { + if (_contentConsumed) + { + // If the content needs to be written to a target stream a 2nd time, then the stream must support + // seeking (e.g. a FileStream), otherwise the stream can't be copied a second time to a target + // stream (e.g. a NetworkStream). + if (_streamToWrite.CanSeek) + _streamToWrite.Position = 0; + else + throw new InvalidOperationException("The stream has already been read."); + } + + _contentConsumed = true; + } +} \ No newline at end of file diff --git a/src/Artemis.UI.Shared/Utilities/StreamProgress.cs b/src/Artemis.UI.Shared/Utilities/StreamProgress.cs new file mode 100644 index 000000000..eb0b7a713 --- /dev/null +++ b/src/Artemis.UI.Shared/Utilities/StreamProgress.cs @@ -0,0 +1,57 @@ +// Heavily based on: +// SkyClip +// - UploadProgress.cs +// -------------------------------------------------------------------- +// Author: Jeff Hansen +// Copyright (C) Jeff Hansen 2015. All rights reserved. + +namespace Artemis.UI.Shared.Utilities; + +/// +/// The upload progress. +/// +public class StreamProgress +{ + /// + /// Initializes a new instance of the class. + /// + /// + /// The bytes transfered. + /// + /// + /// The total bytes. + /// + public StreamProgress(long bytesTransfered, long? totalBytes) + { + BytesTransfered = bytesTransfered; + TotalBytes = totalBytes; + if (totalBytes.HasValue) + ProgressPercentage = (int) ((float) bytesTransfered / totalBytes.Value * 100); + } + + /// + /// Returns a that represents this instance. + /// + /// + /// A that represents this instance. + /// + public override string ToString() + { + return string.Format("{0}% ({1} / {2})", ProgressPercentage, BytesTransfered, TotalBytes); + } + + /// + /// Gets the bytes transfered. + /// + public long BytesTransfered { get; } + + /// + /// Gets the progress percentage. + /// + public int ProgressPercentage { get; } + + /// + /// Gets the total bytes. + /// + public long? TotalBytes { get; } +} \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/Home/WorkshopHomeViewModel.cs b/src/Artemis.UI/Screens/Workshop/Home/WorkshopHomeViewModel.cs index 67391c0bd..dfaf0a8f2 100644 --- a/src/Artemis.UI/Screens/Workshop/Home/WorkshopHomeViewModel.cs +++ b/src/Artemis.UI/Screens/Workshop/Home/WorkshopHomeViewModel.cs @@ -26,7 +26,7 @@ public class WorkshopHomeViewModel : ActivatableViewModelBase, IWorkshopViewMode private async Task ExecuteAddSubmission(CancellationToken arg) { - await _windowService.ShowDialogAsync(); + await _windowService.ShowDialogAsync(); } public EntryType? EntryType => null; diff --git a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/UploadStepView.axaml b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/UploadStepView.axaml index bd215cce5..89a7ec328 100644 --- a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/UploadStepView.axaml +++ b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/UploadStepView.axaml @@ -21,6 +21,12 @@ Wooo, the final step, that was pretty easy, right!? + + diff --git a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/UploadStepViewModel.cs b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/UploadStepViewModel.cs index 8cec0dba1..60d2c92c6 100644 --- a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/UploadStepViewModel.cs +++ b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/UploadStepViewModel.cs @@ -10,6 +10,9 @@ using ReactiveUI; using StrawberryShake; using System.Reactive.Disposables; using Artemis.Core; +using Artemis.UI.Shared.Routing; +using System; +using Artemis.UI.Shared.Utilities; namespace Artemis.UI.Screens.Workshop.SubmissionWizard.Steps; @@ -18,19 +21,33 @@ public class UploadStepViewModel : SubmissionViewModel private readonly IWorkshopClient _workshopClient; private readonly EntryUploadHandlerFactory _entryUploadHandlerFactory; private readonly IWindowService _windowService; + private readonly IRouter _router; + private readonly Progress _progress = new(); + private readonly ObservableAsPropertyHelper _progressPercentage; + private readonly ObservableAsPropertyHelper _progressIndeterminate; + private bool _finished; + private Guid? _entryId; /// - public UploadStepViewModel(IWorkshopClient workshopClient, EntryUploadHandlerFactory entryUploadHandlerFactory, IWindowService windowService) + public UploadStepViewModel(IWorkshopClient workshopClient, EntryUploadHandlerFactory entryUploadHandlerFactory, IWindowService windowService, IRouter router) { _workshopClient = workshopClient; _entryUploadHandlerFactory = entryUploadHandlerFactory; _windowService = windowService; + _router = router; ShowGoBack = false; ContinueText = "Finish"; - Continue = ReactiveCommand.Create(ExecuteContinue, this.WhenAnyValue(vm => vm.Finished)); - + Continue = ReactiveCommand.CreateFromTask(ExecuteContinue, this.WhenAnyValue(vm => vm.Finished)); + + _progressPercentage = Observable.FromEventPattern(x => _progress.ProgressChanged += x, x => _progress.ProgressChanged -= x) + .Select(e => e.EventArgs.ProgressPercentage) + .ToProperty(this, vm => vm.ProgressPercentage); + _progressIndeterminate = Observable.FromEventPattern(x => _progress.ProgressChanged += x, x => _progress.ProgressChanged -= x) + .Select(e => e.EventArgs.ProgressPercentage == 0) + .ToProperty(this, vm => vm.ProgressIndeterminate); + this.WhenActivated(d => Observable.FromAsync(ExecuteUpload).Subscribe().DisposeWith(d)); } @@ -40,6 +57,9 @@ public class UploadStepViewModel : SubmissionViewModel /// public override ReactiveCommand GoBack { get; } = null!; + public int ProgressPercentage => _progressPercentage.Value; + public bool ProgressIndeterminate => _progressIndeterminate.Value; + public bool Finished { get => _finished; @@ -72,7 +92,7 @@ public class UploadStepViewModel : SubmissionViewModel try { IEntryUploadHandler uploadHandler = _entryUploadHandlerFactory.CreateHandler(State.EntryType); - EntryUploadResult uploadResult = await uploadHandler.CreateReleaseAsync(entryId.Value, State.EntrySource!, cancellationToken); + EntryUploadResult uploadResult = await uploadHandler.CreateReleaseAsync(entryId.Value, State.EntrySource!, _progress, cancellationToken); if (!uploadResult.IsSuccess) { string? message = uploadResult.Message; @@ -85,6 +105,7 @@ public class UploadStepViewModel : SubmissionViewModel return; } + _entryId = entryId; Finished = true; } catch (Exception e) @@ -94,8 +115,25 @@ public class UploadStepViewModel : SubmissionViewModel } } - private void ExecuteContinue() + private async Task ExecuteContinue() { - throw new NotImplementedException(); + if (_entryId == null) + return; + + State.Finish(); + switch (State.EntryType) + { + case EntryType.Layout: + await _router.Navigate($"workshop/layouts/{_entryId.Value}"); + break; + case EntryType.Plugin: + await _router.Navigate($"workshop/plugins/{_entryId.Value}"); + break; + case EntryType.Profile: + await _router.Navigate($"workshop/profiles/{_entryId.Value}"); + break; + default: + throw new ArgumentOutOfRangeException(); + } } } \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/SubmissionWizardState.cs b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/SubmissionWizardState.cs index 996496e03..3484c8c36 100644 --- a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/SubmissionWizardState.cs +++ b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/SubmissionWizardState.cs @@ -44,4 +44,14 @@ public class SubmissionWizardState _windowService.ShowExceptionDialog("Wizard screen failed to activate", e); } } + + public void Finish() + { + _wizardViewModel.Close(true); + } + + public void Cancel() + { + _wizardViewModel.Close(false); + } } \ No newline at end of file diff --git a/src/Artemis.WebClient.Workshop/Artemis.WebClient.Workshop.csproj b/src/Artemis.WebClient.Workshop/Artemis.WebClient.Workshop.csproj index e09fefe42..d1c6d5490 100644 --- a/src/Artemis.WebClient.Workshop/Artemis.WebClient.Workshop.csproj +++ b/src/Artemis.WebClient.Workshop/Artemis.WebClient.Workshop.csproj @@ -25,6 +25,7 @@ + diff --git a/src/Artemis.WebClient.Workshop/UploadHandlers/IEntryUploadHandler.cs b/src/Artemis.WebClient.Workshop/UploadHandlers/IEntryUploadHandler.cs index a80787acb..21cdc2d5a 100644 --- a/src/Artemis.WebClient.Workshop/UploadHandlers/IEntryUploadHandler.cs +++ b/src/Artemis.WebClient.Workshop/UploadHandlers/IEntryUploadHandler.cs @@ -1,7 +1,8 @@ - +using Artemis.UI.Shared.Utilities; + namespace Artemis.WebClient.Workshop.UploadHandlers; public interface IEntryUploadHandler { - Task CreateReleaseAsync(Guid entryId, object file, CancellationToken cancellationToken); + Task CreateReleaseAsync(Guid entryId, object file, Progress progress, CancellationToken cancellationToken); } \ No newline at end of file diff --git a/src/Artemis.WebClient.Workshop/UploadHandlers/Implementations/LayoutEntryUploadHandler.cs b/src/Artemis.WebClient.Workshop/UploadHandlers/Implementations/LayoutEntryUploadHandler.cs index 1b3318940..57f2f664c 100644 --- a/src/Artemis.WebClient.Workshop/UploadHandlers/Implementations/LayoutEntryUploadHandler.cs +++ b/src/Artemis.WebClient.Workshop/UploadHandlers/Implementations/LayoutEntryUploadHandler.cs @@ -1,11 +1,11 @@ -using RGB.NET.Layout; +using Artemis.UI.Shared.Utilities; namespace Artemis.WebClient.Workshop.UploadHandlers.Implementations; public class LayoutEntryUploadHandler : IEntryUploadHandler { /// - public async Task CreateReleaseAsync(Guid entryId, object file, CancellationToken cancellationToken) + public async Task CreateReleaseAsync(Guid entryId, object file, Progress progress, CancellationToken cancellationToken) { throw new NotImplementedException(); } diff --git a/src/Artemis.WebClient.Workshop/UploadHandlers/Implementations/ProfileEntryUploadHandler.cs b/src/Artemis.WebClient.Workshop/UploadHandlers/Implementations/ProfileEntryUploadHandler.cs index 4582e4f9b..7f09ce387 100644 --- a/src/Artemis.WebClient.Workshop/UploadHandlers/Implementations/ProfileEntryUploadHandler.cs +++ b/src/Artemis.WebClient.Workshop/UploadHandlers/Implementations/ProfileEntryUploadHandler.cs @@ -3,6 +3,7 @@ using System.Net.Http.Headers; using System.Text; using Artemis.Core; using Artemis.Core.Services; +using Artemis.UI.Shared.Utilities; using Artemis.Web.Workshop.Entities; using Newtonsoft.Json; @@ -20,7 +21,7 @@ public class ProfileEntryUploadHandler : IEntryUploadHandler } /// - public async Task CreateReleaseAsync(Guid entryId, object file, CancellationToken cancellationToken) + public async Task CreateReleaseAsync(Guid entryId, object file, Progress progress, CancellationToken cancellationToken) { if (file is not ProfileConfiguration profileConfiguration) throw new InvalidOperationException("Can only create releases for profile configurations"); @@ -42,19 +43,19 @@ public class ProfileEntryUploadHandler : IEntryUploadHandler // Submit the archive HttpClient client = _httpClientFactory.CreateClient(WorkshopConstants.WORKSHOP_CLIENT_NAME); - + // Construct the request archiveStream.Seek(0, SeekOrigin.Begin); MultipartFormDataContent content = new(); - StreamContent streamContent = new(archiveStream); + ProgressableStreamContent streamContent = new(archiveStream, progress); streamContent.Headers.ContentType = new MediaTypeHeaderValue("application/zip"); content.Add(streamContent, "file", "file.zip"); - + // Submit HttpResponseMessage response = await client.PostAsync("releases/upload/" + entryId, content, cancellationToken); if (!response.IsSuccessStatusCode) return EntryUploadResult.FromFailure($"{response.StatusCode} - {await response.Content.ReadAsStringAsync(cancellationToken)}"); - + Release? release = JsonConvert.DeserializeObject(await response.Content.ReadAsStringAsync(cancellationToken)); return release != null ? EntryUploadResult.FromSuccess(release) : EntryUploadResult.FromFailure("Failed to deserialize response"); } From 176a28761f5f3eb508e41691b878489e6a80d461 Mon Sep 17 00:00:00 2001 From: Robert Date: Wed, 16 Aug 2023 20:01:28 +0200 Subject: [PATCH 14/37] Submission wizard - Upload entry icon Entry lists - Show entry icon --- src/Artemis.UI/Extensions/Bitmap.cs | 30 +++--- .../Workshop/Entries/EntryListView.axaml | 5 +- .../Workshop/Entries/EntryListViewModel.cs | 14 ++- .../Workshop/Profile/ProfileListViewModel.cs | 19 ++-- .../Steps/UploadStepViewModel.cs | 9 +- .../DryIoc/ContainerExtensions.cs | 1 + .../Queries/GetCategories.graphql | 2 +- .../Queries/GetEntries.graphql | 2 +- .../Services/AuthenticationService.cs | 49 +++++++-- .../Services/IWorkshopService.cs | 101 ++++++++++++++++++ .../UploadHandlers/ImageUploadResult.cs | 21 ++++ 11 files changed, 214 insertions(+), 39 deletions(-) create mode 100644 src/Artemis.WebClient.Workshop/Services/IWorkshopService.cs create mode 100644 src/Artemis.WebClient.Workshop/UploadHandlers/ImageUploadResult.cs diff --git a/src/Artemis.UI/Extensions/Bitmap.cs b/src/Artemis.UI/Extensions/Bitmap.cs index 8c14ee4da..e30bc754f 100644 --- a/src/Artemis.UI/Extensions/Bitmap.cs +++ b/src/Artemis.UI/Extensions/Bitmap.cs @@ -1,4 +1,6 @@ +using System; using System.IO; +using System.Threading.Tasks; using Avalonia.Media.Imaging; using SkiaSharp; @@ -24,21 +26,23 @@ public class BitmapExtensions private static Bitmap Resize(SKBitmap source, int size) { - int newWidth, newHeight; - float aspectRatio = (float) source.Width / source.Height; + // Get smaller dimension. + int minDim = Math.Min(source.Width, source.Height); - if (aspectRatio > 1) - { - newWidth = size; - newHeight = (int) (size / aspectRatio); - } - else - { - newWidth = (int) (size * aspectRatio); - newHeight = size; - } + // Calculate crop rectangle position for center crop. + int deltaX = (source.Width - minDim) / 2; + int deltaY = (source.Height - minDim) / 2; + + // Create crop rectangle. + SKRectI rect = new(deltaX, deltaY, deltaX + minDim, deltaY + minDim); + + // Do the actual cropping of the bitmap. + using SKBitmap croppedBitmap = new(minDim, minDim); + source.ExtractSubset(croppedBitmap, rect); + + // Resize to the desired size after cropping. + using SKBitmap resizedBitmap = croppedBitmap.Resize(new SKImageInfo(size, size), SKFilterQuality.High); - using SKBitmap resizedBitmap = source.Resize(new SKImageInfo(newWidth, newHeight), SKFilterQuality.High); return new Bitmap(resizedBitmap.Encode(SKEncodedImageFormat.Png, 100).AsStream()); } } \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/Entries/EntryListView.axaml b/src/Artemis.UI/Screens/Workshop/Entries/EntryListView.axaml index 9af92602f..628846513 100644 --- a/src/Artemis.UI/Screens/Workshop/Entries/EntryListView.axaml +++ b/src/Artemis.UI/Screens/Workshop/Entries/EntryListView.axaml @@ -23,8 +23,9 @@ VerticalAlignment="Center" Margin="0 0 10 0" Width="80" - Height="80"> - + Height="80" + ClipToBounds="True"> + diff --git a/src/Artemis.UI/Screens/Workshop/Entries/EntryListViewModel.cs b/src/Artemis.UI/Screens/Workshop/Entries/EntryListViewModel.cs index 0691c5b53..22e378a1d 100644 --- a/src/Artemis.UI/Screens/Workshop/Entries/EntryListViewModel.cs +++ b/src/Artemis.UI/Screens/Workshop/Entries/EntryListViewModel.cs @@ -1,26 +1,34 @@ using System; using System.Reactive; +using System.Reactive.Linq; +using System.Threading; using System.Threading.Tasks; using Artemis.UI.Shared; using Artemis.UI.Shared.Routing; using Artemis.WebClient.Workshop; +using Artemis.WebClient.Workshop.Services; +using Avalonia.Media.Imaging; using ReactiveUI; namespace Artemis.UI.Screens.Workshop.Entries; -public class EntryListViewModel : ViewModelBase +public class EntryListViewModel : ActivatableViewModelBase { private readonly IRouter _router; + private readonly ObservableAsPropertyHelper _entryIcon; - public EntryListViewModel(IGetEntries_Entries_Items entry, IRouter router) + public EntryListViewModel(IGetEntries_Entries_Items entry, IRouter router, IWorkshopService workshopService) { _router = router; + Entry = entry; + EntryIcon = workshopService.GetEntryIcon(entry.Id, CancellationToken.None); NavigateToEntry = ReactiveCommand.CreateFromTask(ExecuteNavigateToEntry); } public IGetEntries_Entries_Items Entry { get; } - public ReactiveCommand NavigateToEntry { get; } + public Task EntryIcon { get; } + public ReactiveCommand NavigateToEntry { get; } private async Task ExecuteNavigateToEntry() { diff --git a/src/Artemis.UI/Screens/Workshop/Profile/ProfileListViewModel.cs b/src/Artemis.UI/Screens/Workshop/Profile/ProfileListViewModel.cs index 98e45f00b..340c84ee4 100644 --- a/src/Artemis.UI/Screens/Workshop/Profile/ProfileListViewModel.cs +++ b/src/Artemis.UI/Screens/Workshop/Profile/ProfileListViewModel.cs @@ -13,6 +13,7 @@ using Artemis.UI.Shared.Routing; using Artemis.UI.Shared.Services; using Artemis.UI.Shared.Services.Builders; using Artemis.WebClient.Workshop; +using DryIoc.ImTools; using ReactiveUI; using StrawberryShake; @@ -20,8 +21,8 @@ namespace Artemis.UI.Screens.Workshop.Profile; public class ProfileListViewModel : RoutableScreen, IWorkshopViewModel { - private readonly IRouter _router; private readonly INotificationService _notificationService; + private readonly Func _getEntryListViewModel; private readonly IWorkshopClient _workshopClient; private readonly ObservableAsPropertyHelper _showPagination; private readonly ObservableAsPropertyHelper _isLoading; @@ -31,18 +32,22 @@ public class ProfileListViewModel : RoutableScreen getEntryListViewModel) { _workshopClient = workshopClient; - _router = router; _notificationService = notificationService; + _getEntryListViewModel = getEntryListViewModel; _showPagination = this.WhenAnyValue(vm => vm.TotalPages).Select(t => t > 1).ToProperty(this, vm => vm.ShowPagination); _isLoading = this.WhenAnyValue(vm => vm.Page, vm => vm.LoadedPage, (p, c) => p != c).ToProperty(this, vm => vm.IsLoading); CategoriesViewModel = categoriesViewModel; // Respond to page changes - this.WhenAnyValue(vm => vm.Page).Skip(1).Subscribe(p => Task.Run(() => _router.Navigate($"workshop/profiles/{p}"))); + this.WhenAnyValue(vm => vm.Page).Skip(1).Subscribe(p => Task.Run(() => router.Navigate($"workshop/profiles/{p}"))); // Respond to filter changes this.WhenActivated(d => CategoriesViewModel.WhenAnyValue(vm => vm.CategoryFilters).Skip(1).Subscribe(_ => { @@ -57,7 +62,7 @@ public class ProfileListViewModel : RoutableScreen _showPagination.Value; public bool IsLoading => _isLoading.Value; - + public CategoriesViewModel CategoriesViewModel { get; } public List? Entries @@ -108,10 +113,10 @@ public class ProfileListViewModel : RoutableScreen entries = await _workshopClient.GetEntries.ExecuteAsync(filter, EntriesPerPage * (Page - 1), EntriesPerPage, cancellationToken); entries.EnsureNoErrors(); - + if (entries.Data?.Entries?.Items != null) { - Entries = entries.Data.Entries.Items.Select(n => new EntryListViewModel(n, _router)).ToList(); + Entries = entries.Data.Entries.Items.Select(n => _getEntryListViewModel(n)).ToList(); TotalPages = (int) Math.Ceiling(entries.Data.Entries.TotalCount / (double) EntriesPerPage); } else diff --git a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/UploadStepViewModel.cs b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/UploadStepViewModel.cs index 60d2c92c6..05ef0355a 100644 --- a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/UploadStepViewModel.cs +++ b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/UploadStepViewModel.cs @@ -13,12 +13,14 @@ using Artemis.Core; using Artemis.UI.Shared.Routing; using System; using Artemis.UI.Shared.Utilities; +using Artemis.WebClient.Workshop.Services; namespace Artemis.UI.Screens.Workshop.SubmissionWizard.Steps; public class UploadStepViewModel : SubmissionViewModel { private readonly IWorkshopClient _workshopClient; + private readonly IWorkshopService _workshopService; private readonly EntryUploadHandlerFactory _entryUploadHandlerFactory; private readonly IWindowService _windowService; private readonly IRouter _router; @@ -30,9 +32,10 @@ public class UploadStepViewModel : SubmissionViewModel private Guid? _entryId; /// - public UploadStepViewModel(IWorkshopClient workshopClient, EntryUploadHandlerFactory entryUploadHandlerFactory, IWindowService windowService, IRouter router) + public UploadStepViewModel(IWorkshopClient workshopClient, IWorkshopService workshopService, EntryUploadHandlerFactory entryUploadHandlerFactory, IWindowService windowService, IRouter router) { _workshopClient = workshopClient; + _workshopService = workshopService; _entryUploadHandlerFactory = entryUploadHandlerFactory; _windowService = windowService; _router = router; @@ -87,6 +90,10 @@ public class UploadStepViewModel : SubmissionViewModel if (cancellationToken.IsCancellationRequested) return; + + // Upload image + if (State.Icon != null) + await _workshopService.SetEntryIcon(entryId.Value, _progress, State.Icon, cancellationToken); // Create the workshop entry try diff --git a/src/Artemis.WebClient.Workshop/DryIoc/ContainerExtensions.cs b/src/Artemis.WebClient.Workshop/DryIoc/ContainerExtensions.cs index 46c4060fc..05f1ecb21 100644 --- a/src/Artemis.WebClient.Workshop/DryIoc/ContainerExtensions.cs +++ b/src/Artemis.WebClient.Workshop/DryIoc/ContainerExtensions.cs @@ -44,6 +44,7 @@ public static class ContainerExtensions container.Register(Reuse.Singleton); container.Register(Reuse.Singleton); + container.Register(Reuse.Singleton); container.Register(Reuse.Transient); container.RegisterMany(workshopAssembly, type => type.IsAssignableTo(), Reuse.Transient); diff --git a/src/Artemis.WebClient.Workshop/Queries/GetCategories.graphql b/src/Artemis.WebClient.Workshop/Queries/GetCategories.graphql index d39be92f2..864caa217 100644 --- a/src/Artemis.WebClient.Workshop/Queries/GetCategories.graphql +++ b/src/Artemis.WebClient.Workshop/Queries/GetCategories.graphql @@ -1,5 +1,5 @@ query GetCategories { - categories { + categories(order: {name: ASC}) { id name icon diff --git a/src/Artemis.WebClient.Workshop/Queries/GetEntries.graphql b/src/Artemis.WebClient.Workshop/Queries/GetEntries.graphql index c7e639c31..a1879e786 100644 --- a/src/Artemis.WebClient.Workshop/Queries/GetEntries.graphql +++ b/src/Artemis.WebClient.Workshop/Queries/GetEntries.graphql @@ -1,5 +1,5 @@ query GetEntries($filter: EntryFilterInput $skip: Int $take: Int) { - entries(where: $filter skip: $skip take: $take) { + entries(where: $filter skip: $skip take: $take, order: {createdAt: DESC}) { totalCount items { id diff --git a/src/Artemis.WebClient.Workshop/Services/AuthenticationService.cs b/src/Artemis.WebClient.Workshop/Services/AuthenticationService.cs index b159031c1..eb9e4e459 100644 --- a/src/Artemis.WebClient.Workshop/Services/AuthenticationService.cs +++ b/src/Artemis.WebClient.Workshop/Services/AuthenticationService.cs @@ -26,6 +26,7 @@ internal class AuthenticationService : CorePropertyChanged, IAuthenticationServi private readonly BehaviorSubject _isLoggedInSubject = new(false); private AuthenticationToken? _token; + private bool _noStoredRefreshToken; public AuthenticationService(IHttpClientFactory httpClientFactory, IDiscoveryCache discoveryCache, IAuthenticationRepository authenticationRepository) { @@ -50,7 +51,7 @@ internal class AuthenticationService : CorePropertyChanged, IAuthenticationServi private void SetCurrentUser(TokenResponse response) { _token = new AuthenticationToken(response); - _authenticationRepository.SetRefreshToken(_token.RefreshToken); + SetStoredRefreshToken(_token.RefreshToken); JwtSecurityTokenHandler handler = new(); JwtSecurityToken? token = handler.ReadJwtToken(response.IdentityToken); @@ -80,7 +81,10 @@ internal class AuthenticationService : CorePropertyChanged, IAuthenticationServi if (response.IsError) { if (response.Error is OidcConstants.TokenErrors.ExpiredToken or OidcConstants.TokenErrors.InvalidGrant) + { + SetStoredRefreshToken(null); return false; + } throw new ArtemisWebClientException("Failed to request refresh token: " + response.Error); } @@ -118,8 +122,9 @@ internal class AuthenticationService : CorePropertyChanged, IAuthenticationServi { // If not logged in, attempt to auto login first if (!_isLoggedInSubject.Value) - await AutoLogin(); + await InternalAutoLogin(); + // If there is no token, even after an auto-login, there's no bearer to add if (_token == null) return null; @@ -142,14 +147,7 @@ internal class AuthenticationService : CorePropertyChanged, IAuthenticationServi try { - if (!force && _isLoggedInSubject.Value) - return true; - - string? refreshToken = _authenticationRepository.GetRefreshToken(); - if (refreshToken == null) - return false; - - return await UseRefreshToken(refreshToken); + return await InternalAutoLogin(force); } finally { @@ -157,6 +155,7 @@ internal class AuthenticationService : CorePropertyChanged, IAuthenticationServi } } + /// public async Task Login(CancellationToken cancellationToken) { @@ -240,8 +239,36 @@ internal class AuthenticationService : CorePropertyChanged, IAuthenticationServi { _token = null; _claims.Clear(); - _authenticationRepository.SetRefreshToken(null); + SetStoredRefreshToken(null); _isLoggedInSubject.OnNext(false); } + + private async Task InternalAutoLogin(bool force = false) + { + if (!force && _isLoggedInSubject.Value) + return true; + + if (_noStoredRefreshToken) + return false; + + string? refreshToken = GetStoredRefreshToken(); + if (refreshToken == null) + return false; + + return await UseRefreshToken(refreshToken); + } + + private string? GetStoredRefreshToken() + { + string? token = _authenticationRepository.GetRefreshToken(); + _noStoredRefreshToken = token == null; + return token; + } + + private void SetStoredRefreshToken(string? token) + { + _authenticationRepository.SetRefreshToken(token); + _noStoredRefreshToken = token == null; + } } \ No newline at end of file diff --git a/src/Artemis.WebClient.Workshop/Services/IWorkshopService.cs b/src/Artemis.WebClient.Workshop/Services/IWorkshopService.cs new file mode 100644 index 000000000..34e39b5c8 --- /dev/null +++ b/src/Artemis.WebClient.Workshop/Services/IWorkshopService.cs @@ -0,0 +1,101 @@ +using System.Net.Http.Headers; +using Artemis.UI.Shared.Services.MainWindow; +using Artemis.UI.Shared.Utilities; +using Artemis.WebClient.Workshop.UploadHandlers; +using Avalonia.Media.Imaging; +using Avalonia.Threading; + +namespace Artemis.WebClient.Workshop.Services; + +public class WorkshopService : IWorkshopService +{ + private readonly Dictionary _entryIconCache = new(); + private readonly IHttpClientFactory _httpClientFactory; + private readonly SemaphoreSlim _iconCacheLock = new(1); + + public WorkshopService(IHttpClientFactory httpClientFactory, IMainWindowService mainWindowService) + { + _httpClientFactory = httpClientFactory; + mainWindowService.MainWindowClosed += (_, _) => Dispatcher.UIThread.InvokeAsync(async () => + { + await Task.Delay(1000); + ClearCache(); + }); + } + + /// + public async Task GetEntryIcon(Guid entryId, CancellationToken cancellationToken) + { + await _iconCacheLock.WaitAsync(cancellationToken); + try + { + if (_entryIconCache.TryGetValue(entryId, out Stream? cachedBitmap)) + { + cachedBitmap.Seek(0, SeekOrigin.Begin); + return new Bitmap(cachedBitmap); + } + } + finally + { + _iconCacheLock.Release(); + } + + HttpClient client = _httpClientFactory.CreateClient(WorkshopConstants.WORKSHOP_CLIENT_NAME); + try + { + HttpResponseMessage response = await client.GetAsync($"entries/{entryId}/icon", cancellationToken); + response.EnsureSuccessStatusCode(); + Stream data = await response.Content.ReadAsStreamAsync(cancellationToken); + + _entryIconCache[entryId] = data; + return new Bitmap(data); + } + catch (HttpRequestException) + { + // ignored + return null; + } + } + + public async Task SetEntryIcon(Guid entryId, Progress progress, Stream icon, CancellationToken cancellationToken) + { + icon.Seek(0, SeekOrigin.Begin); + + // Submit the archive + HttpClient client = _httpClientFactory.CreateClient(WorkshopConstants.WORKSHOP_CLIENT_NAME); + + // Construct the request + MultipartFormDataContent content = new(); + ProgressableStreamContent streamContent = new(icon, progress); + streamContent.Headers.ContentType = new MediaTypeHeaderValue("image/png"); + content.Add(streamContent, "file", "file.png"); + + // Submit + HttpResponseMessage response = await client.PostAsync($"entries/{entryId}/icon", content, cancellationToken); + if (!response.IsSuccessStatusCode) + return ImageUploadResult.FromFailure($"{response.StatusCode} - {await response.Content.ReadAsStringAsync(cancellationToken)}"); + return ImageUploadResult.FromSuccess(); + } + + private void ClearCache() + { + try + { + List values = _entryIconCache.Values.ToList(); + _entryIconCache.Clear(); + foreach (Stream bitmap in values) + bitmap.Dispose(); + } + catch (Exception e) + { + Console.WriteLine(e); + throw; + } + } +} + +public interface IWorkshopService +{ + Task GetEntryIcon(Guid entryId, CancellationToken cancellationToken); + Task SetEntryIcon(Guid entryId, Progress progress, Stream icon, CancellationToken cancellationToken); +} \ No newline at end of file diff --git a/src/Artemis.WebClient.Workshop/UploadHandlers/ImageUploadResult.cs b/src/Artemis.WebClient.Workshop/UploadHandlers/ImageUploadResult.cs new file mode 100644 index 000000000..97f8e861c --- /dev/null +++ b/src/Artemis.WebClient.Workshop/UploadHandlers/ImageUploadResult.cs @@ -0,0 +1,21 @@ +namespace Artemis.WebClient.Workshop.UploadHandlers; + +public class ImageUploadResult +{ + public bool IsSuccess { get; set; } + public string? Message { get; set; } + + public static ImageUploadResult FromFailure(string? message) + { + return new ImageUploadResult + { + IsSuccess = false, + Message = message + }; + } + + public static ImageUploadResult FromSuccess() + { + return new ImageUploadResult {IsSuccess = true}; + } +} \ No newline at end of file From 77bed1bf94d8083d4de42d4935f08d2f71aa1ac2 Mon Sep 17 00:00:00 2001 From: Robert Date: Sat, 19 Aug 2023 11:32:22 +0200 Subject: [PATCH 15/37] Entry list - Show icons Workshop search - Show icons, update design --- src/Artemis.UI.Shared/Styles/Artemis.axaml | 1 + src/Artemis.UI.Shared/Styles/Control.axaml | 25 ++++++++ .../Workshop/Entries/EntryListView.axaml | 8 ++- .../Workshop/Entries/EntryListViewModel.cs | 22 ++++++- .../Workshop/Profile/ProfileListViewModel.cs | 34 +++++++---- .../Workshop/Search/SearchResultView.axaml | 60 +++++++++++++++++++ .../Workshop/Search/SearchResultView.axaml.cs | 14 +++++ .../Workshop/Search/SearchResultViewModel.cs | 32 ++++++++++ .../Screens/Workshop/Search/SearchView.axaml | 38 ++++-------- .../Workshop/Search/SearchViewModel.cs | 59 +++++++++++++----- .../Workshop/Search/SearchViewStyles.axaml | 2 +- .../Queries/SearchEntries.graphql | 1 + .../Services/AuthenticationService.cs | 53 ++++++++++------ 13 files changed, 273 insertions(+), 76 deletions(-) create mode 100644 src/Artemis.UI.Shared/Styles/Control.axaml create mode 100644 src/Artemis.UI/Screens/Workshop/Search/SearchResultView.axaml create mode 100644 src/Artemis.UI/Screens/Workshop/Search/SearchResultView.axaml.cs create mode 100644 src/Artemis.UI/Screens/Workshop/Search/SearchResultViewModel.cs diff --git a/src/Artemis.UI.Shared/Styles/Artemis.axaml b/src/Artemis.UI.Shared/Styles/Artemis.axaml index 69759ddc7..d32872413 100644 --- a/src/Artemis.UI.Shared/Styles/Artemis.axaml +++ b/src/Artemis.UI.Shared/Styles/Artemis.axaml @@ -32,6 +32,7 @@ + diff --git a/src/Artemis.UI.Shared/Styles/Control.axaml b/src/Artemis.UI.Shared/Styles/Control.axaml new file mode 100644 index 000000000..b20c87ff5 --- /dev/null +++ b/src/Artemis.UI.Shared/Styles/Control.axaml @@ -0,0 +1,25 @@ + + + + + + + + + + + + diff --git a/src/Artemis.UI/Screens/Workshop/Entries/EntryListView.axaml b/src/Artemis.UI/Screens/Workshop/Entries/EntryListView.axaml index 628846513..3dce3c328 100644 --- a/src/Artemis.UI/Screens/Workshop/Entries/EntryListView.axaml +++ b/src/Artemis.UI/Screens/Workshop/Entries/EntryListView.axaml @@ -14,7 +14,8 @@ CornerRadius="8" HorizontalAlignment="Stretch" HorizontalContentAlignment="Stretch" - Command="{CompiledBinding NavigateToEntry}"> + Command="{CompiledBinding NavigateToEntry}" + IsVisible="{CompiledBinding Entry, Converter={x:Static ObjectConverters.IsNotNull}}"> - + diff --git a/src/Artemis.UI/Screens/Workshop/Entries/EntryListViewModel.cs b/src/Artemis.UI/Screens/Workshop/Entries/EntryListViewModel.cs index 22e378a1d..c38c9aef3 100644 --- a/src/Artemis.UI/Screens/Workshop/Entries/EntryListViewModel.cs +++ b/src/Artemis.UI/Screens/Workshop/Entries/EntryListViewModel.cs @@ -1,5 +1,6 @@ using System; using System.Reactive; +using System.Reactive.Disposables; using System.Reactive.Linq; using System.Threading; using System.Threading.Tasks; @@ -15,19 +16,26 @@ namespace Artemis.UI.Screens.Workshop.Entries; public class EntryListViewModel : ActivatableViewModelBase { private readonly IRouter _router; - private readonly ObservableAsPropertyHelper _entryIcon; + private readonly IWorkshopService _workshopService; + private ObservableAsPropertyHelper? _entryIcon; public EntryListViewModel(IGetEntries_Entries_Items entry, IRouter router, IWorkshopService workshopService) { _router = router; + _workshopService = workshopService; Entry = entry; - EntryIcon = workshopService.GetEntryIcon(entry.Id, CancellationToken.None); NavigateToEntry = ReactiveCommand.CreateFromTask(ExecuteNavigateToEntry); + + this.WhenActivated(d => + { + _entryIcon = Observable.FromAsync(GetIcon).ToProperty(this, vm => vm.EntryIcon); + _entryIcon.DisposeWith(d); + }); } public IGetEntries_Entries_Items Entry { get; } - public Task EntryIcon { get; } + public Bitmap? EntryIcon => _entryIcon?.Value; public ReactiveCommand NavigateToEntry { get; } private async Task ExecuteNavigateToEntry() @@ -46,4 +54,12 @@ public class EntryListViewModel : ActivatableViewModelBase throw new ArgumentOutOfRangeException(); } } + + private async Task GetIcon(CancellationToken cancellationToken) + { + // Take at least 100ms to allow the UI to load and make the whole thing smooth + Task iconTask = _workshopService.GetEntryIcon(Entry.Id, cancellationToken); + await Task.Delay(100, cancellationToken); + return await iconTask; + } } \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/Profile/ProfileListViewModel.cs b/src/Artemis.UI/Screens/Workshop/Profile/ProfileListViewModel.cs index 340c84ee4..23ddddfa2 100644 --- a/src/Artemis.UI/Screens/Workshop/Profile/ProfileListViewModel.cs +++ b/src/Artemis.UI/Screens/Workshop/Profile/ProfileListViewModel.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Collections.ObjectModel; using System.Linq; using System.Reactive.Disposables; using System.Reactive.Linq; @@ -13,7 +14,9 @@ using Artemis.UI.Shared.Routing; using Artemis.UI.Shared.Services; using Artemis.UI.Shared.Services.Builders; using Artemis.WebClient.Workshop; +using Avalonia.Threading; using DryIoc.ImTools; +using DynamicData; using ReactiveUI; using StrawberryShake; @@ -26,7 +29,7 @@ public class ProfileListViewModel : RoutableScreen _showPagination; private readonly ObservableAsPropertyHelper _isLoading; - private List? _entries; + private SourceList _entries = new(); private int _page; private int _loadedPage = -1; private int _totalPages = 1; @@ -45,9 +48,17 @@ public class ProfileListViewModel : RoutableScreen vm.Page, vm => vm.LoadedPage, (p, c) => p != c).ToProperty(this, vm => vm.IsLoading); CategoriesViewModel = categoriesViewModel; - + + _entries.Connect() + .ObserveOn(new AvaloniaSynchronizationContext(DispatcherPriority.SystemIdle)) + .Transform(getEntryListViewModel) + .Bind(out ReadOnlyObservableCollection entries) + .Subscribe(); + Entries = entries; + // Respond to page changes this.WhenAnyValue(vm => vm.Page).Skip(1).Subscribe(p => Task.Run(() => router.Navigate($"workshop/profiles/{p}"))); + // Respond to filter changes this.WhenActivated(d => CategoriesViewModel.WhenAnyValue(vm => vm.CategoryFilters).Skip(1).Subscribe(_ => { @@ -65,11 +76,7 @@ public class ProfileListViewModel : RoutableScreen? Entries - { - get => _entries; - set => RaiseAndSetIfChanged(ref _entries, value); - } + public ReadOnlyObservableCollection Entries { get; } public int Page { @@ -99,8 +106,11 @@ public class ProfileListViewModel : RoutableScreen _getEntryListViewModel(n)).ToList(); TotalPages = (int) Math.Ceiling(entries.Data.Entries.TotalCount / (double) EntriesPerPage); + _entries.Edit(e => + { + e.Clear(); + e.AddRange(entries.Data.Entries.Items); + }); } else TotalPages = 1; diff --git a/src/Artemis.UI/Screens/Workshop/Search/SearchResultView.axaml b/src/Artemis.UI/Screens/Workshop/Search/SearchResultView.axaml new file mode 100644 index 000000000..2c9ed6559 --- /dev/null +++ b/src/Artemis.UI/Screens/Workshop/Search/SearchResultView.axaml @@ -0,0 +1,60 @@ + + + + + + + + + + + + by + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/Search/SearchResultView.axaml.cs b/src/Artemis.UI/Screens/Workshop/Search/SearchResultView.axaml.cs new file mode 100644 index 000000000..8490b69b2 --- /dev/null +++ b/src/Artemis.UI/Screens/Workshop/Search/SearchResultView.axaml.cs @@ -0,0 +1,14 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Markup.Xaml; +using Avalonia.ReactiveUI; + +namespace Artemis.UI.Screens.Workshop.Search; + +public partial class SearchResultView : ReactiveUserControl +{ + public SearchResultView() + { + InitializeComponent(); + } +} \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/Search/SearchResultViewModel.cs b/src/Artemis.UI/Screens/Workshop/Search/SearchResultViewModel.cs new file mode 100644 index 000000000..8136ce350 --- /dev/null +++ b/src/Artemis.UI/Screens/Workshop/Search/SearchResultViewModel.cs @@ -0,0 +1,32 @@ +using System.Reactive.Disposables; +using System.Reactive.Linq; +using System.Threading; +using System.Threading.Tasks; +using Artemis.UI.Shared; +using Artemis.WebClient.Workshop; +using Artemis.WebClient.Workshop.Services; +using Avalonia.Media.Imaging; +using ReactiveUI; + +namespace Artemis.UI.Screens.Workshop.Search; + +public class SearchResultViewModel : ActivatableViewModelBase +{ + private readonly IWorkshopService _workshopService; + private ObservableAsPropertyHelper? _entryIcon; + + public SearchResultViewModel(ISearchEntries_SearchEntries entry, IWorkshopService workshopService) + { + _workshopService = workshopService; + + Entry = entry; + this.WhenActivated(d => + { + _entryIcon = Observable.FromAsync(c => _workshopService.GetEntryIcon(Entry.Id, c)).ToProperty(this, vm => vm.EntryIcon); + _entryIcon.DisposeWith(d); + }); + } + + public ISearchEntries_SearchEntries Entry { get; } + public Bitmap? EntryIcon => _entryIcon?.Value; +} \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/Search/SearchView.axaml b/src/Artemis.UI/Screens/Workshop/Search/SearchView.axaml index 171d4c400..8ac1d728f 100644 --- a/src/Artemis.UI/Screens/Workshop/Search/SearchView.axaml +++ b/src/Artemis.UI/Screens/Workshop/Search/SearchView.axaml @@ -11,47 +11,31 @@ - + - - - - - - - - - - - - - - - - - - - - - - + + - + windowing:AppWindow.AllowInteractionInTitleBar="True" /> + + + + \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/Search/SearchViewModel.cs b/src/Artemis.UI/Screens/Workshop/Search/SearchViewModel.cs index b3370ee8b..e27504efc 100644 --- a/src/Artemis.UI/Screens/Workshop/Search/SearchViewModel.cs +++ b/src/Artemis.UI/Screens/Workshop/Search/SearchViewModel.cs @@ -7,32 +7,40 @@ using Artemis.UI.Screens.Workshop.CurrentUser; using Artemis.UI.Shared; using Artemis.UI.Shared.Routing; using Artemis.WebClient.Workshop; +using Artemis.WebClient.Workshop.Services; using ReactiveUI; +using Serilog; using StrawberryShake; namespace Artemis.UI.Screens.Workshop.Search; public class SearchViewModel : ViewModelBase { - public CurrentUserViewModel CurrentUserViewModel { get; } + private readonly ILogger _logger; private readonly IRouter _router; private readonly IWorkshopClient _workshopClient; + private readonly IWorkshopService _workshopService; private EntryType? _entryType; - private ISearchEntries_SearchEntries? _selectedEntry; + private bool _isLoading; + private SearchResultViewModel? _selectedEntry; - public SearchViewModel(IWorkshopClient workshopClient, IRouter router, CurrentUserViewModel currentUserViewModel) + public SearchViewModel(ILogger logger, IWorkshopClient workshopClient, IWorkshopService workshopService, IRouter router, CurrentUserViewModel currentUserViewModel) { - CurrentUserViewModel = currentUserViewModel; + _logger = logger; _workshopClient = workshopClient; + _workshopService = workshopService; _router = router; + CurrentUserViewModel = currentUserViewModel; SearchAsync = ExecuteSearchAsync; this.WhenAnyValue(vm => vm.SelectedEntry).WhereNotNull().Subscribe(NavigateToEntry); } + public CurrentUserViewModel CurrentUserViewModel { get; } + public Func>> SearchAsync { get; } - public ISearchEntries_SearchEntries? SelectedEntry + public SearchResultViewModel? SelectedEntry { get => _selectedEntry; set => RaiseAndSetIfChanged(ref _selectedEntry, value); @@ -44,13 +52,19 @@ public class SearchViewModel : ViewModelBase set => RaiseAndSetIfChanged(ref _entryType, value); } - private void NavigateToEntry(ISearchEntries_SearchEntries entry) + public bool IsLoading + { + get => _isLoading; + set => RaiseAndSetIfChanged(ref _isLoading, value); + } + + private void NavigateToEntry(SearchResultViewModel searchResult) { string? url = null; - if (entry.EntryType == WebClient.Workshop.EntryType.Profile) - url = $"workshop/profiles/{entry.Id}"; - if (entry.EntryType == WebClient.Workshop.EntryType.Layout) - url = $"workshop/layouts/{entry.Id}"; + if (searchResult.Entry.EntryType == WebClient.Workshop.EntryType.Profile) + url = $"workshop/profiles/{searchResult.Entry.Id}"; + if (searchResult.Entry.EntryType == WebClient.Workshop.EntryType.Layout) + url = $"workshop/layouts/{searchResult.Entry.Id}"; if (url != null) Task.Run(() => _router.Navigate(url)); @@ -58,10 +72,25 @@ public class SearchViewModel : ViewModelBase private async Task> ExecuteSearchAsync(string? input, CancellationToken cancellationToken) { - if (string.IsNullOrWhiteSpace(input)) - return new List(); - - IOperationResult results = await _workshopClient.SearchEntries.ExecuteAsync(input, EntryType, cancellationToken); - return results.Data?.SearchEntries.Cast() ?? new List(); + try + { + if (string.IsNullOrWhiteSpace(input) || input.Length < 2) + return new List(); + + IsLoading = true; + IOperationResult results = await _workshopClient.SearchEntries.ExecuteAsync(input, EntryType, cancellationToken); + return results.Data?.SearchEntries.Select(e => new SearchResultViewModel(e, _workshopService) as object) ?? new List(); + } + catch (Exception e) + { + if (e is not TaskCanceledException) + _logger.Error(e, "Failed to execute search"); + } + finally + { + IsLoading = false; + } + + return new List(); } } \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/Search/SearchViewStyles.axaml b/src/Artemis.UI/Screens/Workshop/Search/SearchViewStyles.axaml index 584a83924..c489f0406 100644 --- a/src/Artemis.UI/Screens/Workshop/Search/SearchViewStyles.axaml +++ b/src/Artemis.UI/Screens/Workshop/Search/SearchViewStyles.axaml @@ -19,7 +19,7 @@ - - @@ -103,16 +104,16 @@ - + Release date + Text="{CompiledBinding Release.CreatedAt, Converter={StaticResource DateTimeConverter}, FallbackValue=Loading...}" /> - + Source - + File size Release notes - - + + - - + + diff --git a/src/Artemis.UI/Screens/Settings/Updating/ReleaseView.axaml b/src/Artemis.UI/Screens/Settings/Updating/ReleaseView.axaml index 97e58bd65..47a02b961 100644 --- a/src/Artemis.UI/Screens/Settings/Updating/ReleaseView.axaml +++ b/src/Artemis.UI/Screens/Settings/Updating/ReleaseView.axaml @@ -4,13 +4,17 @@ xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:updating="clr-namespace:Artemis.UI.Screens.Settings.Updating" xmlns:avalonia="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia" + xmlns:converters="clr-namespace:Artemis.UI.Converters" mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" x:Class="Artemis.UI.Screens.Settings.Updating.ReleaseView" x:DataType="updating:ReleaseViewModel"> + + + - + - + diff --git a/src/Artemis.UI/Screens/Workshop/Entries/EntryListView.axaml b/src/Artemis.UI/Screens/Workshop/Entries/EntryListView.axaml index 0c98ad6d0..e29a4f040 100644 --- a/src/Artemis.UI/Screens/Workshop/Entries/EntryListView.axaml +++ b/src/Artemis.UI/Screens/Workshop/Entries/EntryListView.axaml @@ -11,6 +11,7 @@ x:DataType="entries1:EntryListViewModel"> + + + + + + + + + + + + - - Profile details panel diff --git a/src/Artemis.UI/Screens/Workshop/Profile/ProfileDetailsViewModel.cs b/src/Artemis.UI/Screens/Workshop/Profile/ProfileDetailsViewModel.cs index 7f9a618d6..ed92162f8 100644 --- a/src/Artemis.UI/Screens/Workshop/Profile/ProfileDetailsViewModel.cs +++ b/src/Artemis.UI/Screens/Workshop/Profile/ProfileDetailsViewModel.cs @@ -1,11 +1,12 @@ using System; +using System.Reactive.Linq; using System.Threading; using System.Threading.Tasks; using Artemis.UI.Screens.Workshop.Parameters; using Artemis.UI.Shared; using Artemis.UI.Shared.Routing; using Artemis.WebClient.Workshop; -using Avalonia.Media.Imaging; +using ReactiveUI; using StrawberryShake; namespace Artemis.UI.Screens.Workshop.Profile; @@ -13,14 +14,17 @@ namespace Artemis.UI.Screens.Workshop.Profile; public class ProfileDetailsViewModel : RoutableScreen, IWorkshopViewModel { private readonly IWorkshopClient _client; + private readonly ObservableAsPropertyHelper _updatedAt; private IGetEntryById_Entry? _entry; - private Bitmap? _entryIcon; public ProfileDetailsViewModel(IWorkshopClient client) { _client = client; + _updatedAt = this.WhenAnyValue(vm => vm.Entry).Select(e => e?.LatestRelease?.CreatedAt ?? e?.CreatedAt).ToProperty(this, vm => vm.UpdatedAt); } + public DateTimeOffset? UpdatedAt => _updatedAt.Value; + public EntryType? EntryType => null; public IGetEntryById_Entry? Entry diff --git a/src/Artemis.UI/Styles/Markdown.axaml b/src/Artemis.UI/Styles/Markdown.axaml index 5374e71b0..6f03f8291 100644 --- a/src/Artemis.UI/Styles/Markdown.axaml +++ b/src/Artemis.UI/Styles/Markdown.axaml @@ -2,6 +2,9 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:avalonia="clr-namespace:Markdown.Avalonia;assembly=Markdown.Avalonia" xmlns:ctxt="clr-namespace:ColorTextBlock.Avalonia;assembly=ColorTextBlock.Avalonia"> + diff --git a/src/Artemis.WebClient.Workshop/Queries/GetEntries.graphql b/src/Artemis.WebClient.Workshop/Queries/GetEntries.graphql index a1879e786..e5e0648be 100644 --- a/src/Artemis.WebClient.Workshop/Queries/GetEntries.graphql +++ b/src/Artemis.WebClient.Workshop/Queries/GetEntries.graphql @@ -8,7 +8,7 @@ query GetEntries($filter: EntryFilterInput $skip: Int $take: Int) { summary entryType downloads - createdAt + createdAt categories { name icon diff --git a/src/Artemis.WebClient.Workshop/Queries/GetEntryById.graphql b/src/Artemis.WebClient.Workshop/Queries/GetEntryById.graphql index c083be2d8..5933626a0 100644 --- a/src/Artemis.WebClient.Workshop/Queries/GetEntryById.graphql +++ b/src/Artemis.WebClient.Workshop/Queries/GetEntryById.graphql @@ -12,5 +12,12 @@ query GetEntryById($id: UUID!) { name icon } + latestRelease { + id + version + downloadSize + md5Hash + createdAt + } } } \ No newline at end of file diff --git a/src/Artemis.WebClient.Workshop/Services/AuthenticationService.cs b/src/Artemis.WebClient.Workshop/Services/AuthenticationService.cs index f67af2d14..5a75e499c 100644 --- a/src/Artemis.WebClient.Workshop/Services/AuthenticationService.cs +++ b/src/Artemis.WebClient.Workshop/Services/AuthenticationService.cs @@ -245,6 +245,10 @@ internal class AuthenticationService : CorePropertyChanged, IAuthenticationServi listener.Stop(); listener.Close(); } + catch (HttpListenerException e) + { + throw new ArtemisWebClientException($"HTTP listener for login callback failed with error code {e.ErrorCode}", e); + } finally { _authLock.Release(); diff --git a/src/Artemis.WebClient.Workshop/WorkshopConstants.cs b/src/Artemis.WebClient.Workshop/WorkshopConstants.cs index 192a92b56..1dcfa737d 100644 --- a/src/Artemis.WebClient.Workshop/WorkshopConstants.cs +++ b/src/Artemis.WebClient.Workshop/WorkshopConstants.cs @@ -2,7 +2,7 @@ namespace Artemis.WebClient.Workshop; public static class WorkshopConstants { - public const string AUTHORITY_URL = "https://identity.artemis-rgb.com"; - public const string WORKSHOP_URL = "https://workshop.artemis-rgb.com"; + public const string AUTHORITY_URL = "https://localhost:5001"; + public const string WORKSHOP_URL = "https://localhost:7281"; public const string WORKSHOP_CLIENT_NAME = "WorkshopApiClient"; } \ No newline at end of file diff --git a/src/Artemis.WebClient.Workshop/schema.graphql b/src/Artemis.WebClient.Workshop/schema.graphql index 59efce290..8b99d85ea 100644 --- a/src/Artemis.WebClient.Workshop/schema.graphql +++ b/src/Artemis.WebClient.Workshop/schema.graphql @@ -37,8 +37,10 @@ type Entry { downloads: Long! entryType: EntryType! icon: Image + iconId: UUID id: UUID! images: [Image!]! + latestRelease: Release name: String! releases: [Release!]! summary: String! @@ -151,6 +153,7 @@ input EntryFilterInput { downloads: LongOperationFilterInput entryType: EntryTypeOperationFilterInput icon: ImageFilterInput + iconId: UuidOperationFilterInput id: UuidOperationFilterInput images: ListFilterInputTypeOfImageFilterInput name: StringOperationFilterInput @@ -168,6 +171,7 @@ input EntrySortInput { downloads: SortEnumType entryType: SortEnumType icon: ImageSortInput + iconId: SortEnumType id: SortEnumType name: SortEnumType summary: SortEnumType From 671c587df61e598772dd918154845389ef29728d Mon Sep 17 00:00:00 2001 From: Robert Date: Sat, 26 Aug 2023 20:47:48 +0200 Subject: [PATCH 19/37] Profiles - Added workshop installing --- .../Storage/Interfaces/IProfileService.cs | 3 +- .../Services/Storage/ProfileService.cs | 22 +++++-- .../Extensions/HttpClientExtensions.cs | 14 ++--- .../Extensions/ZipArchiveExtensions.cs | 5 +- src/Artemis.UI/Routing/Routes.cs | 2 + .../Sidebar/SidebarCategoryViewModel.cs | 12 ++-- .../Workshop/Home/WorkshopHomeView.axaml | 15 ++--- .../Workshop/Home/WorkshopHomeViewModel.cs | 26 ++++++++- .../Workshop/Home/WorkshopOfflineView.axaml | 36 ++++++++++++ .../Home/WorkshopOfflineView.axaml.cs | 14 +++++ .../Workshop/Home/WorkshopOfflineViewModel.cs | 57 +++++++++++++++++++ .../Workshop/Profile/ProfileDetailsView.axaml | 21 ++++--- .../Profile/ProfileDetailsViewModel.cs | 35 ++++++++++-- .../Steps/UploadStepView.axaml | 40 +++++++------ .../Steps/UploadStepViewModel.cs | 33 +++++++++-- .../Services/Updating/ReleaseInstaller.cs | 16 +++--- .../DownloadHandlers/EntryUploadResult.cs | 28 +++++++++ .../DownloadHandlers/IEntryDownloadHandler.cs | 7 +++ .../ProfileEntryDownloadHandler.cs | 51 +++++++++++++++++ .../DryIoc/ContainerExtensions.cs | 2 + .../Services/IWorkshopService.cs | 35 +++++++++++- 21 files changed, 401 insertions(+), 73 deletions(-) rename src/{Artemis.UI => Artemis.UI.Shared}/Extensions/HttpClientExtensions.cs (78%) create mode 100644 src/Artemis.UI/Screens/Workshop/Home/WorkshopOfflineView.axaml create mode 100644 src/Artemis.UI/Screens/Workshop/Home/WorkshopOfflineView.axaml.cs create mode 100644 src/Artemis.UI/Screens/Workshop/Home/WorkshopOfflineViewModel.cs create mode 100644 src/Artemis.WebClient.Workshop/DownloadHandlers/EntryUploadResult.cs create mode 100644 src/Artemis.WebClient.Workshop/DownloadHandlers/IEntryDownloadHandler.cs create mode 100644 src/Artemis.WebClient.Workshop/DownloadHandlers/Implementations/ProfileEntryDownloadHandler.cs diff --git a/src/Artemis.Core/Services/Storage/Interfaces/IProfileService.cs b/src/Artemis.Core/Services/Storage/Interfaces/IProfileService.cs index 4d2907af4..1744e5335 100644 --- a/src/Artemis.Core/Services/Storage/Interfaces/IProfileService.cs +++ b/src/Artemis.Core/Services/Storage/Interfaces/IProfileService.cs @@ -76,8 +76,9 @@ public interface IProfileService : IArtemisService /// Creates a new profile category and saves it to persistent storage. /// /// The name of the new profile category, must be unique. + /// A boolean indicating whether or not to add the category to the top. /// The newly created profile category. - ProfileCategory CreateProfileCategory(string name); + ProfileCategory CreateProfileCategory(string name, bool addToTop = false); /// /// Permanently deletes the provided profile category. diff --git a/src/Artemis.Core/Services/Storage/ProfileService.cs b/src/Artemis.Core/Services/Storage/ProfileService.cs index 159e2ebf5..3a09a8c06 100644 --- a/src/Artemis.Core/Services/Storage/ProfileService.cs +++ b/src/Artemis.Core/Services/Storage/ProfileService.cs @@ -286,12 +286,26 @@ internal class ProfileService : IProfileService } /// - public ProfileCategory CreateProfileCategory(string name) + public ProfileCategory CreateProfileCategory(string name, bool addToTop = false) { ProfileCategory profileCategory; lock (_profileRepository) { - profileCategory = new ProfileCategory(name, _profileCategories.Count + 1); + if (addToTop) + { + profileCategory = new ProfileCategory(name, 1); + foreach (ProfileCategory category in _profileCategories) + { + category.Order++; + category.Save(); + _profileCategoryRepository.Save(category.Entity); + } + } + else + { + profileCategory = new ProfileCategory(name, _profileCategories.Count + 1); + } + _profileCategories.Add(profileCategory); SaveProfileCategory(profileCategory); } @@ -370,7 +384,7 @@ internal class ProfileService : IProfileService profile.ProfileEntity.IsFreshImport = false; _profileRepository.Save(profile.ProfileEntity); - + // If the provided profile is external (cloned or from the workshop?) but it is loaded locally too, reload the local instance // A bit dodge but it ensures local instances always represent the latest stored version ProfileConfiguration? localInstance = ProfileConfigurations.FirstOrDefault(p => p.Profile != null && p.Profile != profile && p.ProfileId == profile.ProfileEntity.Id); @@ -450,7 +464,7 @@ internal class ProfileService : IProfileService List modules = _pluginManagementService.GetFeaturesOfType(); profileConfiguration.LoadModules(modules); SaveProfileCategory(category); - + return profileConfiguration; } diff --git a/src/Artemis.UI/Extensions/HttpClientExtensions.cs b/src/Artemis.UI.Shared/Extensions/HttpClientExtensions.cs similarity index 78% rename from src/Artemis.UI/Extensions/HttpClientExtensions.cs rename to src/Artemis.UI.Shared/Extensions/HttpClientExtensions.cs index 50af33443..d9adda42f 100644 --- a/src/Artemis.UI/Extensions/HttpClientExtensions.cs +++ b/src/Artemis.UI.Shared/Extensions/HttpClientExtensions.cs @@ -3,12 +3,13 @@ using System.IO; using System.Net.Http; using System.Threading; using System.Threading.Tasks; +using Artemis.UI.Shared.Utilities; -namespace Artemis.UI.Extensions +namespace Artemis.UI.Shared.Extensions { public static class HttpClientProgressExtensions { - public static async Task DownloadDataAsync(this HttpClient client, string requestUrl, Stream destination, IProgress? progress, CancellationToken cancellationToken) + public static async Task DownloadDataAsync(this HttpClient client, string requestUrl, Stream destination, IProgress? progress, CancellationToken cancellationToken) { using HttpResponseMessage response = await client.GetAsync(requestUrl, HttpCompletionOption.ResponseHeadersRead, cancellationToken); response.EnsureSuccessStatusCode(); @@ -23,13 +24,10 @@ namespace Artemis.UI.Extensions } // Such progress and contentLength much reporting Wow! - Progress progressWrapper = new(totalBytes => progress.Report(GetProgressPercentage(totalBytes, contentLength.Value))); - await download.CopyToAsync(destination, 81920, progressWrapper, cancellationToken); - - float GetProgressPercentage(float totalBytes, float currentBytes) => (totalBytes / currentBytes) * 100f; + await download.CopyToAsync(destination, 81920, progress, contentLength, cancellationToken); } - static async Task CopyToAsync(this Stream source, Stream destination, int bufferSize, IProgress progress, CancellationToken cancellationToken) + static async Task CopyToAsync(this Stream source, Stream destination, int bufferSize, IProgress progress, long? contentLength, CancellationToken cancellationToken) { if (bufferSize < 0) throw new ArgumentOutOfRangeException(nameof(bufferSize)); @@ -49,7 +47,7 @@ namespace Artemis.UI.Extensions { await destination.WriteAsync(buffer, 0, bytesRead, cancellationToken).ConfigureAwait(false); totalBytesRead += bytesRead; - progress?.Report(totalBytesRead); + progress?.Report(new StreamProgress(totalBytesRead, contentLength ?? totalBytesRead)); } } } diff --git a/src/Artemis.UI/Extensions/ZipArchiveExtensions.cs b/src/Artemis.UI/Extensions/ZipArchiveExtensions.cs index 0d1fc507d..ad1900caa 100644 --- a/src/Artemis.UI/Extensions/ZipArchiveExtensions.cs +++ b/src/Artemis.UI/Extensions/ZipArchiveExtensions.cs @@ -2,6 +2,7 @@ using System; using System.IO; using System.IO.Compression; using System.Threading; +using Artemis.UI.Shared.Utilities; namespace Artemis.UI.Extensions; @@ -16,7 +17,7 @@ public static class ZipArchiveExtensions /// A boolean indicating whether to override existing files /// The progress to report to. /// A cancellation token - public static void ExtractToDirectory(this ZipArchive source, string destinationDirectoryName, bool overwriteFiles, IProgress progress, CancellationToken cancellationToken) + public static void ExtractToDirectory(this ZipArchive source, string destinationDirectoryName, bool overwriteFiles, IProgress progress, CancellationToken cancellationToken) { if (source == null) throw new ArgumentNullException(nameof(source)); @@ -28,7 +29,7 @@ public static class ZipArchiveExtensions { ZipArchiveEntry entry = source.Entries[index]; entry.ExtractRelativeToDirectory(destinationDirectoryName, overwriteFiles); - progress.Report((index + 1f) / source.Entries.Count * 100f); + progress.Report(new StreamProgress(index + 1, source.Entries.Count)); cancellationToken.ThrowIfCancellationRequested(); } } diff --git a/src/Artemis.UI/Routing/Routes.cs b/src/Artemis.UI/Routing/Routes.cs index d2709888a..dbdf17053 100644 --- a/src/Artemis.UI/Routing/Routes.cs +++ b/src/Artemis.UI/Routing/Routes.cs @@ -5,6 +5,7 @@ using Artemis.UI.Screens.Settings; using Artemis.UI.Screens.Settings.Updating; using Artemis.UI.Screens.SurfaceEditor; using Artemis.UI.Screens.Workshop; +using Artemis.UI.Screens.Workshop.Home; using Artemis.UI.Screens.Workshop.Layout; using Artemis.UI.Screens.Workshop.Profile; using Artemis.UI.Shared.Routing; @@ -21,6 +22,7 @@ public static class Routes { Children = new List() { + new RouteRegistration("offline/{message:string}"), new RouteRegistration("profiles/{page:int}"), new RouteRegistration("profiles/{entryId:guid}"), new RouteRegistration("layouts/{page:int}"), diff --git a/src/Artemis.UI/Screens/Sidebar/SidebarCategoryViewModel.cs b/src/Artemis.UI/Screens/Sidebar/SidebarCategoryViewModel.cs index 5fd46c94c..8911989d7 100644 --- a/src/Artemis.UI/Screens/Sidebar/SidebarCategoryViewModel.cs +++ b/src/Artemis.UI/Screens/Sidebar/SidebarCategoryViewModel.cs @@ -77,16 +77,16 @@ public class SidebarCategoryViewModel : ActivatableViewModelBase Observable.FromEventPattern(x => profileCategory.ProfileConfigurationRemoved += x, x => profileCategory.ProfileConfigurationRemoved -= x) .Subscribe(e => profileConfigurations.RemoveMany(profileConfigurations.Items.Where(c => c == e.EventArgs.ProfileConfiguration))) .DisposeWith(d); + + profileConfigurations.Edit(updater => + { + updater.Clear(); + updater.AddRange(profileCategory.ProfileConfigurations); + }); _isCollapsed = ProfileCategory.WhenAnyValue(vm => vm.IsCollapsed).ToProperty(this, vm => vm.IsCollapsed).DisposeWith(d); _isSuspended = ProfileCategory.WhenAnyValue(vm => vm.IsSuspended).ToProperty(this, vm => vm.IsSuspended).DisposeWith(d); }); - - profileConfigurations.Edit(updater => - { - foreach (ProfileConfiguration profileConfiguration in profileCategory.ProfileConfigurations) - updater.Add(profileConfiguration); - }); } public ReactiveCommand ImportProfile { get; } diff --git a/src/Artemis.UI/Screens/Workshop/Home/WorkshopHomeView.axaml b/src/Artemis.UI/Screens/Workshop/Home/WorkshopHomeView.axaml index d0717860f..0695a7554 100644 --- a/src/Artemis.UI/Screens/Workshop/Home/WorkshopHomeView.axaml +++ b/src/Artemis.UI/Screens/Workshop/Home/WorkshopHomeView.axaml @@ -9,6 +9,8 @@ x:DataType="home:WorkshopHomeViewModel"> + + - + - + - + - + - + Featured submissions Not yet implemented, here we'll show submissions we think are worth some extra attention. - + Recently updated Not yet implemented, here we'll a few of the most recent uploads/updates to the workshop. - \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/Home/WorkshopHomeViewModel.cs b/src/Artemis.UI/Screens/Workshop/Home/WorkshopHomeViewModel.cs index dfaf0a8f2..45663f0d7 100644 --- a/src/Artemis.UI/Screens/Workshop/Home/WorkshopHomeViewModel.cs +++ b/src/Artemis.UI/Screens/Workshop/Home/WorkshopHomeViewModel.cs @@ -1,4 +1,5 @@ using System.Reactive; +using System.Reactive.Disposables; using System.Threading; using System.Threading.Tasks; using Artemis.UI.Screens.Workshop.SubmissionWizard; @@ -6,6 +7,8 @@ using Artemis.UI.Shared; using Artemis.UI.Shared.Routing; using Artemis.UI.Shared.Services; using Artemis.WebClient.Workshop; +using Artemis.WebClient.Workshop.Services; +using Avalonia.Threading; using ReactiveUI; namespace Artemis.UI.Screens.Workshop.Home; @@ -13,21 +16,38 @@ namespace Artemis.UI.Screens.Workshop.Home; public class WorkshopHomeViewModel : ActivatableViewModelBase, IWorkshopViewModel { private readonly IWindowService _windowService; + private readonly IWorkshopService _workshopService; + private bool _workshopReachable; - public WorkshopHomeViewModel(IRouter router, IWindowService windowService) + public WorkshopHomeViewModel(IRouter router, IWindowService windowService, IWorkshopService workshopService) { _windowService = windowService; - AddSubmission = ReactiveCommand.CreateFromTask(ExecuteAddSubmission); - Navigate = ReactiveCommand.CreateFromTask(async r => await router.Navigate(r)); + _workshopService = workshopService; + + AddSubmission = ReactiveCommand.CreateFromTask(ExecuteAddSubmission, this.WhenAnyValue(vm => vm.WorkshopReachable)); + Navigate = ReactiveCommand.CreateFromTask(async r => await router.Navigate(r), this.WhenAnyValue(vm => vm.WorkshopReachable)); + + this.WhenActivated((CompositeDisposable _) => Dispatcher.UIThread.InvokeAsync(ValidateWorkshopStatus)); } public ReactiveCommand AddSubmission { get; } public ReactiveCommand Navigate { get; } + public bool WorkshopReachable + { + get => _workshopReachable; + private set => RaiseAndSetIfChanged(ref _workshopReachable, value); + } + private async Task ExecuteAddSubmission(CancellationToken arg) { await _windowService.ShowDialogAsync(); } + private async Task ValidateWorkshopStatus() + { + WorkshopReachable = await _workshopService.ValidateWorkshopStatus(); + } + public EntryType? EntryType => null; } \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/Home/WorkshopOfflineView.axaml b/src/Artemis.UI/Screens/Workshop/Home/WorkshopOfflineView.axaml new file mode 100644 index 000000000..b700c5375 --- /dev/null +++ b/src/Artemis.UI/Screens/Workshop/Home/WorkshopOfflineView.axaml @@ -0,0 +1,36 @@ + + + + + + + + + + + + + Could not reach the workshop + + + + Please ensure you are connected to the internet. + If this keeps occuring, hit us up on Discord + + + + + + + diff --git a/src/Artemis.UI/Screens/Workshop/Home/WorkshopOfflineView.axaml.cs b/src/Artemis.UI/Screens/Workshop/Home/WorkshopOfflineView.axaml.cs new file mode 100644 index 000000000..a7d5eebdb --- /dev/null +++ b/src/Artemis.UI/Screens/Workshop/Home/WorkshopOfflineView.axaml.cs @@ -0,0 +1,14 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Markup.Xaml; +using Avalonia.ReactiveUI; + +namespace Artemis.UI.Screens.Workshop.Home; + +public partial class WorkshopOfflineView : ReactiveUserControl +{ + public WorkshopOfflineView() + { + InitializeComponent(); + } +} \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/Home/WorkshopOfflineViewModel.cs b/src/Artemis.UI/Screens/Workshop/Home/WorkshopOfflineViewModel.cs new file mode 100644 index 000000000..5ee84b8d4 --- /dev/null +++ b/src/Artemis.UI/Screens/Workshop/Home/WorkshopOfflineViewModel.cs @@ -0,0 +1,57 @@ +using System.Net; +using System.Reactive; +using System.Threading; +using System.Threading.Tasks; +using Artemis.UI.Shared; +using Artemis.UI.Shared.Routing; +using Artemis.WebClient.Workshop; +using Artemis.WebClient.Workshop.Services; +using ReactiveUI; + +namespace Artemis.UI.Screens.Workshop.Home; + +public class WorkshopOfflineViewModel : RoutableScreen, IWorkshopViewModel +{ + private readonly IRouter _router; + private readonly IWorkshopService _workshopService; + private string _message; + + /// + public WorkshopOfflineViewModel(IWorkshopService workshopService, IRouter router) + { + _workshopService = workshopService; + _router = router; + + Retry = ReactiveCommand.CreateFromTask(ExecuteRetry); + } + + public ReactiveCommand Retry { get; } + + public string Message + { + get => _message; + set => RaiseAndSetIfChanged(ref _message, value); + } + + public override Task OnNavigating(WorkshopOfflineParameters parameters, NavigationArguments args, CancellationToken cancellationToken) + { + Message = parameters.Message; + return base.OnNavigating(parameters, args, cancellationToken); + } + + private async Task ExecuteRetry(CancellationToken cancellationToken) + { + IWorkshopService.WorkshopStatus status = await _workshopService.GetWorkshopStatus(); + if (status.IsReachable) + await _router.Navigate("workshop"); + + Message = status.Message; + } + + public EntryType? EntryType => null; +} + +public class WorkshopOfflineParameters +{ + public string Message { get; set; } +} \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/Profile/ProfileDetailsView.axaml b/src/Artemis.UI/Screens/Workshop/Profile/ProfileDetailsView.axaml index 2adad018e..00ffc76c9 100644 --- a/src/Artemis.UI/Screens/Workshop/Profile/ProfileDetailsView.axaml +++ b/src/Artemis.UI/Screens/Workshop/Profile/ProfileDetailsView.axaml @@ -86,29 +86,36 @@ Latest release - diff --git a/src/Artemis.UI/Screens/Workshop/Profile/ProfileDetailsViewModel.cs b/src/Artemis.UI/Screens/Workshop/Profile/ProfileDetailsViewModel.cs index ed92162f8..f15ca7cf6 100644 --- a/src/Artemis.UI/Screens/Workshop/Profile/ProfileDetailsViewModel.cs +++ b/src/Artemis.UI/Screens/Workshop/Profile/ProfileDetailsViewModel.cs @@ -1,11 +1,18 @@ using System; +using System.Reactive; using System.Reactive.Linq; using System.Threading; using System.Threading.Tasks; +using Artemis.Core; using Artemis.UI.Screens.Workshop.Parameters; using Artemis.UI.Shared; using Artemis.UI.Shared.Routing; +using Artemis.UI.Shared.Services; +using Artemis.UI.Shared.Services.Builders; +using Artemis.UI.Shared.Utilities; using Artemis.WebClient.Workshop; +using Artemis.WebClient.Workshop.DownloadHandlers; +using Artemis.WebClient.Workshop.DownloadHandlers.Implementations; using ReactiveUI; using StrawberryShake; @@ -14,18 +21,24 @@ namespace Artemis.UI.Screens.Workshop.Profile; public class ProfileDetailsViewModel : RoutableScreen, IWorkshopViewModel { private readonly IWorkshopClient _client; + private readonly ProfileEntryDownloadHandler _downloadHandler; + private readonly INotificationService _notificationService; private readonly ObservableAsPropertyHelper _updatedAt; private IGetEntryById_Entry? _entry; - public ProfileDetailsViewModel(IWorkshopClient client) + public ProfileDetailsViewModel(IWorkshopClient client, ProfileEntryDownloadHandler downloadHandler, INotificationService notificationService) { _client = client; + _downloadHandler = downloadHandler; + _notificationService = notificationService; _updatedAt = this.WhenAnyValue(vm => vm.Entry).Select(e => e?.LatestRelease?.CreatedAt ?? e?.CreatedAt).ToProperty(this, vm => vm.UpdatedAt); + + DownloadLatestRelease = ReactiveCommand.CreateFromTask(ExecuteDownloadLatestRelease); } - public DateTimeOffset? UpdatedAt => _updatedAt.Value; + public ReactiveCommand DownloadLatestRelease { get; } - public EntryType? EntryType => null; + public DateTimeOffset? UpdatedAt => _updatedAt.Value; public IGetEntryById_Entry? Entry { @@ -43,7 +56,21 @@ public class ProfileDetailsViewModel : RoutableScreen result = await _client.GetEntryById.ExecuteAsync(entryId, cancellationToken); if (result.IsErrorResult()) return; - + Entry = result.Data?.Entry; } + + private async Task ExecuteDownloadLatestRelease(CancellationToken cancellationToken) + { + if (Entry?.LatestRelease == null) + return; + + EntryInstallResult result = await _downloadHandler.InstallProfileAsync(Entry.LatestRelease.Id, new Progress(), cancellationToken); + if (result.IsSuccess) + _notificationService.CreateNotification().WithTitle("Profile installed").WithSeverity(NotificationSeverity.Success).Show(); + else + _notificationService.CreateNotification().WithTitle("Failed to install profile").WithMessage(result.Message).WithSeverity(NotificationSeverity.Error).Show(); + } + + public EntryType? EntryType => null; } \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/UploadStepView.axaml b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/UploadStepView.axaml index 89a7ec328..e3b9e23c3 100644 --- a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/UploadStepView.axaml +++ b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/UploadStepView.axaml @@ -3,10 +3,10 @@ xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:steps="clr-namespace:Artemis.UI.Screens.Workshop.SubmissionWizard.Steps" - mc:Ignorable="d" d:DesignWidth="970" d:DesignHeight="900" + mc:Ignorable="d" d:DesignWidth="970" d:DesignHeight="900" x:Class="Artemis.UI.Screens.Workshop.SubmissionWizard.Steps.UploadStepView" x:DataType="steps:UploadStepViewModel"> - + - + + Uploading your submission... - - Wooo, the final step, that was pretty easy, right!? - - - - - + + + + + All done! Hit finish to view your submission. - + + + 😢 + + Unfortunately something went wrong while uploading your submission. + + Hit finish to view your submission, from there you can try to upload a new release. + If this keeps occuring, hit us up on Discord + + - + \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/UploadStepViewModel.cs b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/UploadStepViewModel.cs index 05ef0355a..14e56958a 100644 --- a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/UploadStepViewModel.cs +++ b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/UploadStepViewModel.cs @@ -28,8 +28,10 @@ public class UploadStepViewModel : SubmissionViewModel private readonly ObservableAsPropertyHelper _progressPercentage; private readonly ObservableAsPropertyHelper _progressIndeterminate; - private bool _finished; private Guid? _entryId; + private bool _finished; + private bool _succeeded; + private bool _failed; /// public UploadStepViewModel(IWorkshopClient workshopClient, IWorkshopService workshopService, EntryUploadHandlerFactory entryUploadHandlerFactory, IWindowService windowService, IRouter router) @@ -43,14 +45,14 @@ public class UploadStepViewModel : SubmissionViewModel ShowGoBack = false; ContinueText = "Finish"; Continue = ReactiveCommand.CreateFromTask(ExecuteContinue, this.WhenAnyValue(vm => vm.Finished)); - + _progressPercentage = Observable.FromEventPattern(x => _progress.ProgressChanged += x, x => _progress.ProgressChanged -= x) .Select(e => e.EventArgs.ProgressPercentage) .ToProperty(this, vm => vm.ProgressPercentage); _progressIndeterminate = Observable.FromEventPattern(x => _progress.ProgressChanged += x, x => _progress.ProgressChanged -= x) .Select(e => e.EventArgs.ProgressPercentage == 0) .ToProperty(this, vm => vm.ProgressIndeterminate); - + this.WhenActivated(d => Observable.FromAsync(ExecuteUpload).Subscribe().DisposeWith(d)); } @@ -69,6 +71,18 @@ public class UploadStepViewModel : SubmissionViewModel set => RaiseAndSetIfChanged(ref _finished, value); } + public bool Succeeded + { + get => _succeeded; + set => RaiseAndSetIfChanged(ref _succeeded, value); + } + + public bool Failed + { + get => _failed; + set => RaiseAndSetIfChanged(ref _failed, value); + } + public async Task ExecuteUpload(CancellationToken cancellationToken) { IOperationResult result = await _workshopClient.AddEntry.ExecuteAsync(new CreateEntryInput @@ -85,12 +99,13 @@ public class UploadStepViewModel : SubmissionViewModel if (result.IsErrorResult() || entryId == null) { await _windowService.ShowConfirmContentDialog("Failed to create workshop entry", result.Errors.ToString() ?? "Not even an error message", "Close", null); + State.ChangeScreen(); return; } if (cancellationToken.IsCancellationRequested) return; - + // Upload image if (State.Icon != null) await _workshopService.SetEntryIcon(entryId.Value, _progress, State.Icon, cancellationToken); @@ -113,21 +128,27 @@ public class UploadStepViewModel : SubmissionViewModel } _entryId = entryId; - Finished = true; + Succeeded = true; } catch (Exception e) { // Something went wrong when creating a release :c // We'll keep the workshop entry so that the user can make changes and try again + Failed = true; + } + finally + { + Finished = true; } } private async Task ExecuteContinue() { + State.Finish(); + if (_entryId == null) return; - State.Finish(); switch (State.EntryType) { case EntryType.Layout: diff --git a/src/Artemis.UI/Services/Updating/ReleaseInstaller.cs b/src/Artemis.UI/Services/Updating/ReleaseInstaller.cs index 04ca4f74f..91a9a2c9c 100644 --- a/src/Artemis.UI/Services/Updating/ReleaseInstaller.cs +++ b/src/Artemis.UI/Services/Updating/ReleaseInstaller.cs @@ -9,6 +9,8 @@ using System.Threading.Tasks; using Artemis.Core; using Artemis.UI.Exceptions; using Artemis.UI.Extensions; +using Artemis.UI.Shared.Extensions; +using Artemis.UI.Shared.Utilities; using Artemis.WebClient.Updating; using Octodiff.Core; using Octodiff.Diagnostics; @@ -32,7 +34,7 @@ public class ReleaseInstaller : CorePropertyChanged private IGetReleaseById_PublishedRelease _release = null!; private IGetReleaseById_PublishedRelease_Artifacts _artifact = null!; - private Progress _stepProgress = new(); + private Progress _stepProgress = new(); private string _status = string.Empty; private float _floatProgress; @@ -69,9 +71,7 @@ public class ReleaseInstaller : CorePropertyChanged public async Task InstallAsync(CancellationToken cancellationToken) { - _stepProgress = new Progress(); - - ((IProgress) _progress).Report(0); + _stepProgress = new Progress(); Status = "Retrieving details"; _logger.Information("Retrieving details for release {ReleaseId}", _releaseId); @@ -99,7 +99,7 @@ public class ReleaseInstaller : CorePropertyChanged { // 10 - 50% _stepProgress.ProgressChanged += StepProgressOnProgressChanged; - void StepProgressOnProgressChanged(object? sender, float e) => ((IProgress) _progress).Report(10f + e * 0.4f); + void StepProgressOnProgressChanged(object? sender, StreamProgress e) => ((IProgress) _progress).Report(10f + e.ProgressPercentage * 0.4f); Status = "Downloading..."; await using MemoryStream stream = new(); @@ -113,7 +113,7 @@ public class ReleaseInstaller : CorePropertyChanged { // 50 - 60% _stepProgress.ProgressChanged += StepProgressOnProgressChanged; - void StepProgressOnProgressChanged(object? sender, float e) => ((IProgress) _progress).Report(50f + e * 0.1f); + void StepProgressOnProgressChanged(object? sender, StreamProgress e) => ((IProgress) _progress).Report(50f + e.ProgressPercentage * 0.1f); Status = "Patching..."; await using FileStream newFileStream = new(Path.Combine(Constants.UpdatingFolder, $"{_release.Version}.zip"), FileMode.Create, FileAccess.ReadWrite, FileShare.Read); @@ -139,7 +139,7 @@ public class ReleaseInstaller : CorePropertyChanged { // 10 - 60% _stepProgress.ProgressChanged += StepProgressOnProgressChanged; - void StepProgressOnProgressChanged(object? sender, float e) => ((IProgress) _progress).Report(10f + e * 0.5f); + void StepProgressOnProgressChanged(object? sender, StreamProgress e) => ((IProgress) _progress).Report(10f + e.ProgressPercentage * 0.5f); Status = "Downloading..."; await using FileStream stream = new(Path.Combine(Constants.UpdatingFolder, $"{_release.Version}.zip"), FileMode.Create, FileAccess.ReadWrite, FileShare.Read); @@ -155,7 +155,7 @@ public class ReleaseInstaller : CorePropertyChanged { // 60 - 100% _stepProgress.ProgressChanged += StepProgressOnProgressChanged; - void StepProgressOnProgressChanged(object? sender, float e) => ((IProgress) _progress).Report(60f + e * 0.4f); + void StepProgressOnProgressChanged(object? sender, StreamProgress e) => ((IProgress) _progress).Report(60f + e.ProgressPercentage * 0.4f); Status = "Extracting..."; // Ensure the directory is empty diff --git a/src/Artemis.WebClient.Workshop/DownloadHandlers/EntryUploadResult.cs b/src/Artemis.WebClient.Workshop/DownloadHandlers/EntryUploadResult.cs new file mode 100644 index 000000000..d1162bee8 --- /dev/null +++ b/src/Artemis.WebClient.Workshop/DownloadHandlers/EntryUploadResult.cs @@ -0,0 +1,28 @@ +using Artemis.Web.Workshop.Entities; + +namespace Artemis.WebClient.Workshop.DownloadHandlers; + +public class EntryInstallResult +{ + public bool IsSuccess { get; set; } + public string? Message { get; set; } + public T? Result { get; set; } + + public static EntryInstallResult FromFailure(string? message) + { + return new EntryInstallResult + { + IsSuccess = false, + Message = message + }; + } + + public static EntryInstallResult FromSuccess(T installationResult) + { + return new EntryInstallResult + { + IsSuccess = true, + Result = installationResult + }; + } +} \ No newline at end of file diff --git a/src/Artemis.WebClient.Workshop/DownloadHandlers/IEntryDownloadHandler.cs b/src/Artemis.WebClient.Workshop/DownloadHandlers/IEntryDownloadHandler.cs new file mode 100644 index 000000000..8d477e7ff --- /dev/null +++ b/src/Artemis.WebClient.Workshop/DownloadHandlers/IEntryDownloadHandler.cs @@ -0,0 +1,7 @@ +using Artemis.UI.Shared.Utilities; + +namespace Artemis.WebClient.Workshop.DownloadHandlers; + +public interface IEntryDownloadHandler +{ +} \ No newline at end of file diff --git a/src/Artemis.WebClient.Workshop/DownloadHandlers/Implementations/ProfileEntryDownloadHandler.cs b/src/Artemis.WebClient.Workshop/DownloadHandlers/Implementations/ProfileEntryDownloadHandler.cs new file mode 100644 index 000000000..496072c9c --- /dev/null +++ b/src/Artemis.WebClient.Workshop/DownloadHandlers/Implementations/ProfileEntryDownloadHandler.cs @@ -0,0 +1,51 @@ +using System.IO.Compression; +using Artemis.Core; +using Artemis.Core.Services; +using Artemis.UI.Shared.Extensions; +using Artemis.UI.Shared.Utilities; +using Newtonsoft.Json; + +namespace Artemis.WebClient.Workshop.DownloadHandlers.Implementations; + +public class ProfileEntryDownloadHandler : IEntryDownloadHandler +{ + private readonly IHttpClientFactory _httpClientFactory; + private readonly IProfileService _profileService; + + public ProfileEntryDownloadHandler(IHttpClientFactory httpClientFactory, IProfileService profileService) + { + _httpClientFactory = httpClientFactory; + _profileService = profileService; + } + + public async Task> InstallProfileAsync(Guid releaseId, Progress progress, CancellationToken cancellationToken) + { + try + { + HttpClient client = _httpClientFactory.CreateClient(WorkshopConstants.WORKSHOP_CLIENT_NAME); + using MemoryStream stream = new(); + await client.DownloadDataAsync($"releases/download/{releaseId}", stream, progress, cancellationToken); + + using ZipArchive zipArchive = new(stream, ZipArchiveMode.Read); + List profiles = zipArchive.Entries.Where(e => e.Name.EndsWith("json", StringComparison.InvariantCultureIgnoreCase)).ToList(); + ZipArchiveEntry userProfileEntry = profiles.First(); + ProfileConfigurationExportModel profile = await GetProfile(userProfileEntry); + + ProfileCategory category = _profileService.ProfileCategories.FirstOrDefault(c => c.Name == "Workshop") ?? _profileService.CreateProfileCategory("Workshop", true); + ProfileConfiguration profileConfiguration = _profileService.ImportProfile(category, profile, true, true, null); + return EntryInstallResult.FromSuccess(profileConfiguration); + } + catch (Exception e) + { + return EntryInstallResult.FromFailure(e.Message); + } + } + + private async Task GetProfile(ZipArchiveEntry userProfileEntry) + { + await using Stream stream = userProfileEntry.Open(); + using StreamReader reader = new(stream); + + return JsonConvert.DeserializeObject(await reader.ReadToEndAsync(), IProfileService.ExportSettings)!; + } +} \ No newline at end of file diff --git a/src/Artemis.WebClient.Workshop/DryIoc/ContainerExtensions.cs b/src/Artemis.WebClient.Workshop/DryIoc/ContainerExtensions.cs index 05f1ecb21..e98f8c8e7 100644 --- a/src/Artemis.WebClient.Workshop/DryIoc/ContainerExtensions.cs +++ b/src/Artemis.WebClient.Workshop/DryIoc/ContainerExtensions.cs @@ -1,4 +1,5 @@ using System.Reflection; +using Artemis.WebClient.Workshop.DownloadHandlers; using Artemis.WebClient.Workshop.Extensions; using Artemis.WebClient.Workshop.Repositories; using Artemis.WebClient.Workshop.Services; @@ -48,5 +49,6 @@ public static class ContainerExtensions container.Register(Reuse.Transient); container.RegisterMany(workshopAssembly, type => type.IsAssignableTo(), Reuse.Transient); + container.RegisterMany(workshopAssembly, type => type.IsAssignableTo(), Reuse.Transient); } } \ No newline at end of file diff --git a/src/Artemis.WebClient.Workshop/Services/IWorkshopService.cs b/src/Artemis.WebClient.Workshop/Services/IWorkshopService.cs index f81e9d0ee..c79bde1e1 100644 --- a/src/Artemis.WebClient.Workshop/Services/IWorkshopService.cs +++ b/src/Artemis.WebClient.Workshop/Services/IWorkshopService.cs @@ -1,4 +1,6 @@ +using System.Net; using System.Net.Http.Headers; +using Artemis.UI.Shared.Routing; using Artemis.UI.Shared.Services.MainWindow; using Artemis.UI.Shared.Utilities; using Artemis.WebClient.Workshop.UploadHandlers; @@ -11,11 +13,13 @@ public class WorkshopService : IWorkshopService { private readonly Dictionary _entryIconCache = new(); private readonly IHttpClientFactory _httpClientFactory; + private readonly IRouter _router; private readonly SemaphoreSlim _iconCacheLock = new(1); - public WorkshopService(IHttpClientFactory httpClientFactory, IMainWindowService mainWindowService) + public WorkshopService(IHttpClientFactory httpClientFactory, IMainWindowService mainWindowService, IRouter router) { _httpClientFactory = httpClientFactory; + _router = router; mainWindowService.MainWindowClosed += (_, _) => Dispatcher.UIThread.InvokeAsync(async () => { await Task.Delay(1000); @@ -43,6 +47,31 @@ public class WorkshopService : IWorkshopService return ImageUploadResult.FromSuccess(); } + /// + public async Task GetWorkshopStatus() + { + try + { + // Don't use the workshop client which adds auth headers + HttpClient client = _httpClientFactory.CreateClient(); + HttpResponseMessage response = await client.SendAsync(new HttpRequestMessage(HttpMethod.Head, WorkshopConstants.WORKSHOP_URL + "/status")); + return new IWorkshopService.WorkshopStatus(response.IsSuccessStatusCode, response.StatusCode.ToString()); + } + catch (HttpRequestException e) + { + return new IWorkshopService.WorkshopStatus(false, e.Message); + } + } + + /// + public async Task ValidateWorkshopStatus() + { + IWorkshopService.WorkshopStatus status = await GetWorkshopStatus(); + if (!status.IsReachable) + await _router.Navigate($"workshop/offline/{status.Message}"); + return status.IsReachable; + } + private void ClearCache() { try @@ -63,4 +92,8 @@ public class WorkshopService : IWorkshopService public interface IWorkshopService { Task SetEntryIcon(Guid entryId, Progress progress, Stream icon, CancellationToken cancellationToken); + Task GetWorkshopStatus(); + Task ValidateWorkshopStatus(); + + public record WorkshopStatus(bool IsReachable, string Message); } \ No newline at end of file From c75e8397566dadf26cc80c8a943b27ff61181fa3 Mon Sep 17 00:00:00 2001 From: Robert Date: Sun, 27 Aug 2023 11:50:08 +0200 Subject: [PATCH 20/37] Profile upload - Use new format --- .../Services/Storage/ProfileService.cs | 6 ----- .../ProfileEntryDownloadHandler.cs | 19 ++-------------- .../Exceptions/ArtemisWebClientException.cs | 4 +--- .../Exceptions/ArtemisWorkshopException.cs | 22 +++++++++++++++++++ .../Services/AccessToken.cs | 1 + .../Services/AuthenticationService.cs | 1 + .../ProfileEntryUploadHandler.cs | 21 +++--------------- 7 files changed, 30 insertions(+), 44 deletions(-) create mode 100644 src/Artemis.WebClient.Workshop/Exceptions/ArtemisWorkshopException.cs diff --git a/src/Artemis.Core/Services/Storage/ProfileService.cs b/src/Artemis.Core/Services/Storage/ProfileService.cs index 4ab6424e4..3975713f2 100644 --- a/src/Artemis.Core/Services/Storage/ProfileService.cs +++ b/src/Artemis.Core/Services/Storage/ProfileService.cs @@ -499,12 +499,6 @@ internal class ProfileService : IProfileService SaveProfileConfigurationIcon(profileConfiguration); } - if (exportModel.ProfileImage != null) - { - profileConfiguration.Icon.SetIconByStream(exportModel.ProfileImage); - SaveProfileConfigurationIcon(profileConfiguration); - } - profileConfiguration.Entity.ProfileId = profileEntity.Id; category.AddProfileConfiguration(profileConfiguration, 0); diff --git a/src/Artemis.WebClient.Workshop/DownloadHandlers/Implementations/ProfileEntryDownloadHandler.cs b/src/Artemis.WebClient.Workshop/DownloadHandlers/Implementations/ProfileEntryDownloadHandler.cs index 496072c9c..cb7e6e7a6 100644 --- a/src/Artemis.WebClient.Workshop/DownloadHandlers/Implementations/ProfileEntryDownloadHandler.cs +++ b/src/Artemis.WebClient.Workshop/DownloadHandlers/Implementations/ProfileEntryDownloadHandler.cs @@ -1,9 +1,7 @@ -using System.IO.Compression; -using Artemis.Core; +using Artemis.Core; using Artemis.Core.Services; using Artemis.UI.Shared.Extensions; using Artemis.UI.Shared.Utilities; -using Newtonsoft.Json; namespace Artemis.WebClient.Workshop.DownloadHandlers.Implementations; @@ -26,13 +24,8 @@ public class ProfileEntryDownloadHandler : IEntryDownloadHandler using MemoryStream stream = new(); await client.DownloadDataAsync($"releases/download/{releaseId}", stream, progress, cancellationToken); - using ZipArchive zipArchive = new(stream, ZipArchiveMode.Read); - List profiles = zipArchive.Entries.Where(e => e.Name.EndsWith("json", StringComparison.InvariantCultureIgnoreCase)).ToList(); - ZipArchiveEntry userProfileEntry = profiles.First(); - ProfileConfigurationExportModel profile = await GetProfile(userProfileEntry); - ProfileCategory category = _profileService.ProfileCategories.FirstOrDefault(c => c.Name == "Workshop") ?? _profileService.CreateProfileCategory("Workshop", true); - ProfileConfiguration profileConfiguration = _profileService.ImportProfile(category, profile, true, true, null); + ProfileConfiguration profileConfiguration = await _profileService.ImportProfile(stream, category, true, true, null); return EntryInstallResult.FromSuccess(profileConfiguration); } catch (Exception e) @@ -40,12 +33,4 @@ public class ProfileEntryDownloadHandler : IEntryDownloadHandler return EntryInstallResult.FromFailure(e.Message); } } - - private async Task GetProfile(ZipArchiveEntry userProfileEntry) - { - await using Stream stream = userProfileEntry.Open(); - using StreamReader reader = new(stream); - - return JsonConvert.DeserializeObject(await reader.ReadToEndAsync(), IProfileService.ExportSettings)!; - } } \ No newline at end of file diff --git a/src/Artemis.WebClient.Workshop/Exceptions/ArtemisWebClientException.cs b/src/Artemis.WebClient.Workshop/Exceptions/ArtemisWebClientException.cs index 1bd2af386..a5400594c 100644 --- a/src/Artemis.WebClient.Workshop/Exceptions/ArtemisWebClientException.cs +++ b/src/Artemis.WebClient.Workshop/Exceptions/ArtemisWebClientException.cs @@ -1,6 +1,4 @@ -using System; - -namespace Artemis.Core; +namespace Artemis.WebClient.Workshop.Exceptions; /// /// An exception thrown when a web client related error occurs diff --git a/src/Artemis.WebClient.Workshop/Exceptions/ArtemisWorkshopException.cs b/src/Artemis.WebClient.Workshop/Exceptions/ArtemisWorkshopException.cs new file mode 100644 index 000000000..968526086 --- /dev/null +++ b/src/Artemis.WebClient.Workshop/Exceptions/ArtemisWorkshopException.cs @@ -0,0 +1,22 @@ +namespace Artemis.WebClient.Workshop.Exceptions; + +/// +/// An exception thrown when a workshop related error occurs +/// +public class ArtemisWorkshopException : Exception +{ + /// + public ArtemisWorkshopException() + { + } + + /// + public ArtemisWorkshopException(string? message) : base(message) + { + } + + /// + public ArtemisWorkshopException(string? message, Exception? innerException) : base(message, innerException) + { + } +} \ No newline at end of file diff --git a/src/Artemis.WebClient.Workshop/Services/AccessToken.cs b/src/Artemis.WebClient.Workshop/Services/AccessToken.cs index f67dd9395..7f4f7ba04 100644 --- a/src/Artemis.WebClient.Workshop/Services/AccessToken.cs +++ b/src/Artemis.WebClient.Workshop/Services/AccessToken.cs @@ -1,4 +1,5 @@ using Artemis.Core; +using Artemis.WebClient.Workshop.Exceptions; using IdentityModel.Client; namespace Artemis.WebClient.Workshop.Services; diff --git a/src/Artemis.WebClient.Workshop/Services/AuthenticationService.cs b/src/Artemis.WebClient.Workshop/Services/AuthenticationService.cs index 5a75e499c..5f47d2567 100644 --- a/src/Artemis.WebClient.Workshop/Services/AuthenticationService.cs +++ b/src/Artemis.WebClient.Workshop/Services/AuthenticationService.cs @@ -7,6 +7,7 @@ using System.Security.Claims; using System.Security.Cryptography; using System.Text; using Artemis.Core; +using Artemis.WebClient.Workshop.Exceptions; using Artemis.WebClient.Workshop.Repositories; using DynamicData; using IdentityModel; diff --git a/src/Artemis.WebClient.Workshop/UploadHandlers/Implementations/ProfileEntryUploadHandler.cs b/src/Artemis.WebClient.Workshop/UploadHandlers/Implementations/ProfileEntryUploadHandler.cs index 7f09ce387..9b4287625 100644 --- a/src/Artemis.WebClient.Workshop/UploadHandlers/Implementations/ProfileEntryUploadHandler.cs +++ b/src/Artemis.WebClient.Workshop/UploadHandlers/Implementations/ProfileEntryUploadHandler.cs @@ -1,8 +1,7 @@ -using System.IO.Compression; -using System.Net.Http.Headers; -using System.Text; +using System.Net.Http.Headers; using Artemis.Core; using Artemis.Core.Services; +using Artemis.Storage.Repositories.Interfaces; using Artemis.UI.Shared.Utilities; using Artemis.Web.Workshop.Entities; using Newtonsoft.Json; @@ -26,26 +25,12 @@ public class ProfileEntryUploadHandler : IEntryUploadHandler if (file is not ProfileConfiguration profileConfiguration) throw new InvalidOperationException("Can only create releases for profile configurations"); - ProfileConfigurationExportModel export = _profileService.ExportProfile(profileConfiguration); - string json = JsonConvert.SerializeObject(export, IProfileService.ExportSettings); - - using MemoryStream archiveStream = new(); - - // Create a ZIP archive with a single entry on the archive stream - using (ZipArchive archive = new(archiveStream, ZipArchiveMode.Create, true)) - { - ZipArchiveEntry entry = archive.CreateEntry("profile.json"); - await using (Stream entryStream = entry.Open()) - { - await entryStream.WriteAsync(Encoding.Default.GetBytes(json), cancellationToken); - } - } + await using Stream archiveStream = await _profileService.ExportProfile(profileConfiguration); // Submit the archive HttpClient client = _httpClientFactory.CreateClient(WorkshopConstants.WORKSHOP_CLIENT_NAME); // Construct the request - archiveStream.Seek(0, SeekOrigin.Begin); MultipartFormDataContent content = new(); ProgressableStreamContent streamContent = new(archiveStream, progress); streamContent.Headers.ContentType = new MediaTypeHeaderValue("application/zip"); From e545d2f3daa6400b443062b9c6f26276d8983b18 Mon Sep 17 00:00:00 2001 From: Robert Date: Thu, 31 Aug 2023 22:04:57 +0200 Subject: [PATCH 21/37] Workshop - Added library view models --- src/Artemis.UI/Routing/RouteViewModel.cs | 26 ++++++++++ src/Artemis.UI/Routing/Routes.cs | 9 +++- .../Screens/Settings/SettingsTab.cs | 20 -------- .../Screens/Settings/SettingsViewModel.cs | 23 +++++---- .../Entries/EntryInstallationDialogView.axaml | 8 +++ .../EntryInstallationDialogView.axaml.cs | 13 +++++ .../EntryInstallationDialogViewModel.cs | 8 +++ .../Workshop/Home/WorkshopHomeView.axaml | 24 ++++++--- .../Library/Tabs/LibraryInstalledView.axaml | 8 +++ .../Tabs/LibraryInstalledView.axaml.cs | 14 ++++++ .../Library/Tabs/LibraryInstalledViewModel.cs | 8 +++ .../Library/Tabs/LibrarySubmissionsView.axaml | 8 +++ .../Tabs/LibrarySubmissionsView.axaml.cs | 14 ++++++ .../Tabs/LibrarySubmissionsViewModel.cs | 8 +++ .../Library/WorkshopLibraryVIew.axaml | 27 ++++++++++ .../Library/WorkshopLibraryVIew.axaml.cs | 37 ++++++++++++++ .../Library/WorkshopLibraryViewModel.cs | 50 +++++++++++++++++++ .../Profile/ProfileDetailsViewModel.cs | 8 ++- 18 files changed, 272 insertions(+), 41 deletions(-) create mode 100644 src/Artemis.UI/Routing/RouteViewModel.cs delete mode 100644 src/Artemis.UI/Screens/Settings/SettingsTab.cs create mode 100644 src/Artemis.UI/Screens/Workshop/Entries/EntryInstallationDialogView.axaml create mode 100644 src/Artemis.UI/Screens/Workshop/Entries/EntryInstallationDialogView.axaml.cs create mode 100644 src/Artemis.UI/Screens/Workshop/Entries/EntryInstallationDialogViewModel.cs create mode 100644 src/Artemis.UI/Screens/Workshop/Library/Tabs/LibraryInstalledView.axaml create mode 100644 src/Artemis.UI/Screens/Workshop/Library/Tabs/LibraryInstalledView.axaml.cs create mode 100644 src/Artemis.UI/Screens/Workshop/Library/Tabs/LibraryInstalledViewModel.cs create mode 100644 src/Artemis.UI/Screens/Workshop/Library/Tabs/LibrarySubmissionsView.axaml create mode 100644 src/Artemis.UI/Screens/Workshop/Library/Tabs/LibrarySubmissionsView.axaml.cs create mode 100644 src/Artemis.UI/Screens/Workshop/Library/Tabs/LibrarySubmissionsViewModel.cs create mode 100644 src/Artemis.UI/Screens/Workshop/Library/WorkshopLibraryVIew.axaml create mode 100644 src/Artemis.UI/Screens/Workshop/Library/WorkshopLibraryVIew.axaml.cs create mode 100644 src/Artemis.UI/Screens/Workshop/Library/WorkshopLibraryViewModel.cs diff --git a/src/Artemis.UI/Routing/RouteViewModel.cs b/src/Artemis.UI/Routing/RouteViewModel.cs new file mode 100644 index 000000000..5ecc0f356 --- /dev/null +++ b/src/Artemis.UI/Routing/RouteViewModel.cs @@ -0,0 +1,26 @@ +using System; + +namespace Artemis.UI.Routing; + +public class RouteViewModel +{ + public RouteViewModel(string path, string name) + { + Path = path; + Name = name; + } + + public string Path { get; } + public string Name { get; } + + public bool Matches(string path) + { + return path.StartsWith(Path, StringComparison.InvariantCultureIgnoreCase); + } + + /// + public override string ToString() + { + return Name; + } +} \ No newline at end of file diff --git a/src/Artemis.UI/Routing/Routes.cs b/src/Artemis.UI/Routing/Routes.cs index ba0949669..4baf359a6 100644 --- a/src/Artemis.UI/Routing/Routes.cs +++ b/src/Artemis.UI/Routing/Routes.cs @@ -8,6 +8,8 @@ using Artemis.UI.Screens.SurfaceEditor; using Artemis.UI.Screens.Workshop; using Artemis.UI.Screens.Workshop.Home; using Artemis.UI.Screens.Workshop.Layout; +using Artemis.UI.Screens.Workshop.Library; +using Artemis.UI.Screens.Workshop.Library.Tabs; using Artemis.UI.Screens.Workshop.Profile; using Artemis.UI.Shared.Routing; @@ -28,7 +30,12 @@ public static class Routes new RouteRegistration("profiles/{page:int}"), new RouteRegistration("profiles/{entryId:guid}"), new RouteRegistration("layouts/{page:int}"), - new RouteRegistration("layouts/{entryId:guid}") + new RouteRegistration("layouts/{entryId:guid}"), + new RouteRegistration("library") {Children = new List() + { + new RouteRegistration("installed"), + new RouteRegistration("submissions"), + }} } }, #endif diff --git a/src/Artemis.UI/Screens/Settings/SettingsTab.cs b/src/Artemis.UI/Screens/Settings/SettingsTab.cs deleted file mode 100644 index f25200fca..000000000 --- a/src/Artemis.UI/Screens/Settings/SettingsTab.cs +++ /dev/null @@ -1,20 +0,0 @@ -using System; - -namespace Artemis.UI.Screens.Settings; - -public class SettingsTab -{ - public SettingsTab(string path, string name) - { - Path = path; - Name = name; - } - - public string Path { get; set; } - public string Name { get; set; } - - public bool Matches(string path) - { - return path.StartsWith($"settings/{Path}", StringComparison.InvariantCultureIgnoreCase); - } -} \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Settings/SettingsViewModel.cs b/src/Artemis.UI/Screens/Settings/SettingsViewModel.cs index 2a747e5f9..561c4e3d6 100644 --- a/src/Artemis.UI/Screens/Settings/SettingsViewModel.cs +++ b/src/Artemis.UI/Screens/Settings/SettingsViewModel.cs @@ -4,6 +4,7 @@ using System.Linq; using System.Reactive.Disposables; using System.Threading; using System.Threading.Tasks; +using Artemis.UI.Routing; using Artemis.UI.Shared; using Artemis.UI.Shared.Routing; using ReactiveUI; @@ -13,30 +14,30 @@ namespace Artemis.UI.Screens.Settings; public class SettingsViewModel : RoutableScreen, IMainScreenViewModel { private readonly IRouter _router; - private SettingsTab? _selectedTab; + private RouteViewModel? _selectedTab; public SettingsViewModel(IRouter router) { _router = router; - SettingTabs = new ObservableCollection + SettingTabs = new ObservableCollection { - new("general", "General"), - new("plugins", "Plugins"), - new("devices", "Devices"), - new("releases", "Releases"), - new("about", "About"), + new("settings/general", "General"), + new("settings/plugins", "Plugins"), + new("settings/devices", "Devices"), + new("settings/releases", "Releases"), + new("settings/about", "About"), }; // Navigate on tab change this.WhenActivated(d => this.WhenAnyValue(vm => vm.SelectedTab) .WhereNotNull() - .Subscribe(s => _router.Navigate($"settings/{s.Path}", new RouterNavigationOptions {IgnoreOnPartialMatch = true})) + .Subscribe(s => _router.Navigate(s.Path, new RouterNavigationOptions {IgnoreOnPartialMatch = true})) .DisposeWith(d)); } - public ObservableCollection SettingTabs { get; } + public ObservableCollection SettingTabs { get; } - public SettingsTab? SelectedTab + public RouteViewModel? SelectedTab { get => _selectedTab; set => RaiseAndSetIfChanged(ref _selectedTab, value); @@ -52,6 +53,6 @@ public class SettingsViewModel : RoutableScreen, IMain // Always show a tab, if there is none forward to the first if (SelectedTab == null) - await _router.Navigate($"settings/{SettingTabs.First().Path}"); + await _router.Navigate(SettingTabs.First().Path); } } \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/Entries/EntryInstallationDialogView.axaml b/src/Artemis.UI/Screens/Workshop/Entries/EntryInstallationDialogView.axaml new file mode 100644 index 000000000..af75201c2 --- /dev/null +++ b/src/Artemis.UI/Screens/Workshop/Entries/EntryInstallationDialogView.axaml @@ -0,0 +1,8 @@ + + Welcome to Avalonia! + diff --git a/src/Artemis.UI/Screens/Workshop/Entries/EntryInstallationDialogView.axaml.cs b/src/Artemis.UI/Screens/Workshop/Entries/EntryInstallationDialogView.axaml.cs new file mode 100644 index 000000000..b228f4b0f --- /dev/null +++ b/src/Artemis.UI/Screens/Workshop/Entries/EntryInstallationDialogView.axaml.cs @@ -0,0 +1,13 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Markup.Xaml; + +namespace Artemis.UI.Screens.Workshop.Entries; + +public partial class EntryInstallationDialogView : UserControl +{ + public EntryInstallationDialogView() + { + InitializeComponent(); + } +} \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/Entries/EntryInstallationDialogViewModel.cs b/src/Artemis.UI/Screens/Workshop/Entries/EntryInstallationDialogViewModel.cs new file mode 100644 index 000000000..7b7cbca67 --- /dev/null +++ b/src/Artemis.UI/Screens/Workshop/Entries/EntryInstallationDialogViewModel.cs @@ -0,0 +1,8 @@ +using Artemis.UI.Shared; + +namespace Artemis.UI.Screens.Workshop.Entries; + +public class EntryInstallationDialogViewModel : ContentDialogViewModelBase +{ + +} \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/Home/WorkshopHomeView.axaml b/src/Artemis.UI/Screens/Workshop/Home/WorkshopHomeView.axaml index 0695a7554..c3b4593f6 100644 --- a/src/Artemis.UI/Screens/Workshop/Home/WorkshopHomeView.axaml +++ b/src/Artemis.UI/Screens/Workshop/Home/WorkshopHomeView.axaml @@ -41,14 +41,6 @@ - - + + + + diff --git a/src/Artemis.UI/Screens/Workshop/Library/Tabs/LibraryInstalledView.axaml b/src/Artemis.UI/Screens/Workshop/Library/Tabs/LibraryInstalledView.axaml new file mode 100644 index 000000000..208718542 --- /dev/null +++ b/src/Artemis.UI/Screens/Workshop/Library/Tabs/LibraryInstalledView.axaml @@ -0,0 +1,8 @@ + + Installed entries management here 🫡 + diff --git a/src/Artemis.UI/Screens/Workshop/Library/Tabs/LibraryInstalledView.axaml.cs b/src/Artemis.UI/Screens/Workshop/Library/Tabs/LibraryInstalledView.axaml.cs new file mode 100644 index 000000000..b98518ae5 --- /dev/null +++ b/src/Artemis.UI/Screens/Workshop/Library/Tabs/LibraryInstalledView.axaml.cs @@ -0,0 +1,14 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Markup.Xaml; +using Avalonia.ReactiveUI; + +namespace Artemis.UI.Screens.Workshop.Library.Tabs; + +public partial class LibraryInstalledView : ReactiveUserControl +{ + public LibraryInstalledView() + { + InitializeComponent(); + } +} \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/Library/Tabs/LibraryInstalledViewModel.cs b/src/Artemis.UI/Screens/Workshop/Library/Tabs/LibraryInstalledViewModel.cs new file mode 100644 index 000000000..b615c18b1 --- /dev/null +++ b/src/Artemis.UI/Screens/Workshop/Library/Tabs/LibraryInstalledViewModel.cs @@ -0,0 +1,8 @@ +using Artemis.UI.Shared; + +namespace Artemis.UI.Screens.Workshop.Library.Tabs; + +public class LibraryInstalledViewModel : ActivatableViewModelBase +{ + +} \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/Library/Tabs/LibrarySubmissionsView.axaml b/src/Artemis.UI/Screens/Workshop/Library/Tabs/LibrarySubmissionsView.axaml new file mode 100644 index 000000000..4ab44f1fc --- /dev/null +++ b/src/Artemis.UI/Screens/Workshop/Library/Tabs/LibrarySubmissionsView.axaml @@ -0,0 +1,8 @@ + + Submission management here 😗 + diff --git a/src/Artemis.UI/Screens/Workshop/Library/Tabs/LibrarySubmissionsView.axaml.cs b/src/Artemis.UI/Screens/Workshop/Library/Tabs/LibrarySubmissionsView.axaml.cs new file mode 100644 index 000000000..baf08341b --- /dev/null +++ b/src/Artemis.UI/Screens/Workshop/Library/Tabs/LibrarySubmissionsView.axaml.cs @@ -0,0 +1,14 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Markup.Xaml; +using Avalonia.ReactiveUI; + +namespace Artemis.UI.Screens.Workshop.Library.Tabs; + +public partial class LibrarySubmissionsView : ReactiveUserControl +{ + public LibrarySubmissionsView() + { + InitializeComponent(); + } +} \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/Library/Tabs/LibrarySubmissionsViewModel.cs b/src/Artemis.UI/Screens/Workshop/Library/Tabs/LibrarySubmissionsViewModel.cs new file mode 100644 index 000000000..cbb98d615 --- /dev/null +++ b/src/Artemis.UI/Screens/Workshop/Library/Tabs/LibrarySubmissionsViewModel.cs @@ -0,0 +1,8 @@ +using Artemis.UI.Shared; + +namespace Artemis.UI.Screens.Workshop.Library.Tabs; + +public class LibrarySubmissionsViewModel : ActivatableViewModelBase +{ + +} \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/Library/WorkshopLibraryVIew.axaml b/src/Artemis.UI/Screens/Workshop/Library/WorkshopLibraryVIew.axaml new file mode 100644 index 000000000..f6e2f5740 --- /dev/null +++ b/src/Artemis.UI/Screens/Workshop/Library/WorkshopLibraryVIew.axaml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + diff --git a/src/Artemis.UI/Screens/Workshop/Library/WorkshopLibraryVIew.axaml.cs b/src/Artemis.UI/Screens/Workshop/Library/WorkshopLibraryVIew.axaml.cs new file mode 100644 index 000000000..f2faa7e5a --- /dev/null +++ b/src/Artemis.UI/Screens/Workshop/Library/WorkshopLibraryVIew.axaml.cs @@ -0,0 +1,37 @@ +using System; +using System.Reactive.Disposables; +using Artemis.UI.Shared; +using Avalonia.ReactiveUI; +using Avalonia.Threading; +using FluentAvalonia.UI.Media.Animation; +using FluentAvalonia.UI.Navigation; +using ReactiveUI; + +namespace Artemis.UI.Screens.Workshop.Library; + +public partial class WorkshopLibraryView : ReactiveUserControl +{ + private int _lastIndex; + + public WorkshopLibraryView() + { + InitializeComponent(); + this.WhenActivated(d => { ViewModel.WhenAnyValue(vm => vm.Screen).WhereNotNull().Subscribe(Navigate).DisposeWith(d); }); + } + + private void Navigate(ViewModelBase viewModel) + { + Dispatcher.UIThread.Invoke(() => + { + if (ViewModel == null) + return; + + SlideNavigationTransitionInfo transitionInfo = new() + { + Effect = ViewModel.Tabs.IndexOf(ViewModel.SelectedTab) > _lastIndex ? SlideNavigationTransitionEffect.FromRight : SlideNavigationTransitionEffect.FromLeft + }; + TabFrame.NavigateFromObject(viewModel, new FrameNavigationOptions {TransitionInfoOverride = transitionInfo}); + _lastIndex = ViewModel.Tabs.IndexOf(ViewModel.SelectedTab); + }); + } +} \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/Library/WorkshopLibraryViewModel.cs b/src/Artemis.UI/Screens/Workshop/Library/WorkshopLibraryViewModel.cs new file mode 100644 index 000000000..707d22cde --- /dev/null +++ b/src/Artemis.UI/Screens/Workshop/Library/WorkshopLibraryViewModel.cs @@ -0,0 +1,50 @@ +using System.Collections.ObjectModel; +using System.Linq; +using System.Reactive.Disposables; +using System.Threading; +using System.Threading.Tasks; +using Artemis.UI.Routing; +using Artemis.UI.Shared; +using Artemis.UI.Shared.Routing; +using Artemis.WebClient.Workshop; +using ReactiveUI; +using System; + +namespace Artemis.UI.Screens.Workshop.Library; + +public class WorkshopLibraryViewModel : RoutableScreen, IWorkshopViewModel +{ + private RouteViewModel? _selectedTab; + + /// + public WorkshopLibraryViewModel(IRouter router) + { + Tabs = new ObservableCollection + { + new("workshop/library/installed", "Installed"), + new("workshop/library/submissions", "Submissions") + }; + + // Navigate on tab change + this.WhenActivated(d => this.WhenAnyValue(vm => vm.SelectedTab) + .WhereNotNull() + .Subscribe(s => router.Navigate(s.Path, new RouterNavigationOptions {IgnoreOnPartialMatch = true})) + .DisposeWith(d)); + } + + public EntryType? EntryType => null; + public ObservableCollection Tabs { get; } + + public RouteViewModel? SelectedTab + { + get => _selectedTab; + set => RaiseAndSetIfChanged(ref _selectedTab, value); + } + + public override async Task OnNavigating(NavigationArguments args, CancellationToken cancellationToken) + { + SelectedTab = Tabs.FirstOrDefault(t => t.Matches(args.Path)); + if (SelectedTab == null) + await args.Router.Navigate(Tabs.First().Path); + } +} \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/Profile/ProfileDetailsViewModel.cs b/src/Artemis.UI/Screens/Workshop/Profile/ProfileDetailsViewModel.cs index f15ca7cf6..83e5280f3 100644 --- a/src/Artemis.UI/Screens/Workshop/Profile/ProfileDetailsViewModel.cs +++ b/src/Artemis.UI/Screens/Workshop/Profile/ProfileDetailsViewModel.cs @@ -23,14 +23,16 @@ public class ProfileDetailsViewModel : RoutableScreen _updatedAt; private IGetEntryById_Entry? _entry; - public ProfileDetailsViewModel(IWorkshopClient client, ProfileEntryDownloadHandler downloadHandler, INotificationService notificationService) + public ProfileDetailsViewModel(IWorkshopClient client, ProfileEntryDownloadHandler downloadHandler, INotificationService notificationService, IWindowService windowService) { _client = client; _downloadHandler = downloadHandler; _notificationService = notificationService; + _windowService = windowService; _updatedAt = this.WhenAnyValue(vm => vm.Entry).Select(e => e?.LatestRelease?.CreatedAt ?? e?.CreatedAt).ToProperty(this, vm => vm.UpdatedAt); DownloadLatestRelease = ReactiveCommand.CreateFromTask(ExecuteDownloadLatestRelease); @@ -65,6 +67,10 @@ public class ProfileDetailsViewModel : RoutableScreen result = await _downloadHandler.InstallProfileAsync(Entry.LatestRelease.Id, new Progress(), cancellationToken); if (result.IsSuccess) _notificationService.CreateNotification().WithTitle("Profile installed").WithSeverity(NotificationSeverity.Success).Show(); From 9c6d7329a600f489295c02b99122625ba8888976 Mon Sep 17 00:00:00 2001 From: Robert Date: Fri, 1 Sep 2023 20:33:32 +0200 Subject: [PATCH 22/37] Workshop Library - Added library pages UI - Tweaked design to more closely match WinUI 3 gallery examples --- src/Artemis.UI.Shared/Styles/Border.axaml | 22 +-- src/Artemis.UI/Assets/Animations/empty.json | 1 + .../IActivatableViewModelExtensions.cs | 15 ++ src/Artemis.UI/Routing/Routes.cs | 15 +- .../Screens/Sidebar/SidebarViewModel.cs | 1 + .../Entries/EntryListBaseViewModel.cs | 148 ++++++++++++++++ ...ListView.axaml => EntryListItemView.axaml} | 10 +- ...ew.axaml.cs => EntryListItemView.axaml.cs} | 4 +- ...ViewModel.cs => EntryListItemViewModel.cs} | 4 +- .../Workshop/Home/WorkshopHomeViewModel.cs | 8 +- .../Workshop/Home/WorkshopOfflineViewModel.cs | 4 +- .../Workshop/Layout/LayoutListView.axaml | 41 +++-- .../Workshop/Layout/LayoutListViewModel.cs | 48 ++--- .../Library/Tabs/LibrarySubmissionsView.axaml | 68 ++++++- .../Tabs/LibrarySubmissionsViewModel.cs | 104 ++++++++++- .../Library/WorkshopLibraryVIew.axaml | 25 ++- .../Library/WorkshopLibraryVIew.axaml.cs | 25 +-- .../Workshop/Profile/ProfileDetailsView.axaml | 3 +- .../Workshop/Profile/ProfileListView.axaml | 16 +- .../Workshop/Profile/ProfileListViewModel.cs | 167 +++--------------- .../Artemis.WebClient.Workshop.csproj | 3 + .../Queries/GetSubmittedEntries.graphql | 10 ++ .../Services/IWorkshopService.cs | 46 ++--- .../graphql.config.yml | 2 +- src/Artemis.WebClient.Workshop/schema.graphql | 17 ++ 25 files changed, 519 insertions(+), 288 deletions(-) create mode 100644 src/Artemis.UI/Assets/Animations/empty.json create mode 100644 src/Artemis.UI/Extensions/IActivatableViewModelExtensions.cs create mode 100644 src/Artemis.UI/Screens/Workshop/Entries/EntryListBaseViewModel.cs rename src/Artemis.UI/Screens/Workshop/Entries/{EntryListView.axaml => EntryListItemView.axaml} (94%) rename src/Artemis.UI/Screens/Workshop/Entries/{EntryListView.axaml.cs => EntryListItemView.axaml.cs} (51%) rename src/Artemis.UI/Screens/Workshop/Entries/{EntryListViewModel.cs => EntryListItemViewModel.cs} (89%) create mode 100644 src/Artemis.WebClient.Workshop/Queries/GetSubmittedEntries.graphql diff --git a/src/Artemis.UI.Shared/Styles/Border.axaml b/src/Artemis.UI.Shared/Styles/Border.axaml index d753809a0..e3997e5ec 100644 --- a/src/Artemis.UI.Shared/Styles/Border.axaml +++ b/src/Artemis.UI.Shared/Styles/Border.axaml @@ -23,15 +23,11 @@ - - - 8 - - + + + + + + + You are not logged in + + In order to manage your submissions you must be logged in. + + + + + + + + Oh boy, it's empty here 🤔 + + Any entries you submit to the workshop you can later manage here + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/Library/Tabs/LibrarySubmissionsViewModel.cs b/src/Artemis.UI/Screens/Workshop/Library/Tabs/LibrarySubmissionsViewModel.cs index cbb98d615..3421b6aff 100644 --- a/src/Artemis.UI/Screens/Workshop/Library/Tabs/LibrarySubmissionsViewModel.cs +++ b/src/Artemis.UI/Screens/Workshop/Library/Tabs/LibrarySubmissionsViewModel.cs @@ -1,8 +1,108 @@ +using System; +using System.Collections.ObjectModel; +using System.Reactive; +using System.Threading; +using System.Threading.Tasks; +using Artemis.UI.Extensions; +using Artemis.UI.Screens.Workshop.CurrentUser; +using Artemis.UI.Screens.Workshop.SubmissionWizard; using Artemis.UI.Shared; +using Artemis.UI.Shared.Routing; +using Artemis.UI.Shared.Services; +using Artemis.WebClient.Workshop; +using Artemis.WebClient.Workshop.Services; +using DynamicData; +using ReactiveUI; +using StrawberryShake; namespace Artemis.UI.Screens.Workshop.Library.Tabs; -public class LibrarySubmissionsViewModel : ActivatableViewModelBase +public class LibrarySubmissionsViewModel : ActivatableViewModelBase, IWorkshopViewModel { - + private readonly IWorkshopClient _client; + private readonly SourceCache _entries; + private readonly IWindowService _windowService; + private bool _isLoading = true; + private bool _workshopReachable; + + public LibrarySubmissionsViewModel(IWorkshopClient client, IAuthenticationService authenticationService, IWindowService windowService, IWorkshopService workshopService, IRouter router) + { + _client = client; + _windowService = windowService; + _entries = new SourceCache(e => e.Id); + _entries.Connect().Bind(out ReadOnlyObservableCollection entries).Subscribe(); + + AddSubmission = ReactiveCommand.CreateFromTask(ExecuteAddSubmission, this.WhenAnyValue(vm => vm.WorkshopReachable)); + Login = ReactiveCommand.CreateFromTask(ExecuteLogin, this.WhenAnyValue(vm => vm.WorkshopReachable)); + NavigateToEntry = ReactiveCommand.CreateFromTask(ExecuteNavigateToEntry); + + IsLoggedIn = authenticationService.IsLoggedIn; + Entries = entries; + + this.WhenActivatedAsync(async d => + { + WorkshopReachable = await workshopService.ValidateWorkshopStatus(d.AsCancellationToken()); + if (WorkshopReachable) + await GetEntries(d.AsCancellationToken()); + }); + } + + public ReactiveCommand Login { get; } + public ReactiveCommand AddSubmission { get; } + public ReactiveCommand NavigateToEntry { get; } + + public IObservable IsLoggedIn { get; } + public ReadOnlyObservableCollection Entries { get; } + + public bool WorkshopReachable + { + get => _workshopReachable; + set => RaiseAndSetIfChanged(ref _workshopReachable, value); + } + + public bool IsLoading + { + get => _isLoading; + set => RaiseAndSetIfChanged(ref _isLoading, value); + } + + private async Task ExecuteLogin(CancellationToken ct) + { + await _windowService.CreateContentDialog().WithViewModel(out WorkshopLoginViewModel _).WithTitle("Workshop login").ShowAsync(); + } + + private async Task ExecuteAddSubmission(CancellationToken arg) + { + await _windowService.ShowDialogAsync(); + } + + private Task ExecuteNavigateToEntry(IGetSubmittedEntries_SubmittedEntries entry, CancellationToken cancellationToken) + { + return Task.CompletedTask; + } + + private async Task GetEntries(CancellationToken ct) + { + IsLoading = true; + + try + { + IOperationResult result = await _client.GetSubmittedEntries.ExecuteAsync(null, ct); + + if (result.Data?.SubmittedEntries == null) + _entries.Clear(); + else + _entries.Edit(e => + { + e.Clear(); + e.AddOrUpdate(result.Data.SubmittedEntries); + }); + } + finally + { + IsLoading = false; + } + } + + public EntryType? EntryType => null; } \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/Library/WorkshopLibraryVIew.axaml b/src/Artemis.UI/Screens/Workshop/Library/WorkshopLibraryVIew.axaml index f6e2f5740..1dafa387c 100644 --- a/src/Artemis.UI/Screens/Workshop/Library/WorkshopLibraryVIew.axaml +++ b/src/Artemis.UI/Screens/Workshop/Library/WorkshopLibraryVIew.axaml @@ -2,26 +2,25 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" - xmlns:ui="clr-namespace:FluentAvalonia.UI.Controls;assembly=FluentAvalonia" + xmlns:controls="clr-namespace:FluentAvalonia.UI.Controls;assembly=FluentAvalonia" xmlns:library="clr-namespace:Artemis.UI.Screens.Workshop.Library" - xmlns:routing="clr-namespace:Artemis.UI.Routing" - xmlns:ui1="clr-namespace:Artemis.UI" + xmlns:ui="clr-namespace:Artemis.UI" mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" x:Class="Artemis.UI.Screens.Workshop.Library.WorkshopLibraryView" x:DataType="library:WorkshopLibraryViewModel"> - - + + - - + - - - - - - + + + + + + diff --git a/src/Artemis.UI/Screens/Workshop/Library/WorkshopLibraryVIew.axaml.cs b/src/Artemis.UI/Screens/Workshop/Library/WorkshopLibraryVIew.axaml.cs index f2faa7e5a..5057296ac 100644 --- a/src/Artemis.UI/Screens/Workshop/Library/WorkshopLibraryVIew.axaml.cs +++ b/src/Artemis.UI/Screens/Workshop/Library/WorkshopLibraryVIew.axaml.cs @@ -21,17 +21,18 @@ public partial class WorkshopLibraryView : ReactiveUserControl - { - if (ViewModel == null) - return; - - SlideNavigationTransitionInfo transitionInfo = new() - { - Effect = ViewModel.Tabs.IndexOf(ViewModel.SelectedTab) > _lastIndex ? SlideNavigationTransitionEffect.FromRight : SlideNavigationTransitionEffect.FromLeft - }; - TabFrame.NavigateFromObject(viewModel, new FrameNavigationOptions {TransitionInfoOverride = transitionInfo}); - _lastIndex = ViewModel.Tabs.IndexOf(ViewModel.SelectedTab); - }); + Dispatcher.UIThread.Invoke(() => TabFrame.NavigateFromObject(viewModel, new FrameNavigationOptions {TransitionInfoOverride = GetTransitionInfo()})); + } + + private SlideNavigationTransitionInfo GetTransitionInfo() + { + if (ViewModel?.SelectedTab == null) + return new SlideNavigationTransitionInfo(); + + SlideNavigationTransitionEffect effect = ViewModel.Tabs.IndexOf(ViewModel.SelectedTab) > _lastIndex ? SlideNavigationTransitionEffect.FromRight : SlideNavigationTransitionEffect.FromLeft; + SlideNavigationTransitionInfo info = new() {Effect = effect}; + _lastIndex = ViewModel.Tabs.IndexOf(ViewModel.SelectedTab); + + return info; } } \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/Profile/ProfileDetailsView.axaml b/src/Artemis.UI/Screens/Workshop/Profile/ProfileDetailsView.axaml index 00ffc76c9..f41c65768 100644 --- a/src/Artemis.UI/Screens/Workshop/Profile/ProfileDetailsView.axaml +++ b/src/Artemis.UI/Screens/Workshop/Profile/ProfileDetailsView.axaml @@ -23,8 +23,7 @@ - - - Categories - - - + + + Categories + + + - - + + + diff --git a/src/Artemis.UI/Screens/Workshop/Profile/ProfileListViewModel.cs b/src/Artemis.UI/Screens/Workshop/Profile/ProfileListViewModel.cs index 23ddddfa2..521c9a351 100644 --- a/src/Artemis.UI/Screens/Workshop/Profile/ProfileListViewModel.cs +++ b/src/Artemis.UI/Screens/Workshop/Profile/ProfileListViewModel.cs @@ -1,165 +1,44 @@ using System; -using System.Collections.Generic; -using System.Collections.ObjectModel; -using System.Linq; -using System.Reactive.Disposables; -using System.Reactive.Linq; -using System.Threading; -using System.Threading.Tasks; using Artemis.UI.Screens.Workshop.Categories; using Artemis.UI.Screens.Workshop.Entries; -using Artemis.UI.Screens.Workshop.Parameters; -using Artemis.UI.Shared; using Artemis.UI.Shared.Routing; using Artemis.UI.Shared.Services; -using Artemis.UI.Shared.Services.Builders; using Artemis.WebClient.Workshop; -using Avalonia.Threading; -using DryIoc.ImTools; -using DynamicData; -using ReactiveUI; -using StrawberryShake; namespace Artemis.UI.Screens.Workshop.Profile; -public class ProfileListViewModel : RoutableScreen, IWorkshopViewModel +public class ProfileListViewModel : EntryListBaseViewModel { - private readonly INotificationService _notificationService; - private readonly Func _getEntryListViewModel; - private readonly IWorkshopClient _workshopClient; - private readonly ObservableAsPropertyHelper _showPagination; - private readonly ObservableAsPropertyHelper _isLoading; - private SourceList _entries = new(); - private int _page; - private int _loadedPage = -1; - private int _totalPages = 1; - private int _entriesPerPage = 10; - + /// public ProfileListViewModel(IWorkshopClient workshopClient, - IRouter router, - CategoriesViewModel categoriesViewModel, + IRouter router, + CategoriesViewModel categoriesViewModel, INotificationService notificationService, - Func getEntryListViewModel) + Func getEntryListViewModel) + : base(workshopClient, router, categoriesViewModel, notificationService, getEntryListViewModel) { - _workshopClient = workshopClient; - _notificationService = notificationService; - _getEntryListViewModel = getEntryListViewModel; - _showPagination = this.WhenAnyValue(vm => vm.TotalPages).Select(t => t > 1).ToProperty(this, vm => vm.ShowPagination); - _isLoading = this.WhenAnyValue(vm => vm.Page, vm => vm.LoadedPage, (p, c) => p != c).ToProperty(this, vm => vm.IsLoading); + } - CategoriesViewModel = categoriesViewModel; - - _entries.Connect() - .ObserveOn(new AvaloniaSynchronizationContext(DispatcherPriority.SystemIdle)) - .Transform(getEntryListViewModel) - .Bind(out ReadOnlyObservableCollection entries) - .Subscribe(); - Entries = entries; - - // Respond to page changes - this.WhenAnyValue(vm => vm.Page).Skip(1).Subscribe(p => Task.Run(() => router.Navigate($"workshop/profiles/{p}"))); - - // Respond to filter changes - this.WhenActivated(d => CategoriesViewModel.WhenAnyValue(vm => vm.CategoryFilters).Skip(1).Subscribe(_ => + #region Overrides of EntryListBaseViewModel + + /// + protected override string GetPagePath(int page) + { + return $"workshop/profiles/{page}"; + } + + /// + protected override EntryFilterInput GetFilter() + { + return new EntryFilterInput { - // Reset to page one, will trigger a query - if (Page != 1) - Page = 1; - // If already at page one, force a query - else - Task.Run(() => Query(CancellationToken.None)); - }).DisposeWith(d)); - } - - public bool ShowPagination => _showPagination.Value; - public bool IsLoading => _isLoading.Value; - - public CategoriesViewModel CategoriesViewModel { get; } - - public ReadOnlyObservableCollection Entries { get; } - - public int Page - { - get => _page; - set => RaiseAndSetIfChanged(ref _page, value); - } - - public int LoadedPage - { - get => _loadedPage; - set => RaiseAndSetIfChanged(ref _loadedPage, value); - } - - public int TotalPages - { - get => _totalPages; - set => RaiseAndSetIfChanged(ref _totalPages, value); - } - - public int EntriesPerPage - { - get => _entriesPerPage; - set => RaiseAndSetIfChanged(ref _entriesPerPage, value); - } - - public override async Task OnNavigating(WorkshopListParameters parameters, NavigationArguments args, CancellationToken cancellationToken) - { - Page = Math.Max(1, parameters.Page); - - // Throttle page changes, wait longer for the first one to keep UI smooth - // if (Entries == null) - // await Task.Delay(400, cancellationToken); - // else - await Task.Delay(200, cancellationToken); - - if (!cancellationToken.IsCancellationRequested) - await Query(cancellationToken); - } - - private async Task Query(CancellationToken cancellationToken) - { - try - { - EntryFilterInput filter = GetFilter(); - IOperationResult entries = await _workshopClient.GetEntries.ExecuteAsync(filter, EntriesPerPage * (Page - 1), EntriesPerPage, cancellationToken); - entries.EnsureNoErrors(); - - if (entries.Data?.Entries?.Items != null) + And = new[] { - TotalPages = (int) Math.Ceiling(entries.Data.Entries.TotalCount / (double) EntriesPerPage); - _entries.Edit(e => - { - e.Clear(); - e.AddRange(entries.Data.Entries.Items); - }); + new EntryFilterInput {EntryType = new EntryTypeOperationFilterInput {Eq = WebClient.Workshop.EntryType.Profile}}, + base.GetFilter() } - else - TotalPages = 1; - } - catch (Exception e) - { - _notificationService.CreateNotification() - .WithTitle("Failed to load entries") - .WithMessage(e.Message) - .WithSeverity(NotificationSeverity.Error) - .Show(); - } - finally - { - LoadedPage = Page; - } - } - - private EntryFilterInput GetFilter() - { - EntryFilterInput filter = new() - { - EntryType = new EntryTypeOperationFilterInput {Eq = WebClient.Workshop.EntryType.Profile}, - And = CategoriesViewModel.CategoryFilters }; - - return filter; } - public EntryType? EntryType => null; + #endregion } \ No newline at end of file diff --git a/src/Artemis.WebClient.Workshop/Artemis.WebClient.Workshop.csproj b/src/Artemis.WebClient.Workshop/Artemis.WebClient.Workshop.csproj index d1c6d5490..d2838936b 100644 --- a/src/Artemis.WebClient.Workshop/Artemis.WebClient.Workshop.csproj +++ b/src/Artemis.WebClient.Workshop/Artemis.WebClient.Workshop.csproj @@ -35,5 +35,8 @@ MSBuild:GenerateGraphQLCode + + MSBuild:GenerateGraphQLCode + diff --git a/src/Artemis.WebClient.Workshop/Queries/GetSubmittedEntries.graphql b/src/Artemis.WebClient.Workshop/Queries/GetSubmittedEntries.graphql new file mode 100644 index 000000000..422fcaf90 --- /dev/null +++ b/src/Artemis.WebClient.Workshop/Queries/GetSubmittedEntries.graphql @@ -0,0 +1,10 @@ +query GetSubmittedEntries($filter: EntryFilterInput) { + submittedEntries(where: $filter order: {createdAt: DESC}) { + id + name + summary + entryType + downloads + createdAt + } +} \ No newline at end of file diff --git a/src/Artemis.WebClient.Workshop/Services/IWorkshopService.cs b/src/Artemis.WebClient.Workshop/Services/IWorkshopService.cs index c79bde1e1..6b18e7d12 100644 --- a/src/Artemis.WebClient.Workshop/Services/IWorkshopService.cs +++ b/src/Artemis.WebClient.Workshop/Services/IWorkshopService.cs @@ -1,30 +1,19 @@ -using System.Net; using System.Net.Http.Headers; using Artemis.UI.Shared.Routing; -using Artemis.UI.Shared.Services.MainWindow; using Artemis.UI.Shared.Utilities; using Artemis.WebClient.Workshop.UploadHandlers; -using Avalonia.Media.Imaging; -using Avalonia.Threading; namespace Artemis.WebClient.Workshop.Services; public class WorkshopService : IWorkshopService { - private readonly Dictionary _entryIconCache = new(); private readonly IHttpClientFactory _httpClientFactory; private readonly IRouter _router; - private readonly SemaphoreSlim _iconCacheLock = new(1); - public WorkshopService(IHttpClientFactory httpClientFactory, IMainWindowService mainWindowService, IRouter router) + public WorkshopService(IHttpClientFactory httpClientFactory, IRouter router) { _httpClientFactory = httpClientFactory; _router = router; - mainWindowService.MainWindowClosed += (_, _) => Dispatcher.UIThread.InvokeAsync(async () => - { - await Task.Delay(1000); - ClearCache(); - }); } public async Task SetEntryIcon(Guid entryId, Progress progress, Stream icon, CancellationToken cancellationToken) @@ -48,52 +37,41 @@ public class WorkshopService : IWorkshopService } /// - public async Task GetWorkshopStatus() + public async Task GetWorkshopStatus(CancellationToken cancellationToken) { try { // Don't use the workshop client which adds auth headers HttpClient client = _httpClientFactory.CreateClient(); - HttpResponseMessage response = await client.SendAsync(new HttpRequestMessage(HttpMethod.Head, WorkshopConstants.WORKSHOP_URL + "/status")); + HttpResponseMessage response = await client.SendAsync(new HttpRequestMessage(HttpMethod.Head, WorkshopConstants.WORKSHOP_URL + "/status"), cancellationToken); return new IWorkshopService.WorkshopStatus(response.IsSuccessStatusCode, response.StatusCode.ToString()); } + catch (OperationCanceledException e) + { + return new IWorkshopService.WorkshopStatus(false, e.Message); + } catch (HttpRequestException e) { return new IWorkshopService.WorkshopStatus(false, e.Message); } } + /// /// - public async Task ValidateWorkshopStatus() + public async Task ValidateWorkshopStatus(CancellationToken cancellationToken) { - IWorkshopService.WorkshopStatus status = await GetWorkshopStatus(); + IWorkshopService.WorkshopStatus status = await GetWorkshopStatus(cancellationToken); if (!status.IsReachable) await _router.Navigate($"workshop/offline/{status.Message}"); return status.IsReachable; } - - private void ClearCache() - { - try - { - List values = _entryIconCache.Values.ToList(); - _entryIconCache.Clear(); - foreach (Stream bitmap in values) - bitmap.Dispose(); - } - catch (Exception e) - { - Console.WriteLine(e); - throw; - } - } } public interface IWorkshopService { Task SetEntryIcon(Guid entryId, Progress progress, Stream icon, CancellationToken cancellationToken); - Task GetWorkshopStatus(); - Task ValidateWorkshopStatus(); + Task GetWorkshopStatus(CancellationToken cancellationToken); + Task ValidateWorkshopStatus(CancellationToken cancellationToken); public record WorkshopStatus(bool IsReachable, string Message); } \ No newline at end of file diff --git a/src/Artemis.WebClient.Workshop/graphql.config.yml b/src/Artemis.WebClient.Workshop/graphql.config.yml index 9662a514f..a8ba99703 100644 --- a/src/Artemis.WebClient.Workshop/graphql.config.yml +++ b/src/Artemis.WebClient.Workshop/graphql.config.yml @@ -2,7 +2,7 @@ schema: schema.graphql extensions: endpoints: Default GraphQL Endpoint: - url: https://workshop.artemis-rgb.com/graphql + url: https://localhost:7281/graphql headers: user-agent: JS GraphQL introspect: true diff --git a/src/Artemis.WebClient.Workshop/schema.graphql b/src/Artemis.WebClient.Workshop/schema.graphql index 8b99d85ea..bed72a4a2 100644 --- a/src/Artemis.WebClient.Workshop/schema.graphql +++ b/src/Artemis.WebClient.Workshop/schema.graphql @@ -41,6 +41,7 @@ type Entry { id: UUID! images: [Image!]! latestRelease: Release + latestReleaseId: UUID name: String! releases: [Release!]! summary: String! @@ -62,6 +63,7 @@ type Query { entries(order: [EntrySortInput!], skip: Int, take: Int, where: EntryFilterInput): EntriesCollectionSegment entry(id: UUID!): Entry searchEntries(input: String!, order: [EntrySortInput!], type: EntryType, where: EntryFilterInput): [Entry!]! + submittedEntries(order: [EntrySortInput!], where: EntryFilterInput): [Entry!]! } type Release { @@ -156,6 +158,8 @@ input EntryFilterInput { iconId: UuidOperationFilterInput id: UuidOperationFilterInput images: ListFilterInputTypeOfImageFilterInput + latestRelease: ReleaseFilterInput + latestReleaseId: UuidOperationFilterInput name: StringOperationFilterInput or: [EntryFilterInput!] releases: ListFilterInputTypeOfReleaseFilterInput @@ -173,6 +177,8 @@ input EntrySortInput { icon: ImageSortInput iconId: SortEnumType id: SortEnumType + latestRelease: ReleaseSortInput + latestReleaseId: SortEnumType name: SortEnumType summary: SortEnumType } @@ -267,6 +273,17 @@ input ReleaseFilterInput { version: StringOperationFilterInput } +input ReleaseSortInput { + createdAt: SortEnumType + downloadSize: SortEnumType + downloads: SortEnumType + entry: EntrySortInput + entryId: SortEnumType + id: SortEnumType + md5Hash: SortEnumType + version: SortEnumType +} + input StringOperationFilterInput { and: [StringOperationFilterInput!] contains: String From 742496b13dfb9099d4b4ed1b9abef961878fda29 Mon Sep 17 00:00:00 2001 From: Robert Date: Sat, 2 Sep 2023 09:43:42 +0200 Subject: [PATCH 23/37] Workshop - UI tweaks --- .../Screens/Workshop/Entries/EntryListItemView.axaml | 2 +- .../Screens/Workshop/Profile/ProfileDetailsView.axaml | 3 +-- .../SubmissionWizard/Steps/SpecificationsStepView.axaml | 3 +-- src/Artemis.WebClient.Workshop/Services/IWorkshopService.cs | 5 ++--- 4 files changed, 5 insertions(+), 8 deletions(-) diff --git a/src/Artemis.UI/Screens/Workshop/Entries/EntryListItemView.axaml b/src/Artemis.UI/Screens/Workshop/Entries/EntryListItemView.axaml index 6d761081f..2831515f6 100644 --- a/src/Artemis.UI/Screens/Workshop/Entries/EntryListItemView.axaml +++ b/src/Artemis.UI/Screens/Workshop/Entries/EntryListItemView.axaml @@ -64,7 +64,7 @@ - + diff --git a/src/Artemis.UI/Screens/Workshop/Profile/ProfileDetailsView.axaml b/src/Artemis.UI/Screens/Workshop/Profile/ProfileDetailsView.axaml index f41c65768..0189b487c 100644 --- a/src/Artemis.UI/Screens/Workshop/Profile/ProfileDetailsView.axaml +++ b/src/Artemis.UI/Screens/Workshop/Profile/ProfileDetailsView.axaml @@ -85,8 +85,7 @@ Latest release - diff --git a/src/Artemis.WebClient.Workshop/Services/IWorkshopService.cs b/src/Artemis.WebClient.Workshop/Services/IWorkshopService.cs index 6b18e7d12..f859a4f2c 100644 --- a/src/Artemis.WebClient.Workshop/Services/IWorkshopService.cs +++ b/src/Artemis.WebClient.Workshop/Services/IWorkshopService.cs @@ -55,13 +55,12 @@ public class WorkshopService : IWorkshopService return new IWorkshopService.WorkshopStatus(false, e.Message); } } - - /// + /// public async Task ValidateWorkshopStatus(CancellationToken cancellationToken) { IWorkshopService.WorkshopStatus status = await GetWorkshopStatus(cancellationToken); - if (!status.IsReachable) + if (!status.IsReachable && !cancellationToken.IsCancellationRequested) await _router.Navigate($"workshop/offline/{status.Message}"); return status.IsReachable; } From 318ec99ad48aa1bf30ec7f06620017dca8af00ad Mon Sep 17 00:00:00 2001 From: Robert Date: Sat, 2 Sep 2023 10:57:18 +0200 Subject: [PATCH 24/37] Workshop library - Match browser design after all --- .../Library/Tabs/LibrarySubmissionsView.axaml | 95 ++++++++++++++----- .../Tabs/LibrarySubmissionsViewModel.cs | 1 + .../Queries/GetSubmittedEntries.graphql | 4 + 3 files changed, 78 insertions(+), 22 deletions(-) diff --git a/src/Artemis.UI/Screens/Workshop/Library/Tabs/LibrarySubmissionsView.axaml b/src/Artemis.UI/Screens/Workshop/Library/Tabs/LibrarySubmissionsView.axaml index e40bf95f9..b57eee7d1 100644 --- a/src/Artemis.UI/Screens/Workshop/Library/Tabs/LibrarySubmissionsView.axaml +++ b/src/Artemis.UI/Screens/Workshop/Library/Tabs/LibrarySubmissionsView.axaml @@ -3,13 +3,16 @@ xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:tabs="clr-namespace:Artemis.UI.Screens.Workshop.Library.Tabs" - xmlns:controls="clr-namespace:FluentAvalonia.UI.Controls;assembly=FluentAvalonia" xmlns:workshop="clr-namespace:Artemis.WebClient.Workshop;assembly=Artemis.WebClient.Workshop" + xmlns:avalonia="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia" + xmlns:asyncImageLoader="clr-namespace:AsyncImageLoader;assembly=AsyncImageLoader.Avalonia" + xmlns:converters="clr-namespace:Artemis.UI.Converters" mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="650" x:Class="Artemis.UI.Screens.Workshop.Library.Tabs.LibrarySubmissionsView" x:DataType="tabs:LibrarySubmissionsViewModel"> - + + @@ -41,27 +44,75 @@ - - - - - - - - + + + + + + + + + diff --git a/src/Artemis.UI/Screens/Workshop/Library/Tabs/LibrarySubmissionsViewModel.cs b/src/Artemis.UI/Screens/Workshop/Library/Tabs/LibrarySubmissionsViewModel.cs index 3421b6aff..a43448109 100644 --- a/src/Artemis.UI/Screens/Workshop/Library/Tabs/LibrarySubmissionsViewModel.cs +++ b/src/Artemis.UI/Screens/Workshop/Library/Tabs/LibrarySubmissionsViewModel.cs @@ -69,6 +69,7 @@ public class LibrarySubmissionsViewModel : ActivatableViewModelBase, IWorkshopVi private async Task ExecuteLogin(CancellationToken ct) { await _windowService.CreateContentDialog().WithViewModel(out WorkshopLoginViewModel _).WithTitle("Workshop login").ShowAsync(); + await GetEntries(ct); } private async Task ExecuteAddSubmission(CancellationToken arg) diff --git a/src/Artemis.WebClient.Workshop/Queries/GetSubmittedEntries.graphql b/src/Artemis.WebClient.Workshop/Queries/GetSubmittedEntries.graphql index 422fcaf90..d4199e074 100644 --- a/src/Artemis.WebClient.Workshop/Queries/GetSubmittedEntries.graphql +++ b/src/Artemis.WebClient.Workshop/Queries/GetSubmittedEntries.graphql @@ -6,5 +6,9 @@ query GetSubmittedEntries($filter: EntryFilterInput) { entryType downloads createdAt + categories { + name + icon + } } } \ No newline at end of file From c132edeb51f4c58d2e207fffc91e5a3bd3f9bc4c Mon Sep 17 00:00:00 2001 From: Robert Date: Sat, 2 Sep 2023 15:52:17 +0200 Subject: [PATCH 25/37] Router - Require routable VMs to implement new RoutableScreen --- .../Routing/Routable/IRoutableHostScreen.cs | 13 +++ .../Routing/Routable/IRoutableScreen.cs | 14 +++ .../Routable/RoutableHostScreenOfTScreen.cs | 42 +++++++++ .../RoutableHostScreenOfTScreenTParam.cs | 44 ++++++++++ .../Routing/Routable/RoutableScreen.cs | 55 +++++++++--- ...eenTParam.cs => RoutableScreenOfTParam.cs} | 66 +------------- .../Routable/RoutableScreenOfTScreen.cs | 87 ------------------- .../Routing/Route/RouteRegistration.cs | 2 +- .../Routing/Route/RouteResolution.cs | 13 +-- .../Routing/Router/IRouter.cs | 4 +- .../Routing/Router/Navigation.cs | 43 +++++---- .../Routing/Router/Router.cs | 28 +++--- src/Artemis.UI/Routing/Routes.cs | 5 +- src/Artemis.UI/Screens/Home/HomeViewModel.cs | 3 +- .../ProfileEditor/ProfileEditorViewModel.cs | 3 +- src/Artemis.UI/Screens/Root/BlankViewModel.cs | 3 +- src/Artemis.UI/Screens/Root/RootView.axaml.cs | 3 +- src/Artemis.UI/Screens/Root/RootViewModel.cs | 10 +-- .../Screens/Settings/SettingsViewModel.cs | 2 +- .../Settings/Tabs/AboutTabViewModel.cs | 4 +- .../Settings/Tabs/DevicesTabViewModel.cs | 4 +- .../Settings/Tabs/GeneralTabViewModel.cs | 4 +- .../Settings/Tabs/PluginsTabViewModel.cs | 5 +- .../Settings/Tabs/ReleasesTabViewModel.cs | 2 +- .../Updating/ReleaseDetailsViewModel.cs | 2 +- .../SurfaceEditor/SurfaceEditorViewModel.cs | 3 +- .../Entries/EntryListBaseViewModel.cs | 2 +- .../Workshop/Home/WorkshopHomeViewModel.cs | 2 +- .../Workshop/Home/WorkshopOfflineViewModel.cs | 2 +- .../Workshop/Layout/LayoutDetailsViewModel.cs | 2 +- ...alledView.axaml => InstalledTabView.axaml} | 2 +- ...iew.axaml.cs => InstalledTabView.axaml.cs} | 4 +- ...dViewModel.cs => InstalledTabViewModel.cs} | 3 +- .../Library/Tabs/SubmissionsDetailView.axaml | 8 ++ .../Tabs/SubmissionsDetailView.axaml.cs | 14 +++ .../Tabs/SubmissionsDetailViewModel.cs | 16 ++++ ...onsView.axaml => SubmissionsTabView.axaml} | 6 +- ...w.axaml.cs => SubmissionsTabView.axaml.cs} | 4 +- ...iewModel.cs => SubmissionsTabViewModel.cs} | 5 +- .../Library/WorkshopLibraryViewModel.cs | 2 +- .../Profile/ProfileDetailsViewModel.cs | 2 +- .../Workshop/Search/SearchViewModel.cs | 9 +- .../Screens/Workshop/WorkshopViewModel.cs | 24 +---- 43 files changed, 284 insertions(+), 287 deletions(-) create mode 100644 src/Artemis.UI.Shared/Routing/Routable/IRoutableHostScreen.cs create mode 100644 src/Artemis.UI.Shared/Routing/Routable/IRoutableScreen.cs create mode 100644 src/Artemis.UI.Shared/Routing/Routable/RoutableHostScreenOfTScreen.cs create mode 100644 src/Artemis.UI.Shared/Routing/Routable/RoutableHostScreenOfTScreenTParam.cs rename src/Artemis.UI.Shared/Routing/Routable/{RoutableScreenOfTScreenTParam.cs => RoutableScreenOfTParam.cs} (68%) delete mode 100644 src/Artemis.UI.Shared/Routing/Routable/RoutableScreenOfTScreen.cs rename src/Artemis.UI/Screens/Workshop/Library/Tabs/{LibraryInstalledView.axaml => InstalledTabView.axaml} (82%) rename src/Artemis.UI/Screens/Workshop/Library/Tabs/{LibraryInstalledView.axaml.cs => InstalledTabView.axaml.cs} (61%) rename src/Artemis.UI/Screens/Workshop/Library/Tabs/{LibraryInstalledViewModel.cs => InstalledTabViewModel.cs} (51%) create mode 100644 src/Artemis.UI/Screens/Workshop/Library/Tabs/SubmissionsDetailView.axaml create mode 100644 src/Artemis.UI/Screens/Workshop/Library/Tabs/SubmissionsDetailView.axaml.cs create mode 100644 src/Artemis.UI/Screens/Workshop/Library/Tabs/SubmissionsDetailViewModel.cs rename src/Artemis.UI/Screens/Workshop/Library/Tabs/{LibrarySubmissionsView.axaml => SubmissionsTabView.axaml} (97%) rename src/Artemis.UI/Screens/Workshop/Library/Tabs/{LibrarySubmissionsView.axaml.cs => SubmissionsTabView.axaml.cs} (60%) rename src/Artemis.UI/Screens/Workshop/Library/Tabs/{LibrarySubmissionsViewModel.cs => SubmissionsTabViewModel.cs} (92%) diff --git a/src/Artemis.UI.Shared/Routing/Routable/IRoutableHostScreen.cs b/src/Artemis.UI.Shared/Routing/Routable/IRoutableHostScreen.cs new file mode 100644 index 000000000..b09afb676 --- /dev/null +++ b/src/Artemis.UI.Shared/Routing/Routable/IRoutableHostScreen.cs @@ -0,0 +1,13 @@ +namespace Artemis.UI.Shared.Routing; + +/// +/// For internal use. +/// +/// +/// +internal interface IRoutableHostScreen : IRoutableScreen +{ + bool RecycleScreen { get; } + IRoutableScreen? InternalScreen { get; } + void InternalChangeScreen(IRoutableScreen? screen); +} \ No newline at end of file diff --git a/src/Artemis.UI.Shared/Routing/Routable/IRoutableScreen.cs b/src/Artemis.UI.Shared/Routing/Routable/IRoutableScreen.cs new file mode 100644 index 000000000..3b28384cd --- /dev/null +++ b/src/Artemis.UI.Shared/Routing/Routable/IRoutableScreen.cs @@ -0,0 +1,14 @@ +using System.Threading; +using System.Threading.Tasks; +using ReactiveUI; + +namespace Artemis.UI.Shared.Routing; + +/// +/// For internal use. +/// +internal interface IRoutableScreen : IActivatableViewModel +{ + Task InternalOnNavigating(NavigationArguments args, CancellationToken cancellationToken); + Task InternalOnClosing(NavigationArguments args); +} \ No newline at end of file diff --git a/src/Artemis.UI.Shared/Routing/Routable/RoutableHostScreenOfTScreen.cs b/src/Artemis.UI.Shared/Routing/Routable/RoutableHostScreenOfTScreen.cs new file mode 100644 index 000000000..c61c27820 --- /dev/null +++ b/src/Artemis.UI.Shared/Routing/Routable/RoutableHostScreenOfTScreen.cs @@ -0,0 +1,42 @@ +namespace Artemis.UI.Shared.Routing; + +/// +/// Represents a view model to which routing can take place and which in turn can host another view model. +/// +/// The type of view model the screen can host. +public abstract class RoutableHostScreen : RoutableScreen, IRoutableHostScreen where TScreen : RoutableScreen +{ + private bool _recycleScreen = true; + private TScreen? _screen; + + /// + /// Gets the currently active child screen. + /// + public TScreen? Screen + { + get => _screen; + private set => RaiseAndSetIfChanged(ref _screen, value); + } + + /// + public bool RecycleScreen + { + get => _recycleScreen; + protected set => RaiseAndSetIfChanged(ref _recycleScreen, value); + } + + IRoutableScreen? IRoutableHostScreen.InternalScreen => Screen; + + void IRoutableHostScreen.InternalChangeScreen(IRoutableScreen? screen) + { + if (screen == null) + { + Screen = null; + return; + } + + if (screen is not TScreen typedScreen) + throw new ArtemisRoutingException($"Screen cannot be hosted, {screen.GetType().Name} is not assignable to {typeof(TScreen).Name}."); + Screen = typedScreen; + } +} \ No newline at end of file diff --git a/src/Artemis.UI.Shared/Routing/Routable/RoutableHostScreenOfTScreenTParam.cs b/src/Artemis.UI.Shared/Routing/Routable/RoutableHostScreenOfTScreenTParam.cs new file mode 100644 index 000000000..89773e8d4 --- /dev/null +++ b/src/Artemis.UI.Shared/Routing/Routable/RoutableHostScreenOfTScreenTParam.cs @@ -0,0 +1,44 @@ +namespace Artemis.UI.Shared.Routing; + +/// +/// Represents a view model to which routing with parameters can take place and which in turn can host another view +/// model. +/// +/// The type of view model the screen can host. +/// The type of parameters the screen expects. It must have a parameterless constructor. +public abstract class RoutableHostScreen : RoutableScreen, IRoutableHostScreen where TScreen : RoutableScreen where TParam : new() +{ + private bool _recycleScreen = true; + private TScreen? _screen; + + /// + /// Gets the currently active child screen. + /// + public TScreen? Screen + { + get => _screen; + private set => RaiseAndSetIfChanged(ref _screen, value); + } + + /// + public bool RecycleScreen + { + get => _recycleScreen; + protected set => RaiseAndSetIfChanged(ref _recycleScreen, value); + } + + IRoutableScreen? IRoutableHostScreen.InternalScreen => Screen; + + void IRoutableHostScreen.InternalChangeScreen(IRoutableScreen? screen) + { + if (screen == null) + { + Screen = null; + return; + } + + if (screen is not TScreen typedScreen) + throw new ArtemisRoutingException($"Screen cannot be hosted, {screen.GetType().Name} is not assignable to {typeof(TScreen).Name}."); + Screen = typedScreen; + } +} \ No newline at end of file diff --git a/src/Artemis.UI.Shared/Routing/Routable/RoutableScreen.cs b/src/Artemis.UI.Shared/Routing/Routable/RoutableScreen.cs index 53973de46..7db5dbe2e 100644 --- a/src/Artemis.UI.Shared/Routing/Routable/RoutableScreen.cs +++ b/src/Artemis.UI.Shared/Routing/Routable/RoutableScreen.cs @@ -1,24 +1,55 @@ using System.Threading; using System.Threading.Tasks; -using ReactiveUI; namespace Artemis.UI.Shared.Routing; /// -/// For internal use. +/// Represents a view model to which routing can take place. /// -/// -/// -internal interface IRoutableScreen : IActivatableViewModel +public abstract class RoutableScreen : ActivatableViewModelBase, IRoutableScreen { /// - /// Gets or sets a value indicating whether or not to reuse the child screen instance if the type has not changed. + /// Called before navigating to this screen. /// - /// Defaults to . - bool RecycleScreen { get; } + /// Navigation arguments containing information about the navigation action. + public virtual Task BeforeNavigating(NavigationArguments args) + { + return Task.CompletedTask; + } - object? InternalScreen { get; } - void InternalChangeScreen(object? screen); - Task InternalOnNavigating(NavigationArguments args, CancellationToken cancellationToken); - Task InternalOnClosing(NavigationArguments args); + /// + /// Called while navigating to this screen. + /// + /// Navigation arguments containing information about the navigation action. + /// + /// A cancellation token that can be used by other objects or threads to receive notice of + /// cancellation. + /// + public virtual Task OnNavigating(NavigationArguments args, CancellationToken cancellationToken) + { + return Task.CompletedTask; + } + + /// + /// Called before navigating away from this screen. + /// + /// Navigation arguments containing information about the navigation action. + public virtual Task OnClosing(NavigationArguments args) + { + return Task.CompletedTask; + } + + #region Overrides of RoutableScreen + + async Task IRoutableScreen.InternalOnNavigating(NavigationArguments args, CancellationToken cancellationToken) + { + await OnNavigating(args, cancellationToken); + } + + async Task IRoutableScreen.InternalOnClosing(NavigationArguments args) + { + await OnClosing(args); + } + + #endregion } \ No newline at end of file diff --git a/src/Artemis.UI.Shared/Routing/Routable/RoutableScreenOfTScreenTParam.cs b/src/Artemis.UI.Shared/Routing/Routable/RoutableScreenOfTParam.cs similarity index 68% rename from src/Artemis.UI.Shared/Routing/Routable/RoutableScreenOfTScreenTParam.cs rename to src/Artemis.UI.Shared/Routing/Routable/RoutableScreenOfTParam.cs index ac62129dc..bbe71fe2a 100644 --- a/src/Artemis.UI.Shared/Routing/Routable/RoutableScreenOfTScreenTParam.cs +++ b/src/Artemis.UI.Shared/Routing/Routable/RoutableScreenOfTParam.cs @@ -4,39 +4,15 @@ using System.Linq; using System.Linq.Expressions; using System.Threading; using System.Threading.Tasks; -using Avalonia.Platform; namespace Artemis.UI.Shared.Routing; /// -/// Represents a view model to which routing with parameters can take place and which in turn can host another view -/// model. +/// Represents a view model to which routing with parameters can take place. /// -/// The type of view model the screen can host. /// The type of parameters the screen expects. It must have a parameterless constructor. -public abstract class RoutableScreen : ActivatableViewModelBase, IRoutableScreen where TScreen : class where TParam : new() +public abstract class RoutableScreen : RoutableScreen, IRoutableScreen where TParam : new() { - private bool _recycleScreen = true; - private TScreen? _screen; - - /// - /// Gets the currently active child screen. - /// - public TScreen? Screen - { - get => _screen; - private set => RaiseAndSetIfChanged(ref _screen, value); - } - - /// - /// Called before navigating to this screen. - /// - /// Navigation arguments containing information about the navigation action. - public virtual Task BeforeNavigating(NavigationArguments args) - { - return Task.CompletedTask; - } - /// /// Called while navigating to this screen. /// @@ -50,40 +26,7 @@ public abstract class RoutableScreen : ActivatableViewModelBase { return Task.CompletedTask; } - - /// - /// Called before navigating away from this screen. - /// - /// Navigation arguments containing information about the navigation action. - public virtual Task OnClosing(NavigationArguments args) - { - return Task.CompletedTask; - } - - /// - public bool RecycleScreen - { - get => _recycleScreen; - protected set => RaiseAndSetIfChanged(ref _recycleScreen, value); - } - - #region Overrides of RoutableScreen - - object? IRoutableScreen.InternalScreen => Screen; - - void IRoutableScreen.InternalChangeScreen(object? screen) - { - if (screen == null) - { - Screen = null; - return; - } - - if (screen is not TScreen typedScreen) - throw new ArtemisRoutingException($"Provided screen is not assignable to {typeof(TScreen).FullName}"); - Screen = typedScreen; - } - + async Task IRoutableScreen.InternalOnNavigating(NavigationArguments args, CancellationToken cancellationToken) { Func activator = GetParameterActivator(); @@ -92,6 +35,7 @@ public abstract class RoutableScreen : ActivatableViewModelBase throw new ArtemisRoutingException($"Did not retrieve the required amount of parameters, expects {_parameterPropertyCount}, got {args.SegmentParameters.Length}."); TParam parameters = activator(args.SegmentParameters); + await OnNavigating(args, cancellationToken); await OnNavigating(parameters, args, cancellationToken); } @@ -100,8 +44,6 @@ public abstract class RoutableScreen : ActivatableViewModelBase await OnClosing(args); } - #endregion - #region Parameter generation // ReSharper disable once StaticMemberInGenericType - That's intentional, each kind of TParam should have its own property count diff --git a/src/Artemis.UI.Shared/Routing/Routable/RoutableScreenOfTScreen.cs b/src/Artemis.UI.Shared/Routing/Routable/RoutableScreenOfTScreen.cs deleted file mode 100644 index b4f60ca47..000000000 --- a/src/Artemis.UI.Shared/Routing/Routable/RoutableScreenOfTScreen.cs +++ /dev/null @@ -1,87 +0,0 @@ -using System.Threading; -using System.Threading.Tasks; - -namespace Artemis.UI.Shared.Routing; - -/// -/// Represents a view model to which routing can take place and which in turn can host another view model. -/// -/// The type of view model the screen can host. -public abstract class RoutableScreen : ActivatableViewModelBase, IRoutableScreen where TScreen : class -{ - private TScreen? _screen; - private bool _recycleScreen = true; - - /// - /// Gets the currently active child screen. - /// - public TScreen? Screen - { - get => _screen; - private set => RaiseAndSetIfChanged(ref _screen, value); - } - - /// - public bool RecycleScreen - { - get => _recycleScreen; - protected set => RaiseAndSetIfChanged(ref _recycleScreen, value); - } - - /// - /// Called before navigating to this screen. - /// - /// Navigation arguments containing information about the navigation action. - public virtual Task BeforeNavigating(NavigationArguments args) - { - return Task.CompletedTask; - } - - /// - /// Called while navigating to this screen. - /// - /// Navigation arguments containing information about the navigation action. - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. - public virtual Task OnNavigating(NavigationArguments args, CancellationToken cancellationToken) - { - return Task.CompletedTask; - } - - /// - /// Called before navigating away from this screen. - /// - /// Navigation arguments containing information about the navigation action. - public virtual Task OnClosing(NavigationArguments args) - { - return Task.CompletedTask; - } - - #region Overrides of RoutableScreen - - object? IRoutableScreen.InternalScreen => Screen; - - void IRoutableScreen.InternalChangeScreen(object? screen) - { - if (screen == null) - { - Screen = null; - return; - } - - if (screen is not TScreen typedScreen) - throw new ArtemisRoutingException($"Screen cannot be hosted, {screen.GetType().Name} is not assignable to {typeof(TScreen).Name}."); - Screen = typedScreen; - } - - async Task IRoutableScreen.InternalOnNavigating(NavigationArguments args, CancellationToken cancellationToken) - { - await OnNavigating(args, cancellationToken); - } - - async Task IRoutableScreen.InternalOnClosing(NavigationArguments args) - { - await OnClosing(args); - } - - #endregion -} \ No newline at end of file diff --git a/src/Artemis.UI.Shared/Routing/Route/RouteRegistration.cs b/src/Artemis.UI.Shared/Routing/Route/RouteRegistration.cs index 2d9f8508f..c62437d03 100644 --- a/src/Artemis.UI.Shared/Routing/Route/RouteRegistration.cs +++ b/src/Artemis.UI.Shared/Routing/Route/RouteRegistration.cs @@ -7,7 +7,7 @@ namespace Artemis.UI.Shared.Routing; /// Represents a registration for a route and its associated view model. /// /// The type of the view model associated with the route. -public class RouteRegistration : IRouterRegistration where TViewModel : ViewModelBase +public class RouteRegistration : IRouterRegistration where TViewModel : RoutableScreen { /// /// Initializes a new instance of the class. diff --git a/src/Artemis.UI.Shared/Routing/Route/RouteResolution.cs b/src/Artemis.UI.Shared/Routing/Route/RouteResolution.cs index 32d285ccd..b80bad548 100644 --- a/src/Artemis.UI.Shared/Routing/Route/RouteResolution.cs +++ b/src/Artemis.UI.Shared/Routing/Route/RouteResolution.cs @@ -74,19 +74,12 @@ internal class RouteResolution }; } - public object GetViewModel(IContainer container) + public RoutableScreen GetViewModel(IContainer container) { - if (ViewModel == null) - throw new ArtemisRoutingException("Cannot get a view model of a non-success route resolution"); - - object? viewModel = container.Resolve(ViewModel); - if (viewModel == null) - throw new ArtemisRoutingException($"Could not resolve view model of type {ViewModel}"); - - return viewModel; + return GetViewModel(container); } - public T GetViewModel(IContainer container) + public T GetViewModel(IContainer container) where T : RoutableScreen { if (ViewModel == null) throw new ArtemisRoutingException("Cannot get a view model of a non-success route resolution"); diff --git a/src/Artemis.UI.Shared/Routing/Router/IRouter.cs b/src/Artemis.UI.Shared/Routing/Router/IRouter.cs index 44d77bb5c..cfbd0eb89 100644 --- a/src/Artemis.UI.Shared/Routing/Router/IRouter.cs +++ b/src/Artemis.UI.Shared/Routing/Router/IRouter.cs @@ -50,7 +50,7 @@ public interface IRouter /// /// The root screen to set. /// The type of the root screen. It must be a class. - void SetRoot(RoutableScreen root) where TScreen : class; + void SetRoot(RoutableHostScreen root) where TScreen : RoutableScreen; /// /// Sets the root screen from which navigation takes place. @@ -58,7 +58,7 @@ public interface IRouter /// The root screen to set. /// The type of the root screen. It must be a class. /// The type of the parameters for the root screen. It must have a parameterless constructor. - void SetRoot(RoutableScreen root) where TScreen : class where TParam : new(); + void SetRoot(RoutableHostScreen root) where TScreen : RoutableScreen where TParam : new(); /// /// Clears the route used by the previous window, so that it is not restored when the main window opens. diff --git a/src/Artemis.UI.Shared/Routing/Router/Navigation.cs b/src/Artemis.UI.Shared/Routing/Router/Navigation.cs index d04fded6f..50505a706 100644 --- a/src/Artemis.UI.Shared/Routing/Router/Navigation.cs +++ b/src/Artemis.UI.Shared/Routing/Router/Navigation.cs @@ -14,12 +14,12 @@ internal class Navigation private readonly IContainer _container; private readonly ILogger _logger; - private readonly IRoutableScreen _root; + private readonly IRoutableHostScreen _root; private readonly RouteResolution _resolution; private readonly RouterNavigationOptions _options; private CancellationTokenSource _cts; - public Navigation(IContainer container, ILogger logger, IRoutableScreen root, RouteResolution resolution, RouterNavigationOptions options) + public Navigation(IContainer container, ILogger logger, IRoutableHostScreen root, RouteResolution resolution, RouterNavigationOptions options) { _container = container; _logger = logger; @@ -54,21 +54,21 @@ internal class Navigation _cts.Cancel(); } - private async Task NavigateResolution(RouteResolution resolution, NavigationArguments args, IRoutableScreen host) + private async Task NavigateResolution(RouteResolution resolution, NavigationArguments args, IRoutableHostScreen host) { if (Cancelled) return; // Reuse the screen if its type has not changed, if a new one must be created, don't do so on the UI thread - object screen; + IRoutableScreen screen; if (_options.RecycleScreens && host.RecycleScreen && host.InternalScreen != null && host.InternalScreen.GetType() == resolution.ViewModel) screen = host.InternalScreen; else screen = await Task.Run(() => resolution.GetViewModel(_container)); // If resolution has a child, ensure the screen can host it - if (resolution.Child != null && screen is not IRoutableScreen) - throw new ArtemisRoutingException($"Route resolved with a child but view model of type {resolution.ViewModel} is does mot implement {nameof(IRoutableScreen)}."); + if (resolution.Child != null && screen is not IRoutableHostScreen) + throw new ArtemisRoutingException($"Route resolved with a child but view model of type {resolution.ViewModel} is does mot implement {nameof(IRoutableHostScreen)}."); // Only change the screen if it wasn't reused if (!ReferenceEquals(host.InternalScreen, screen)) @@ -87,27 +87,24 @@ internal class Navigation if (CancelIfRequested(args, "ChangeScreen", screen)) return; - - // If the screen implements some form of Navigable, activate it + + // Navigate on the screen args.SegmentParameters = resolution.Parameters ?? Array.Empty(); - if (screen is IRoutableScreen routableScreen) + try { - try - { - await routableScreen.InternalOnNavigating(args, _cts.Token); - } - catch (Exception e) - { - Cancel(); - if (e is not TaskCanceledException) - _logger.Error(e, "Failed to navigate to {Path}", resolution.Path); - } - - if (CancelIfRequested(args, "OnNavigating", screen)) - return; + await screen.InternalOnNavigating(args, _cts.Token); + } + catch (Exception e) + { + Cancel(); + if (e is not TaskCanceledException) + _logger.Error(e, "Failed to navigate to {Path}", resolution.Path); } - if (screen is IRoutableScreen childScreen) + if (CancelIfRequested(args, "OnNavigating", screen)) + return; + + if (screen is IRoutableHostScreen childScreen) { // Navigate the child too if (resolution.Child != null) diff --git a/src/Artemis.UI.Shared/Routing/Router/Router.cs b/src/Artemis.UI.Shared/Routing/Router/Router.cs index 028d7be02..84e44c4ac 100644 --- a/src/Artemis.UI.Shared/Routing/Router/Router.cs +++ b/src/Artemis.UI.Shared/Routing/Router/Router.cs @@ -14,15 +14,15 @@ internal class Router : CorePropertyChanged, IRouter, IDisposable private readonly Stack _backStack = new(); private readonly BehaviorSubject _currentRouteSubject; private readonly Stack _forwardStack = new(); - private readonly Func _getNavigation; + private readonly Func _getNavigation; private readonly ILogger _logger; private readonly IMainWindowService _mainWindowService; private Navigation? _currentNavigation; - private IRoutableScreen? _root; + private IRoutableHostScreen? _root; private string? _previousWindowRoute; - public Router(ILogger logger, IMainWindowService mainWindowService, Func getNavigation) + public Router(ILogger logger, IMainWindowService mainWindowService, Func getNavigation) { _logger = logger; _mainWindowService = mainWindowService; @@ -45,21 +45,17 @@ internal class Router : CorePropertyChanged, IRouter, IDisposable return RouteResolution.AsFailure(path); } - private async Task RequestClose(object screen, NavigationArguments args) + private async Task RequestClose(IRoutableScreen screen, NavigationArguments args) { - if (screen is not IRoutableScreen routableScreen) - return true; - - await routableScreen.InternalOnClosing(args); - if (args.Cancelled) - { - _logger.Debug("Navigation to {Path} cancelled during RequestClose by {Screen}", args.Path, screen.GetType().Name); + // Drill down to child screens first + if (screen is IRoutableHostScreen hostScreen && hostScreen.InternalScreen != null && !await RequestClose(hostScreen.InternalScreen, args)) return false; - } - if (routableScreen.InternalScreen == null) + await screen.InternalOnClosing(args); + if (!args.Cancelled) return true; - return await RequestClose(routableScreen.InternalScreen, args); + _logger.Debug("Navigation to {Path} cancelled during RequestClose by {Screen}", args.Path, screen.GetType().Name); + return false; } private bool PathEquals(string path, bool allowPartialMatch) @@ -161,13 +157,13 @@ internal class Router : CorePropertyChanged, IRouter, IDisposable } /// - public void SetRoot(RoutableScreen root) where TScreen : class + public void SetRoot(RoutableHostScreen root) where TScreen : RoutableScreen { _root = root; } /// - public void SetRoot(RoutableScreen root) where TScreen : class where TParam : new() + public void SetRoot(RoutableHostScreen root) where TScreen : RoutableScreen where TParam : new() { _root = root; } diff --git a/src/Artemis.UI/Routing/Routes.cs b/src/Artemis.UI/Routing/Routes.cs index da329999b..128209f99 100644 --- a/src/Artemis.UI/Routing/Routes.cs +++ b/src/Artemis.UI/Routing/Routes.cs @@ -35,8 +35,9 @@ public static class Routes { Children = new List() { - new RouteRegistration("installed"), - new RouteRegistration("submissions"), + new RouteRegistration("installed"), + new RouteRegistration("submissions"), + new RouteRegistration("submissions/{entryId:guid}"), } } } diff --git a/src/Artemis.UI/Screens/Home/HomeViewModel.cs b/src/Artemis.UI/Screens/Home/HomeViewModel.cs index bd4266895..3460ea13b 100644 --- a/src/Artemis.UI/Screens/Home/HomeViewModel.cs +++ b/src/Artemis.UI/Screens/Home/HomeViewModel.cs @@ -1,12 +1,13 @@ using Artemis.Core.Services; using Artemis.UI.Screens.StartupWizard; using Artemis.UI.Shared; +using Artemis.UI.Shared.Routing; using Artemis.UI.Shared.Services; using Avalonia.Threading; namespace Artemis.UI.Screens.Home; -public class HomeViewModel : ViewModelBase, IMainScreenViewModel +public class HomeViewModel : RoutableScreen, IMainScreenViewModel { public HomeViewModel(ISettingsService settingsService, IWindowService windowService) { diff --git a/src/Artemis.UI/Screens/ProfileEditor/ProfileEditorViewModel.cs b/src/Artemis.UI/Screens/ProfileEditor/ProfileEditorViewModel.cs index 89781e125..b3da24ce2 100644 --- a/src/Artemis.UI/Screens/ProfileEditor/ProfileEditorViewModel.cs +++ b/src/Artemis.UI/Screens/ProfileEditor/ProfileEditorViewModel.cs @@ -17,14 +17,13 @@ using Artemis.UI.Shared; using Artemis.UI.Shared.Routing; using Artemis.UI.Shared.Services.MainWindow; using Artemis.UI.Shared.Services.ProfileEditor; -using Avalonia.Threading; using DynamicData; using DynamicData.Binding; using ReactiveUI; namespace Artemis.UI.Screens.ProfileEditor; -public class ProfileEditorViewModel : RoutableScreen, IMainScreenViewModel +public class ProfileEditorViewModel : RoutableScreen, IMainScreenViewModel { private readonly IProfileEditorService _profileEditorService; private readonly IProfileService _profileService; diff --git a/src/Artemis.UI/Screens/Root/BlankViewModel.cs b/src/Artemis.UI/Screens/Root/BlankViewModel.cs index fe445a6ee..3cfbb2c3b 100644 --- a/src/Artemis.UI/Screens/Root/BlankViewModel.cs +++ b/src/Artemis.UI/Screens/Root/BlankViewModel.cs @@ -1,8 +1,9 @@ using Artemis.UI.Shared; +using Artemis.UI.Shared.Routing; namespace Artemis.UI.Screens.Root; -public class BlankViewModel : ViewModelBase, IMainScreenViewModel +public class BlankViewModel : RoutableScreen, IMainScreenViewModel { /// public ViewModelBase? TitleBarViewModel => null; diff --git a/src/Artemis.UI/Screens/Root/RootView.axaml.cs b/src/Artemis.UI/Screens/Root/RootView.axaml.cs index 23d822d48..8267c9c3e 100644 --- a/src/Artemis.UI/Screens/Root/RootView.axaml.cs +++ b/src/Artemis.UI/Screens/Root/RootView.axaml.cs @@ -1,5 +1,6 @@ using System; using System.Reactive.Disposables; +using Artemis.UI.Shared.Routing; using Avalonia.ReactiveUI; using Avalonia.Threading; using ReactiveUI; @@ -14,7 +15,7 @@ public partial class RootView : ReactiveUserControl this.WhenActivated(d => ViewModel.WhenAnyValue(vm => vm.Screen).Subscribe(Navigate).DisposeWith(d)); } - private void Navigate(IMainScreenViewModel viewModel) + private void Navigate(RoutableScreen viewModel) { try { diff --git a/src/Artemis.UI/Screens/Root/RootViewModel.cs b/src/Artemis.UI/Screens/Root/RootViewModel.cs index 9b2a46ddf..d057893e5 100644 --- a/src/Artemis.UI/Screens/Root/RootViewModel.cs +++ b/src/Artemis.UI/Screens/Root/RootViewModel.cs @@ -18,7 +18,7 @@ using ReactiveUI; namespace Artemis.UI.Screens.Root; -public class RootViewModel : RoutableScreen, IMainWindowProvider +public class RootViewModel : RoutableHostScreen, IMainWindowProvider { private readonly ICoreService _coreService; private readonly IDebugService _debugService; @@ -100,12 +100,10 @@ public class RootViewModel : RoutableScreen, IMainWindowPr _router.GoForward(); } - private void UpdateTitleBarViewModel(IMainScreenViewModel? viewModel) + private void UpdateTitleBarViewModel(RoutableScreen? viewModel) { - if (viewModel?.TitleBarViewModel != null) - TitleBarViewModel = viewModel.TitleBarViewModel; - else - TitleBarViewModel = _defaultTitleBarViewModel; + IMainScreenViewModel? mainScreenViewModel = viewModel as IMainScreenViewModel; + TitleBarViewModel = mainScreenViewModel?.TitleBarViewModel ?? _defaultTitleBarViewModel; } private void CurrentMainWindowOnClosing(object? sender, EventArgs e) diff --git a/src/Artemis.UI/Screens/Settings/SettingsViewModel.cs b/src/Artemis.UI/Screens/Settings/SettingsViewModel.cs index 561c4e3d6..63b97b1ab 100644 --- a/src/Artemis.UI/Screens/Settings/SettingsViewModel.cs +++ b/src/Artemis.UI/Screens/Settings/SettingsViewModel.cs @@ -11,7 +11,7 @@ using ReactiveUI; namespace Artemis.UI.Screens.Settings; -public class SettingsViewModel : RoutableScreen, IMainScreenViewModel +public class SettingsViewModel : RoutableHostScreen, IMainScreenViewModel { private readonly IRouter _router; private RouteViewModel? _selectedTab; diff --git a/src/Artemis.UI/Screens/Settings/Tabs/AboutTabViewModel.cs b/src/Artemis.UI/Screens/Settings/Tabs/AboutTabViewModel.cs index 883372aec..984187e6f 100644 --- a/src/Artemis.UI/Screens/Settings/Tabs/AboutTabViewModel.cs +++ b/src/Artemis.UI/Screens/Settings/Tabs/AboutTabViewModel.cs @@ -1,9 +1,9 @@ using Artemis.Core; -using Artemis.UI.Shared; +using Artemis.UI.Shared.Routing; namespace Artemis.UI.Screens.Settings; -public class AboutTabViewModel : ActivatableViewModelBase +public class AboutTabViewModel : RoutableScreen { public AboutTabViewModel() { diff --git a/src/Artemis.UI/Screens/Settings/Tabs/DevicesTabViewModel.cs b/src/Artemis.UI/Screens/Settings/Tabs/DevicesTabViewModel.cs index 620da173e..e6c1366c1 100644 --- a/src/Artemis.UI/Screens/Settings/Tabs/DevicesTabViewModel.cs +++ b/src/Artemis.UI/Screens/Settings/Tabs/DevicesTabViewModel.cs @@ -8,7 +8,7 @@ using Artemis.Core; using Artemis.Core.Services; using Artemis.UI.DryIoc.Factories; using Artemis.UI.Screens.Device; -using Artemis.UI.Shared; +using Artemis.UI.Shared.Routing; using Artemis.UI.Shared.Services; using Avalonia.Threading; using DynamicData; @@ -16,7 +16,7 @@ using ReactiveUI; namespace Artemis.UI.Screens.Settings; -public class DevicesTabViewModel : ActivatableViewModelBase +public class DevicesTabViewModel : RoutableScreen { private readonly IDeviceVmFactory _deviceVmFactory; private readonly IRgbService _rgbService; diff --git a/src/Artemis.UI/Screens/Settings/Tabs/GeneralTabViewModel.cs b/src/Artemis.UI/Screens/Settings/Tabs/GeneralTabViewModel.cs index 110c948e8..74adce108 100644 --- a/src/Artemis.UI/Screens/Settings/Tabs/GeneralTabViewModel.cs +++ b/src/Artemis.UI/Screens/Settings/Tabs/GeneralTabViewModel.cs @@ -13,8 +13,8 @@ using Artemis.Core.Services; using Artemis.UI.Screens.StartupWizard; using Artemis.UI.Services.Interfaces; using Artemis.UI.Services.Updating; -using Artemis.UI.Shared; using Artemis.UI.Shared.Providers; +using Artemis.UI.Shared.Routing; using Artemis.UI.Shared.Services; using Artemis.UI.Shared.Services.Builders; using Avalonia.Threading; @@ -26,7 +26,7 @@ using Serilog.Events; namespace Artemis.UI.Screens.Settings; -public class GeneralTabViewModel : ActivatableViewModelBase +public class GeneralTabViewModel : RoutableScreen { private readonly IAutoRunProvider? _autoRunProvider; private readonly IDebugService _debugService; diff --git a/src/Artemis.UI/Screens/Settings/Tabs/PluginsTabViewModel.cs b/src/Artemis.UI/Screens/Settings/Tabs/PluginsTabViewModel.cs index 217ca1383..6113a20b4 100644 --- a/src/Artemis.UI/Screens/Settings/Tabs/PluginsTabViewModel.cs +++ b/src/Artemis.UI/Screens/Settings/Tabs/PluginsTabViewModel.cs @@ -9,18 +9,17 @@ using Artemis.Core; using Artemis.Core.Services; using Artemis.UI.DryIoc.Factories; using Artemis.UI.Screens.Plugins; -using Artemis.UI.Shared; +using Artemis.UI.Shared.Routing; using Artemis.UI.Shared.Services; using Artemis.UI.Shared.Services.Builders; using Avalonia.ReactiveUI; -using Avalonia.Threading; using DynamicData; using DynamicData.Binding; using ReactiveUI; namespace Artemis.UI.Screens.Settings; -public class PluginsTabViewModel : ActivatableViewModelBase +public class PluginsTabViewModel : RoutableScreen { private readonly INotificationService _notificationService; private readonly IPluginManagementService _pluginManagementService; diff --git a/src/Artemis.UI/Screens/Settings/Tabs/ReleasesTabViewModel.cs b/src/Artemis.UI/Screens/Settings/Tabs/ReleasesTabViewModel.cs index fe6a1a532..d316b62a8 100644 --- a/src/Artemis.UI/Screens/Settings/Tabs/ReleasesTabViewModel.cs +++ b/src/Artemis.UI/Screens/Settings/Tabs/ReleasesTabViewModel.cs @@ -22,7 +22,7 @@ using StrawberryShake; namespace Artemis.UI.Screens.Settings; -public class ReleasesTabViewModel : RoutableScreen +public class ReleasesTabViewModel : RoutableHostScreen { private readonly ILogger _logger; private readonly IUpdateService _updateService; diff --git a/src/Artemis.UI/Screens/Settings/Updating/ReleaseDetailsViewModel.cs b/src/Artemis.UI/Screens/Settings/Updating/ReleaseDetailsViewModel.cs index 2ea6be462..2d9a1a413 100644 --- a/src/Artemis.UI/Screens/Settings/Updating/ReleaseDetailsViewModel.cs +++ b/src/Artemis.UI/Screens/Settings/Updating/ReleaseDetailsViewModel.cs @@ -18,7 +18,7 @@ using StrawberryShake; namespace Artemis.UI.Screens.Settings.Updating; -public class ReleaseDetailsViewModel : RoutableScreen +public class ReleaseDetailsViewModel : RoutableScreen { private readonly ObservableAsPropertyHelper _fileSize; private readonly ILogger _logger; diff --git a/src/Artemis.UI/Screens/SurfaceEditor/SurfaceEditorViewModel.cs b/src/Artemis.UI/Screens/SurfaceEditor/SurfaceEditorViewModel.cs index a6e28ea39..fadaaaf0e 100644 --- a/src/Artemis.UI/Screens/SurfaceEditor/SurfaceEditorViewModel.cs +++ b/src/Artemis.UI/Screens/SurfaceEditor/SurfaceEditorViewModel.cs @@ -10,6 +10,7 @@ using Artemis.Core.Services; using Artemis.UI.DryIoc.Factories; using Artemis.UI.Extensions; using Artemis.UI.Shared; +using Artemis.UI.Shared.Routing; using Artemis.UI.Shared.Services; using Avalonia; using ReactiveUI; @@ -17,7 +18,7 @@ using SkiaSharp; namespace Artemis.UI.Screens.SurfaceEditor; -public class SurfaceEditorViewModel : ActivatableViewModelBase, IMainScreenViewModel +public class SurfaceEditorViewModel : RoutableScreen, IMainScreenViewModel { private readonly IDeviceService _deviceService; private readonly IDeviceVmFactory _deviceVmFactory; diff --git a/src/Artemis.UI/Screens/Workshop/Entries/EntryListBaseViewModel.cs b/src/Artemis.UI/Screens/Workshop/Entries/EntryListBaseViewModel.cs index 2c51081e3..970b966fb 100644 --- a/src/Artemis.UI/Screens/Workshop/Entries/EntryListBaseViewModel.cs +++ b/src/Artemis.UI/Screens/Workshop/Entries/EntryListBaseViewModel.cs @@ -18,7 +18,7 @@ using StrawberryShake; namespace Artemis.UI.Screens.Workshop.Entries; -public abstract class EntryListBaseViewModel : RoutableScreen, IWorkshopViewModel +public abstract class EntryListBaseViewModel : RoutableScreen { private readonly INotificationService _notificationService; private readonly IWorkshopClient _workshopClient; diff --git a/src/Artemis.UI/Screens/Workshop/Home/WorkshopHomeViewModel.cs b/src/Artemis.UI/Screens/Workshop/Home/WorkshopHomeViewModel.cs index 2dad32aa0..de2e8f54d 100644 --- a/src/Artemis.UI/Screens/Workshop/Home/WorkshopHomeViewModel.cs +++ b/src/Artemis.UI/Screens/Workshop/Home/WorkshopHomeViewModel.cs @@ -14,7 +14,7 @@ using ReactiveUI; namespace Artemis.UI.Screens.Workshop.Home; -public class WorkshopHomeViewModel : ActivatableViewModelBase, IWorkshopViewModel +public class WorkshopHomeViewModel : RoutableScreen { private readonly IWindowService _windowService; private readonly IWorkshopService _workshopService; diff --git a/src/Artemis.UI/Screens/Workshop/Home/WorkshopOfflineViewModel.cs b/src/Artemis.UI/Screens/Workshop/Home/WorkshopOfflineViewModel.cs index ee46c079b..e9370d1d5 100644 --- a/src/Artemis.UI/Screens/Workshop/Home/WorkshopOfflineViewModel.cs +++ b/src/Artemis.UI/Screens/Workshop/Home/WorkshopOfflineViewModel.cs @@ -10,7 +10,7 @@ using ReactiveUI; namespace Artemis.UI.Screens.Workshop.Home; -public class WorkshopOfflineViewModel : RoutableScreen, IWorkshopViewModel +public class WorkshopOfflineViewModel : RoutableScreen { private readonly IRouter _router; private readonly IWorkshopService _workshopService; diff --git a/src/Artemis.UI/Screens/Workshop/Layout/LayoutDetailsViewModel.cs b/src/Artemis.UI/Screens/Workshop/Layout/LayoutDetailsViewModel.cs index dcfb608bf..3bd224a6a 100644 --- a/src/Artemis.UI/Screens/Workshop/Layout/LayoutDetailsViewModel.cs +++ b/src/Artemis.UI/Screens/Workshop/Layout/LayoutDetailsViewModel.cs @@ -9,7 +9,7 @@ using StrawberryShake; namespace Artemis.UI.Screens.Workshop.Layout; -public class LayoutDetailsViewModel : RoutableScreen, IWorkshopViewModel +public class LayoutDetailsViewModel : RoutableScreen { private readonly IWorkshopClient _client; private IGetEntryById_Entry? _entry; diff --git a/src/Artemis.UI/Screens/Workshop/Library/Tabs/LibraryInstalledView.axaml b/src/Artemis.UI/Screens/Workshop/Library/Tabs/InstalledTabView.axaml similarity index 82% rename from src/Artemis.UI/Screens/Workshop/Library/Tabs/LibraryInstalledView.axaml rename to src/Artemis.UI/Screens/Workshop/Library/Tabs/InstalledTabView.axaml index 208718542..df334299a 100644 --- a/src/Artemis.UI/Screens/Workshop/Library/Tabs/LibraryInstalledView.axaml +++ b/src/Artemis.UI/Screens/Workshop/Library/Tabs/InstalledTabView.axaml @@ -3,6 +3,6 @@ xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" - x:Class="Artemis.UI.Screens.Workshop.Library.Tabs.LibraryInstalledView"> + x:Class="Artemis.UI.Screens.Workshop.Library.Tabs.InstalledTabView"> Installed entries management here 🫡 diff --git a/src/Artemis.UI/Screens/Workshop/Library/Tabs/LibraryInstalledView.axaml.cs b/src/Artemis.UI/Screens/Workshop/Library/Tabs/InstalledTabView.axaml.cs similarity index 61% rename from src/Artemis.UI/Screens/Workshop/Library/Tabs/LibraryInstalledView.axaml.cs rename to src/Artemis.UI/Screens/Workshop/Library/Tabs/InstalledTabView.axaml.cs index b98518ae5..8a9a8b444 100644 --- a/src/Artemis.UI/Screens/Workshop/Library/Tabs/LibraryInstalledView.axaml.cs +++ b/src/Artemis.UI/Screens/Workshop/Library/Tabs/InstalledTabView.axaml.cs @@ -5,9 +5,9 @@ using Avalonia.ReactiveUI; namespace Artemis.UI.Screens.Workshop.Library.Tabs; -public partial class LibraryInstalledView : ReactiveUserControl +public partial class InstalledTabView : ReactiveUserControl { - public LibraryInstalledView() + public InstalledTabView() { InitializeComponent(); } diff --git a/src/Artemis.UI/Screens/Workshop/Library/Tabs/LibraryInstalledViewModel.cs b/src/Artemis.UI/Screens/Workshop/Library/Tabs/InstalledTabViewModel.cs similarity index 51% rename from src/Artemis.UI/Screens/Workshop/Library/Tabs/LibraryInstalledViewModel.cs rename to src/Artemis.UI/Screens/Workshop/Library/Tabs/InstalledTabViewModel.cs index b615c18b1..3bfdf406f 100644 --- a/src/Artemis.UI/Screens/Workshop/Library/Tabs/LibraryInstalledViewModel.cs +++ b/src/Artemis.UI/Screens/Workshop/Library/Tabs/InstalledTabViewModel.cs @@ -1,8 +1,9 @@ using Artemis.UI.Shared; +using Artemis.UI.Shared.Routing; namespace Artemis.UI.Screens.Workshop.Library.Tabs; -public class LibraryInstalledViewModel : ActivatableViewModelBase +public class InstalledTabViewModel : RoutableScreen { } \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/Library/Tabs/SubmissionsDetailView.axaml b/src/Artemis.UI/Screens/Workshop/Library/Tabs/SubmissionsDetailView.axaml new file mode 100644 index 000000000..1a4585317 --- /dev/null +++ b/src/Artemis.UI/Screens/Workshop/Library/Tabs/SubmissionsDetailView.axaml @@ -0,0 +1,8 @@ + + Welcome to Avalonia! + diff --git a/src/Artemis.UI/Screens/Workshop/Library/Tabs/SubmissionsDetailView.axaml.cs b/src/Artemis.UI/Screens/Workshop/Library/Tabs/SubmissionsDetailView.axaml.cs new file mode 100644 index 000000000..1af01b4d4 --- /dev/null +++ b/src/Artemis.UI/Screens/Workshop/Library/Tabs/SubmissionsDetailView.axaml.cs @@ -0,0 +1,14 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Markup.Xaml; +using Avalonia.ReactiveUI; + +namespace Artemis.UI.Screens.Workshop.Library.Tabs; + +public partial class SubmissionsDetailView : ReactiveUserControl +{ + public SubmissionsDetailView() + { + InitializeComponent(); + } +} \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/Library/Tabs/SubmissionsDetailViewModel.cs b/src/Artemis.UI/Screens/Workshop/Library/Tabs/SubmissionsDetailViewModel.cs new file mode 100644 index 000000000..b81f5a368 --- /dev/null +++ b/src/Artemis.UI/Screens/Workshop/Library/Tabs/SubmissionsDetailViewModel.cs @@ -0,0 +1,16 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Artemis.UI.Screens.Workshop.Parameters; +using Artemis.UI.Shared.Routing; + +namespace Artemis.UI.Screens.Workshop.Library.Tabs; + +public class SubmissionsDetailViewModel : RoutableScreen +{ + public override Task OnNavigating(WorkshopDetailParameters parameters, NavigationArguments args, CancellationToken cancellationToken) + { + Console.WriteLine(parameters.EntryId); + return Task.CompletedTask; + } +} \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/Library/Tabs/LibrarySubmissionsView.axaml b/src/Artemis.UI/Screens/Workshop/Library/Tabs/SubmissionsTabView.axaml similarity index 97% rename from src/Artemis.UI/Screens/Workshop/Library/Tabs/LibrarySubmissionsView.axaml rename to src/Artemis.UI/Screens/Workshop/Library/Tabs/SubmissionsTabView.axaml index b57eee7d1..9fb0f3055 100644 --- a/src/Artemis.UI/Screens/Workshop/Library/Tabs/LibrarySubmissionsView.axaml +++ b/src/Artemis.UI/Screens/Workshop/Library/Tabs/SubmissionsTabView.axaml @@ -8,8 +8,8 @@ xmlns:asyncImageLoader="clr-namespace:AsyncImageLoader;assembly=AsyncImageLoader.Avalonia" xmlns:converters="clr-namespace:Artemis.UI.Converters" mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="650" - x:Class="Artemis.UI.Screens.Workshop.Library.Tabs.LibrarySubmissionsView" - x:DataType="tabs:LibrarySubmissionsViewModel"> + x:Class="Artemis.UI.Screens.Workshop.Library.Tabs.SubmissionsTabView" + x:DataType="tabs:SubmissionsTabViewModel"> @@ -54,7 +54,7 @@ Margin="0 0 0 5" HorizontalAlignment="Stretch" HorizontalContentAlignment="Stretch" - Command="{Binding $parent[tabs:LibrarySubmissionsView].DataContext.NavigateToEntry}" + Command="{Binding $parent[tabs:SubmissionsTabView].DataContext.NavigateToEntry}" CommandParameter="{CompiledBinding}"> diff --git a/src/Artemis.UI/Screens/Workshop/Library/Tabs/LibrarySubmissionsView.axaml.cs b/src/Artemis.UI/Screens/Workshop/Library/Tabs/SubmissionsTabView.axaml.cs similarity index 60% rename from src/Artemis.UI/Screens/Workshop/Library/Tabs/LibrarySubmissionsView.axaml.cs rename to src/Artemis.UI/Screens/Workshop/Library/Tabs/SubmissionsTabView.axaml.cs index baf08341b..cedd6574c 100644 --- a/src/Artemis.UI/Screens/Workshop/Library/Tabs/LibrarySubmissionsView.axaml.cs +++ b/src/Artemis.UI/Screens/Workshop/Library/Tabs/SubmissionsTabView.axaml.cs @@ -5,9 +5,9 @@ using Avalonia.ReactiveUI; namespace Artemis.UI.Screens.Workshop.Library.Tabs; -public partial class LibrarySubmissionsView : ReactiveUserControl +public partial class SubmissionsTabView : ReactiveUserControl { - public LibrarySubmissionsView() + public SubmissionsTabView() { InitializeComponent(); } diff --git a/src/Artemis.UI/Screens/Workshop/Library/Tabs/LibrarySubmissionsViewModel.cs b/src/Artemis.UI/Screens/Workshop/Library/Tabs/SubmissionsTabViewModel.cs similarity index 92% rename from src/Artemis.UI/Screens/Workshop/Library/Tabs/LibrarySubmissionsViewModel.cs rename to src/Artemis.UI/Screens/Workshop/Library/Tabs/SubmissionsTabViewModel.cs index a43448109..719f56353 100644 --- a/src/Artemis.UI/Screens/Workshop/Library/Tabs/LibrarySubmissionsViewModel.cs +++ b/src/Artemis.UI/Screens/Workshop/Library/Tabs/SubmissionsTabViewModel.cs @@ -6,7 +6,6 @@ using System.Threading.Tasks; using Artemis.UI.Extensions; using Artemis.UI.Screens.Workshop.CurrentUser; using Artemis.UI.Screens.Workshop.SubmissionWizard; -using Artemis.UI.Shared; using Artemis.UI.Shared.Routing; using Artemis.UI.Shared.Services; using Artemis.WebClient.Workshop; @@ -17,7 +16,7 @@ using StrawberryShake; namespace Artemis.UI.Screens.Workshop.Library.Tabs; -public class LibrarySubmissionsViewModel : ActivatableViewModelBase, IWorkshopViewModel +public class SubmissionsTabViewModel : RoutableScreen { private readonly IWorkshopClient _client; private readonly SourceCache _entries; @@ -25,7 +24,7 @@ public class LibrarySubmissionsViewModel : ActivatableViewModelBase, IWorkshopVi private bool _isLoading = true; private bool _workshopReachable; - public LibrarySubmissionsViewModel(IWorkshopClient client, IAuthenticationService authenticationService, IWindowService windowService, IWorkshopService workshopService, IRouter router) + public SubmissionsTabViewModel(IWorkshopClient client, IAuthenticationService authenticationService, IWindowService windowService, IWorkshopService workshopService, IRouter router) { _client = client; _windowService = windowService; diff --git a/src/Artemis.UI/Screens/Workshop/Library/WorkshopLibraryViewModel.cs b/src/Artemis.UI/Screens/Workshop/Library/WorkshopLibraryViewModel.cs index 707d22cde..93588e198 100644 --- a/src/Artemis.UI/Screens/Workshop/Library/WorkshopLibraryViewModel.cs +++ b/src/Artemis.UI/Screens/Workshop/Library/WorkshopLibraryViewModel.cs @@ -12,7 +12,7 @@ using System; namespace Artemis.UI.Screens.Workshop.Library; -public class WorkshopLibraryViewModel : RoutableScreen, IWorkshopViewModel +public class WorkshopLibraryViewModel : RoutableHostScreen { private RouteViewModel? _selectedTab; diff --git a/src/Artemis.UI/Screens/Workshop/Profile/ProfileDetailsViewModel.cs b/src/Artemis.UI/Screens/Workshop/Profile/ProfileDetailsViewModel.cs index 83e5280f3..614ca84e7 100644 --- a/src/Artemis.UI/Screens/Workshop/Profile/ProfileDetailsViewModel.cs +++ b/src/Artemis.UI/Screens/Workshop/Profile/ProfileDetailsViewModel.cs @@ -18,7 +18,7 @@ using StrawberryShake; namespace Artemis.UI.Screens.Workshop.Profile; -public class ProfileDetailsViewModel : RoutableScreen, IWorkshopViewModel +public class ProfileDetailsViewModel : RoutableScreen { private readonly IWorkshopClient _client; private readonly ProfileEntryDownloadHandler _downloadHandler; diff --git a/src/Artemis.UI/Screens/Workshop/Search/SearchViewModel.cs b/src/Artemis.UI/Screens/Workshop/Search/SearchViewModel.cs index 8ae11ad48..f8da8558c 100644 --- a/src/Artemis.UI/Screens/Workshop/Search/SearchViewModel.cs +++ b/src/Artemis.UI/Screens/Workshop/Search/SearchViewModel.cs @@ -19,7 +19,6 @@ public class SearchViewModel : ViewModelBase private readonly ILogger _logger; private readonly IRouter _router; private readonly IWorkshopClient _workshopClient; - private EntryType? _entryType; private bool _isLoading; private SearchResultViewModel? _selectedEntry; @@ -44,12 +43,6 @@ public class SearchViewModel : ViewModelBase set => RaiseAndSetIfChanged(ref _selectedEntry, value); } - public EntryType? EntryType - { - get => _entryType; - set => RaiseAndSetIfChanged(ref _entryType, value); - } - public bool IsLoading { get => _isLoading; @@ -76,7 +69,7 @@ public class SearchViewModel : ViewModelBase return new List(); IsLoading = true; - IOperationResult results = await _workshopClient.SearchEntries.ExecuteAsync(input, EntryType, cancellationToken); + IOperationResult results = await _workshopClient.SearchEntries.ExecuteAsync(input, null, cancellationToken); return results.Data?.SearchEntries.Select(e => new SearchResultViewModel(e) as object) ?? new List(); } catch (Exception e) diff --git a/src/Artemis.UI/Screens/Workshop/WorkshopViewModel.cs b/src/Artemis.UI/Screens/Workshop/WorkshopViewModel.cs index f6b53a013..aa6fc9eb2 100644 --- a/src/Artemis.UI/Screens/Workshop/WorkshopViewModel.cs +++ b/src/Artemis.UI/Screens/Workshop/WorkshopViewModel.cs @@ -1,38 +1,18 @@ -using System; -using System.Threading; -using System.Threading.Tasks; -using Artemis.UI.Screens.Workshop.Home; +using Artemis.UI.Screens.Workshop.Home; using Artemis.UI.Screens.Workshop.Search; using Artemis.UI.Shared; using Artemis.UI.Shared.Routing; -using Artemis.WebClient.Workshop; namespace Artemis.UI.Screens.Workshop; -public class WorkshopViewModel : RoutableScreen, IMainScreenViewModel +public class WorkshopViewModel : RoutableHostScreen, IMainScreenViewModel { - private readonly SearchViewModel _searchViewModel; - public WorkshopViewModel(SearchViewModel searchViewModel, WorkshopHomeViewModel homeViewModel) { - _searchViewModel = searchViewModel; - TitleBarViewModel = searchViewModel; HomeViewModel = homeViewModel; } public ViewModelBase TitleBarViewModel { get; } public WorkshopHomeViewModel HomeViewModel { get; } - - /// - public override Task OnNavigating(NavigationArguments args, CancellationToken cancellationToken) - { - _searchViewModel.EntryType = Screen?.EntryType; - return Task.CompletedTask; - } -} - -public interface IWorkshopViewModel -{ - public EntryType? EntryType { get; } } \ No newline at end of file From a0536b4302b7b697aa5de1e7dc8da74025ee83e4 Mon Sep 17 00:00:00 2001 From: Robert Date: Sat, 2 Sep 2023 22:58:35 +0200 Subject: [PATCH 26/37] Workshop - Did all the things (Markdown stuff) --- .../Services/Interfaces/IWindowService.cs | 2 +- .../Services/Window/WindowService.cs | 7 +- src/Artemis.UI/Artemis.UI.csproj | 18 ++ src/Artemis.UI/Routing/RouteViewModel.cs | 6 +- src/Artemis.UI/Routing/Routes.cs | 20 +- src/Artemis.UI/Screens/Root/RootViewModel.cs | 2 +- .../Screens/Settings/SettingsViewModel.cs | 10 +- .../Screens/Sidebar/SidebarViewModel.cs | 4 +- .../Workshop/Entries/EntriesView.axaml | 32 +++ .../Workshop/Entries/EntriesView.axaml.cs | 28 +++ .../Workshop/Entries/EntriesViewModel.cs | 65 ++++++ .../Entries/EntryListItemViewModel.cs | 4 +- ...BaseViewModel.cs => EntryListViewModel.cs} | 6 +- .../Entries/EntrySpecificationsView.axaml | 105 +++++++++ .../Entries/EntrySpecificationsView.axaml.cs | 34 +++ .../Entries/EntrySpecificationsViewModel.cs | 173 +++++++++++++++ .../Entries/Tabs/LayoutListView.axaml | 41 ++++ .../Tabs}/LayoutListView.axaml.cs | 2 +- .../Tabs}/LayoutListViewModel.cs | 7 +- .../Entries/Tabs/ProfileListView.axaml | 42 ++++ .../Tabs}/ProfileListView.axaml.cs | 2 +- .../Tabs}/ProfileListViewModel.cs | 7 +- .../Entries/Windows/MarkdownPreviewView.axaml | 43 ++++ .../Windows/MarkdownPreviewView.axaml.cs | 12 + .../Windows/MarkdownPreviewViewModel.cs | 21 ++ .../Workshop/Home/WorkshopHomeView.axaml | 4 +- .../Workshop/Home/WorkshopHomeViewModel.cs | 2 - .../Workshop/Home/WorkshopOfflineViewModel.cs | 2 - .../Workshop/Layout/LayoutDetailsView.axaml | 140 ++++++++++-- .../Workshop/Layout/LayoutDetailsViewModel.cs | 30 ++- .../Workshop/Layout/LayoutListView.axaml | 42 ---- .../{Tabs => }/SubmissionsDetailView.axaml | 6 +- .../{Tabs => }/SubmissionsDetailView.axaml.cs | 5 +- .../{Tabs => }/SubmissionsDetailViewModel.cs | 10 +- .../Library/Tabs/SubmissionsTabViewModel.cs | 8 +- .../Library/WorkshopLibraryVIew.axaml | 8 +- .../Library/WorkshopLibraryVIew.axaml.cs | 20 +- .../Library/WorkshopLibraryViewModel.cs | 35 ++- .../Workshop/Profile/ProfileDetailsView.axaml | 208 +++++++++--------- .../Profile/ProfileDetailsViewModel.cs | 3 - .../Workshop/Profile/ProfileListView.axaml | 43 ---- .../Workshop/Search/SearchViewModel.cs | 4 +- .../Steps/SpecificationsStepView.axaml | 103 +-------- .../Steps/SpecificationsStepViewModel.cs | 147 +++---------- .../Steps/UploadStepViewModel.cs | 6 +- src/Artemis.UI/Services/DebugService.cs | 3 +- src/Artemis.UI/Styles/Artemis.axaml | 16 +- 47 files changed, 1017 insertions(+), 521 deletions(-) create mode 100644 src/Artemis.UI/Screens/Workshop/Entries/EntriesView.axaml create mode 100644 src/Artemis.UI/Screens/Workshop/Entries/EntriesView.axaml.cs create mode 100644 src/Artemis.UI/Screens/Workshop/Entries/EntriesViewModel.cs rename src/Artemis.UI/Screens/Workshop/Entries/{EntryListBaseViewModel.cs => EntryListViewModel.cs} (94%) create mode 100644 src/Artemis.UI/Screens/Workshop/Entries/EntrySpecificationsView.axaml create mode 100644 src/Artemis.UI/Screens/Workshop/Entries/EntrySpecificationsView.axaml.cs create mode 100644 src/Artemis.UI/Screens/Workshop/Entries/EntrySpecificationsViewModel.cs create mode 100644 src/Artemis.UI/Screens/Workshop/Entries/Tabs/LayoutListView.axaml rename src/Artemis.UI/Screens/Workshop/{Layout => Entries/Tabs}/LayoutListView.axaml.cs (77%) rename src/Artemis.UI/Screens/Workshop/{Layout => Entries/Tabs}/LayoutListViewModel.cs (85%) create mode 100644 src/Artemis.UI/Screens/Workshop/Entries/Tabs/ProfileListView.axaml rename src/Artemis.UI/Screens/Workshop/{Profile => Entries/Tabs}/ProfileListView.axaml.cs (78%) rename src/Artemis.UI/Screens/Workshop/{Profile => Entries/Tabs}/ProfileListViewModel.cs (84%) create mode 100644 src/Artemis.UI/Screens/Workshop/Entries/Windows/MarkdownPreviewView.axaml create mode 100644 src/Artemis.UI/Screens/Workshop/Entries/Windows/MarkdownPreviewView.axaml.cs create mode 100644 src/Artemis.UI/Screens/Workshop/Entries/Windows/MarkdownPreviewViewModel.cs delete mode 100644 src/Artemis.UI/Screens/Workshop/Layout/LayoutListView.axaml rename src/Artemis.UI/Screens/Workshop/Library/{Tabs => }/SubmissionsDetailView.axaml (53%) rename src/Artemis.UI/Screens/Workshop/Library/{Tabs => }/SubmissionsDetailView.axaml.cs (62%) rename src/Artemis.UI/Screens/Workshop/Library/{Tabs => }/SubmissionsDetailViewModel.cs (57%) delete mode 100644 src/Artemis.UI/Screens/Workshop/Profile/ProfileListView.axaml diff --git a/src/Artemis.UI.Shared/Services/Interfaces/IWindowService.cs b/src/Artemis.UI.Shared/Services/Interfaces/IWindowService.cs index ff4394fde..15756d2a0 100644 --- a/src/Artemis.UI.Shared/Services/Interfaces/IWindowService.cs +++ b/src/Artemis.UI.Shared/Services/Interfaces/IWindowService.cs @@ -16,7 +16,7 @@ public interface IWindowService : IArtemisSharedUIService /// /// The type of view model to create /// The created view model - TViewModel ShowWindow(params object[] parameters); + Window ShowWindow(out TViewModel viewModel, params object[] parameters); /// /// Given a ViewModel, show its corresponding View as a window diff --git a/src/Artemis.UI.Shared/Services/Window/WindowService.cs b/src/Artemis.UI.Shared/Services/Window/WindowService.cs index c30688eef..5db1716d2 100644 --- a/src/Artemis.UI.Shared/Services/Window/WindowService.cs +++ b/src/Artemis.UI.Shared/Services/Window/WindowService.cs @@ -21,14 +21,13 @@ internal class WindowService : IWindowService _container = container; } - public T ShowWindow(params object[] parameters) + public Window ShowWindow(out T viewModel, params object[] parameters) { - T viewModel = _container.Resolve(parameters); + viewModel = _container.Resolve(parameters); if (viewModel == null) throw new ArtemisSharedUIException($"Failed to show window for VM of type {typeof(T).Name}, could not create instance."); - ShowWindow(viewModel); - return viewModel; + return ShowWindow(viewModel); } public Window ShowWindow(object viewModel) diff --git a/src/Artemis.UI/Artemis.UI.csproj b/src/Artemis.UI/Artemis.UI.csproj index f7ffa8c34..fd7f60a06 100644 --- a/src/Artemis.UI/Artemis.UI.csproj +++ b/src/Artemis.UI/Artemis.UI.csproj @@ -20,6 +20,7 @@ + @@ -28,6 +29,7 @@ + @@ -43,9 +45,25 @@ + + + + + ProfileListView.axaml + Code + + + LayoutListView.axaml + Code + + + SubmissionsDetailView.axaml + Code + + \ No newline at end of file diff --git a/src/Artemis.UI/Routing/RouteViewModel.cs b/src/Artemis.UI/Routing/RouteViewModel.cs index 5ecc0f356..78cc458c9 100644 --- a/src/Artemis.UI/Routing/RouteViewModel.cs +++ b/src/Artemis.UI/Routing/RouteViewModel.cs @@ -4,18 +4,20 @@ namespace Artemis.UI.Routing; public class RouteViewModel { - public RouteViewModel(string path, string name) + public RouteViewModel(string name, string path, string? mathPath = null) { Path = path; Name = name; + MathPath = mathPath; } public string Path { get; } public string Name { get; } + public string? MathPath { get; } public bool Matches(string path) { - return path.StartsWith(Path, StringComparison.InvariantCultureIgnoreCase); + return path.StartsWith(MathPath ?? Path, StringComparison.InvariantCultureIgnoreCase); } /// diff --git a/src/Artemis.UI/Routing/Routes.cs b/src/Artemis.UI/Routing/Routes.cs index 128209f99..4ad88a88d 100644 --- a/src/Artemis.UI/Routing/Routes.cs +++ b/src/Artemis.UI/Routing/Routes.cs @@ -6,6 +6,8 @@ using Artemis.UI.Screens.Settings; using Artemis.UI.Screens.Settings.Updating; using Artemis.UI.Screens.SurfaceEditor; using Artemis.UI.Screens.Workshop; +using Artemis.UI.Screens.Workshop.Entries; +using Artemis.UI.Screens.Workshop.Entries.Tabs; using Artemis.UI.Screens.Workshop.Home; using Artemis.UI.Screens.Workshop.Layout; using Artemis.UI.Screens.Workshop.Library; @@ -24,16 +26,22 @@ public static class Routes #if DEBUG new RouteRegistration("workshop") { - Children = new List() + Children = new List { new RouteRegistration("offline/{message:string}"), - new RouteRegistration("profiles/{page:int}"), - new RouteRegistration("profiles/{entryId:guid}"), - new RouteRegistration("layouts/{page:int}"), - new RouteRegistration("layouts/{entryId:guid}"), + new RouteRegistration("entries") + { + Children = new List + { + new RouteRegistration("profiles/{page:int}"), + new RouteRegistration("profiles/details/{entryId:guid}"), + new RouteRegistration("layouts/{page:int}"), + new RouteRegistration("layouts/details/{entryId:guid}"), + } + }, new RouteRegistration("library") { - Children = new List() + Children = new List { new RouteRegistration("installed"), new RouteRegistration("submissions"), diff --git a/src/Artemis.UI/Screens/Root/RootViewModel.cs b/src/Artemis.UI/Screens/Root/RootViewModel.cs index d057893e5..8b319b300 100644 --- a/src/Artemis.UI/Screens/Root/RootViewModel.cs +++ b/src/Artemis.UI/Screens/Root/RootViewModel.cs @@ -128,7 +128,7 @@ public class RootViewModel : RoutableHostScreen, IMainWindowProv private void ShowSplashScreen() { - _windowService.ShowWindow(); + _windowService.ShowWindow(out SplashViewModel _); } #region Tray commands diff --git a/src/Artemis.UI/Screens/Settings/SettingsViewModel.cs b/src/Artemis.UI/Screens/Settings/SettingsViewModel.cs index 63b97b1ab..26e8edcd2 100644 --- a/src/Artemis.UI/Screens/Settings/SettingsViewModel.cs +++ b/src/Artemis.UI/Screens/Settings/SettingsViewModel.cs @@ -21,11 +21,11 @@ public class SettingsViewModel : RoutableHostScreen, IMainScreen _router = router; SettingTabs = new ObservableCollection { - new("settings/general", "General"), - new("settings/plugins", "Plugins"), - new("settings/devices", "Devices"), - new("settings/releases", "Releases"), - new("settings/about", "About"), + new("General", "settings/general"), + new("Plugins", "settings/plugins"), + new("Devices", "settings/devices"), + new("Releases", "settings/releases"), + new("About", "settings/about"), }; // Navigate on tab change diff --git a/src/Artemis.UI/Screens/Sidebar/SidebarViewModel.cs b/src/Artemis.UI/Screens/Sidebar/SidebarViewModel.cs index 6b7d6c035..0210d557f 100644 --- a/src/Artemis.UI/Screens/Sidebar/SidebarViewModel.cs +++ b/src/Artemis.UI/Screens/Sidebar/SidebarViewModel.cs @@ -41,8 +41,8 @@ public class SidebarViewModel : ActivatableViewModelBase #if DEBUG new(MaterialIconKind.TestTube, "Workshop", "workshop", null, new ObservableCollection { - new(MaterialIconKind.FolderVideo, "Profiles", "workshop/profiles/1", "workshop/profiles"), - new(MaterialIconKind.KeyboardVariant, "Layouts", "workshop/layouts/1", "workshop/layouts"), + new(MaterialIconKind.FolderVideo, "Profiles", "workshop/entries/profiles/1", "workshop/entries/profiles"), + new(MaterialIconKind.KeyboardVariant, "Layouts", "workshop/entries/layouts/1", "workshop/entries/layouts"), new(MaterialIconKind.Bookshelf, "Library", "workshop/library"), }), #endif diff --git a/src/Artemis.UI/Screens/Workshop/Entries/EntriesView.axaml b/src/Artemis.UI/Screens/Workshop/Entries/EntriesView.axaml new file mode 100644 index 000000000..ecc131eb9 --- /dev/null +++ b/src/Artemis.UI/Screens/Workshop/Entries/EntriesView.axaml @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + diff --git a/src/Artemis.UI/Screens/Workshop/Entries/EntriesView.axaml.cs b/src/Artemis.UI/Screens/Workshop/Entries/EntriesView.axaml.cs new file mode 100644 index 000000000..3f2106645 --- /dev/null +++ b/src/Artemis.UI/Screens/Workshop/Entries/EntriesView.axaml.cs @@ -0,0 +1,28 @@ +using System.Reactive.Disposables; +using Artemis.UI.Shared; +using Avalonia.ReactiveUI; +using Avalonia.Threading; +using FluentAvalonia.UI.Controls; +using ReactiveUI; +using System; + +namespace Artemis.UI.Screens.Workshop.Entries; + +public partial class EntriesView : ReactiveUserControl +{ + public EntriesView() + { + InitializeComponent(); + this.WhenActivated(d => { ViewModel.WhenAnyValue(vm => vm.Screen).WhereNotNull().Subscribe(Navigate).DisposeWith(d); }); + } + + private void Navigate(ViewModelBase viewModel) + { + Dispatcher.UIThread.Invoke(() => TabFrame.NavigateFromObject(viewModel)); + } + + private void NavigationView_OnBackRequested(object? sender, NavigationViewBackRequestedEventArgs e) + { + ViewModel?.GoBack(); + } +} \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/Entries/EntriesViewModel.cs b/src/Artemis.UI/Screens/Workshop/Entries/EntriesViewModel.cs new file mode 100644 index 000000000..903a4d649 --- /dev/null +++ b/src/Artemis.UI/Screens/Workshop/Entries/EntriesViewModel.cs @@ -0,0 +1,65 @@ +using System.Collections.ObjectModel; +using System.Linq; +using System.Reactive.Disposables; +using System.Threading; +using System.Threading.Tasks; +using Artemis.UI.Routing; +using Artemis.UI.Shared.Routing; +using ReactiveUI; +using System; +using System.Reactive.Linq; + +namespace Artemis.UI.Screens.Workshop.Entries; + +public class EntriesViewModel : RoutableHostScreen +{ + private readonly IRouter _router; + private RouteViewModel? _selectedTab; + private ObservableAsPropertyHelper? _viewingDetails; + + public EntriesViewModel(IRouter router) + { + _router = router; + + Tabs = new ObservableCollection + { + new("Profiles", "workshop/entries/profiles/1", "workshop/entries/profiles"), + new("Layouts", "workshop/entries/layouts/1", "workshop/entries/layouts") + }; + + this.WhenActivated(d => + { + // Show back button on details page + _viewingDetails = _router.CurrentPath.Select(p => p != null && p.Contains("details")).ToProperty(this, vm => vm.ViewingDetails).DisposeWith(d); + // Navigate on tab change + this.WhenAnyValue(vm => vm.SelectedTab) + .WhereNotNull() + .Subscribe(s => router.Navigate(s.Path, new RouterNavigationOptions {IgnoreOnPartialMatch = true})) + .DisposeWith(d); + }); + } + + public bool ViewingDetails => _viewingDetails?.Value ?? false; + public ObservableCollection Tabs { get; } + + public RouteViewModel? SelectedTab + { + get => _selectedTab; + set => RaiseAndSetIfChanged(ref _selectedTab, value); + } + + public override async Task OnNavigating(NavigationArguments args, CancellationToken cancellationToken) + { + SelectedTab = Tabs.FirstOrDefault(t => t.Matches(args.Path)); + if (SelectedTab == null) + await args.Router.Navigate(Tabs.First().Path); + } + + public void GoBack() + { + if (ViewingDetails) + _router.GoBack(); + else + _router.Navigate("workshop"); + } +} \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/Entries/EntryListItemViewModel.cs b/src/Artemis.UI/Screens/Workshop/Entries/EntryListItemViewModel.cs index a654b476e..4160048ff 100644 --- a/src/Artemis.UI/Screens/Workshop/Entries/EntryListItemViewModel.cs +++ b/src/Artemis.UI/Screens/Workshop/Entries/EntryListItemViewModel.cs @@ -33,10 +33,10 @@ public class EntryListItemViewModel : ActivatableViewModelBase switch (Entry.EntryType) { case EntryType.Layout: - await _router.Navigate($"workshop/layouts/{Entry.Id}"); + await _router.Navigate($"workshop/entries/layouts/details/{Entry.Id}"); break; case EntryType.Profile: - await _router.Navigate($"workshop/profiles/{Entry.Id}"); + await _router.Navigate($"workshop/entries/profiles/details/{Entry.Id}"); break; case EntryType.Plugin: break; diff --git a/src/Artemis.UI/Screens/Workshop/Entries/EntryListBaseViewModel.cs b/src/Artemis.UI/Screens/Workshop/Entries/EntryListViewModel.cs similarity index 94% rename from src/Artemis.UI/Screens/Workshop/Entries/EntryListBaseViewModel.cs rename to src/Artemis.UI/Screens/Workshop/Entries/EntryListViewModel.cs index 970b966fb..ff4d2a138 100644 --- a/src/Artemis.UI/Screens/Workshop/Entries/EntryListBaseViewModel.cs +++ b/src/Artemis.UI/Screens/Workshop/Entries/EntryListViewModel.cs @@ -18,7 +18,7 @@ using StrawberryShake; namespace Artemis.UI.Screens.Workshop.Entries; -public abstract class EntryListBaseViewModel : RoutableScreen +public abstract class EntryListViewModel : RoutableScreen { private readonly INotificationService _notificationService; private readonly IWorkshopClient _workshopClient; @@ -31,7 +31,7 @@ public abstract class EntryListBaseViewModel : RoutableScreen getEntryListViewModel) { _workshopClient = workshopClient; @@ -143,6 +143,4 @@ public abstract class EntryListBaseViewModel : RoutableScreen null; } \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/Entries/EntrySpecificationsView.axaml b/src/Artemis.UI/Screens/Workshop/Entries/EntrySpecificationsView.axaml new file mode 100644 index 000000000..ea41ca8c5 --- /dev/null +++ b/src/Artemis.UI/Screens/Workshop/Entries/EntrySpecificationsView.axaml @@ -0,0 +1,105 @@ + + + + + + + + + + + + + + + + + + + + + + Icon required + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + At least one category is required + + + + + + + + + + + + + Markdown supported, a better editor planned + + + + + \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/Entries/EntrySpecificationsView.axaml.cs b/src/Artemis.UI/Screens/Workshop/Entries/EntrySpecificationsView.axaml.cs new file mode 100644 index 000000000..e8155b0de --- /dev/null +++ b/src/Artemis.UI/Screens/Workshop/Entries/EntrySpecificationsView.axaml.cs @@ -0,0 +1,34 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Media; +using Avalonia.Media.Immutable; +using Avalonia.ReactiveUI; +using AvaloniaEdit.TextMate; +using TextMateSharp.Grammars; + +namespace Artemis.UI.Screens.Workshop.Entries; + +public partial class EntrySpecificationsView : ReactiveUserControl +{ + public EntrySpecificationsView() + { + InitializeComponent(); + + RegistryOptions options = new(ThemeName.Dark); + TextMate.Installation? install = DescriptionEditor.InstallTextMate(options); + + install.SetGrammar(options.GetScopeByExtension(".md")); + } + + #region Overrides of Visual + + /// + protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) + { + if (this.TryFindResource("SystemAccentColorLight3", out object? resource) && resource is Color color) + DescriptionEditor.TextArea.TextView.LinkTextForegroundBrush = new ImmutableSolidColorBrush(color); + base.OnAttachedToVisualTree(e); + } + + #endregion +} \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/Entries/EntrySpecificationsViewModel.cs b/src/Artemis.UI/Screens/Workshop/Entries/EntrySpecificationsViewModel.cs new file mode 100644 index 000000000..a4b3f439c --- /dev/null +++ b/src/Artemis.UI/Screens/Workshop/Entries/EntrySpecificationsViewModel.cs @@ -0,0 +1,173 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using System.Reactive; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using System.Threading; +using System.Threading.Tasks; +using Artemis.UI.Extensions; +using Artemis.UI.Screens.Workshop.Categories; +using Artemis.UI.Screens.Workshop.Entries.Windows; +using Artemis.UI.Shared; +using Artemis.UI.Shared.Services; +using Artemis.WebClient.Workshop; +using Avalonia.Controls; +using AvaloniaEdit.Document; +using DynamicData; +using DynamicData.Aggregation; +using DynamicData.Binding; +using ReactiveUI; +using ReactiveUI.Validation.Extensions; +using ReactiveUI.Validation.Helpers; +using StrawberryShake; +using Bitmap = Avalonia.Media.Imaging.Bitmap; + +namespace Artemis.UI.Screens.Workshop.Entries; + +public class EntrySpecificationsViewModel : ValidatableViewModelBase +{ + private readonly IWindowService _windowService; + private ObservableAsPropertyHelper? _categoriesValid; + private ObservableAsPropertyHelper? _iconValid; + private string _description = string.Empty; + private string _name = string.Empty; + private string _summary = string.Empty; + private Bitmap? _iconBitmap; + private Window? _previewWindow; + private TextDocument? _markdownDocument; + + public EntrySpecificationsViewModel(IWorkshopClient workshopClient, IWindowService windowService) + { + _windowService = windowService; + SelectIcon = ReactiveCommand.CreateFromTask(ExecuteSelectIcon); + OpenMarkdownPreview = ReactiveCommand.Create(ExecuteOpenMarkdownPreview); + + // this.WhenAnyValue(vm => vm.Description).Subscribe(d => MarkdownDocument.Text = d); + this.WhenActivated(d => + { + // Load categories + Observable.FromAsync(workshopClient.GetCategories.ExecuteAsync).Subscribe(PopulateCategories).DisposeWith(d); + + this.ClearValidationRules(); + + MarkdownDocument = new TextDocument(new StringTextSource(Description)); + MarkdownDocument.TextChanged += MarkdownDocumentOnTextChanged; + Disposable.Create(() => + { + _previewWindow?.Close(); + MarkdownDocument.TextChanged -= MarkdownDocumentOnTextChanged; + MarkdownDocument = null; + ClearIcon(); + }).DisposeWith(d); + }); + } + + private void MarkdownDocumentOnTextChanged(object? sender, EventArgs e) + { + Description = MarkdownDocument.Text; + } + + public ReactiveCommand SelectIcon { get; } + public ReactiveCommand OpenMarkdownPreview { get; } + + public ObservableCollection Categories { get; } = new(); + public ObservableCollection Tags { get; } = new(); + public bool CategoriesValid => _categoriesValid?.Value ?? true; + public bool IconValid => _iconValid?.Value ?? true; + + public string Name + { + get => _name; + set => RaiseAndSetIfChanged(ref _name, value); + } + + public string Summary + { + get => _summary; + set => RaiseAndSetIfChanged(ref _summary, value); + } + + public string Description + { + get => _description; + set => RaiseAndSetIfChanged(ref _description, value); + } + + public Bitmap? IconBitmap + { + get => _iconBitmap; + set => RaiseAndSetIfChanged(ref _iconBitmap, value); + } + + public TextDocument? MarkdownDocument + { + get => _markdownDocument; + set => RaiseAndSetIfChanged(ref _markdownDocument, value); + } + + public List PreselectedCategories { get; set; } = new List(); + + public void SetupDataValidation() + { + // Hopefully this can be avoided in the future + // https://github.com/reactiveui/ReactiveUI.Validation/discussions/558 + this.ValidationRule(vm => vm.Name, s => !string.IsNullOrWhiteSpace(s), "Name is required"); + this.ValidationRule(vm => vm.Summary, s => !string.IsNullOrWhiteSpace(s), "Summary is required"); + this.ValidationRule(vm => vm.Description, s => !string.IsNullOrWhiteSpace(s), "Description is required"); + + // These don't use inputs that support validation messages, do so manually + ValidationHelper iconRule = this.ValidationRule(vm => vm.IconBitmap, s => s != null, "Icon required"); + ValidationHelper categoriesRule = this.ValidationRule(vm => vm.Categories, Categories.ToObservableChangeSet().AutoRefresh(c => c.IsSelected).Filter(c => c.IsSelected).IsNotEmpty(), + "At least one category must be selected" + ); + _iconValid = iconRule.ValidationChanged.Select(c => c.IsValid).ToProperty(this, vm => vm.IconValid); + _categoriesValid = categoriesRule.ValidationChanged.Select(c => c.IsValid).ToProperty(this, vm => vm.CategoriesValid); + } + + private async Task ExecuteSelectIcon() + { + string[]? result = await _windowService.CreateOpenFileDialog() + .HavingFilter(f => f.WithExtension("png").WithExtension("jpg").WithExtension("bmp").WithName("Bitmap image")) + .ShowAsync(); + + if (result == null) + return; + + IconBitmap?.Dispose(); + IconBitmap = BitmapExtensions.LoadAndResize(result[0], 128); + } + + private void ExecuteOpenMarkdownPreview() + { + if (_previewWindow != null) + { + _previewWindow.Activate(); + return; + } + + _previewWindow = _windowService.ShowWindow(out MarkdownPreviewViewModel _, this.WhenAnyValue(vm => vm.Description)); + _previewWindow.Closed += PreviewWindowOnClosed; + } + + private void PreviewWindowOnClosed(object? sender, EventArgs e) + { + if (_previewWindow != null) + _previewWindow.Closed -= PreviewWindowOnClosed; + _previewWindow = null; + } + + private void ClearIcon() + { + IconBitmap?.Dispose(); + IconBitmap = null; + } + + private void PopulateCategories(IOperationResult result) + { + Categories.Clear(); + if (result.Data != null) + Categories.AddRange(result.Data.Categories.Select(c => new CategoryViewModel(c) {IsSelected = PreselectedCategories.Contains(c.Id)})); + } +} \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/Entries/Tabs/LayoutListView.axaml b/src/Artemis.UI/Screens/Workshop/Entries/Tabs/LayoutListView.axaml new file mode 100644 index 000000000..575f24ffc --- /dev/null +++ b/src/Artemis.UI/Screens/Workshop/Entries/Tabs/LayoutListView.axaml @@ -0,0 +1,41 @@ + + + + + + Categories + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/Layout/LayoutListView.axaml.cs b/src/Artemis.UI/Screens/Workshop/Entries/Tabs/LayoutListView.axaml.cs similarity index 77% rename from src/Artemis.UI/Screens/Workshop/Layout/LayoutListView.axaml.cs rename to src/Artemis.UI/Screens/Workshop/Entries/Tabs/LayoutListView.axaml.cs index dff088223..6b574bb29 100644 --- a/src/Artemis.UI/Screens/Workshop/Layout/LayoutListView.axaml.cs +++ b/src/Artemis.UI/Screens/Workshop/Entries/Tabs/LayoutListView.axaml.cs @@ -1,6 +1,6 @@ using Avalonia.ReactiveUI; -namespace Artemis.UI.Screens.Workshop.Layout; +namespace Artemis.UI.Screens.Workshop.Entries.Tabs; public partial class LayoutListView : ReactiveUserControl { diff --git a/src/Artemis.UI/Screens/Workshop/Layout/LayoutListViewModel.cs b/src/Artemis.UI/Screens/Workshop/Entries/Tabs/LayoutListViewModel.cs similarity index 85% rename from src/Artemis.UI/Screens/Workshop/Layout/LayoutListViewModel.cs rename to src/Artemis.UI/Screens/Workshop/Entries/Tabs/LayoutListViewModel.cs index 8f5b108e7..a32408d09 100644 --- a/src/Artemis.UI/Screens/Workshop/Layout/LayoutListViewModel.cs +++ b/src/Artemis.UI/Screens/Workshop/Entries/Tabs/LayoutListViewModel.cs @@ -1,13 +1,12 @@ using System; using Artemis.UI.Screens.Workshop.Categories; -using Artemis.UI.Screens.Workshop.Entries; using Artemis.UI.Shared.Routing; using Artemis.UI.Shared.Services; using Artemis.WebClient.Workshop; -namespace Artemis.UI.Screens.Workshop.Layout; +namespace Artemis.UI.Screens.Workshop.Entries.Tabs; -public class LayoutListViewModel : EntryListBaseViewModel +public class LayoutListViewModel : EntryListViewModel { /// public LayoutListViewModel(IWorkshopClient workshopClient, @@ -24,7 +23,7 @@ public class LayoutListViewModel : EntryListBaseViewModel /// protected override string GetPagePath(int page) { - return $"workshop/layouts/{page}"; + return $"workshop/entries/layouts/{page}"; } /// diff --git a/src/Artemis.UI/Screens/Workshop/Entries/Tabs/ProfileListView.axaml b/src/Artemis.UI/Screens/Workshop/Entries/Tabs/ProfileListView.axaml new file mode 100644 index 000000000..e7b733741 --- /dev/null +++ b/src/Artemis.UI/Screens/Workshop/Entries/Tabs/ProfileListView.axaml @@ -0,0 +1,42 @@ + + + + + + Categories + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/Profile/ProfileListView.axaml.cs b/src/Artemis.UI/Screens/Workshop/Entries/Tabs/ProfileListView.axaml.cs similarity index 78% rename from src/Artemis.UI/Screens/Workshop/Profile/ProfileListView.axaml.cs rename to src/Artemis.UI/Screens/Workshop/Entries/Tabs/ProfileListView.axaml.cs index 6c55237f5..62adad88b 100644 --- a/src/Artemis.UI/Screens/Workshop/Profile/ProfileListView.axaml.cs +++ b/src/Artemis.UI/Screens/Workshop/Entries/Tabs/ProfileListView.axaml.cs @@ -1,6 +1,6 @@ using Avalonia.ReactiveUI; -namespace Artemis.UI.Screens.Workshop.Profile; +namespace Artemis.UI.Screens.Workshop.Entries.Tabs; public partial class ProfileListView : ReactiveUserControl { diff --git a/src/Artemis.UI/Screens/Workshop/Profile/ProfileListViewModel.cs b/src/Artemis.UI/Screens/Workshop/Entries/Tabs/ProfileListViewModel.cs similarity index 84% rename from src/Artemis.UI/Screens/Workshop/Profile/ProfileListViewModel.cs rename to src/Artemis.UI/Screens/Workshop/Entries/Tabs/ProfileListViewModel.cs index 521c9a351..49df897db 100644 --- a/src/Artemis.UI/Screens/Workshop/Profile/ProfileListViewModel.cs +++ b/src/Artemis.UI/Screens/Workshop/Entries/Tabs/ProfileListViewModel.cs @@ -1,13 +1,12 @@ using System; using Artemis.UI.Screens.Workshop.Categories; -using Artemis.UI.Screens.Workshop.Entries; using Artemis.UI.Shared.Routing; using Artemis.UI.Shared.Services; using Artemis.WebClient.Workshop; -namespace Artemis.UI.Screens.Workshop.Profile; +namespace Artemis.UI.Screens.Workshop.Entries.Tabs; -public class ProfileListViewModel : EntryListBaseViewModel +public class ProfileListViewModel : EntryListViewModel { /// public ProfileListViewModel(IWorkshopClient workshopClient, @@ -24,7 +23,7 @@ public class ProfileListViewModel : EntryListBaseViewModel /// protected override string GetPagePath(int page) { - return $"workshop/profiles/{page}"; + return $"workshop/entries/profiles/{page}"; } /// diff --git a/src/Artemis.UI/Screens/Workshop/Entries/Windows/MarkdownPreviewView.axaml b/src/Artemis.UI/Screens/Workshop/Entries/Windows/MarkdownPreviewView.axaml new file mode 100644 index 000000000..8898bef0a --- /dev/null +++ b/src/Artemis.UI/Screens/Workshop/Entries/Windows/MarkdownPreviewView.axaml @@ -0,0 +1,43 @@ + + + + Markdown Previewer + + In this window you can preview the Markdown you're writing in the main window of the application. + + The preview updates realtime, so it might be a good idea to keep this window visible while you're typing. + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/Entries/Windows/MarkdownPreviewView.axaml.cs b/src/Artemis.UI/Screens/Workshop/Entries/Windows/MarkdownPreviewView.axaml.cs new file mode 100644 index 000000000..ec5b62868 --- /dev/null +++ b/src/Artemis.UI/Screens/Workshop/Entries/Windows/MarkdownPreviewView.axaml.cs @@ -0,0 +1,12 @@ + +using Artemis.UI.Shared; + +namespace Artemis.UI.Screens.Workshop.Entries.Windows; + +public partial class MarkdownPreviewView : ReactiveAppWindow +{ + public MarkdownPreviewView() + { + InitializeComponent(); + } +} \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/Entries/Windows/MarkdownPreviewViewModel.cs b/src/Artemis.UI/Screens/Workshop/Entries/Windows/MarkdownPreviewViewModel.cs new file mode 100644 index 000000000..4e8680d0e --- /dev/null +++ b/src/Artemis.UI/Screens/Workshop/Entries/Windows/MarkdownPreviewViewModel.cs @@ -0,0 +1,21 @@ +using System; +using Artemis.UI.Shared; + +namespace Artemis.UI.Screens.Workshop.Entries.Windows; + +public class MarkdownPreviewViewModel : ActivatableViewModelBase +{ + public event EventHandler? Closed; + + public IObservable Markdown { get; } + + public MarkdownPreviewViewModel(IObservable markdown) + { + Markdown = markdown; + } + + protected virtual void OnClosed() + { + Closed?.Invoke(this, EventArgs.Empty); + } +} \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/Home/WorkshopHomeView.axaml b/src/Artemis.UI/Screens/Workshop/Home/WorkshopHomeView.axaml index c3b4593f6..433f87c50 100644 --- a/src/Artemis.UI/Screens/Workshop/Home/WorkshopHomeView.axaml +++ b/src/Artemis.UI/Screens/Workshop/Home/WorkshopHomeView.axaml @@ -41,7 +41,7 @@ - - + - - + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/Layout/LayoutDetailsViewModel.cs b/src/Artemis.UI/Screens/Workshop/Layout/LayoutDetailsViewModel.cs index 3bd224a6a..e559e7e09 100644 --- a/src/Artemis.UI/Screens/Workshop/Layout/LayoutDetailsViewModel.cs +++ b/src/Artemis.UI/Screens/Workshop/Layout/LayoutDetailsViewModel.cs @@ -1,10 +1,17 @@ using System; +using System.Reactive; +using System.Reactive.Linq; using System.Threading; using System.Threading.Tasks; +using Artemis.Core; using Artemis.UI.Screens.Workshop.Parameters; -using Artemis.UI.Shared; using Artemis.UI.Shared.Routing; +using Artemis.UI.Shared.Services; +using Artemis.UI.Shared.Services.Builders; +using Artemis.UI.Shared.Utilities; using Artemis.WebClient.Workshop; +using Artemis.WebClient.Workshop.DownloadHandlers; +using ReactiveUI; using StrawberryShake; namespace Artemis.UI.Screens.Workshop.Layout; @@ -12,19 +19,29 @@ namespace Artemis.UI.Screens.Workshop.Layout; public class LayoutDetailsViewModel : RoutableScreen { private readonly IWorkshopClient _client; + private readonly INotificationService _notificationService; + private readonly IWindowService _windowService; + private readonly ObservableAsPropertyHelper _updatedAt; private IGetEntryById_Entry? _entry; - public LayoutDetailsViewModel(IWorkshopClient client) + public LayoutDetailsViewModel(IWorkshopClient client, INotificationService notificationService, IWindowService windowService) { _client = client; + _notificationService = notificationService; + _windowService = windowService; + _updatedAt = this.WhenAnyValue(vm => vm.Entry).Select(e => e?.LatestRelease?.CreatedAt ?? e?.CreatedAt).ToProperty(this, vm => vm.UpdatedAt); + + DownloadLatestRelease = ReactiveCommand.CreateFromTask(ExecuteDownloadLatestRelease); } - public EntryType? EntryType => null; + public ReactiveCommand DownloadLatestRelease { get; } + + public DateTimeOffset? UpdatedAt => _updatedAt.Value; public IGetEntryById_Entry? Entry { get => _entry; - set => RaiseAndSetIfChanged(ref _entry, value); + private set => RaiseAndSetIfChanged(ref _entry, value); } public override async Task OnNavigating(WorkshopDetailParameters parameters, NavigationArguments args, CancellationToken cancellationToken) @@ -40,4 +57,9 @@ public class LayoutDetailsViewModel : RoutableScreen Entry = result.Data?.Entry; } + + private Task ExecuteDownloadLatestRelease(CancellationToken cancellationToken) + { + return Task.CompletedTask; + } } \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/Layout/LayoutListView.axaml b/src/Artemis.UI/Screens/Workshop/Layout/LayoutListView.axaml deleted file mode 100644 index 77801f79e..000000000 --- a/src/Artemis.UI/Screens/Workshop/Layout/LayoutListView.axaml +++ /dev/null @@ -1,42 +0,0 @@ - - - - - - - Categories - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/Library/Tabs/SubmissionsDetailView.axaml b/src/Artemis.UI/Screens/Workshop/Library/SubmissionsDetailView.axaml similarity index 53% rename from src/Artemis.UI/Screens/Workshop/Library/Tabs/SubmissionsDetailView.axaml rename to src/Artemis.UI/Screens/Workshop/Library/SubmissionsDetailView.axaml index 1a4585317..ca36904b6 100644 --- a/src/Artemis.UI/Screens/Workshop/Library/Tabs/SubmissionsDetailView.axaml +++ b/src/Artemis.UI/Screens/Workshop/Library/SubmissionsDetailView.axaml @@ -2,7 +2,9 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" + xmlns:library="clr-namespace:Artemis.UI.Screens.Workshop.Library" mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" - x:Class="Artemis.UI.Screens.Workshop.Library.Tabs.SubmissionsDetailView"> - Welcome to Avalonia! + x:Class="Artemis.UI.Screens.Workshop.Library.SubmissionsDetailView" + x:DataType="library:SubmissionsDetailViewModel"> + diff --git a/src/Artemis.UI/Screens/Workshop/Library/Tabs/SubmissionsDetailView.axaml.cs b/src/Artemis.UI/Screens/Workshop/Library/SubmissionsDetailView.axaml.cs similarity index 62% rename from src/Artemis.UI/Screens/Workshop/Library/Tabs/SubmissionsDetailView.axaml.cs rename to src/Artemis.UI/Screens/Workshop/Library/SubmissionsDetailView.axaml.cs index 1af01b4d4..bca7ef914 100644 --- a/src/Artemis.UI/Screens/Workshop/Library/Tabs/SubmissionsDetailView.axaml.cs +++ b/src/Artemis.UI/Screens/Workshop/Library/SubmissionsDetailView.axaml.cs @@ -1,9 +1,6 @@ -using Avalonia; -using Avalonia.Controls; -using Avalonia.Markup.Xaml; using Avalonia.ReactiveUI; -namespace Artemis.UI.Screens.Workshop.Library.Tabs; +namespace Artemis.UI.Screens.Workshop.Library; public partial class SubmissionsDetailView : ReactiveUserControl { diff --git a/src/Artemis.UI/Screens/Workshop/Library/Tabs/SubmissionsDetailViewModel.cs b/src/Artemis.UI/Screens/Workshop/Library/SubmissionsDetailViewModel.cs similarity index 57% rename from src/Artemis.UI/Screens/Workshop/Library/Tabs/SubmissionsDetailViewModel.cs rename to src/Artemis.UI/Screens/Workshop/Library/SubmissionsDetailViewModel.cs index b81f5a368..283a490e7 100644 --- a/src/Artemis.UI/Screens/Workshop/Library/Tabs/SubmissionsDetailViewModel.cs +++ b/src/Artemis.UI/Screens/Workshop/Library/SubmissionsDetailViewModel.cs @@ -1,13 +1,21 @@ using System; using System.Threading; using System.Threading.Tasks; +using Artemis.UI.Screens.Workshop.Entries; using Artemis.UI.Screens.Workshop.Parameters; using Artemis.UI.Shared.Routing; -namespace Artemis.UI.Screens.Workshop.Library.Tabs; +namespace Artemis.UI.Screens.Workshop.Library; public class SubmissionsDetailViewModel : RoutableScreen { + public EntrySpecificationsViewModel EntrySpecificationsViewModel { get; } + + public SubmissionsDetailViewModel(EntrySpecificationsViewModel entrySpecificationsViewModel) + { + EntrySpecificationsViewModel = entrySpecificationsViewModel; + } + public override Task OnNavigating(WorkshopDetailParameters parameters, NavigationArguments args, CancellationToken cancellationToken) { Console.WriteLine(parameters.EntryId); diff --git a/src/Artemis.UI/Screens/Workshop/Library/Tabs/SubmissionsTabViewModel.cs b/src/Artemis.UI/Screens/Workshop/Library/Tabs/SubmissionsTabViewModel.cs index 719f56353..ebcf95fce 100644 --- a/src/Artemis.UI/Screens/Workshop/Library/Tabs/SubmissionsTabViewModel.cs +++ b/src/Artemis.UI/Screens/Workshop/Library/Tabs/SubmissionsTabViewModel.cs @@ -21,6 +21,7 @@ public class SubmissionsTabViewModel : RoutableScreen private readonly IWorkshopClient _client; private readonly SourceCache _entries; private readonly IWindowService _windowService; + private readonly IRouter _router; private bool _isLoading = true; private bool _workshopReachable; @@ -28,6 +29,7 @@ public class SubmissionsTabViewModel : RoutableScreen { _client = client; _windowService = windowService; + _router = router; _entries = new SourceCache(e => e.Id); _entries.Connect().Bind(out ReadOnlyObservableCollection entries).Subscribe(); @@ -76,9 +78,9 @@ public class SubmissionsTabViewModel : RoutableScreen await _windowService.ShowDialogAsync(); } - private Task ExecuteNavigateToEntry(IGetSubmittedEntries_SubmittedEntries entry, CancellationToken cancellationToken) + private async Task ExecuteNavigateToEntry(IGetSubmittedEntries_SubmittedEntries entry, CancellationToken cancellationToken) { - return Task.CompletedTask; + await _router.Navigate($"workshop/library/submissions/{entry.Id}"); } private async Task GetEntries(CancellationToken ct) @@ -103,6 +105,4 @@ public class SubmissionsTabViewModel : RoutableScreen IsLoading = false; } } - - public EntryType? EntryType => null; } \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/Library/WorkshopLibraryVIew.axaml b/src/Artemis.UI/Screens/Workshop/Library/WorkshopLibraryVIew.axaml index 1dafa387c..0c9eff517 100644 --- a/src/Artemis.UI/Screens/Workshop/Library/WorkshopLibraryVIew.axaml +++ b/src/Artemis.UI/Screens/Workshop/Library/WorkshopLibraryVIew.axaml @@ -8,7 +8,13 @@ mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" x:Class="Artemis.UI.Screens.Workshop.Library.WorkshopLibraryView" x:DataType="library:WorkshopLibraryViewModel"> - + - - - - - - - - - - - - - - - - - - - - Icon required - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - At least one category is required - - - - - - - - - - Markdown supported, a better editor planned - + + - - \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/SpecificationsStepViewModel.cs b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/SpecificationsStepViewModel.cs index 495e5d591..ebb75cd42 100644 --- a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/SpecificationsStepViewModel.cs +++ b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/SpecificationsStepViewModel.cs @@ -1,93 +1,42 @@ using System; using System.Collections.Generic; -using System.Collections.ObjectModel; using System.IO; using System.Linq; using System.Reactive; using System.Reactive.Disposables; -using System.Reactive.Linq; -using System.Threading.Tasks; using Artemis.UI.Extensions; -using Artemis.UI.Screens.Workshop.Categories; +using Artemis.UI.Screens.Workshop.Entries; using Artemis.UI.Screens.Workshop.SubmissionWizard.Steps.Profile; -using Artemis.UI.Shared.Services; using Artemis.WebClient.Workshop; using Avalonia.Threading; using DynamicData; -using DynamicData.Aggregation; -using DynamicData.Binding; using ReactiveUI; using ReactiveUI.Validation.Extensions; -using ReactiveUI.Validation.Helpers; -using StrawberryShake; -using Bitmap = Avalonia.Media.Imaging.Bitmap; namespace Artemis.UI.Screens.Workshop.SubmissionWizard.Steps; public class SpecificationsStepViewModel : SubmissionViewModel { - private readonly IWindowService _windowService; - private ObservableAsPropertyHelper? _categoriesValid; - private ObservableAsPropertyHelper? _iconValid; - private string _description = string.Empty; - private string _name = string.Empty; - private string _summary = string.Empty; - private Bitmap? _iconBitmap; - - public SpecificationsStepViewModel(IWorkshopClient workshopClient, IWindowService windowService) + public SpecificationsStepViewModel(EntrySpecificationsViewModel entrySpecificationsViewModel) { - _windowService = windowService; + EntrySpecificationsViewModel = entrySpecificationsViewModel; GoBack = ReactiveCommand.Create(ExecuteGoBack); - Continue = ReactiveCommand.Create(ExecuteContinue, ValidationContext.Valid); - SelectIcon = ReactiveCommand.CreateFromTask(ExecuteSelectIcon); + Continue = ReactiveCommand.Create(ExecuteContinue, EntrySpecificationsViewModel.ValidationContext.Valid); - this.WhenActivated(d => + this.WhenActivated((CompositeDisposable d) => { DisplayName = $"{State.EntryType} Information"; - // Load categories - Observable.FromAsync(workshopClient.GetCategories.ExecuteAsync).Subscribe(PopulateCategories).DisposeWith(d); - // Apply the state ApplyFromState(); - this.ClearValidationRules(); - Disposable.Create(ClearIcon).DisposeWith(d); + EntrySpecificationsViewModel.ClearValidationRules(); }); } + public EntrySpecificationsViewModel EntrySpecificationsViewModel { get; } public override ReactiveCommand Continue { get; } public override ReactiveCommand GoBack { get; } - public ReactiveCommand SelectIcon { get; } - - public ObservableCollection Categories { get; } = new(); - public ObservableCollection Tags { get; } = new(); - public bool CategoriesValid => _categoriesValid?.Value ?? true; - public bool IconValid => _iconValid?.Value ?? true; - - public string Name - { - get => _name; - set => RaiseAndSetIfChanged(ref _name, value); - } - - public string Summary - { - get => _summary; - set => RaiseAndSetIfChanged(ref _summary, value); - } - - public string Description - { - get => _description; - set => RaiseAndSetIfChanged(ref _description, value); - } - - public Bitmap? IconBitmap - { - get => _iconBitmap; - set => RaiseAndSetIfChanged(ref _iconBitmap, value); - } private void ExecuteGoBack() { @@ -110,101 +59,61 @@ public class SpecificationsStepViewModel : SubmissionViewModel private void ExecuteContinue() { - if (!ValidationContext.Validations.Any()) + if (!EntrySpecificationsViewModel.ValidationContext.Validations.Any()) { // The ValidationContext seems to update asynchronously, so stop and schedule a retry - SetupDataValidation(); + EntrySpecificationsViewModel.SetupDataValidation(); Dispatcher.UIThread.Post(ExecuteContinue); return; } ApplyToState(); - - if (!ValidationContext.GetIsValid()) + + if (!EntrySpecificationsViewModel.ValidationContext.GetIsValid()) return; - + State.ChangeScreen(); } - private async Task ExecuteSelectIcon() - { - string[]? result = await _windowService.CreateOpenFileDialog() - .HavingFilter(f => f.WithExtension("png").WithExtension("jpg").WithExtension("bmp").WithName("Bitmap image")) - .ShowAsync(); - - if (result == null) - return; - - IconBitmap?.Dispose(); - IconBitmap = BitmapExtensions.LoadAndResize(result[0], 128); - } - - private void ClearIcon() - { - IconBitmap?.Dispose(); - IconBitmap = null; - } - - private void PopulateCategories(IOperationResult result) - { - Categories.Clear(); - if (result.Data != null) - Categories.AddRange(result.Data.Categories.Select(c => new CategoryViewModel(c) {IsSelected = State.Categories.Contains(c.Id)})); - } - - private void SetupDataValidation() - { - // Hopefully this can be avoided in the future - // https://github.com/reactiveui/ReactiveUI.Validation/discussions/558 - this.ValidationRule(vm => vm.Name, s => !string.IsNullOrWhiteSpace(s), "Name is required"); - this.ValidationRule(vm => vm.Summary, s => !string.IsNullOrWhiteSpace(s), "Summary is required"); - this.ValidationRule(vm => vm.Description, s => !string.IsNullOrWhiteSpace(s), "Description is required"); - - // These don't use inputs that support validation messages, do so manually - ValidationHelper iconRule = this.ValidationRule(vm => vm.IconBitmap, s => s != null, "Icon required"); - ValidationHelper categoriesRule = this.ValidationRule(vm => vm.Categories, Categories.ToObservableChangeSet().AutoRefresh(c => c.IsSelected).Filter(c => c.IsSelected).IsNotEmpty(), - "At least one category must be selected" - ); - _iconValid = iconRule.ValidationChanged.Select(c => c.IsValid).ToProperty(this, vm => vm.IconValid); - _categoriesValid = categoriesRule.ValidationChanged.Select(c => c.IsValid).ToProperty(this, vm => vm.CategoriesValid); - } - private void ApplyFromState() { // Basic fields - Name = State.Name; - Summary = State.Summary; - Description = State.Description; + EntrySpecificationsViewModel.Name = State.Name; + EntrySpecificationsViewModel.Summary = State.Summary; + EntrySpecificationsViewModel.Description = State.Description; // Tags - Tags.Clear(); - Tags.AddRange(State.Tags); + EntrySpecificationsViewModel.Tags.Clear(); + EntrySpecificationsViewModel.Tags.AddRange(State.Tags); + + // Categories + EntrySpecificationsViewModel.PreselectedCategories = State.Categories; // Icon if (State.Icon != null) { State.Icon.Seek(0, SeekOrigin.Begin); - IconBitmap = BitmapExtensions.LoadAndResize(State.Icon, 128); + EntrySpecificationsViewModel.IconBitmap = BitmapExtensions.LoadAndResize(State.Icon, 128); } } private void ApplyToState() { // Basic fields - State.Name = Name; - State.Summary = Summary; - State.Description = Description; + State.Name = EntrySpecificationsViewModel.Name; + State.Summary = EntrySpecificationsViewModel.Summary; + State.Description = EntrySpecificationsViewModel.Description; // Categories and tasks - State.Categories = Categories.Where(c => c.IsSelected).Select(c => c.Id).ToList(); - State.Tags = new List(Tags); + State.Categories = EntrySpecificationsViewModel.Categories.Where(c => c.IsSelected).Select(c => c.Id).ToList(); + State.Tags = new List(EntrySpecificationsViewModel.Tags); // Icon State.Icon?.Dispose(); - if (IconBitmap != null) + if (EntrySpecificationsViewModel.IconBitmap != null) { State.Icon = new MemoryStream(); - IconBitmap.Save(State.Icon); + EntrySpecificationsViewModel.IconBitmap.Save(State.Icon); } else { diff --git a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/UploadStepViewModel.cs b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/UploadStepViewModel.cs index 14e56958a..fc726e908 100644 --- a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/UploadStepViewModel.cs +++ b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/UploadStepViewModel.cs @@ -152,13 +152,13 @@ public class UploadStepViewModel : SubmissionViewModel switch (State.EntryType) { case EntryType.Layout: - await _router.Navigate($"workshop/layouts/{_entryId.Value}"); + await _router.Navigate($"workshop/entries/layouts/{_entryId.Value}"); break; case EntryType.Plugin: - await _router.Navigate($"workshop/plugins/{_entryId.Value}"); + await _router.Navigate($"workshop/entries/plugins/{_entryId.Value}"); break; case EntryType.Profile: - await _router.Navigate($"workshop/profiles/{_entryId.Value}"); + await _router.Navigate($"workshop/entries/profiles/{_entryId.Value}"); break; default: throw new ArgumentOutOfRangeException(); diff --git a/src/Artemis.UI/Services/DebugService.cs b/src/Artemis.UI/Services/DebugService.cs index 138bfa994..62b644f29 100644 --- a/src/Artemis.UI/Services/DebugService.cs +++ b/src/Artemis.UI/Services/DebugService.cs @@ -22,7 +22,8 @@ public class DebugService : IDebugService private void CreateDebugger() { - _debugViewModel = _windowService.ShowWindow(); + _windowService.ShowWindow(out DebugViewModel debugViewModel); + _debugViewModel = debugViewModel; } public void ClearDebugger() diff --git a/src/Artemis.UI/Styles/Artemis.axaml b/src/Artemis.UI/Styles/Artemis.axaml index 0bd25e4aa..a4ac040f9 100644 --- a/src/Artemis.UI/Styles/Artemis.axaml +++ b/src/Artemis.UI/Styles/Artemis.axaml @@ -1,7 +1,9 @@  + xmlns:avalonia="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia" + xmlns:aedit="using:AvaloniaEdit" + xmlns:aedit2="using:AvaloniaEdit.Editing"> @@ -9,6 +11,18 @@ + + + + + + + From bf3d5fc75d791ed66e926f89858f52cb4c3652d6 Mon Sep 17 00:00:00 2001 From: Robert Date: Sun, 3 Sep 2023 16:51:22 +0200 Subject: [PATCH 27/37] UI - Replaced Consolas with Roboto Mono Workshop library - Added most of submission management --- .../Services/Window/ExceptionDialogView.axaml | 2 +- src/Artemis.UI.Shared/Styles/Button.axaml | 29 +++ .../Styles/Controls/DataModelPicker.axaml | 6 +- src/Artemis.UI/Artemis.UI.csproj | 6 +- .../Assets/Fonts/RobotoMono-Bold.ttf | Bin 0 -> 87008 bytes .../Assets/Fonts/RobotoMono-BoldItalic.ttf | Bin 0 -> 94148 bytes .../Assets/Fonts/RobotoMono-Italic.ttf | Bin 0 -> 93904 bytes .../Assets/Fonts/RobotoMono-Regular.ttf | Bin 0 -> 86908 bytes .../Assets/Fonts/RobotoMono-SemiBold.ttf | Bin 0 -> 87076 bytes .../Fonts/RobotoMono-SemiBoldItalic.ttf | Bin 0 -> 93940 bytes src/Artemis.UI/MainWindow.axaml | 3 + src/Artemis.UI/Routing/Routes.cs | 2 +- .../Tabs/DataModel/DataModelDebugView.axaml | 25 ++- .../Debugger/Tabs/Logs/LogsDebugView.axaml | 2 +- .../Tabs/Routing/RoutingDebugView.axaml | 2 +- .../Screens/VisualScripting/CableView.axaml | 10 +- .../Entries/EntrySpecificationsView.axaml | 204 ++++++++++-------- .../Entries/EntrySpecificationsView.axaml.cs | 74 ++++++- .../Entries/EntrySpecificationsViewModel.cs | 29 +-- .../Entries/Windows/MarkdownPreviewView.axaml | 43 ---- .../Windows/MarkdownPreviewView.axaml.cs | 12 -- .../Windows/MarkdownPreviewViewModel.cs | 21 -- .../Library/SubmissionDetailView.axaml | 50 +++++ .../Library/SubmissionDetailView.axaml.cs | 11 + .../Library/SubmissionDetailViewModel.cs | 131 +++++++++++ .../Library/SubmissionsDetailView.axaml | 10 - .../Library/SubmissionsDetailView.axaml.cs | 11 - .../Library/SubmissionsDetailViewModel.cs | 24 --- .../Library/WorkshopLibraryVIew.axaml | 2 +- .../Library/WorkshopLibraryViewModel.cs | 12 +- .../Steps/SpecificationsStepView.axaml | 4 +- src/Artemis.UI/Styles/Artemis.axaml | 3 +- .../Screens/DisplayValueNodeCustomView.axaml | 4 +- .../Artemis.WebClient.Workshop.csproj | 6 + .../Queries/GetSubmittedEntryById.graphql | 17 ++ .../Queries/RemoveEntry.graphql | 5 + src/Artemis.WebClient.Workshop/schema.graphql | 1 + 37 files changed, 487 insertions(+), 274 deletions(-) create mode 100644 src/Artemis.UI/Assets/Fonts/RobotoMono-Bold.ttf create mode 100644 src/Artemis.UI/Assets/Fonts/RobotoMono-BoldItalic.ttf create mode 100644 src/Artemis.UI/Assets/Fonts/RobotoMono-Italic.ttf create mode 100644 src/Artemis.UI/Assets/Fonts/RobotoMono-Regular.ttf create mode 100644 src/Artemis.UI/Assets/Fonts/RobotoMono-SemiBold.ttf create mode 100644 src/Artemis.UI/Assets/Fonts/RobotoMono-SemiBoldItalic.ttf delete mode 100644 src/Artemis.UI/Screens/Workshop/Entries/Windows/MarkdownPreviewView.axaml delete mode 100644 src/Artemis.UI/Screens/Workshop/Entries/Windows/MarkdownPreviewView.axaml.cs delete mode 100644 src/Artemis.UI/Screens/Workshop/Entries/Windows/MarkdownPreviewViewModel.cs create mode 100644 src/Artemis.UI/Screens/Workshop/Library/SubmissionDetailView.axaml create mode 100644 src/Artemis.UI/Screens/Workshop/Library/SubmissionDetailView.axaml.cs create mode 100644 src/Artemis.UI/Screens/Workshop/Library/SubmissionDetailViewModel.cs delete mode 100644 src/Artemis.UI/Screens/Workshop/Library/SubmissionsDetailView.axaml delete mode 100644 src/Artemis.UI/Screens/Workshop/Library/SubmissionsDetailView.axaml.cs delete mode 100644 src/Artemis.UI/Screens/Workshop/Library/SubmissionsDetailViewModel.cs create mode 100644 src/Artemis.WebClient.Workshop/Queries/GetSubmittedEntryById.graphql create mode 100644 src/Artemis.WebClient.Workshop/Queries/RemoveEntry.graphql diff --git a/src/Artemis.UI.Shared/Services/Window/ExceptionDialogView.axaml b/src/Artemis.UI.Shared/Services/Window/ExceptionDialogView.axaml index 3bb950a0f..b228ebe2f 100644 --- a/src/Artemis.UI.Shared/Services/Window/ExceptionDialogView.axaml +++ b/src/Artemis.UI.Shared/Services/Window/ExceptionDialogView.axaml @@ -32,7 +32,7 @@ diff --git a/src/Artemis.UI.Shared/Styles/Button.axaml b/src/Artemis.UI.Shared/Styles/Button.axaml index dbecb197c..12863d150 100644 --- a/src/Artemis.UI.Shared/Styles/Button.axaml +++ b/src/Artemis.UI.Shared/Styles/Button.axaml @@ -43,6 +43,10 @@ + + @@ -104,4 +108,29 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/src/Artemis.UI.Shared/Styles/Controls/DataModelPicker.axaml b/src/Artemis.UI.Shared/Styles/Controls/DataModelPicker.axaml index 662a1109f..d2757ca40 100644 --- a/src/Artemis.UI.Shared/Styles/Controls/DataModelPicker.axaml +++ b/src/Artemis.UI.Shared/Styles/Controls/DataModelPicker.axaml @@ -66,7 +66,7 @@ @@ -81,7 +81,7 @@ IsVisible="{CompiledBinding IsEventPicker, RelativeSource={RelativeSource FindAncestor, AncestorType={x:Type dataModelPicker:DataModelPicker}}}"/> - + @@ -90,7 +90,7 @@ diff --git a/src/Artemis.UI/Artemis.UI.csproj b/src/Artemis.UI/Artemis.UI.csproj index fd7f60a06..c1b71c6f2 100644 --- a/src/Artemis.UI/Artemis.UI.csproj +++ b/src/Artemis.UI/Artemis.UI.csproj @@ -61,9 +61,13 @@ LayoutListView.axaml Code - + SubmissionsDetailView.axaml Code + + + + \ No newline at end of file diff --git a/src/Artemis.UI/Assets/Fonts/RobotoMono-Bold.ttf b/src/Artemis.UI/Assets/Fonts/RobotoMono-Bold.ttf new file mode 100644 index 0000000000000000000000000000000000000000..8eff26c1e4c2db1fddb6bb4214cb9210ecc73b49 GIT binary patch literal 87008 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

|D#_5b?_xd;YLNd!&wRU5ruCR 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 Yf6Pr_U>wykuR_E%)A70ts$}r>Z_omeuK)l5 literal 0 HcmV?d00001 diff --git a/src/Artemis.UI/Assets/Fonts/RobotoMono-Regular.ttf b/src/Artemis.UI/Assets/Fonts/RobotoMono-Regular.ttf new file mode 100644 index 0000000000000000000000000000000000000000..d9371a1bd81110dc267ab390a2a4b4ab43a3ae3b GIT binary patch literal 86908 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� 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

nJbwj1L6l!)aZL? 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 YQI(|_t78b>V!a>q5PKKkkG)d<7yH$AZvX%Q literal 0 HcmV?d00001 diff --git a/src/Artemis.UI/Assets/Fonts/RobotoMono-SemiBoldItalic.ttf b/src/Artemis.UI/Assets/Fonts/RobotoMono-SemiBoldItalic.ttf new file mode 100644 index 0000000000000000000000000000000000000000..72b0fc6d96409e969aa86d90628148edd26c05f7 GIT binary patch literal 93940 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 literal 0 HcmV?d00001 diff --git a/src/Artemis.UI/MainWindow.axaml b/src/Artemis.UI/MainWindow.axaml index 307ad0b56..70f2b258b 100644 --- a/src/Artemis.UI/MainWindow.axaml +++ b/src/Artemis.UI/MainWindow.axaml @@ -12,6 +12,9 @@ MinWidth="600" MinHeight="400" PointerReleased="InputElement_OnPointerReleased"> + + + - - - + + + + + + + + - - - - - - - - - + + + + + + + + + - - - Icon required - - - - - + + + Icon required + + + + + - - - + + + + + + + + + + + + + + + + + + + + + + + + + At least one category is required + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - At least one category is required - - - + + Synchronized scrolling + + - - - - - - - - Markdown supported, a better editor planned - - - - + \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/Entries/EntrySpecificationsView.axaml.cs b/src/Artemis.UI/Screens/Workshop/Entries/EntrySpecificationsView.axaml.cs index e8155b0de..b3289a3bf 100644 --- a/src/Artemis.UI/Screens/Workshop/Entries/EntrySpecificationsView.axaml.cs +++ b/src/Artemis.UI/Screens/Workshop/Entries/EntrySpecificationsView.axaml.cs @@ -1,34 +1,100 @@ +using System.Linq; +using Artemis.UI.Shared.Extensions; using Avalonia; using Avalonia.Controls; using Avalonia.Media; using Avalonia.Media.Immutable; using Avalonia.ReactiveUI; using AvaloniaEdit.TextMate; +using ReactiveUI; using TextMateSharp.Grammars; namespace Artemis.UI.Screens.Workshop.Entries; public partial class EntrySpecificationsView : ReactiveUserControl { + private ScrollViewer? _editorScrollViewer; + private ScrollViewer? _previewScrollViewer; + private bool _updating; + public EntrySpecificationsView() { InitializeComponent(); + DescriptionEditor.Options.AllowScrollBelowDocument = false; RegistryOptions options = new(ThemeName.Dark); TextMate.Installation? install = DescriptionEditor.InstallTextMate(options); install.SetGrammar(options.GetScopeByExtension(".md")); + + this.WhenActivated(_ => SetupScrollSync()); } - #region Overrides of Visual - - /// protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) { if (this.TryFindResource("SystemAccentColorLight3", out object? resource) && resource is Color color) DescriptionEditor.TextArea.TextView.LinkTextForegroundBrush = new ImmutableSolidColorBrush(color); + base.OnAttachedToVisualTree(e); } - #endregion + private void SetupScrollSync() + { + if (_editorScrollViewer != null) + _editorScrollViewer.PropertyChanged -= EditorScrollViewerOnPropertyChanged; + if (_previewScrollViewer != null) + _previewScrollViewer.PropertyChanged -= PreviewScrollViewerOnPropertyChanged; + + _editorScrollViewer = DescriptionEditor.GetVisualChildrenOfType().FirstOrDefault(); + _previewScrollViewer = DescriptionPreview.GetVisualChildrenOfType().FirstOrDefault(); + + if (_editorScrollViewer != null) + _editorScrollViewer.PropertyChanged += EditorScrollViewerOnPropertyChanged; + if (_previewScrollViewer != null) + _previewScrollViewer.PropertyChanged += PreviewScrollViewerOnPropertyChanged; + } + + private void EditorScrollViewerOnPropertyChanged(object? sender, AvaloniaPropertyChangedEventArgs e) + { + if (e.Property.Name != nameof(ScrollViewer.Offset) || _updating || SynchronizedScrolling.IsChecked != true) + return; + + try + { + _updating = true; + SynchronizeScrollViewers(_editorScrollViewer, _previewScrollViewer); + } + finally + { + _updating = false; + } + } + + private void PreviewScrollViewerOnPropertyChanged(object? sender, AvaloniaPropertyChangedEventArgs e) + { + if (e.Property.Name != nameof(ScrollViewer.Offset) || _updating || SynchronizedScrolling.IsChecked != true) + return; + + try + { + _updating = true; + SynchronizeScrollViewers(_previewScrollViewer, _editorScrollViewer); + } + finally + { + _updating = false; + } + } + + private void SynchronizeScrollViewers(ScrollViewer? source, ScrollViewer? target) + { + if (source == null || target == null) + return; + + double sourceScrollableHeight = source.Extent.Height - source.Viewport.Height; + double targetScrollableHeight = target.Extent.Height - target.Viewport.Height; + + if (sourceScrollableHeight != 0) + target.Offset = new Vector(target.Offset.X, targetScrollableHeight * (source.Offset.Y / sourceScrollableHeight)); + } } \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/Entries/EntrySpecificationsViewModel.cs b/src/Artemis.UI/Screens/Workshop/Entries/EntrySpecificationsViewModel.cs index a4b3f439c..af20fa4b4 100644 --- a/src/Artemis.UI/Screens/Workshop/Entries/EntrySpecificationsViewModel.cs +++ b/src/Artemis.UI/Screens/Workshop/Entries/EntrySpecificationsViewModel.cs @@ -5,15 +5,12 @@ using System.Linq; using System.Reactive; using System.Reactive.Disposables; using System.Reactive.Linq; -using System.Threading; using System.Threading.Tasks; using Artemis.UI.Extensions; using Artemis.UI.Screens.Workshop.Categories; -using Artemis.UI.Screens.Workshop.Entries.Windows; using Artemis.UI.Shared; using Artemis.UI.Shared.Services; using Artemis.WebClient.Workshop; -using Avalonia.Controls; using AvaloniaEdit.Document; using DynamicData; using DynamicData.Aggregation; @@ -35,16 +32,13 @@ public class EntrySpecificationsViewModel : ValidatableViewModelBase private string _name = string.Empty; private string _summary = string.Empty; private Bitmap? _iconBitmap; - private Window? _previewWindow; private TextDocument? _markdownDocument; public EntrySpecificationsViewModel(IWorkshopClient workshopClient, IWindowService windowService) { _windowService = windowService; SelectIcon = ReactiveCommand.CreateFromTask(ExecuteSelectIcon); - OpenMarkdownPreview = ReactiveCommand.Create(ExecuteOpenMarkdownPreview); - // this.WhenAnyValue(vm => vm.Description).Subscribe(d => MarkdownDocument.Text = d); this.WhenActivated(d => { // Load categories @@ -56,7 +50,6 @@ public class EntrySpecificationsViewModel : ValidatableViewModelBase MarkdownDocument.TextChanged += MarkdownDocumentOnTextChanged; Disposable.Create(() => { - _previewWindow?.Close(); MarkdownDocument.TextChanged -= MarkdownDocumentOnTextChanged; MarkdownDocument = null; ClearIcon(); @@ -66,11 +59,10 @@ public class EntrySpecificationsViewModel : ValidatableViewModelBase private void MarkdownDocumentOnTextChanged(object? sender, EventArgs e) { - Description = MarkdownDocument.Text; + Description = MarkdownDocument?.Text ?? string.Empty; } public ReactiveCommand SelectIcon { get; } - public ReactiveCommand OpenMarkdownPreview { get; } public ObservableCollection Categories { get; } = new(); public ObservableCollection Tags { get; } = new(); @@ -139,25 +131,6 @@ public class EntrySpecificationsViewModel : ValidatableViewModelBase IconBitmap = BitmapExtensions.LoadAndResize(result[0], 128); } - private void ExecuteOpenMarkdownPreview() - { - if (_previewWindow != null) - { - _previewWindow.Activate(); - return; - } - - _previewWindow = _windowService.ShowWindow(out MarkdownPreviewViewModel _, this.WhenAnyValue(vm => vm.Description)); - _previewWindow.Closed += PreviewWindowOnClosed; - } - - private void PreviewWindowOnClosed(object? sender, EventArgs e) - { - if (_previewWindow != null) - _previewWindow.Closed -= PreviewWindowOnClosed; - _previewWindow = null; - } - private void ClearIcon() { IconBitmap?.Dispose(); diff --git a/src/Artemis.UI/Screens/Workshop/Entries/Windows/MarkdownPreviewView.axaml b/src/Artemis.UI/Screens/Workshop/Entries/Windows/MarkdownPreviewView.axaml deleted file mode 100644 index 8898bef0a..000000000 --- a/src/Artemis.UI/Screens/Workshop/Entries/Windows/MarkdownPreviewView.axaml +++ /dev/null @@ -1,43 +0,0 @@ - - - - Markdown Previewer - - In this window you can preview the Markdown you're writing in the main window of the application. - - The preview updates realtime, so it might be a good idea to keep this window visible while you're typing. - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/Entries/Windows/MarkdownPreviewView.axaml.cs b/src/Artemis.UI/Screens/Workshop/Entries/Windows/MarkdownPreviewView.axaml.cs deleted file mode 100644 index ec5b62868..000000000 --- a/src/Artemis.UI/Screens/Workshop/Entries/Windows/MarkdownPreviewView.axaml.cs +++ /dev/null @@ -1,12 +0,0 @@ - -using Artemis.UI.Shared; - -namespace Artemis.UI.Screens.Workshop.Entries.Windows; - -public partial class MarkdownPreviewView : ReactiveAppWindow -{ - public MarkdownPreviewView() - { - InitializeComponent(); - } -} \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/Entries/Windows/MarkdownPreviewViewModel.cs b/src/Artemis.UI/Screens/Workshop/Entries/Windows/MarkdownPreviewViewModel.cs deleted file mode 100644 index 4e8680d0e..000000000 --- a/src/Artemis.UI/Screens/Workshop/Entries/Windows/MarkdownPreviewViewModel.cs +++ /dev/null @@ -1,21 +0,0 @@ -using System; -using Artemis.UI.Shared; - -namespace Artemis.UI.Screens.Workshop.Entries.Windows; - -public class MarkdownPreviewViewModel : ActivatableViewModelBase -{ - public event EventHandler? Closed; - - public IObservable Markdown { get; } - - public MarkdownPreviewViewModel(IObservable markdown) - { - Markdown = markdown; - } - - protected virtual void OnClosed() - { - Closed?.Invoke(this, EventArgs.Empty); - } -} \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/Library/SubmissionDetailView.axaml b/src/Artemis.UI/Screens/Workshop/Library/SubmissionDetailView.axaml new file mode 100644 index 000000000..4967d463b --- /dev/null +++ b/src/Artemis.UI/Screens/Workshop/Library/SubmissionDetailView.axaml @@ -0,0 +1,50 @@ + + + + + + + + + Management + + + + + + downloads + + + + + Created + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/Library/SubmissionDetailView.axaml.cs b/src/Artemis.UI/Screens/Workshop/Library/SubmissionDetailView.axaml.cs new file mode 100644 index 000000000..729f02a2b --- /dev/null +++ b/src/Artemis.UI/Screens/Workshop/Library/SubmissionDetailView.axaml.cs @@ -0,0 +1,11 @@ +using Avalonia.ReactiveUI; + +namespace Artemis.UI.Screens.Workshop.Library; + +public partial class SubmissionDetailView : ReactiveUserControl +{ + public SubmissionDetailView() + { + InitializeComponent(); + } +} \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/Library/SubmissionDetailViewModel.cs b/src/Artemis.UI/Screens/Workshop/Library/SubmissionDetailViewModel.cs new file mode 100644 index 000000000..869080c80 --- /dev/null +++ b/src/Artemis.UI/Screens/Workshop/Library/SubmissionDetailViewModel.cs @@ -0,0 +1,131 @@ +using System; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Reactive; +using System.Threading; +using System.Threading.Tasks; +using Artemis.UI.Screens.Workshop.Entries; +using Artemis.UI.Screens.Workshop.Parameters; +using Artemis.UI.Shared.Routing; +using Artemis.UI.Shared.Services; +using Artemis.WebClient.Workshop; +using Avalonia.Media.Imaging; +using ReactiveUI; +using StrawberryShake; + +namespace Artemis.UI.Screens.Workshop.Library; + +public class SubmissionDetailViewModel : RoutableScreen +{ + private readonly IWorkshopClient _client; + private readonly IHttpClientFactory _httpClientFactory; + private readonly Func _getEntrySpecificationsViewModel; + private readonly IWindowService _windowService; + private readonly IRouter _router; + private IGetSubmittedEntryById_Entry? _entry; + private EntrySpecificationsViewModel? _entrySpecificationsViewModel; + + public SubmissionDetailViewModel(IWorkshopClient client, + IHttpClientFactory httpClientFactory, + Func entrySpecificationsViewModel, + IWindowService windowService, + IRouter router) + { + _client = client; + _httpClientFactory = httpClientFactory; + _getEntrySpecificationsViewModel = entrySpecificationsViewModel; + _windowService = windowService; + _router = router; + + CreateRelease = ReactiveCommand.CreateFromTask(ExecuteCreateRelease); + DeleteSubmission = ReactiveCommand.CreateFromTask(ExecuteDeleteSubmission); + } + + public ReactiveCommand CreateRelease { get; } + public ReactiveCommand DeleteSubmission { get; } + + public EntrySpecificationsViewModel? EntrySpecificationsViewModel + { + get => _entrySpecificationsViewModel; + set => RaiseAndSetIfChanged(ref _entrySpecificationsViewModel, value); + } + + public IGetSubmittedEntryById_Entry? Entry + { + get => _entry; + set => RaiseAndSetIfChanged(ref _entry, value); + } + + public override async Task OnNavigating(WorkshopDetailParameters parameters, NavigationArguments args, CancellationToken cancellationToken) + { + IOperationResult result = await _client.GetSubmittedEntryById.ExecuteAsync(parameters.EntryId, cancellationToken); + if (result.IsErrorResult()) + return; + + Entry = result.Data?.Entry; + await ApplyFromEntry(cancellationToken); + } + + private async Task ApplyFromEntry(CancellationToken cancellationToken) + { + if (Entry == null) + return; + + EntrySpecificationsViewModel viewModel = _getEntrySpecificationsViewModel(); + + viewModel.IconBitmap = await GetEntryIcon(cancellationToken); + viewModel.Name = Entry.Name; + viewModel.Summary = Entry.Summary; + viewModel.Description = Entry.Description; + viewModel.PreselectedCategories = Entry.Categories.Select(c => c.Id).ToList(); + + viewModel.Tags.Clear(); + foreach (string tag in Entry.Tags.Select(c => c.Name)) + viewModel.Tags.Add(tag); + + EntrySpecificationsViewModel = viewModel; + } + + private async Task GetEntryIcon(CancellationToken cancellationToken) + { + if (Entry == null) + return null; + + HttpClient client = _httpClientFactory.CreateClient(WorkshopConstants.WORKSHOP_CLIENT_NAME); + try + { + HttpResponseMessage response = await client.GetAsync($"entries/{Entry.Id}/icon", cancellationToken); + response.EnsureSuccessStatusCode(); + Stream data = await response.Content.ReadAsStreamAsync(cancellationToken); + return new Bitmap(data); + } + catch (HttpRequestException) + { + // ignored + return null; + } + } + + private Task ExecuteCreateRelease(CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + + private async Task ExecuteDeleteSubmission(CancellationToken cancellationToken) + { + if (Entry == null) + return; + + bool confirmed = await _windowService.ShowConfirmContentDialog( + "Delete submission?", + "You cannot undo this by yourself.\r\n" + + "Users that have already downloaded your submission will keep it."); + if (!confirmed) + return; + + IOperationResult result = await _client.RemoveEntry.ExecuteAsync(Entry.Id, cancellationToken); + result.EnsureNoErrors(); + await _router.Navigate("workshop/library/submissions"); + } +} \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/Library/SubmissionsDetailView.axaml b/src/Artemis.UI/Screens/Workshop/Library/SubmissionsDetailView.axaml deleted file mode 100644 index ca36904b6..000000000 --- a/src/Artemis.UI/Screens/Workshop/Library/SubmissionsDetailView.axaml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/src/Artemis.UI/Screens/Workshop/Library/SubmissionsDetailView.axaml.cs b/src/Artemis.UI/Screens/Workshop/Library/SubmissionsDetailView.axaml.cs deleted file mode 100644 index bca7ef914..000000000 --- a/src/Artemis.UI/Screens/Workshop/Library/SubmissionsDetailView.axaml.cs +++ /dev/null @@ -1,11 +0,0 @@ -using Avalonia.ReactiveUI; - -namespace Artemis.UI.Screens.Workshop.Library; - -public partial class SubmissionsDetailView : ReactiveUserControl -{ - public SubmissionsDetailView() - { - InitializeComponent(); - } -} \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/Library/SubmissionsDetailViewModel.cs b/src/Artemis.UI/Screens/Workshop/Library/SubmissionsDetailViewModel.cs deleted file mode 100644 index 283a490e7..000000000 --- a/src/Artemis.UI/Screens/Workshop/Library/SubmissionsDetailViewModel.cs +++ /dev/null @@ -1,24 +0,0 @@ -using System; -using System.Threading; -using System.Threading.Tasks; -using Artemis.UI.Screens.Workshop.Entries; -using Artemis.UI.Screens.Workshop.Parameters; -using Artemis.UI.Shared.Routing; - -namespace Artemis.UI.Screens.Workshop.Library; - -public class SubmissionsDetailViewModel : RoutableScreen -{ - public EntrySpecificationsViewModel EntrySpecificationsViewModel { get; } - - public SubmissionsDetailViewModel(EntrySpecificationsViewModel entrySpecificationsViewModel) - { - EntrySpecificationsViewModel = entrySpecificationsViewModel; - } - - public override Task OnNavigating(WorkshopDetailParameters parameters, NavigationArguments args, CancellationToken cancellationToken) - { - Console.WriteLine(parameters.EntryId); - return Task.CompletedTask; - } -} \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/Library/WorkshopLibraryVIew.axaml b/src/Artemis.UI/Screens/Workshop/Library/WorkshopLibraryVIew.axaml index 0c9eff517..af8f455a4 100644 --- a/src/Artemis.UI/Screens/Workshop/Library/WorkshopLibraryVIew.axaml +++ b/src/Artemis.UI/Screens/Workshop/Library/WorkshopLibraryVIew.axaml @@ -11,7 +11,7 @@ diff --git a/src/Artemis.UI/Screens/Workshop/Library/WorkshopLibraryViewModel.cs b/src/Artemis.UI/Screens/Workshop/Library/WorkshopLibraryViewModel.cs index d9f1a6c99..56abc8ffd 100644 --- a/src/Artemis.UI/Screens/Workshop/Library/WorkshopLibraryViewModel.cs +++ b/src/Artemis.UI/Screens/Workshop/Library/WorkshopLibraryViewModel.cs @@ -15,7 +15,7 @@ public class WorkshopLibraryViewModel : RoutableHostScreen { private readonly IRouter _router; private RouteViewModel? _selectedTab; - private ObservableAsPropertyHelper? _canGoBack; + private ObservableAsPropertyHelper? _viewingDetails; /// public WorkshopLibraryViewModel(IRouter router) @@ -30,8 +30,7 @@ public class WorkshopLibraryViewModel : RoutableHostScreen this.WhenActivated(d => { - // Show back button on details page - _canGoBack = _router.CurrentPath.Select(p => p != null && p.StartsWith("workshop/library/submissions/")).ToProperty(this, vm => vm.CanGoBack).DisposeWith(d); + _viewingDetails = _router.CurrentPath.Select(p => p != null && p.StartsWith("workshop/library/submissions/")).ToProperty(this, vm => vm.ViewingDetails).DisposeWith(d); // Navigate on tab change this.WhenAnyValue(vm => vm.SelectedTab) .WhereNotNull() @@ -40,7 +39,7 @@ public class WorkshopLibraryViewModel : RoutableHostScreen }); } - public bool CanGoBack => _canGoBack?.Value ?? false; + public bool ViewingDetails => _viewingDetails?.Value ?? false; public ObservableCollection Tabs { get; } public RouteViewModel? SelectedTab @@ -58,6 +57,9 @@ public class WorkshopLibraryViewModel : RoutableHostScreen public void GoBack() { - _router.GoBack(); + if (ViewingDetails) + _router.GoBack(); + else + _router.Navigate("workshop"); } } \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/SpecificationsStepView.axaml b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/SpecificationsStepView.axaml index 5573ca5c1..18e53f90a 100644 --- a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/SpecificationsStepView.axaml +++ b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/SpecificationsStepView.axaml @@ -21,8 +21,6 @@ - - - + \ No newline at end of file diff --git a/src/Artemis.UI/Styles/Artemis.axaml b/src/Artemis.UI/Styles/Artemis.axaml index a4ac040f9..55109b2d3 100644 --- a/src/Artemis.UI/Styles/Artemis.axaml +++ b/src/Artemis.UI/Styles/Artemis.axaml @@ -15,7 +15,7 @@ @@ -26,6 +26,7 @@ + avares://Artemis.UI/Assets/Fonts#Roboto Mono diff --git a/src/Artemis.VisualScripting/Nodes/Static/Screens/DisplayValueNodeCustomView.axaml b/src/Artemis.VisualScripting/Nodes/Static/Screens/DisplayValueNodeCustomView.axaml index 8ddaf5f77..014b2bdb5 100644 --- a/src/Artemis.VisualScripting/Nodes/Static/Screens/DisplayValueNodeCustomView.axaml +++ b/src/Artemis.VisualScripting/Nodes/Static/Screens/DisplayValueNodeCustomView.axaml @@ -25,7 +25,7 @@ Text="{CompiledBinding Converter={StaticResource SKColorToStringConverter}}" VerticalAlignment="Center" HorizontalAlignment="Stretch" - FontFamily="Consolas"/> + FontFamily="{StaticResource RobotoMono}"/> - + diff --git a/src/Artemis.WebClient.Workshop/Artemis.WebClient.Workshop.csproj b/src/Artemis.WebClient.Workshop/Artemis.WebClient.Workshop.csproj index d2838936b..32d841d41 100644 --- a/src/Artemis.WebClient.Workshop/Artemis.WebClient.Workshop.csproj +++ b/src/Artemis.WebClient.Workshop/Artemis.WebClient.Workshop.csproj @@ -38,5 +38,11 @@ MSBuild:GenerateGraphQLCode + + MSBuild:GenerateGraphQLCode + + + MSBuild:GenerateGraphQLCode + diff --git a/src/Artemis.WebClient.Workshop/Queries/GetSubmittedEntryById.graphql b/src/Artemis.WebClient.Workshop/Queries/GetSubmittedEntryById.graphql new file mode 100644 index 000000000..92da65c60 --- /dev/null +++ b/src/Artemis.WebClient.Workshop/Queries/GetSubmittedEntryById.graphql @@ -0,0 +1,17 @@ +query GetSubmittedEntryById($id: UUID!) { + entry(id: $id) { + id + name + summary + entryType + downloads + createdAt + description + categories { + id + } + tags { + name + } + } +} \ No newline at end of file diff --git a/src/Artemis.WebClient.Workshop/Queries/RemoveEntry.graphql b/src/Artemis.WebClient.Workshop/Queries/RemoveEntry.graphql new file mode 100644 index 000000000..4f46da2c5 --- /dev/null +++ b/src/Artemis.WebClient.Workshop/Queries/RemoveEntry.graphql @@ -0,0 +1,5 @@ +mutation RemoveEntry ($id: UUID!) { + removeEntry(id: $id) { + id + } +} diff --git a/src/Artemis.WebClient.Workshop/schema.graphql b/src/Artemis.WebClient.Workshop/schema.graphql index bed72a4a2..52b3ebb56 100644 --- a/src/Artemis.WebClient.Workshop/schema.graphql +++ b/src/Artemis.WebClient.Workshop/schema.graphql @@ -55,6 +55,7 @@ type Image { type Mutation { addEntry(input: CreateEntryInput!): Entry + removeEntry(id: UUID!): Entry updateEntry(input: UpdateEntryInput!): Entry } From 2ee170b80374d847466229a3a3bda91a9c5807f7 Mon Sep 17 00:00:00 2001 From: Robert Date: Mon, 4 Sep 2023 20:30:57 +0200 Subject: [PATCH 28/37] Workshop - Fixed deep linking to an entry Workshop - Added the ability to upload new releases to existing submissions --- .../Routing/Router/Navigation.cs | 6 +- .../Routing/Router/Router.cs | 8 +- .../Routing/Router/RouterNavigationOptions.cs | 24 ++++ src/Artemis.UI/Routing/RouteViewModel.cs | 8 +- .../Workshop/Entries/EntriesViewModel.cs | 2 +- .../Workshop/Home/WorkshopHomeViewModel.cs | 8 +- .../Library/SubmissionDetailView.axaml | 4 + .../Library/SubmissionDetailViewModel.cs | 38 ++++- .../Library/Tabs/SubmissionsTabViewModel.cs | 2 +- .../IWorkshopWizardViewModel.cs | 7 + .../SubmissionWizard/ReleaseWizardView.axaml | 61 ++++++++ .../ReleaseWizardView.axaml.cs | 35 +++++ .../ReleaseWizardViewModel.cs | 51 +++++++ .../Steps/EntryTypeStepViewModel.cs | 3 +- .../ProfileAdaptionHintsStepViewModel.cs | 5 +- .../Profile/ProfileSelectionStepViewModel.cs | 43 +++--- .../Steps/UploadStepViewModel.cs | 136 ++++++++++-------- .../SubmissionWizard/SubmissionViewModel.cs | 1 - .../SubmissionWizard/SubmissionWizardState.cs | 17 ++- .../SubmissionWizardView.axaml | 2 +- .../SubmissionWizardView.axaml.cs | 2 + .../SubmissionWizardViewModel.cs | 16 ++- .../Artemis.WebClient.Workshop.csproj | 3 + .../Queries/UpdateEntry.graphql | 5 + .../Services/IWorkshopService.cs | 21 ++- 25 files changed, 375 insertions(+), 133 deletions(-) create mode 100644 src/Artemis.UI/Screens/Workshop/SubmissionWizard/IWorkshopWizardViewModel.cs create mode 100644 src/Artemis.UI/Screens/Workshop/SubmissionWizard/ReleaseWizardView.axaml create mode 100644 src/Artemis.UI/Screens/Workshop/SubmissionWizard/ReleaseWizardView.axaml.cs create mode 100644 src/Artemis.UI/Screens/Workshop/SubmissionWizard/ReleaseWizardViewModel.cs create mode 100644 src/Artemis.WebClient.Workshop/Queries/UpdateEntry.graphql diff --git a/src/Artemis.UI.Shared/Routing/Router/Navigation.cs b/src/Artemis.UI.Shared/Routing/Router/Navigation.cs index 50505a706..0d0f6083c 100644 --- a/src/Artemis.UI.Shared/Routing/Router/Navigation.cs +++ b/src/Artemis.UI.Shared/Routing/Router/Navigation.cs @@ -118,11 +118,9 @@ internal class Navigation Completed = true; } - public bool PathEquals(string path, bool allowPartialMatch) + public bool PathEquals(string path, RouterNavigationOptions options) { - if (allowPartialMatch) - return _resolution.Path.StartsWith(path, StringComparison.InvariantCultureIgnoreCase); - return string.Equals(_resolution.Path, path, StringComparison.InvariantCultureIgnoreCase); + return options.PathEquals(_resolution.Path, path); } private bool CancelIfRequested(NavigationArguments args, string stage, object screen) diff --git a/src/Artemis.UI.Shared/Routing/Router/Router.cs b/src/Artemis.UI.Shared/Routing/Router/Router.cs index 84e44c4ac..282511c40 100644 --- a/src/Artemis.UI.Shared/Routing/Router/Router.cs +++ b/src/Artemis.UI.Shared/Routing/Router/Router.cs @@ -58,11 +58,9 @@ internal class Router : CorePropertyChanged, IRouter, IDisposable return false; } - private bool PathEquals(string path, bool allowPartialMatch) + private bool PathEquals(string path, RouterNavigationOptions options) { - if (allowPartialMatch) - return _currentRouteSubject.Value != null && _currentRouteSubject.Value.StartsWith(path, StringComparison.InvariantCultureIgnoreCase); - return string.Equals(_currentRouteSubject.Value, path, StringComparison.InvariantCultureIgnoreCase); + return _currentRouteSubject.Value != null && options.PathEquals(_currentRouteSubject.Value, path); } /// @@ -84,7 +82,7 @@ internal class Router : CorePropertyChanged, IRouter, IDisposable { if (_root == null) throw new ArtemisRoutingException("Cannot navigate without a root having been set"); - if (PathEquals(path, options.IgnoreOnPartialMatch) || (_currentNavigation != null && _currentNavigation.PathEquals(path, options.IgnoreOnPartialMatch))) + if (PathEquals(path, options) || (_currentNavigation != null && _currentNavigation.PathEquals(path, options))) return; string? previousPath = _currentRouteSubject.Value; diff --git a/src/Artemis.UI.Shared/Routing/Router/RouterNavigationOptions.cs b/src/Artemis.UI.Shared/Routing/Router/RouterNavigationOptions.cs index a5c63ab88..273f85c2c 100644 --- a/src/Artemis.UI.Shared/Routing/Router/RouterNavigationOptions.cs +++ b/src/Artemis.UI.Shared/Routing/Router/RouterNavigationOptions.cs @@ -1,3 +1,5 @@ +using System; + namespace Artemis.UI.Shared.Routing; ///

@@ -21,9 +23,31 @@ public class RouterNavigationOptions /// If set to true, a route change from page/subpage1/subpage2 to page/subpage1 will be ignored. public bool IgnoreOnPartialMatch { get; set; } = false; + /// + /// Gets or sets the path to use when determining whether the path is a partial match, + /// only has any effect if is . + /// + public string? PartialMatchOverride { get; set; } + /// /// Gets or sets a boolean value indicating whether logging should be enabled. /// Errors and warnings are always logged. /// public bool EnableLogging { get; set; } = true; + + /// + /// Determines whether the given two paths are considered equal using these navigation options. + /// + /// The current path. + /// The target path. + /// if the paths are considered equal; otherwise . + internal bool PathEquals(string current, string target) + { + if (PartialMatchOverride != null && IgnoreOnPartialMatch) + target = PartialMatchOverride; + + if (IgnoreOnPartialMatch) + return current.StartsWith(target, StringComparison.InvariantCultureIgnoreCase); + return string.Equals(current, target, StringComparison.InvariantCultureIgnoreCase); + } } \ No newline at end of file diff --git a/src/Artemis.UI/Routing/RouteViewModel.cs b/src/Artemis.UI/Routing/RouteViewModel.cs index 78cc458c9..df49bc79c 100644 --- a/src/Artemis.UI/Routing/RouteViewModel.cs +++ b/src/Artemis.UI/Routing/RouteViewModel.cs @@ -4,20 +4,20 @@ namespace Artemis.UI.Routing; public class RouteViewModel { - public RouteViewModel(string name, string path, string? mathPath = null) + public RouteViewModel(string name, string path, string? matchPath = null) { Path = path; Name = name; - MathPath = mathPath; + MatchPath = matchPath; } public string Path { get; } public string Name { get; } - public string? MathPath { get; } + public string? MatchPath { get; } public bool Matches(string path) { - return path.StartsWith(MathPath ?? Path, StringComparison.InvariantCultureIgnoreCase); + return path.StartsWith(MatchPath ?? Path, StringComparison.InvariantCultureIgnoreCase); } /// diff --git a/src/Artemis.UI/Screens/Workshop/Entries/EntriesViewModel.cs b/src/Artemis.UI/Screens/Workshop/Entries/EntriesViewModel.cs index 903a4d649..0d966ba89 100644 --- a/src/Artemis.UI/Screens/Workshop/Entries/EntriesViewModel.cs +++ b/src/Artemis.UI/Screens/Workshop/Entries/EntriesViewModel.cs @@ -34,7 +34,7 @@ public class EntriesViewModel : RoutableHostScreen // Navigate on tab change this.WhenAnyValue(vm => vm.SelectedTab) .WhereNotNull() - .Subscribe(s => router.Navigate(s.Path, new RouterNavigationOptions {IgnoreOnPartialMatch = true})) + .Subscribe(s => router.Navigate(s.Path, new RouterNavigationOptions {IgnoreOnPartialMatch = true, PartialMatchOverride = s.MatchPath})) .DisposeWith(d); }); } diff --git a/src/Artemis.UI/Screens/Workshop/Home/WorkshopHomeViewModel.cs b/src/Artemis.UI/Screens/Workshop/Home/WorkshopHomeViewModel.cs index 07fb24452..a0f82de17 100644 --- a/src/Artemis.UI/Screens/Workshop/Home/WorkshopHomeViewModel.cs +++ b/src/Artemis.UI/Screens/Workshop/Home/WorkshopHomeViewModel.cs @@ -1,15 +1,11 @@ using System.Reactive; -using System.Reactive.Disposables; using System.Threading; using System.Threading.Tasks; using Artemis.UI.Extensions; using Artemis.UI.Screens.Workshop.SubmissionWizard; -using Artemis.UI.Shared; using Artemis.UI.Shared.Routing; using Artemis.UI.Shared.Services; -using Artemis.WebClient.Workshop; using Artemis.WebClient.Workshop.Services; -using Avalonia.Threading; using ReactiveUI; namespace Artemis.UI.Screens.Workshop.Home; @@ -17,13 +13,11 @@ namespace Artemis.UI.Screens.Workshop.Home; public class WorkshopHomeViewModel : RoutableScreen { private readonly IWindowService _windowService; - private readonly IWorkshopService _workshopService; private bool _workshopReachable; public WorkshopHomeViewModel(IRouter router, IWindowService windowService, IWorkshopService workshopService) { _windowService = windowService; - _workshopService = workshopService; AddSubmission = ReactiveCommand.CreateFromTask(ExecuteAddSubmission, this.WhenAnyValue(vm => vm.WorkshopReachable)); Navigate = ReactiveCommand.CreateFromTask(async r => await router.Navigate(r), this.WhenAnyValue(vm => vm.WorkshopReachable)); @@ -42,6 +36,6 @@ public class WorkshopHomeViewModel : RoutableScreen private async Task ExecuteAddSubmission(CancellationToken arg) { - await _windowService.ShowDialogAsync(); + await _windowService.ShowDialogAsync(); } } \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/Library/SubmissionDetailView.axaml b/src/Artemis.UI/Screens/Workshop/Library/SubmissionDetailView.axaml index 4967d463b..c42098365 100644 --- a/src/Artemis.UI/Screens/Workshop/Library/SubmissionDetailView.axaml +++ b/src/Artemis.UI/Screens/Workshop/Library/SubmissionDetailView.axaml @@ -5,6 +5,7 @@ xmlns:library="clr-namespace:Artemis.UI.Screens.Workshop.Library" xmlns:avalonia="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia" xmlns:converters="clr-namespace:Artemis.UI.Converters" + xmlns:controls="clr-namespace:FluentAvalonia.UI.Controls;assembly=FluentAvalonia" mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" x:Class="Artemis.UI.Screens.Workshop.Library.SubmissionDetailView" x:DataType="library:SubmissionDetailViewModel"> @@ -43,6 +44,9 @@ + + View workshop page + diff --git a/src/Artemis.UI/Screens/Workshop/Library/SubmissionDetailViewModel.cs b/src/Artemis.UI/Screens/Workshop/Library/SubmissionDetailViewModel.cs index 869080c80..11d35afb1 100644 --- a/src/Artemis.UI/Screens/Workshop/Library/SubmissionDetailViewModel.cs +++ b/src/Artemis.UI/Screens/Workshop/Library/SubmissionDetailViewModel.cs @@ -7,9 +7,11 @@ using System.Threading; using System.Threading.Tasks; using Artemis.UI.Screens.Workshop.Entries; using Artemis.UI.Screens.Workshop.Parameters; +using Artemis.UI.Screens.Workshop.SubmissionWizard; using Artemis.UI.Shared.Routing; using Artemis.UI.Shared.Services; using Artemis.WebClient.Workshop; +using Artemis.WebClient.Workshop.Services; using Avalonia.Media.Imaging; using ReactiveUI; using StrawberryShake; @@ -22,26 +24,32 @@ public class SubmissionDetailViewModel : RoutableScreen _getEntrySpecificationsViewModel; private readonly IWindowService _windowService; + private readonly IWorkshopService _workshopService; private readonly IRouter _router; private IGetSubmittedEntryById_Entry? _entry; private EntrySpecificationsViewModel? _entrySpecificationsViewModel; public SubmissionDetailViewModel(IWorkshopClient client, - IHttpClientFactory httpClientFactory, - Func entrySpecificationsViewModel, + IHttpClientFactory httpClientFactory, IWindowService windowService, - IRouter router) + IWorkshopService workshopService, + IRouter router, + Func entrySpecificationsViewModel) { _client = client; _httpClientFactory = httpClientFactory; - _getEntrySpecificationsViewModel = entrySpecificationsViewModel; _windowService = windowService; + _workshopService = workshopService; _router = router; + _getEntrySpecificationsViewModel = entrySpecificationsViewModel; + Save = ReactiveCommand.CreateFromTask(ExecuteSave); CreateRelease = ReactiveCommand.CreateFromTask(ExecuteCreateRelease); DeleteSubmission = ReactiveCommand.CreateFromTask(ExecuteDeleteSubmission); + ViewWorkshopPage = ReactiveCommand.CreateFromTask(ExecuteViewWorkshopPage); } + public ReactiveCommand Save { get; } public ReactiveCommand CreateRelease { get; } public ReactiveCommand DeleteSubmission { get; } @@ -57,6 +65,8 @@ public class SubmissionDetailViewModel : RoutableScreen RaiseAndSetIfChanged(ref _entry, value); } + public ReactiveCommand ViewWorkshopPage { get; } + public override async Task OnNavigating(WorkshopDetailParameters parameters, NavigationArguments args, CancellationToken cancellationToken) { IOperationResult result = await _client.GetSubmittedEntryById.ExecuteAsync(parameters.EntryId, cancellationToken); @@ -107,16 +117,24 @@ public class SubmissionDetailViewModel : RoutableScreen result = await _client.UpdateEntry.ExecuteAsync(input, cancellationToken); + result.EnsureNoErrors(); + } + + private async Task ExecuteCreateRelease(CancellationToken cancellationToken) + { + if (Entry != null) + await _windowService.ShowDialogAsync(Entry); } private async Task ExecuteDeleteSubmission(CancellationToken cancellationToken) { if (Entry == null) return; - + bool confirmed = await _windowService.ShowConfirmContentDialog( "Delete submission?", "You cannot undo this by yourself.\r\n" + @@ -128,4 +146,10 @@ public class SubmissionDetailViewModel : RoutableScreen(); + await _windowService.ShowDialogAsync(); } private async Task ExecuteNavigateToEntry(IGetSubmittedEntries_SubmittedEntries entry, CancellationToken cancellationToken) diff --git a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/IWorkshopWizardViewModel.cs b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/IWorkshopWizardViewModel.cs new file mode 100644 index 000000000..58724c14c --- /dev/null +++ b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/IWorkshopWizardViewModel.cs @@ -0,0 +1,7 @@ +namespace Artemis.UI.Screens.Workshop.SubmissionWizard; + +public interface IWorkshopWizardViewModel +{ + SubmissionViewModel Screen { get; set; } + bool ShouldClose { get; set; } +} \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/ReleaseWizardView.axaml b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/ReleaseWizardView.axaml new file mode 100644 index 000000000..522bd4d33 --- /dev/null +++ b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/ReleaseWizardView.axaml @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/ReleaseWizardView.axaml.cs b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/ReleaseWizardView.axaml.cs new file mode 100644 index 000000000..50bbc4ace --- /dev/null +++ b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/ReleaseWizardView.axaml.cs @@ -0,0 +1,35 @@ +using System; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using Artemis.UI.Shared; +using Avalonia; +using Avalonia.Threading; +using ReactiveUI; + +namespace Artemis.UI.Screens.Workshop.SubmissionWizard; + +public partial class ReleaseWizardView: ReactiveAppWindow +{ + public ReleaseWizardView() + { + InitializeComponent(); +#if DEBUG + this.AttachDevTools(); +#endif + + this.WhenActivated(d => ViewModel.WhenAnyValue(vm => vm.Screen).WhereNotNull().Subscribe(Navigate).DisposeWith(d)); + this.WhenActivated(d => ViewModel.WhenAnyValue(vm => vm.ShouldClose).Where(c => c).Subscribe(_ => Close()).DisposeWith(d)); + } + + private void Navigate(SubmissionViewModel viewModel) + { + try + { + Dispatcher.UIThread.Invoke(() => Frame.NavigateFromObject(viewModel)); + } + catch (Exception e) + { + ViewModel?.WindowService.ShowExceptionDialog("Wizard screen failed to activate", e); + } + } +} \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/ReleaseWizardViewModel.cs b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/ReleaseWizardViewModel.cs new file mode 100644 index 000000000..8ad0db0f6 --- /dev/null +++ b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/ReleaseWizardViewModel.cs @@ -0,0 +1,51 @@ +using Artemis.UI.Screens.Workshop.CurrentUser; +using Artemis.UI.Shared; +using Artemis.UI.Shared.Services; +using Artemis.WebClient.Workshop; +using DryIoc; + +namespace Artemis.UI.Screens.Workshop.SubmissionWizard; + +public class ReleaseWizardViewModel : ActivatableViewModelBase, IWorkshopWizardViewModel +{ + private readonly SubmissionWizardState _state; + private SubmissionViewModel? _screen; + private bool _shouldClose; + + public ReleaseWizardViewModel(IContainer container, IWindowService windowService, CurrentUserViewModel currentUserViewModel, IGetSubmittedEntryById_Entry entry) + { + _state = new SubmissionWizardState(this, container, windowService) + { + EntryType = entry.EntryType, + EntryId = entry.Id + }; + + WindowService = windowService; + CurrentUserViewModel = currentUserViewModel; + CurrentUserViewModel.AllowLogout = false; + Entry = entry; + + _state.StartForCurrentEntry(); + } + + public IWindowService WindowService { get; } + public IGetSubmittedEntryById_Entry Entry { get; } + public CurrentUserViewModel CurrentUserViewModel { get; } + + public SubmissionViewModel? Screen + { + get => _screen; + set + { + if (value != null) + value.State = _state; + RaiseAndSetIfChanged(ref _screen, value); + } + } + + public bool ShouldClose + { + get => _shouldClose; + set => RaiseAndSetIfChanged(ref _shouldClose, value); + } +} \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/EntryTypeStepViewModel.cs b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/EntryTypeStepViewModel.cs index cce78ea9d..620b0e6f4 100644 --- a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/EntryTypeStepViewModel.cs +++ b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/EntryTypeStepViewModel.cs @@ -35,7 +35,6 @@ public class EntryTypeStepViewModel : SubmissionViewModel return; State.EntryType = SelectedEntryType.Value; - if (State.EntryType == EntryType.Profile) - State.ChangeScreen(); + State.StartForCurrentEntry(); } } \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/Profile/ProfileAdaptionHintsStepViewModel.cs b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/Profile/ProfileAdaptionHintsStepViewModel.cs index 768ca5405..b9ec09c15 100644 --- a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/Profile/ProfileAdaptionHintsStepViewModel.cs +++ b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/Profile/ProfileAdaptionHintsStepViewModel.cs @@ -62,6 +62,9 @@ public class ProfileAdaptionHintsStepViewModel : SubmissionViewModel if (Layers.Any(l => l.AdaptionHintCount == 0)) return; - State.ChangeScreen(); + if (State.EntryId == null) + State.ChangeScreen(); + else + State.ChangeScreen(); } } \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/Profile/ProfileSelectionStepViewModel.cs b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/Profile/ProfileSelectionStepViewModel.cs index 3cc2b409a..86b49a6d4 100644 --- a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/Profile/ProfileSelectionStepViewModel.cs +++ b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/Profile/ProfileSelectionStepViewModel.cs @@ -1,34 +1,22 @@ using System; using System.Collections.ObjectModel; -using System.IO; using System.Linq; using System.Reactive; using System.Reactive.Disposables; using System.Reactive.Linq; using Artemis.Core; using Artemis.Core.Services; -using Artemis.Storage.Entities.Profile; -using Artemis.Storage.Repositories.Interfaces; using Artemis.UI.Extensions; using Artemis.UI.Screens.Workshop.Profile; -using Avalonia; -using Avalonia.Controls; -using Avalonia.Controls.Shapes; -using Avalonia.Layout; -using Avalonia.Media; -using Avalonia.Media.Imaging; using Material.Icons; -using Material.Icons.Avalonia; using ReactiveUI; using SkiaSharp; -using Path = Avalonia.Controls.Shapes.Path; namespace Artemis.UI.Screens.Workshop.SubmissionWizard.Steps.Profile; public class ProfileSelectionStepViewModel : SubmissionViewModel { private readonly IProfileService _profileService; - private readonly IProfileCategoryRepository _profileCategoryRepository; private ProfileConfiguration? _selectedProfile; /// @@ -49,26 +37,12 @@ public class ProfileSelectionStepViewModel : SubmissionViewModel this.WhenAnyValue(vm => vm.SelectedProfile).Subscribe(p => Update(p)); this.WhenActivated((CompositeDisposable _) => { + ShowGoBack = State.EntryId == null; if (State.EntrySource is ProfileConfiguration profileConfiguration) SelectedProfile = Profiles.FirstOrDefault(p => p.ProfileId == profileConfiguration.ProfileId); }); } - private void Update(ProfileConfiguration? profileConfiguration) - { - ProfilePreview.ProfileConfiguration = null; - - foreach (ProfileConfiguration configuration in Profiles) - { - if (configuration == profileConfiguration) - _profileService.ActivateProfile(configuration); - else - _profileService.DeactivateProfile(configuration); - } - - ProfilePreview.ProfileConfiguration = profileConfiguration; - } - public ObservableCollection Profiles { get; } public ProfilePreviewViewModel ProfilePreview { get; } @@ -84,6 +58,21 @@ public class ProfileSelectionStepViewModel : SubmissionViewModel /// public override ReactiveCommand GoBack { get; } + private void Update(ProfileConfiguration? profileConfiguration) + { + ProfilePreview.ProfileConfiguration = null; + + foreach (ProfileConfiguration configuration in Profiles) + { + if (configuration == profileConfiguration) + _profileService.ActivateProfile(configuration); + else + _profileService.DeactivateProfile(configuration); + } + + ProfilePreview.ProfileConfiguration = profileConfiguration; + } + private void ExecuteContinue() { if (SelectedProfile == null) diff --git a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/UploadStepViewModel.cs b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/UploadStepViewModel.cs index fc726e908..c98e44efa 100644 --- a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/UploadStepViewModel.cs +++ b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/UploadStepViewModel.cs @@ -1,37 +1,36 @@ using System; using System.Reactive; +using System.Reactive.Disposables; using System.Reactive.Linq; using System.Threading; using System.Threading.Tasks; +using Artemis.UI.Shared.Routing; using Artemis.UI.Shared.Services; +using Artemis.UI.Shared.Utilities; using Artemis.WebClient.Workshop; +using Artemis.WebClient.Workshop.Exceptions; +using Artemis.WebClient.Workshop.Services; using Artemis.WebClient.Workshop.UploadHandlers; using ReactiveUI; using StrawberryShake; -using System.Reactive.Disposables; -using Artemis.Core; -using Artemis.UI.Shared.Routing; -using System; -using Artemis.UI.Shared.Utilities; -using Artemis.WebClient.Workshop.Services; namespace Artemis.UI.Screens.Workshop.SubmissionWizard.Steps; public class UploadStepViewModel : SubmissionViewModel { + private readonly EntryUploadHandlerFactory _entryUploadHandlerFactory; + private readonly Progress _progress = new(); + private readonly ObservableAsPropertyHelper _progressIndeterminate; + private readonly ObservableAsPropertyHelper _progressPercentage; + private readonly IRouter _router; + private readonly IWindowService _windowService; private readonly IWorkshopClient _workshopClient; private readonly IWorkshopService _workshopService; - private readonly EntryUploadHandlerFactory _entryUploadHandlerFactory; - private readonly IWindowService _windowService; - private readonly IRouter _router; - private readonly Progress _progress = new(); - private readonly ObservableAsPropertyHelper _progressPercentage; - private readonly ObservableAsPropertyHelper _progressIndeterminate; private Guid? _entryId; + private bool _failed; private bool _finished; private bool _succeeded; - private bool _failed; /// public UploadStepViewModel(IWorkshopClient workshopClient, IWorkshopService workshopService, EntryUploadHandlerFactory entryUploadHandlerFactory, IWindowService windowService, IRouter router) @@ -83,7 +82,45 @@ public class UploadStepViewModel : SubmissionViewModel set => RaiseAndSetIfChanged(ref _failed, value); } - public async Task ExecuteUpload(CancellationToken cancellationToken) + private async Task ExecuteUpload(CancellationToken cancellationToken) + { + // Use the existing entry or create a new one + _entryId = State.EntryId ?? await CreateEntry(cancellationToken); + + // If a new entry had to be created but that failed, stop here, CreateEntry will send the user back + if (_entryId == null) + return; + + try + { + IEntryUploadHandler uploadHandler = _entryUploadHandlerFactory.CreateHandler(State.EntryType); + EntryUploadResult uploadResult = await uploadHandler.CreateReleaseAsync(_entryId.Value, State.EntrySource!, _progress, cancellationToken); + if (!uploadResult.IsSuccess) + { + string? message = uploadResult.Message; + if (message != null) + message += "\r\n\r\n"; + else + message = ""; + message += "Your submission has still been saved, you may try to upload a new release"; + await _windowService.ShowConfirmContentDialog("Failed to upload workshop entry", message, "Close", null); + } + + Succeeded = true; + } + catch (Exception) + { + // Something went wrong when creating a release :c + // We'll keep the workshop entry so that the user can make changes and try again + Failed = true; + } + finally + { + Finished = true; + } + } + + private async Task CreateEntry(CancellationToken cancellationToken) { IOperationResult result = await _workshopClient.AddEntry.ExecuteAsync(new CreateEntryInput { @@ -100,68 +137,45 @@ public class UploadStepViewModel : SubmissionViewModel { await _windowService.ShowConfirmContentDialog("Failed to create workshop entry", result.Errors.ToString() ?? "Not even an error message", "Close", null); State.ChangeScreen(); - return; + return null; } if (cancellationToken.IsCancellationRequested) - return; + { + State.ChangeScreen(); + return null; + } + + + if (State.Icon == null) + return entryId; // Upload image - if (State.Icon != null) - await _workshopService.SetEntryIcon(entryId.Value, _progress, State.Icon, cancellationToken); - - // Create the workshop entry try { - IEntryUploadHandler uploadHandler = _entryUploadHandlerFactory.CreateHandler(State.EntryType); - EntryUploadResult uploadResult = await uploadHandler.CreateReleaseAsync(entryId.Value, State.EntrySource!, _progress, cancellationToken); - if (!uploadResult.IsSuccess) - { - string? message = uploadResult.Message; - if (message != null) - message += "\r\n\r\n"; - else - message = ""; - message += "Your submission has still been saved, you may try to upload a new release"; - await _windowService.ShowConfirmContentDialog("Failed to upload workshop entry", message, "Close", null); - return; - } - - _entryId = entryId; - Succeeded = true; + ImageUploadResult imageUploadResult = await _workshopService.SetEntryIcon(entryId.Value, _progress, State.Icon, cancellationToken); + if (!imageUploadResult.IsSuccess) + throw new ArtemisWorkshopException(imageUploadResult.Message); } catch (Exception e) { - // Something went wrong when creating a release :c - // We'll keep the workshop entry so that the user can make changes and try again - Failed = true; - } - finally - { - Finished = true; + // It's not critical if this fails + await _windowService.ShowConfirmContentDialog( + "Failed to upload icon", + "Your submission will continue, you can try upload a new image afterwards\r\n" + e.Message, + "Continue", + null + ); } + + return entryId; } private async Task ExecuteContinue() { - State.Finish(); + State.Close(); - if (_entryId == null) - return; - - switch (State.EntryType) - { - case EntryType.Layout: - await _router.Navigate($"workshop/entries/layouts/{_entryId.Value}"); - break; - case EntryType.Plugin: - await _router.Navigate($"workshop/entries/plugins/{_entryId.Value}"); - break; - case EntryType.Profile: - await _router.Navigate($"workshop/entries/profiles/{_entryId.Value}"); - break; - default: - throw new ArgumentOutOfRangeException(); - } + if (_entryId != null) + await _router.Navigate($"workshop/library/submissions/{_entryId.Value}"); } } \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/SubmissionViewModel.cs b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/SubmissionViewModel.cs index 3cd79cb65..fec465981 100644 --- a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/SubmissionViewModel.cs +++ b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/SubmissionViewModel.cs @@ -11,7 +11,6 @@ public abstract class SubmissionViewModel : ValidatableViewModelBase private bool _showGoBack = true; private bool _showHeader = true; - public SubmissionWizardViewModel WizardViewModel { get; set; } = null!; public SubmissionWizardState State { get; set; } = null!; public abstract ReactiveCommand Continue { get; } diff --git a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/SubmissionWizardState.cs b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/SubmissionWizardState.cs index 3484c8c36..b6f8d53fc 100644 --- a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/SubmissionWizardState.cs +++ b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/SubmissionWizardState.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.IO; +using Artemis.UI.Screens.Workshop.SubmissionWizard.Steps.Profile; using Artemis.UI.Shared.Services; using Artemis.WebClient.Workshop; using DryIoc; @@ -11,9 +12,9 @@ public class SubmissionWizardState { private readonly IContainer _container; private readonly IWindowService _windowService; - private readonly SubmissionWizardViewModel _wizardViewModel; + private readonly IWorkshopWizardViewModel _wizardViewModel; - public SubmissionWizardState(SubmissionWizardViewModel wizardViewModel, IContainer container, IWindowService windowService) + public SubmissionWizardState(IWorkshopWizardViewModel wizardViewModel, IContainer container, IWindowService windowService) { _wizardViewModel = wizardViewModel; _container = container; @@ -21,6 +22,7 @@ public class SubmissionWizardState } public EntryType EntryType { get; set; } + public Guid? EntryId { get; set; } public string Name { get; set; } = string.Empty; public Stream? Icon { get; set; } @@ -45,13 +47,16 @@ public class SubmissionWizardState } } - public void Finish() + public void Close() { - _wizardViewModel.Close(true); + _wizardViewModel.ShouldClose = true; } - public void Cancel() + public void StartForCurrentEntry() { - _wizardViewModel.Close(false); + if (EntryType == EntryType.Profile) + ChangeScreen(); + else + throw new NotImplementedException(); } } \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/SubmissionWizardView.axaml b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/SubmissionWizardView.axaml index e313f0f9f..d64ed0199 100644 --- a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/SubmissionWizardView.axaml +++ b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/SubmissionWizardView.axaml @@ -18,7 +18,7 @@ - + diff --git a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/SubmissionWizardView.axaml.cs b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/SubmissionWizardView.axaml.cs index cb510d01e..6c7ece92f 100644 --- a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/SubmissionWizardView.axaml.cs +++ b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/SubmissionWizardView.axaml.cs @@ -1,5 +1,6 @@ using System; using System.Reactive.Disposables; +using System.Reactive.Linq; using Artemis.UI.Shared; using Avalonia; using Avalonia.Threading; @@ -17,6 +18,7 @@ public partial class SubmissionWizardView : ReactiveAppWindow ViewModel.WhenAnyValue(vm => vm.Screen).Subscribe(Navigate).DisposeWith(d)); + this.WhenActivated(d => ViewModel.WhenAnyValue(vm => vm.ShouldClose).Where(c => c).Subscribe(_ => Close()).DisposeWith(d)); } private void Navigate(SubmissionViewModel viewModel) diff --git a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/SubmissionWizardViewModel.cs b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/SubmissionWizardViewModel.cs index f2361b8ed..a18c63e71 100644 --- a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/SubmissionWizardViewModel.cs +++ b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/SubmissionWizardViewModel.cs @@ -6,16 +6,19 @@ using DryIoc; namespace Artemis.UI.Screens.Workshop.SubmissionWizard; -public class SubmissionWizardViewModel : DialogViewModelBase +public class SubmissionWizardViewModel : ActivatableViewModelBase, IWorkshopWizardViewModel { private readonly SubmissionWizardState _state; private SubmissionViewModel _screen; + private bool _shouldClose; - public SubmissionWizardViewModel(IContainer container, IWindowService windowService, CurrentUserViewModel currentUserViewModel, WelcomeStepViewModel welcomeStepViewModel) + public SubmissionWizardViewModel(IContainer container, + IWindowService windowService, + CurrentUserViewModel currentUserViewModel, + WelcomeStepViewModel welcomeStepViewModel) { _state = new SubmissionWizardState(this, container, windowService); _screen = welcomeStepViewModel; - _screen.WizardViewModel = this; _screen.State = _state; WindowService = windowService; @@ -31,9 +34,14 @@ public class SubmissionWizardViewModel : DialogViewModelBase get => _screen; set { - value.WizardViewModel = this; value.State = _state; RaiseAndSetIfChanged(ref _screen, value); } } + + public bool ShouldClose + { + get => _shouldClose; + set => RaiseAndSetIfChanged(ref _shouldClose, value); + } } \ No newline at end of file diff --git a/src/Artemis.WebClient.Workshop/Artemis.WebClient.Workshop.csproj b/src/Artemis.WebClient.Workshop/Artemis.WebClient.Workshop.csproj index 32d841d41..389fa75e5 100644 --- a/src/Artemis.WebClient.Workshop/Artemis.WebClient.Workshop.csproj +++ b/src/Artemis.WebClient.Workshop/Artemis.WebClient.Workshop.csproj @@ -44,5 +44,8 @@ MSBuild:GenerateGraphQLCode + + MSBuild:GenerateGraphQLCode + diff --git a/src/Artemis.WebClient.Workshop/Queries/UpdateEntry.graphql b/src/Artemis.WebClient.Workshop/Queries/UpdateEntry.graphql new file mode 100644 index 000000000..fe53ec3e5 --- /dev/null +++ b/src/Artemis.WebClient.Workshop/Queries/UpdateEntry.graphql @@ -0,0 +1,5 @@ +mutation UpdateEntry ($input: UpdateEntryInput!) { + updateEntry(input: $input) { + id + } +} diff --git a/src/Artemis.WebClient.Workshop/Services/IWorkshopService.cs b/src/Artemis.WebClient.Workshop/Services/IWorkshopService.cs index f859a4f2c..5c7dbd35f 100644 --- a/src/Artemis.WebClient.Workshop/Services/IWorkshopService.cs +++ b/src/Artemis.WebClient.Workshop/Services/IWorkshopService.cs @@ -64,6 +64,24 @@ public class WorkshopService : IWorkshopService await _router.Navigate($"workshop/offline/{status.Message}"); return status.IsReachable; } + + public async Task NavigateToEntry(Guid entryId, EntryType entryType) + { + switch (entryType) + { + case EntryType.Profile: + await _router.Navigate($"workshop/entries/profiles/details/{entryId}"); + break; + case EntryType.Layout: + await _router.Navigate($"workshop/entries/layouts/details/{entryId}"); + break; + case EntryType.Plugin: + await _router.Navigate($"workshop/entries/plugins/details/{entryId}"); + break; + default: + throw new ArgumentOutOfRangeException(nameof(entryType)); + } + } } public interface IWorkshopService @@ -71,6 +89,7 @@ public interface IWorkshopService Task SetEntryIcon(Guid entryId, Progress progress, Stream icon, CancellationToken cancellationToken); Task GetWorkshopStatus(CancellationToken cancellationToken); Task ValidateWorkshopStatus(CancellationToken cancellationToken); - + Task NavigateToEntry(Guid entryId, EntryType entryType); + public record WorkshopStatus(bool IsReachable, string Message); } \ No newline at end of file From fcde1d4ecc580c892bf56d085742b21c0574f3db Mon Sep 17 00:00:00 2001 From: Robert Date: Tue, 5 Sep 2023 21:39:43 +0200 Subject: [PATCH 29/37] UI - Tweaked monospace font sizing --- .../Styles/Controls/DataModelPicker.axaml | 4 +- .../Tabs/DataModel/DataModelDebugView.axaml | 2 +- .../Debugger/Tabs/Logs/LogsDebugView.axaml | 4 +- .../Tabs/Routing/RoutingDebugView.axaml | 1 + .../Screens/VisualScripting/CableView.axaml | 11 +- .../Entries/EntrySpecificationsView.axaml | 44 +++--- .../Entries/EntrySpecificationsViewModel.cs | 86 ++++++----- .../Library/SubmissionDetailView.axaml | 10 +- .../Library/SubmissionDetailViewModel.cs | 139 +++++++++++++----- .../Steps/EntryTypeStepViewModel.cs | 8 - .../Steps/LoginStepViewModel.cs | 8 - .../ProfileAdaptionHintsLayerViewModel.cs | 11 +- .../ProfileAdaptionHintsStepViewModel.cs | 13 +- .../Profile/ProfileSelectionStepViewModel.cs | 7 - .../Steps/SpecificationsStepView.axaml.cs | 2 - .../Steps/SpecificationsStepViewModel.cs | 60 ++++---- .../Steps/SubmitStepView.axaml.cs | 2 - .../Steps/SubmitStepViewModel.cs | 18 +-- .../Steps/UploadStepView.axaml.cs | 2 - .../Steps/UploadStepViewModel.cs | 7 - .../Steps/ValidateEmailStepViewModel.cs | 8 +- .../Steps/WelcomeStepViewModel.cs | 8 - .../SubmissionWizard/SubmissionViewModel.cs | 17 ++- .../Screens/DisplayValueNodeCustomView.axaml | 2 +- .../Services/IWorkshopService.cs | 21 ++- 25 files changed, 281 insertions(+), 214 deletions(-) diff --git a/src/Artemis.UI.Shared/Styles/Controls/DataModelPicker.axaml b/src/Artemis.UI.Shared/Styles/Controls/DataModelPicker.axaml index d2757ca40..0175ae880 100644 --- a/src/Artemis.UI.Shared/Styles/Controls/DataModelPicker.axaml +++ b/src/Artemis.UI.Shared/Styles/Controls/DataModelPicker.axaml @@ -67,6 +67,7 @@ @@ -81,7 +82,7 @@ IsVisible="{CompiledBinding IsEventPicker, RelativeSource={RelativeSource FindAncestor, AncestorType={x:Type dataModelPicker:DataModelPicker}}}"/> - + @@ -91,6 +92,7 @@ diff --git a/src/Artemis.UI/Screens/Debugger/Tabs/DataModel/DataModelDebugView.axaml b/src/Artemis.UI/Screens/Debugger/Tabs/DataModel/DataModelDebugView.axaml index 1ae47d2af..cd12f8f18 100644 --- a/src/Artemis.UI/Screens/Debugger/Tabs/DataModel/DataModelDebugView.axaml +++ b/src/Artemis.UI/Screens/Debugger/Tabs/DataModel/DataModelDebugView.axaml @@ -52,8 +52,8 @@ diff --git a/src/Artemis.UI/Screens/Debugger/Tabs/Logs/LogsDebugView.axaml b/src/Artemis.UI/Screens/Debugger/Tabs/Logs/LogsDebugView.axaml index dd438e85b..06ef67204 100644 --- a/src/Artemis.UI/Screens/Debugger/Tabs/Logs/LogsDebugView.axaml +++ b/src/Artemis.UI/Screens/Debugger/Tabs/Logs/LogsDebugView.axaml @@ -10,8 +10,8 @@ + SelectionBrush="{StaticResource TextControlSelectionHighlightColor}"/> \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Debugger/Tabs/Routing/RoutingDebugView.axaml b/src/Artemis.UI/Screens/Debugger/Tabs/Routing/RoutingDebugView.axaml index 8ea97a606..68230f577 100644 --- a/src/Artemis.UI/Screens/Debugger/Tabs/Routing/RoutingDebugView.axaml +++ b/src/Artemis.UI/Screens/Debugger/Tabs/Routing/RoutingDebugView.axaml @@ -20,6 +20,7 @@ diff --git a/src/Artemis.UI/Screens/VisualScripting/CableView.axaml b/src/Artemis.UI/Screens/VisualScripting/CableView.axaml index 7f9f935a5..31fedf0fa 100644 --- a/src/Artemis.UI/Screens/VisualScripting/CableView.axaml +++ b/src/Artemis.UI/Screens/VisualScripting/CableView.axaml @@ -41,7 +41,8 @@ Text="{CompiledBinding Converter={StaticResource SKColorToStringConverter}, Mode=OneWay}" VerticalAlignment="Center" HorizontalAlignment="Stretch" - FontFamily="{StaticResource RobotoMono}" /> + FontFamily="{StaticResource RobotoMono}" + FontSize="13"/> - + - + - + - + diff --git a/src/Artemis.UI/Screens/Workshop/Entries/EntrySpecificationsView.axaml b/src/Artemis.UI/Screens/Workshop/Entries/EntrySpecificationsView.axaml index 8dbb6539e..94bf49bb5 100644 --- a/src/Artemis.UI/Screens/Workshop/Entries/EntrySpecificationsView.axaml +++ b/src/Artemis.UI/Screens/Workshop/Entries/EntrySpecificationsView.axaml @@ -12,7 +12,7 @@ mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="800" x:Class="Artemis.UI.Screens.Workshop.Entries.EntrySpecificationsView" x:DataType="entries:EntrySpecificationsViewModel"> - + @@ -25,8 +25,8 @@ - - + + + + \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/Library/SubmissionDetailViewModel.cs b/src/Artemis.UI/Screens/Workshop/Library/SubmissionDetailViewModel.cs index 11d35afb1..2b05edc76 100644 --- a/src/Artemis.UI/Screens/Workshop/Library/SubmissionDetailViewModel.cs +++ b/src/Artemis.UI/Screens/Workshop/Library/SubmissionDetailViewModel.cs @@ -1,7 +1,9 @@ using System; +using System.Collections.Generic; +using System.Collections.Specialized; +using System.ComponentModel; using System.IO; using System.Linq; -using System.Net.Http; using System.Reactive; using System.Threading; using System.Threading.Tasks; @@ -10,8 +12,11 @@ using Artemis.UI.Screens.Workshop.Parameters; using Artemis.UI.Screens.Workshop.SubmissionWizard; using Artemis.UI.Shared.Routing; using Artemis.UI.Shared.Services; +using Artemis.UI.Shared.Utilities; using Artemis.WebClient.Workshop; +using Artemis.WebClient.Workshop.Exceptions; using Artemis.WebClient.Workshop.Services; +using Artemis.WebClient.Workshop.UploadHandlers; using Avalonia.Media.Imaging; using ReactiveUI; using StrawberryShake; @@ -21,37 +26,33 @@ namespace Artemis.UI.Screens.Workshop.Library; public class SubmissionDetailViewModel : RoutableScreen { private readonly IWorkshopClient _client; - private readonly IHttpClientFactory _httpClientFactory; - private readonly Func _getEntrySpecificationsViewModel; + private readonly Func _getGetSpecificationsVm; + private readonly IRouter _router; private readonly IWindowService _windowService; private readonly IWorkshopService _workshopService; - private readonly IRouter _router; private IGetSubmittedEntryById_Entry? _entry; private EntrySpecificationsViewModel? _entrySpecificationsViewModel; + private bool _hasChanges; - public SubmissionDetailViewModel(IWorkshopClient client, - IHttpClientFactory httpClientFactory, - IWindowService windowService, - IWorkshopService workshopService, - IRouter router, - Func entrySpecificationsViewModel) - { + public SubmissionDetailViewModel(IWorkshopClient client, IWindowService windowService, IWorkshopService workshopService, IRouter router, Func getSpecificationsVm) { _client = client; - _httpClientFactory = httpClientFactory; _windowService = windowService; _workshopService = workshopService; _router = router; - _getEntrySpecificationsViewModel = entrySpecificationsViewModel; + _getGetSpecificationsVm = getSpecificationsVm; - Save = ReactiveCommand.CreateFromTask(ExecuteSave); CreateRelease = ReactiveCommand.CreateFromTask(ExecuteCreateRelease); DeleteSubmission = ReactiveCommand.CreateFromTask(ExecuteDeleteSubmission); ViewWorkshopPage = ReactiveCommand.CreateFromTask(ExecuteViewWorkshopPage); + DiscardChanges = ReactiveCommand.CreateFromTask(ExecuteDiscardChanges, this.WhenAnyValue(vm => vm.HasChanges)); + SaveChanges = ReactiveCommand.CreateFromTask(ExecuteSaveChanges, this.WhenAnyValue(vm => vm.HasChanges)); } - public ReactiveCommand Save { get; } public ReactiveCommand CreateRelease { get; } public ReactiveCommand DeleteSubmission { get; } + public ReactiveCommand ViewWorkshopPage { get; } + public ReactiveCommand SaveChanges { get; } + public ReactiveCommand DiscardChanges { get; } public EntrySpecificationsViewModel? EntrySpecificationsViewModel { @@ -65,8 +66,12 @@ public class SubmissionDetailViewModel : RoutableScreen RaiseAndSetIfChanged(ref _entry, value); } - public ReactiveCommand ViewWorkshopPage { get; } - + public bool HasChanges + { + get => _hasChanges; + private set => RaiseAndSetIfChanged(ref _hasChanges, value); + } + public override async Task OnNavigating(WorkshopDetailParameters parameters, NavigationArguments args, CancellationToken cancellationToken) { IOperationResult result = await _client.GetSubmittedEntryById.ExecuteAsync(parameters.EntryId, cancellationToken); @@ -77,12 +82,29 @@ public class SubmissionDetailViewModel : RoutableScreen GetEntryIcon(CancellationToken cancellationToken) @@ -102,26 +127,59 @@ public class SubmissionDetailViewModel : RoutableScreen categories = EntrySpecificationsViewModel.Categories.Where(c => c.IsSelected).Select(c => c.Id).OrderBy(c => c).ToList(); + List tags = EntrySpecificationsViewModel.Tags.OrderBy(t => t).ToList(); + + HasChanges = EntrySpecificationsViewModel.Name != Entry.Name || + EntrySpecificationsViewModel.Description != Entry.Description || + EntrySpecificationsViewModel.Summary != Entry.Summary || + EntrySpecificationsViewModel.IconChanged || + !tags.SequenceEqual(Entry.Tags.Select(t => t.Name).OrderBy(t => t)) || + !categories.SequenceEqual(Entry.Categories.Select(c => c.Id).OrderBy(c => c)); + } + + private async Task ExecuteDiscardChanges() + { + await ApplyFromEntry(CancellationToken.None); + } + + private async Task ExecuteSaveChanges(CancellationToken cancellationToken) + { + if (Entry == null || EntrySpecificationsViewModel == null || !EntrySpecificationsViewModel.ValidationContext.GetIsValid()) + return; + + UpdateEntryInput input = new() + { + Id = Entry.Id, + Name = EntrySpecificationsViewModel.Name, + Summary = EntrySpecificationsViewModel.Summary, + Description = EntrySpecificationsViewModel.Description, + Categories = EntrySpecificationsViewModel.SelectedCategories, + Tags = EntrySpecificationsViewModel.Tags + }; + IOperationResult result = await _client.UpdateEntry.ExecuteAsync(input, cancellationToken); result.EnsureNoErrors(); + + if (EntrySpecificationsViewModel.IconChanged && EntrySpecificationsViewModel.IconBitmap != null) + { + using MemoryStream stream = new(); + EntrySpecificationsViewModel.IconBitmap.Save(stream); + ImageUploadResult imageResult = await _workshopService.SetEntryIcon(Entry.Id, new Progress(), stream, cancellationToken); + if (!imageResult.IsSuccess) + throw new ArtemisWorkshopException("Failed to upload image. " + imageResult.Message); + } + + HasChanges = false; } private async Task ExecuteCreateRelease(CancellationToken cancellationToken) @@ -152,4 +210,19 @@ public class SubmissionDetailViewModel : RoutableScreen RaiseAndSetIfChanged(ref _selectedEntryType, value); } - /// - public override ReactiveCommand Continue { get; } - - /// - public override ReactiveCommand GoBack { get; } - private void ExecuteContinue() { if (SelectedEntryType == null) diff --git a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/LoginStepViewModel.cs b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/LoginStepViewModel.cs index 83b7d3ca0..5b6cf680b 100644 --- a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/LoginStepViewModel.cs +++ b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/LoginStepViewModel.cs @@ -1,6 +1,4 @@ -using System; using System.Linq; -using System.Reactive; using System.Security.Claims; using System.Threading; using System.Threading.Tasks; @@ -29,12 +27,6 @@ public class LoginStepViewModel : SubmissionViewModel ContinueText = "Log In"; } - /// - public override ReactiveCommand Continue { get; } - - /// - public override ReactiveCommand GoBack { get; } = null!; - private async Task ExecuteLogin(CancellationToken ct) { ContentDialogResult result = await _windowService.CreateContentDialog().WithViewModel(out WorkshopLoginViewModel _).WithTitle("Workshop login").ShowAsync(); diff --git a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/Profile/ProfileAdaptionHintsLayerViewModel.cs b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/Profile/ProfileAdaptionHintsLayerViewModel.cs index ade952508..48b741698 100644 --- a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/Profile/ProfileAdaptionHintsLayerViewModel.cs +++ b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/Profile/ProfileAdaptionHintsLayerViewModel.cs @@ -6,31 +6,30 @@ using Artemis.Core.Services; using Artemis.UI.Screens.ProfileEditor.ProfileTree.Dialogs; using Artemis.UI.Shared; using Artemis.UI.Shared.Services; -using FluentAvalonia.Core; using ReactiveUI; namespace Artemis.UI.Screens.Workshop.SubmissionWizard.Steps.Profile; public class ProfileAdaptionHintsLayerViewModel : ViewModelBase { - private readonly IWindowService _windowService; - private readonly IProfileService _profileService; private readonly ObservableAsPropertyHelper _adaptionHintText; + private readonly IProfileService _profileService; + private readonly IWindowService _windowService; private int _adaptionHintCount; - public Layer Layer { get; } - public ProfileAdaptionHintsLayerViewModel(Layer layer, IWindowService windowService, IProfileService profileService) { _windowService = windowService; _profileService = profileService; _adaptionHintText = this.WhenAnyValue(vm => vm.AdaptionHintCount).Select(c => c == 1 ? "1 adaption hint" : $"{c} adaption hints").ToProperty(this, vm => vm.AdaptionHintText); - + Layer = layer; EditAdaptionHints = ReactiveCommand.CreateFromTask(ExecuteEditAdaptionHints); AdaptionHintCount = layer.Adapter.AdaptionHints.Count; } + public Layer Layer { get; } + public ReactiveCommand EditAdaptionHints { get; } public int AdaptionHintCount diff --git a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/Profile/ProfileAdaptionHintsStepViewModel.cs b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/Profile/ProfileAdaptionHintsStepViewModel.cs index b9ec09c15..fc55b987a 100644 --- a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/Profile/ProfileAdaptionHintsStepViewModel.cs +++ b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/Profile/ProfileAdaptionHintsStepViewModel.cs @@ -3,23 +3,22 @@ using System.Collections.ObjectModel; using System.Linq; using System.Reactive; using System.Reactive.Disposables; -using System.Reactive.Linq; using System.Threading.Tasks; using Artemis.Core; using Artemis.Core.Services; using Artemis.UI.Screens.ProfileEditor.ProfileTree.Dialogs; using Artemis.UI.Shared.Services; using DynamicData; -using ReactiveUI; using DynamicData.Aggregation; +using ReactiveUI; namespace Artemis.UI.Screens.Workshop.SubmissionWizard.Steps.Profile; public class ProfileAdaptionHintsStepViewModel : SubmissionViewModel { - private readonly IWindowService _windowService; - private readonly IProfileService _profileService; private readonly SourceList _layers; + private readonly IProfileService _profileService; + private readonly IWindowService _windowService; public ProfileAdaptionHintsStepViewModel(IWindowService windowService, IProfileService profileService, Func getLayerViewModel) { @@ -36,18 +35,14 @@ public class ProfileAdaptionHintsStepViewModel : SubmissionViewModel this.WhenActivated((CompositeDisposable _) => { if (State.EntrySource is ProfileConfiguration profileConfiguration && profileConfiguration.Profile != null) - { _layers.Edit(l => { l.Clear(); l.AddRange(profileConfiguration.Profile.GetAllLayers().Select(getLayerViewModel)); }); - } }); } - public override ReactiveCommand Continue { get; } - public override ReactiveCommand GoBack { get; } public ReactiveCommand EditAdaptionHints { get; } public ReadOnlyObservableCollection Layers { get; } @@ -61,7 +56,7 @@ public class ProfileAdaptionHintsStepViewModel : SubmissionViewModel { if (Layers.Any(l => l.AdaptionHintCount == 0)) return; - + if (State.EntryId == null) State.ChangeScreen(); else diff --git a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/Profile/ProfileSelectionStepViewModel.cs b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/Profile/ProfileSelectionStepViewModel.cs index 86b49a6d4..adad736d7 100644 --- a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/Profile/ProfileSelectionStepViewModel.cs +++ b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/Profile/ProfileSelectionStepViewModel.cs @@ -1,7 +1,6 @@ using System; using System.Collections.ObjectModel; using System.Linq; -using System.Reactive; using System.Reactive.Disposables; using System.Reactive.Linq; using Artemis.Core; @@ -52,12 +51,6 @@ public class ProfileSelectionStepViewModel : SubmissionViewModel set => RaiseAndSetIfChanged(ref _selectedProfile, value); } - /// - public override ReactiveCommand Continue { get; } - - /// - public override ReactiveCommand GoBack { get; } - private void Update(ProfileConfiguration? profileConfiguration) { ProfilePreview.ProfileConfiguration = null; diff --git a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/SpecificationsStepView.axaml.cs b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/SpecificationsStepView.axaml.cs index f7d256121..6d05e97a1 100644 --- a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/SpecificationsStepView.axaml.cs +++ b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/SpecificationsStepView.axaml.cs @@ -1,5 +1,3 @@ -using Avalonia; -using Avalonia.Controls; using Avalonia.Markup.Xaml; using Avalonia.ReactiveUI; diff --git a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/SpecificationsStepViewModel.cs b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/SpecificationsStepViewModel.cs index ebb75cd42..4e8a17c20 100644 --- a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/SpecificationsStepViewModel.cs +++ b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/SpecificationsStepViewModel.cs @@ -2,41 +2,38 @@ using System; using System.Collections.Generic; using System.IO; using System.Linq; -using System.Reactive; using System.Reactive.Disposables; using Artemis.UI.Extensions; using Artemis.UI.Screens.Workshop.Entries; using Artemis.UI.Screens.Workshop.SubmissionWizard.Steps.Profile; using Artemis.WebClient.Workshop; -using Avalonia.Threading; using DynamicData; using ReactiveUI; -using ReactiveUI.Validation.Extensions; namespace Artemis.UI.Screens.Workshop.SubmissionWizard.Steps; public class SpecificationsStepViewModel : SubmissionViewModel { - public SpecificationsStepViewModel(EntrySpecificationsViewModel entrySpecificationsViewModel) - { - EntrySpecificationsViewModel = entrySpecificationsViewModel; - GoBack = ReactiveCommand.Create(ExecuteGoBack); - Continue = ReactiveCommand.Create(ExecuteContinue, EntrySpecificationsViewModel.ValidationContext.Valid); + private readonly Func _getEntrySpecificationsViewModel; + private EntrySpecificationsViewModel? _entrySpecificationsViewModel; + public SpecificationsStepViewModel(Func getEntrySpecificationsViewModel) + { + _getEntrySpecificationsViewModel = getEntrySpecificationsViewModel; + + GoBack = ReactiveCommand.Create(ExecuteGoBack); this.WhenActivated((CompositeDisposable d) => { DisplayName = $"{State.EntryType} Information"; - - // Apply the state ApplyFromState(); - - EntrySpecificationsViewModel.ClearValidationRules(); }); } - public EntrySpecificationsViewModel EntrySpecificationsViewModel { get; } - public override ReactiveCommand Continue { get; } - public override ReactiveCommand GoBack { get; } + public EntrySpecificationsViewModel? EntrySpecificationsViewModel + { + get => _entrySpecificationsViewModel; + set => RaiseAndSetIfChanged(ref _entrySpecificationsViewModel, value); + } private void ExecuteGoBack() { @@ -59,46 +56,45 @@ public class SpecificationsStepViewModel : SubmissionViewModel private void ExecuteContinue() { - if (!EntrySpecificationsViewModel.ValidationContext.Validations.Any()) - { - // The ValidationContext seems to update asynchronously, so stop and schedule a retry - EntrySpecificationsViewModel.SetupDataValidation(); - Dispatcher.UIThread.Post(ExecuteContinue); + if (EntrySpecificationsViewModel == null || !EntrySpecificationsViewModel.ValidationContext.GetIsValid()) return; - } ApplyToState(); - - if (!EntrySpecificationsViewModel.ValidationContext.GetIsValid()) - return; - State.ChangeScreen(); } private void ApplyFromState() { + EntrySpecificationsViewModel viewModel = _getEntrySpecificationsViewModel(); + // Basic fields - EntrySpecificationsViewModel.Name = State.Name; - EntrySpecificationsViewModel.Summary = State.Summary; - EntrySpecificationsViewModel.Description = State.Description; + viewModel.Name = State.Name; + viewModel.Summary = State.Summary; + viewModel.Description = State.Description; // Tags - EntrySpecificationsViewModel.Tags.Clear(); - EntrySpecificationsViewModel.Tags.AddRange(State.Tags); + viewModel.Tags.Clear(); + viewModel.Tags.AddRange(State.Tags); // Categories - EntrySpecificationsViewModel.PreselectedCategories = State.Categories; + viewModel.PreselectedCategories = State.Categories; // Icon if (State.Icon != null) { State.Icon.Seek(0, SeekOrigin.Begin); - EntrySpecificationsViewModel.IconBitmap = BitmapExtensions.LoadAndResize(State.Icon, 128); + viewModel.IconBitmap = BitmapExtensions.LoadAndResize(State.Icon, 128); } + + EntrySpecificationsViewModel = viewModel; + Continue = ReactiveCommand.Create(ExecuteContinue, EntrySpecificationsViewModel.ValidationContext.Valid); } private void ApplyToState() { + if (EntrySpecificationsViewModel == null) + return; + // Basic fields State.Name = EntrySpecificationsViewModel.Name; State.Summary = EntrySpecificationsViewModel.Summary; diff --git a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/SubmitStepView.axaml.cs b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/SubmitStepView.axaml.cs index 49f0af7ff..ba64d1d7d 100644 --- a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/SubmitStepView.axaml.cs +++ b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/SubmitStepView.axaml.cs @@ -1,5 +1,3 @@ -using Avalonia; -using Avalonia.Controls; using Avalonia.Markup.Xaml; using Avalonia.ReactiveUI; diff --git a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/SubmitStepViewModel.cs b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/SubmitStepViewModel.cs index 0752ccc0d..8544c30ef 100644 --- a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/SubmitStepViewModel.cs +++ b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/SubmitStepViewModel.cs @@ -1,17 +1,16 @@ +using System; using System.Collections.ObjectModel; +using System.IO; using System.Linq; -using System.Reactive; using System.Reactive.Disposables; using System.Reactive.Linq; using Artemis.UI.Screens.Workshop.Categories; using Artemis.WebClient.Workshop; using Artemis.WebClient.Workshop.Services; +using Avalonia.Media.Imaging; using IdentityModel; using ReactiveUI; using StrawberryShake; -using System; -using System.IO; -using Avalonia.Media.Imaging; namespace Artemis.UI.Screens.Workshop.SubmissionWizard.Steps; @@ -26,9 +25,9 @@ public class SubmitStepViewModel : SubmissionViewModel CurrentUser = authenticationService.Claims.FirstOrDefault(c => c.Type == JwtClaimTypes.Name)?.Value; GoBack = ReactiveCommand.Create(() => State.ChangeScreen()); Continue = ReactiveCommand.Create(() => State.ChangeScreen()); - + ContinueText = "Submit"; - + this.WhenActivated(d => { if (State.Icon != null) @@ -37,6 +36,7 @@ public class SubmitStepViewModel : SubmissionViewModel IconBitmap = new Bitmap(State.Icon); IconBitmap.DisposeWith(d); } + Observable.FromAsync(workshopClient.GetCategories.ExecuteAsync).Subscribe(PopulateCategories).DisposeWith(d); }); } @@ -55,19 +55,13 @@ public class SubmitStepViewModel : SubmissionViewModel set => RaiseAndSetIfChanged(ref _categories, value); } - public override ReactiveCommand Continue { get; } - - public override ReactiveCommand GoBack { get; } - private void PopulateCategories(IOperationResult result) { if (result.Data == null) Categories = null; else - { Categories = new ReadOnlyObservableCollection( new ObservableCollection(result.Data.Categories.Where(c => State.Categories.Contains(c.Id)).Select(c => new CategoryViewModel(c))) ); - } } } \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/UploadStepView.axaml.cs b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/UploadStepView.axaml.cs index 7dc094d0c..21d6bb439 100644 --- a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/UploadStepView.axaml.cs +++ b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/UploadStepView.axaml.cs @@ -1,5 +1,3 @@ -using Avalonia; -using Avalonia.Controls; using Avalonia.Markup.Xaml; using Avalonia.ReactiveUI; diff --git a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/UploadStepViewModel.cs b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/UploadStepViewModel.cs index c98e44efa..7d0413714 100644 --- a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/UploadStepViewModel.cs +++ b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/UploadStepViewModel.cs @@ -1,5 +1,4 @@ using System; -using System.Reactive; using System.Reactive.Disposables; using System.Reactive.Linq; using System.Threading; @@ -55,12 +54,6 @@ public class UploadStepViewModel : SubmissionViewModel this.WhenActivated(d => Observable.FromAsync(ExecuteUpload).Subscribe().DisposeWith(d)); } - /// - public override ReactiveCommand Continue { get; } - - /// - public override ReactiveCommand GoBack { get; } = null!; - public int ProgressPercentage => _progressPercentage.Value; public bool ProgressIndeterminate => _progressIndeterminate.Value; diff --git a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/ValidateEmailStepViewModel.cs b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/ValidateEmailStepViewModel.cs index 3778c2673..a2a840421 100644 --- a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/ValidateEmailStepViewModel.cs +++ b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/ValidateEmailStepViewModel.cs @@ -27,7 +27,7 @@ public class ValidateEmailStepViewModel : SubmissionViewModel Continue = ReactiveCommand.Create(ExecuteContinue); Refresh = ReactiveCommand.CreateFromTask(ExecuteRefresh); Resend = ReactiveCommand.Create(() => Utilities.OpenUrl(WorkshopConstants.AUTHORITY_URL + "/account/confirm/resend")); - + ShowGoBack = false; ShowHeader = false; @@ -43,12 +43,6 @@ public class ValidateEmailStepViewModel : SubmissionViewModel }); } - /// - public override ReactiveCommand Continue { get; } - - /// - public override ReactiveCommand GoBack { get; } = null!; - public ReactiveCommand Refresh { get; } public ReactiveCommand Resend { get; } diff --git a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/WelcomeStepViewModel.cs b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/WelcomeStepViewModel.cs index 3160fbb1e..87c96fb7d 100644 --- a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/WelcomeStepViewModel.cs +++ b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/WelcomeStepViewModel.cs @@ -1,6 +1,4 @@ -using System; using System.Linq; -using System.Reactive; using System.Threading.Tasks; using Artemis.WebClient.Workshop.Services; using IdentityModel; @@ -21,12 +19,6 @@ public class WelcomeStepViewModel : SubmissionViewModel ShowGoBack = false; } - /// - public override ReactiveCommand Continue { get; } - - /// - public override ReactiveCommand GoBack { get; } = null!; - private async Task ExecuteContinue() { bool loggedIn = await _authenticationService.AutoLogin(true); diff --git a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/SubmissionViewModel.cs b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/SubmissionViewModel.cs index fec465981..282b34590 100644 --- a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/SubmissionViewModel.cs +++ b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/SubmissionViewModel.cs @@ -6,15 +6,26 @@ namespace Artemis.UI.Screens.Workshop.SubmissionWizard; public abstract class SubmissionViewModel : ValidatableViewModelBase { + private ReactiveCommand? _continue; + private ReactiveCommand? _goBack; private string _continueText = "Continue"; private bool _showFinish; private bool _showGoBack = true; private bool _showHeader = true; public SubmissionWizardState State { get; set; } = null!; - - public abstract ReactiveCommand Continue { get; } - public abstract ReactiveCommand GoBack { get; } + + public ReactiveCommand? Continue + { + get => _continue; + set => RaiseAndSetIfChanged(ref _continue, value); + } + + public ReactiveCommand? GoBack + { + get => _goBack; + set => RaiseAndSetIfChanged(ref _goBack, value); + } public bool ShowHeader { diff --git a/src/Artemis.VisualScripting/Nodes/Static/Screens/DisplayValueNodeCustomView.axaml b/src/Artemis.VisualScripting/Nodes/Static/Screens/DisplayValueNodeCustomView.axaml index 014b2bdb5..8831dd1e9 100644 --- a/src/Artemis.VisualScripting/Nodes/Static/Screens/DisplayValueNodeCustomView.axaml +++ b/src/Artemis.VisualScripting/Nodes/Static/Screens/DisplayValueNodeCustomView.axaml @@ -46,7 +46,7 @@ - + diff --git a/src/Artemis.WebClient.Workshop/Services/IWorkshopService.cs b/src/Artemis.WebClient.Workshop/Services/IWorkshopService.cs index 5c7dbd35f..d1e0fb287 100644 --- a/src/Artemis.WebClient.Workshop/Services/IWorkshopService.cs +++ b/src/Artemis.WebClient.Workshop/Services/IWorkshopService.cs @@ -16,6 +16,22 @@ public class WorkshopService : IWorkshopService _router = router; } + public async Task GetEntryIcon(Guid entryId, CancellationToken cancellationToken) + { + HttpClient client = _httpClientFactory.CreateClient(WorkshopConstants.WORKSHOP_CLIENT_NAME); + try + { + HttpResponseMessage response = await client.GetAsync($"entries/{entryId}/icon", cancellationToken); + response.EnsureSuccessStatusCode(); + return await response.Content.ReadAsStreamAsync(cancellationToken); + } + catch (HttpRequestException) + { + // ignored + return null; + } + } + public async Task SetEntryIcon(Guid entryId, Progress progress, Stream icon, CancellationToken cancellationToken) { icon.Seek(0, SeekOrigin.Begin); @@ -55,7 +71,7 @@ public class WorkshopService : IWorkshopService return new IWorkshopService.WorkshopStatus(false, e.Message); } } - + /// public async Task ValidateWorkshopStatus(CancellationToken cancellationToken) { @@ -86,10 +102,11 @@ public class WorkshopService : IWorkshopService public interface IWorkshopService { + Task GetEntryIcon(Guid entryId, CancellationToken cancellationToken); Task SetEntryIcon(Guid entryId, Progress progress, Stream icon, CancellationToken cancellationToken); Task GetWorkshopStatus(CancellationToken cancellationToken); Task ValidateWorkshopStatus(CancellationToken cancellationToken); Task NavigateToEntry(Guid entryId, EntryType entryType); - + public record WorkshopStatus(bool IsReachable, string Message); } \ No newline at end of file From c69be2836ee8e2b5f3529caa2617ea6fc8a492df Mon Sep 17 00:00:00 2001 From: Robert Date: Wed, 6 Sep 2023 20:39:43 +0200 Subject: [PATCH 30/37] Workshop - Added installed entries and update profiles when reinstalling them --- .../Models/Profile/ProfileCategory.cs | 1 + .../Storage/Interfaces/IProfileService.cs | 14 +++- .../Services/Storage/ProfileService.cs | 15 +++- .../Entities/Workshop/EntryEntity.cs | 21 ++++++ .../Repositories/EntryRepository.cs | 54 ++++++++++++++ .../Interfaces/IEntryRepository.cs | 16 ++++ .../Profile/ProfileDetailsViewModel.cs | 8 +- .../Screens/Workshop/Search/SearchView.axaml | 7 +- .../Workshop/Search/SearchViewModel.cs | 10 ++- ...andler.cs => IEntryInstallationHandler.cs} | 2 +- .../ProfileEntryDownloadHandler.cs | 36 --------- .../ProfileEntryInstallationHandler.cs | 73 +++++++++++++++++++ .../DryIoc/ContainerExtensions.cs | 2 +- .../Services/InstalledEntry.cs | 72 ++++++++++++++++++ .../IAuthenticationService.cs | 0 .../Services/Interfaces/IWorkshopService.cs | 18 +++++ ...IWorkshopService.cs => WorkshopService.cs} | 36 ++++++--- 17 files changed, 327 insertions(+), 58 deletions(-) create mode 100644 src/Artemis.Storage/Entities/Workshop/EntryEntity.cs create mode 100644 src/Artemis.Storage/Repositories/EntryRepository.cs create mode 100644 src/Artemis.Storage/Repositories/Interfaces/IEntryRepository.cs rename src/Artemis.WebClient.Workshop/DownloadHandlers/{IEntryDownloadHandler.cs => IEntryInstallationHandler.cs} (69%) delete mode 100644 src/Artemis.WebClient.Workshop/DownloadHandlers/Implementations/ProfileEntryDownloadHandler.cs create mode 100644 src/Artemis.WebClient.Workshop/DownloadHandlers/Implementations/ProfileEntryInstallationHandler.cs create mode 100644 src/Artemis.WebClient.Workshop/Services/InstalledEntry.cs rename src/Artemis.WebClient.Workshop/Services/{ => Interfaces}/IAuthenticationService.cs (100%) create mode 100644 src/Artemis.WebClient.Workshop/Services/Interfaces/IWorkshopService.cs rename src/Artemis.WebClient.Workshop/Services/{IWorkshopService.cs => WorkshopService.cs} (83%) diff --git a/src/Artemis.Core/Models/Profile/ProfileCategory.cs b/src/Artemis.Core/Models/Profile/ProfileCategory.cs index 13a3e2f4b..4dc3f73e6 100644 --- a/src/Artemis.Core/Models/Profile/ProfileCategory.cs +++ b/src/Artemis.Core/Models/Profile/ProfileCategory.cs @@ -98,6 +98,7 @@ public class ProfileCategory : CorePropertyChanged, IStorageModel /// public void AddProfileConfiguration(ProfileConfiguration configuration, int? targetIndex) { + // TODO: Look into this, it doesn't seem to make sense // Removing the original will shift every item in the list forwards, keep that in mind with the target index if (configuration.Category == this && targetIndex != null && targetIndex.Value > _profileConfigurations.IndexOf(configuration)) targetIndex -= 1; diff --git a/src/Artemis.Core/Services/Storage/Interfaces/IProfileService.cs b/src/Artemis.Core/Services/Storage/Interfaces/IProfileService.cs index 1f1eccf8c..59fae7a9d 100644 --- a/src/Artemis.Core/Services/Storage/Interfaces/IProfileService.cs +++ b/src/Artemis.Core/Services/Storage/Interfaces/IProfileService.cs @@ -127,7 +127,7 @@ public interface IProfileService : IArtemisService Task ExportProfile(ProfileConfiguration profileConfiguration); /// - /// Imports the provided base64 encoded GZIPed JSON as a profile configuration. + /// Imports the provided ZIP archive stream as a profile configuration. /// /// The zip archive containing the profile to import. /// The in which to import the profile. @@ -137,8 +137,17 @@ public interface IProfileService : IArtemisService /// any changes are made to it. /// /// Text to add after the name of the profile (separated by a dash). + /// The index at which to import the profile into the category. /// The resulting profile configuration. - Task ImportProfile(Stream archiveStream, ProfileCategory category, bool makeUnique, bool markAsFreshImport, string? nameAffix = "imported"); + Task ImportProfile(Stream archiveStream, ProfileCategory category, bool makeUnique, bool markAsFreshImport, string? nameAffix = "imported", int targetIndex = 0); + + /// + /// Imports the provided ZIP archive stream into the provided profile configuration + /// + /// The zip archive containing the profile to import. + /// The profile configuration to overwrite. + /// The resulting profile configuration. + Task OverwriteProfile(MemoryStream archiveStream, ProfileConfiguration profileConfiguration); /// /// Adapts a given profile to the currently active devices. @@ -176,4 +185,5 @@ public interface IProfileService : IArtemisService /// Occurs whenever a profile category is removed. /// public event EventHandler? ProfileCategoryRemoved; + } \ No newline at end of file diff --git a/src/Artemis.Core/Services/Storage/ProfileService.cs b/src/Artemis.Core/Services/Storage/ProfileService.cs index 3975713f2..7321fc6b2 100644 --- a/src/Artemis.Core/Services/Storage/ProfileService.cs +++ b/src/Artemis.Core/Services/Storage/ProfileService.cs @@ -438,7 +438,7 @@ internal class ProfileService : IProfileService } /// - public async Task ImportProfile(Stream archiveStream, ProfileCategory category, bool makeUnique, bool markAsFreshImport, string? nameAffix) + public async Task ImportProfile(Stream archiveStream, ProfileCategory category, bool makeUnique, bool markAsFreshImport, string? nameAffix, int targetIndex = 0) { using ZipArchive archive = new(archiveStream, ZipArchiveMode.Read, true); @@ -500,7 +500,7 @@ internal class ProfileService : IProfileService } profileConfiguration.Entity.ProfileId = profileEntity.Id; - category.AddProfileConfiguration(profileConfiguration, 0); + category.AddProfileConfiguration(profileConfiguration, targetIndex); List modules = _pluginManagementService.GetFeaturesOfType(); profileConfiguration.LoadModules(modules); @@ -509,6 +509,17 @@ internal class ProfileService : IProfileService return profileConfiguration; } + /// + public async Task OverwriteProfile(MemoryStream archiveStream, ProfileConfiguration profileConfiguration) + { + ProfileConfiguration imported = await ImportProfile(archiveStream, profileConfiguration.Category, true, true, null, profileConfiguration.Order + 1); + + DeleteProfile(profileConfiguration); + SaveProfileCategory(imported.Category); + + return imported; + } + /// public void AdaptProfile(Profile profile) { diff --git a/src/Artemis.Storage/Entities/Workshop/EntryEntity.cs b/src/Artemis.Storage/Entities/Workshop/EntryEntity.cs new file mode 100644 index 000000000..e1680ec1f --- /dev/null +++ b/src/Artemis.Storage/Entities/Workshop/EntryEntity.cs @@ -0,0 +1,21 @@ +using System; + +namespace Artemis.Storage.Entities.Workshop; + +public class EntryEntity +{ + public Guid Id { get; set; } + + public Guid EntryId { get; set; } + public int EntryType { get; set; } + + public string Author { get; set; } + public string Name { get; set; } = string.Empty; + public string Summary { get; set; } = string.Empty; + + public Guid ReleaseId { get; set; } + public string ReleaseVersion { get; set; } + public DateTimeOffset InstalledAt { get; set; } + + public string LocalReference { get; set; } +} \ No newline at end of file diff --git a/src/Artemis.Storage/Repositories/EntryRepository.cs b/src/Artemis.Storage/Repositories/EntryRepository.cs new file mode 100644 index 000000000..b4a572535 --- /dev/null +++ b/src/Artemis.Storage/Repositories/EntryRepository.cs @@ -0,0 +1,54 @@ +using System; +using System.Collections.Generic; +using Artemis.Storage.Entities.Workshop; +using Artemis.Storage.Repositories.Interfaces; +using LiteDB; + +namespace Artemis.Storage.Repositories; + +internal class EntryRepository : IEntryRepository +{ + private readonly LiteRepository _repository; + + public EntryRepository(LiteRepository repository) + { + _repository = repository; + _repository.Database.GetCollection().EnsureIndex(s => s.Id); + _repository.Database.GetCollection().EnsureIndex(s => s.EntryId); + } + + public void Add(EntryEntity entryEntity) + { + _repository.Insert(entryEntity); + } + + public void Remove(EntryEntity entryEntity) + { + _repository.Delete(entryEntity.Id); + } + + public EntryEntity Get(Guid id) + { + return _repository.FirstOrDefault(s => s.Id == id); + } + + public EntryEntity GetByEntryId(Guid entryId) + { + return _repository.FirstOrDefault(s => s.EntryId == entryId); + } + + public List GetAll() + { + return _repository.Query().ToList(); + } + + public void Save(EntryEntity entryEntity) + { + _repository.Upsert(entryEntity); + } + + public void Save(IEnumerable entryEntities) + { + _repository.Upsert(entryEntities); + } +} \ No newline at end of file diff --git a/src/Artemis.Storage/Repositories/Interfaces/IEntryRepository.cs b/src/Artemis.Storage/Repositories/Interfaces/IEntryRepository.cs new file mode 100644 index 000000000..19429b863 --- /dev/null +++ b/src/Artemis.Storage/Repositories/Interfaces/IEntryRepository.cs @@ -0,0 +1,16 @@ +using System; +using System.Collections.Generic; +using Artemis.Storage.Entities.Workshop; + +namespace Artemis.Storage.Repositories.Interfaces; + +public interface IEntryRepository : IRepository +{ + void Add(EntryEntity entryEntity); + void Remove(EntryEntity entryEntity); + EntryEntity Get(Guid id); + EntryEntity GetByEntryId(Guid entryId); + List GetAll(); + void Save(EntryEntity entryEntity); + void Save(IEnumerable entryEntities); +} \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/Profile/ProfileDetailsViewModel.cs b/src/Artemis.UI/Screens/Workshop/Profile/ProfileDetailsViewModel.cs index 273ee6c9d..2d026efec 100644 --- a/src/Artemis.UI/Screens/Workshop/Profile/ProfileDetailsViewModel.cs +++ b/src/Artemis.UI/Screens/Workshop/Profile/ProfileDetailsViewModel.cs @@ -20,16 +20,16 @@ namespace Artemis.UI.Screens.Workshop.Profile; public class ProfileDetailsViewModel : RoutableScreen { private readonly IWorkshopClient _client; - private readonly ProfileEntryDownloadHandler _downloadHandler; + private readonly ProfileEntryInstallationHandler _installationHandler; private readonly INotificationService _notificationService; private readonly IWindowService _windowService; private readonly ObservableAsPropertyHelper _updatedAt; private IGetEntryById_Entry? _entry; - public ProfileDetailsViewModel(IWorkshopClient client, ProfileEntryDownloadHandler downloadHandler, INotificationService notificationService, IWindowService windowService) + public ProfileDetailsViewModel(IWorkshopClient client, ProfileEntryInstallationHandler installationHandler, INotificationService notificationService, IWindowService windowService) { _client = client; - _downloadHandler = downloadHandler; + _installationHandler = installationHandler; _notificationService = notificationService; _windowService = windowService; _updatedAt = this.WhenAnyValue(vm => vm.Entry).Select(e => e?.LatestRelease?.CreatedAt ?? e?.CreatedAt).ToProperty(this, vm => vm.UpdatedAt); @@ -70,7 +70,7 @@ public class ProfileDetailsViewModel : RoutableScreen if (!confirm) return; - EntryInstallResult result = await _downloadHandler.InstallProfileAsync(Entry.LatestRelease.Id, new Progress(), cancellationToken); + EntryInstallResult result = await _installationHandler.InstallProfileAsync(Entry, Entry.LatestRelease.Id, new Progress(), cancellationToken); if (result.IsSuccess) _notificationService.CreateNotification().WithTitle("Profile installed").WithSeverity(NotificationSeverity.Success).Show(); else diff --git a/src/Artemis.UI/Screens/Workshop/Search/SearchView.axaml b/src/Artemis.UI/Screens/Workshop/Search/SearchView.axaml index 8ac1d728f..c66d738c1 100644 --- a/src/Artemis.UI/Screens/Workshop/Search/SearchView.axaml +++ b/src/Artemis.UI/Screens/Workshop/Search/SearchView.axaml @@ -5,6 +5,7 @@ xmlns:search="clr-namespace:Artemis.UI.Screens.Workshop.Search" xmlns:workshop="clr-namespace:Artemis.WebClient.Workshop;assembly=Artemis.WebClient.Workshop" xmlns:windowing="clr-namespace:FluentAvalonia.UI.Windowing;assembly=FluentAvalonia" + xmlns:avalonia="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia" mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" x:Class="Artemis.UI.Screens.Workshop.Search.SearchView" x:DataType="search:SearchViewModel"> @@ -30,12 +31,16 @@ + + \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/Search/SearchViewModel.cs b/src/Artemis.UI/Screens/Workshop/Search/SearchViewModel.cs index 130bef52d..4fe24197d 100644 --- a/src/Artemis.UI/Screens/Workshop/Search/SearchViewModel.cs +++ b/src/Artemis.UI/Screens/Workshop/Search/SearchViewModel.cs @@ -4,6 +4,7 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; using Artemis.UI.Screens.Workshop.CurrentUser; +using Artemis.UI.Services.Interfaces; using Artemis.UI.Shared; using Artemis.UI.Shared.Routing; using Artemis.WebClient.Workshop; @@ -18,15 +19,17 @@ public class SearchViewModel : ViewModelBase { private readonly ILogger _logger; private readonly IRouter _router; + private readonly IDebugService _debugService; private readonly IWorkshopClient _workshopClient; private bool _isLoading; private SearchResultViewModel? _selectedEntry; - public SearchViewModel(ILogger logger, IWorkshopClient workshopClient, IRouter router, CurrentUserViewModel currentUserViewModel) + public SearchViewModel(ILogger logger, IWorkshopClient workshopClient, IRouter router, CurrentUserViewModel currentUserViewModel, IDebugService debugService) { _logger = logger; _workshopClient = workshopClient; _router = router; + _debugService = debugService; CurrentUserViewModel = currentUserViewModel; SearchAsync = ExecuteSearchAsync; @@ -49,6 +52,11 @@ public class SearchViewModel : ViewModelBase set => RaiseAndSetIfChanged(ref _isLoading, value); } + public void ShowDebugger() + { + _debugService.ShowDebugger(); + } + private void NavigateToEntry(SearchResultViewModel searchResult) { string? url = null; diff --git a/src/Artemis.WebClient.Workshop/DownloadHandlers/IEntryDownloadHandler.cs b/src/Artemis.WebClient.Workshop/DownloadHandlers/IEntryInstallationHandler.cs similarity index 69% rename from src/Artemis.WebClient.Workshop/DownloadHandlers/IEntryDownloadHandler.cs rename to src/Artemis.WebClient.Workshop/DownloadHandlers/IEntryInstallationHandler.cs index 8d477e7ff..435e17e82 100644 --- a/src/Artemis.WebClient.Workshop/DownloadHandlers/IEntryDownloadHandler.cs +++ b/src/Artemis.WebClient.Workshop/DownloadHandlers/IEntryInstallationHandler.cs @@ -2,6 +2,6 @@ namespace Artemis.WebClient.Workshop.DownloadHandlers; -public interface IEntryDownloadHandler +public interface IEntryInstallationHandler { } \ No newline at end of file diff --git a/src/Artemis.WebClient.Workshop/DownloadHandlers/Implementations/ProfileEntryDownloadHandler.cs b/src/Artemis.WebClient.Workshop/DownloadHandlers/Implementations/ProfileEntryDownloadHandler.cs deleted file mode 100644 index cb7e6e7a6..000000000 --- a/src/Artemis.WebClient.Workshop/DownloadHandlers/Implementations/ProfileEntryDownloadHandler.cs +++ /dev/null @@ -1,36 +0,0 @@ -using Artemis.Core; -using Artemis.Core.Services; -using Artemis.UI.Shared.Extensions; -using Artemis.UI.Shared.Utilities; - -namespace Artemis.WebClient.Workshop.DownloadHandlers.Implementations; - -public class ProfileEntryDownloadHandler : IEntryDownloadHandler -{ - private readonly IHttpClientFactory _httpClientFactory; - private readonly IProfileService _profileService; - - public ProfileEntryDownloadHandler(IHttpClientFactory httpClientFactory, IProfileService profileService) - { - _httpClientFactory = httpClientFactory; - _profileService = profileService; - } - - public async Task> InstallProfileAsync(Guid releaseId, Progress progress, CancellationToken cancellationToken) - { - try - { - HttpClient client = _httpClientFactory.CreateClient(WorkshopConstants.WORKSHOP_CLIENT_NAME); - using MemoryStream stream = new(); - await client.DownloadDataAsync($"releases/download/{releaseId}", stream, progress, cancellationToken); - - ProfileCategory category = _profileService.ProfileCategories.FirstOrDefault(c => c.Name == "Workshop") ?? _profileService.CreateProfileCategory("Workshop", true); - ProfileConfiguration profileConfiguration = await _profileService.ImportProfile(stream, category, true, true, null); - return EntryInstallResult.FromSuccess(profileConfiguration); - } - catch (Exception e) - { - return EntryInstallResult.FromFailure(e.Message); - } - } -} \ No newline at end of file diff --git a/src/Artemis.WebClient.Workshop/DownloadHandlers/Implementations/ProfileEntryInstallationHandler.cs b/src/Artemis.WebClient.Workshop/DownloadHandlers/Implementations/ProfileEntryInstallationHandler.cs new file mode 100644 index 000000000..83d057e61 --- /dev/null +++ b/src/Artemis.WebClient.Workshop/DownloadHandlers/Implementations/ProfileEntryInstallationHandler.cs @@ -0,0 +1,73 @@ +using Artemis.Core; +using Artemis.Core.Services; +using Artemis.UI.Shared.Extensions; +using Artemis.UI.Shared.Utilities; +using Artemis.WebClient.Workshop.Services; + +namespace Artemis.WebClient.Workshop.DownloadHandlers.Implementations; + +public class ProfileEntryInstallationHandler : IEntryInstallationHandler +{ + private readonly IHttpClientFactory _httpClientFactory; + private readonly IProfileService _profileService; + private readonly IWorkshopService _workshopService; + + public ProfileEntryInstallationHandler(IHttpClientFactory httpClientFactory, IProfileService profileService, IWorkshopService workshopService) + { + _httpClientFactory = httpClientFactory; + _profileService = profileService; + _workshopService = workshopService; + } + + public async Task> InstallProfileAsync(IGetEntryById_Entry entry, Guid releaseId, Progress progress, CancellationToken cancellationToken) + { + using MemoryStream stream = new(); + + // Download the provided release + try + { + HttpClient client = _httpClientFactory.CreateClient(WorkshopConstants.WORKSHOP_CLIENT_NAME); + await client.DownloadDataAsync($"releases/download/{releaseId}", stream, progress, cancellationToken); + } + catch (Exception e) + { + return EntryInstallResult.FromFailure(e.Message); + } + + // Find existing installation to potentially replace the profile + InstalledEntry? installedEntry = _workshopService.GetInstalledEntry(entry); + if (installedEntry != null && Guid.TryParse(installedEntry.LocalReference, out Guid profileId)) + { + ProfileConfiguration? existing = _profileService.ProfileCategories.SelectMany(c => c.ProfileConfigurations).FirstOrDefault(c => c.ProfileId == profileId); + if (existing != null) + { + ProfileConfiguration overwritten = await _profileService.OverwriteProfile(stream, existing); + installedEntry.LocalReference = overwritten.ProfileId.ToString(); + + // Update the release and return the profile configuration + UpdateRelease(releaseId, installedEntry); + return EntryInstallResult.FromSuccess(overwritten); + } + } + + // Ensure there is an installed entry + installedEntry ??= _workshopService.CreateInstalledEntry(entry); + + // Add the profile as a fresh import + ProfileCategory category = _profileService.ProfileCategories.FirstOrDefault(c => c.Name == "Workshop") ?? _profileService.CreateProfileCategory("Workshop", true); + ProfileConfiguration imported = await _profileService.ImportProfile(stream, category, true, true, null); + installedEntry.LocalReference = imported.ProfileId.ToString(); + + // Update the release and return the profile configuration + UpdateRelease(releaseId, installedEntry); + return EntryInstallResult.FromSuccess(imported); + } + + private void UpdateRelease(Guid releaseId, InstalledEntry installedEntry) + { + installedEntry.ReleaseId = releaseId; + installedEntry.ReleaseVersion = "TODO"; + installedEntry.InstalledAt = DateTimeOffset.UtcNow; + _workshopService.SaveInstalledEntry(installedEntry); + } +} \ No newline at end of file diff --git a/src/Artemis.WebClient.Workshop/DryIoc/ContainerExtensions.cs b/src/Artemis.WebClient.Workshop/DryIoc/ContainerExtensions.cs index e98f8c8e7..5a4a29e6e 100644 --- a/src/Artemis.WebClient.Workshop/DryIoc/ContainerExtensions.cs +++ b/src/Artemis.WebClient.Workshop/DryIoc/ContainerExtensions.cs @@ -49,6 +49,6 @@ public static class ContainerExtensions container.Register(Reuse.Transient); container.RegisterMany(workshopAssembly, type => type.IsAssignableTo(), Reuse.Transient); - container.RegisterMany(workshopAssembly, type => type.IsAssignableTo(), Reuse.Transient); + container.RegisterMany(workshopAssembly, type => type.IsAssignableTo(), Reuse.Transient); } } \ No newline at end of file diff --git a/src/Artemis.WebClient.Workshop/Services/InstalledEntry.cs b/src/Artemis.WebClient.Workshop/Services/InstalledEntry.cs new file mode 100644 index 000000000..8ad0b50ab --- /dev/null +++ b/src/Artemis.WebClient.Workshop/Services/InstalledEntry.cs @@ -0,0 +1,72 @@ +using Artemis.Storage.Entities.Workshop; + +namespace Artemis.WebClient.Workshop.Services; + +public class InstalledEntry +{ + internal InstalledEntry(EntryEntity entity) + { + Entity = entity; + + Load(); + } + + public InstalledEntry(IGetEntryById_Entry entry) + { + Entity = new EntryEntity(); + + EntryId = entry.Id; + EntryType = entry.EntryType; + + Author = entry.Author; + Name = entry.Name; + Summary = entry.Summary; + } + + public Guid EntryId { get; set; } + public EntryType EntryType { get; set; } + + public string Author { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; + public string Summary { get; set; } = string.Empty; + + public Guid ReleaseId { get; set; } + public string ReleaseVersion { get; set; } = string.Empty; + public DateTimeOffset InstalledAt { get; set; } + + public string? LocalReference { get; set; } + + internal EntryEntity Entity { get; } + + internal void Load() + { + EntryId = Entity.EntryId; + EntryType = (EntryType) Entity.EntryType; + + Author = Entity.Author; + Name = Entity.Name; + Summary = Entity.Summary; + + ReleaseId = Entity.ReleaseId; + ReleaseVersion = Entity.ReleaseVersion; + InstalledAt = Entity.InstalledAt; + + LocalReference = Entity.LocalReference; + } + + internal void Save() + { + Entity.EntryId = EntryId; + Entity.EntryType = (int) EntryType; + + Entity.Author = Author; + Entity.Name = Name; + Entity.Summary = Summary; + + Entity.ReleaseId = ReleaseId; + Entity.ReleaseVersion = ReleaseVersion; + Entity.InstalledAt = InstalledAt; + + Entity.LocalReference = LocalReference; + } +} \ No newline at end of file diff --git a/src/Artemis.WebClient.Workshop/Services/IAuthenticationService.cs b/src/Artemis.WebClient.Workshop/Services/Interfaces/IAuthenticationService.cs similarity index 100% rename from src/Artemis.WebClient.Workshop/Services/IAuthenticationService.cs rename to src/Artemis.WebClient.Workshop/Services/Interfaces/IAuthenticationService.cs diff --git a/src/Artemis.WebClient.Workshop/Services/Interfaces/IWorkshopService.cs b/src/Artemis.WebClient.Workshop/Services/Interfaces/IWorkshopService.cs new file mode 100644 index 000000000..d7c0f8764 --- /dev/null +++ b/src/Artemis.WebClient.Workshop/Services/Interfaces/IWorkshopService.cs @@ -0,0 +1,18 @@ +using Artemis.UI.Shared.Utilities; +using Artemis.WebClient.Workshop.UploadHandlers; + +namespace Artemis.WebClient.Workshop.Services; + +public interface IWorkshopService +{ + Task GetEntryIcon(Guid entryId, CancellationToken cancellationToken); + Task SetEntryIcon(Guid entryId, Progress progress, Stream icon, CancellationToken cancellationToken); + Task GetWorkshopStatus(CancellationToken cancellationToken); + Task ValidateWorkshopStatus(CancellationToken cancellationToken); + Task NavigateToEntry(Guid entryId, EntryType entryType); + InstalledEntry? GetInstalledEntry(IGetEntryById_Entry entry); + InstalledEntry CreateInstalledEntry(IGetEntryById_Entry entry); + void SaveInstalledEntry(InstalledEntry entry); + + public record WorkshopStatus(bool IsReachable, string Message); +} \ No newline at end of file diff --git a/src/Artemis.WebClient.Workshop/Services/IWorkshopService.cs b/src/Artemis.WebClient.Workshop/Services/WorkshopService.cs similarity index 83% rename from src/Artemis.WebClient.Workshop/Services/IWorkshopService.cs rename to src/Artemis.WebClient.Workshop/Services/WorkshopService.cs index d1e0fb287..af29c9bb0 100644 --- a/src/Artemis.WebClient.Workshop/Services/IWorkshopService.cs +++ b/src/Artemis.WebClient.Workshop/Services/WorkshopService.cs @@ -1,4 +1,6 @@ using System.Net.Http.Headers; +using Artemis.Storage.Entities.Workshop; +using Artemis.Storage.Repositories.Interfaces; using Artemis.UI.Shared.Routing; using Artemis.UI.Shared.Utilities; using Artemis.WebClient.Workshop.UploadHandlers; @@ -9,11 +11,13 @@ public class WorkshopService : IWorkshopService { private readonly IHttpClientFactory _httpClientFactory; private readonly IRouter _router; + private readonly IEntryRepository _entryRepository; - public WorkshopService(IHttpClientFactory httpClientFactory, IRouter router) + public WorkshopService(IHttpClientFactory httpClientFactory, IRouter router, IEntryRepository entryRepository) { _httpClientFactory = httpClientFactory; _router = router; + _entryRepository = entryRepository; } public async Task GetEntryIcon(Guid entryId, CancellationToken cancellationToken) @@ -98,15 +102,27 @@ public class WorkshopService : IWorkshopService throw new ArgumentOutOfRangeException(nameof(entryType)); } } -} -public interface IWorkshopService -{ - Task GetEntryIcon(Guid entryId, CancellationToken cancellationToken); - Task SetEntryIcon(Guid entryId, Progress progress, Stream icon, CancellationToken cancellationToken); - Task GetWorkshopStatus(CancellationToken cancellationToken); - Task ValidateWorkshopStatus(CancellationToken cancellationToken); - Task NavigateToEntry(Guid entryId, EntryType entryType); + /// + public InstalledEntry? GetInstalledEntry(IGetEntryById_Entry entry) + { + EntryEntity? entity = _entryRepository.GetByEntryId(entry.Id); + if (entity == null) + return null; - public record WorkshopStatus(bool IsReachable, string Message); + return new InstalledEntry(entity); + } + + /// + public InstalledEntry CreateInstalledEntry(IGetEntryById_Entry entry) + { + return new InstalledEntry(entry); + } + + /// + public void SaveInstalledEntry(InstalledEntry entry) + { + entry.Save(); + _entryRepository.Save(entry.Entity); + } } \ No newline at end of file From 876465cfdb45733fcec4c2a146ff21bb771423d0 Mon Sep 17 00:00:00 2001 From: Robert Date: Wed, 6 Sep 2023 21:24:15 +0200 Subject: [PATCH 31/37] Workshop Library - Installed entries WIP --- .../Library/Tabs/InstalledTabItemView.axaml | 53 +++++++++++++++++++ .../Tabs/InstalledTabItemView.axaml.cs | 13 +++++ .../Library/Tabs/InstalledTabItemViewModel.cs | 14 +++++ .../Library/Tabs/InstalledTabView.axaml | 16 ++++-- .../Library/Tabs/InstalledTabViewModel.cs | 50 ++++++++++++++++- .../Services/Interfaces/IWorkshopService.cs | 2 + .../Services/WorkshopService.cs | 5 ++ 7 files changed, 148 insertions(+), 5 deletions(-) create mode 100644 src/Artemis.UI/Screens/Workshop/Library/Tabs/InstalledTabItemView.axaml create mode 100644 src/Artemis.UI/Screens/Workshop/Library/Tabs/InstalledTabItemView.axaml.cs create mode 100644 src/Artemis.UI/Screens/Workshop/Library/Tabs/InstalledTabItemViewModel.cs diff --git a/src/Artemis.UI/Screens/Workshop/Library/Tabs/InstalledTabItemView.axaml b/src/Artemis.UI/Screens/Workshop/Library/Tabs/InstalledTabItemView.axaml new file mode 100644 index 000000000..4d01b32b0 --- /dev/null +++ b/src/Artemis.UI/Screens/Workshop/Library/Tabs/InstalledTabItemView.axaml @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/Library/Tabs/InstalledTabItemView.axaml.cs b/src/Artemis.UI/Screens/Workshop/Library/Tabs/InstalledTabItemView.axaml.cs new file mode 100644 index 000000000..be6199b60 --- /dev/null +++ b/src/Artemis.UI/Screens/Workshop/Library/Tabs/InstalledTabItemView.axaml.cs @@ -0,0 +1,13 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Markup.Xaml; + +namespace Artemis.UI.Screens.Workshop.Library.Tabs; + +public partial class InstalledTabItemView : UserControl +{ + public InstalledTabItemView() + { + InitializeComponent(); + } +} \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/Library/Tabs/InstalledTabItemViewModel.cs b/src/Artemis.UI/Screens/Workshop/Library/Tabs/InstalledTabItemViewModel.cs new file mode 100644 index 000000000..54f171c52 --- /dev/null +++ b/src/Artemis.UI/Screens/Workshop/Library/Tabs/InstalledTabItemViewModel.cs @@ -0,0 +1,14 @@ +using Artemis.UI.Shared; +using Artemis.WebClient.Workshop.Services; + +namespace Artemis.UI.Screens.Workshop.Library.Tabs; + +public class InstalledTabItemViewModel : ViewModelBase +{ + public InstalledTabItemViewModel(InstalledEntry installedEntry) + { + InstalledEntry = installedEntry; + } + + public InstalledEntry InstalledEntry { get; } +} \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/Library/Tabs/InstalledTabView.axaml b/src/Artemis.UI/Screens/Workshop/Library/Tabs/InstalledTabView.axaml index df334299a..9689f09bf 100644 --- a/src/Artemis.UI/Screens/Workshop/Library/Tabs/InstalledTabView.axaml +++ b/src/Artemis.UI/Screens/Workshop/Library/Tabs/InstalledTabView.axaml @@ -2,7 +2,17 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" + xmlns:tabs="clr-namespace:Artemis.UI.Screens.Workshop.Library.Tabs" mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" - x:Class="Artemis.UI.Screens.Workshop.Library.Tabs.InstalledTabView"> - Installed entries management here 🫡 - + x:Class="Artemis.UI.Screens.Workshop.Library.Tabs.InstalledTabView" + x:DataType="tabs:InstalledTabViewModel"> + + + + + + + + + + \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/Library/Tabs/InstalledTabViewModel.cs b/src/Artemis.UI/Screens/Workshop/Library/Tabs/InstalledTabViewModel.cs index 3bfdf406f..9e02249f7 100644 --- a/src/Artemis.UI/Screens/Workshop/Library/Tabs/InstalledTabViewModel.cs +++ b/src/Artemis.UI/Screens/Workshop/Library/Tabs/InstalledTabViewModel.cs @@ -1,9 +1,55 @@ -using Artemis.UI.Shared; +using System; +using System.Collections.ObjectModel; +using System.Reactive.Disposables; +using System.Reactive.Linq; using Artemis.UI.Shared.Routing; +using Artemis.WebClient.Workshop.Services; +using Avalonia.ReactiveUI; +using DynamicData; +using DynamicData.Binding; +using ReactiveUI; namespace Artemis.UI.Screens.Workshop.Library.Tabs; public class InstalledTabViewModel : RoutableScreen { - + private string? _searchEntryInput; + + public InstalledTabViewModel(IWorkshopService workshopService, Func getInstalledTabItemViewModel) + { + SourceList installedEntries = new(); + IObservable> pluginFilter = this.WhenAnyValue(vm => vm.SearchEntryInput).Throttle(TimeSpan.FromMilliseconds(100)).Select(CreatePredicate); + + installedEntries.Connect() + .Filter(pluginFilter) + .Sort(SortExpressionComparer.Ascending(p => p.Name)) + .Transform(getInstalledTabItemViewModel) + .ObserveOn(AvaloniaScheduler.Instance) + .Bind(out ReadOnlyObservableCollection installedEntryViewModels) + .Subscribe(); + InstalledEntries = installedEntryViewModels; + + this.WhenActivated(d => + { + installedEntries.AddRange(workshopService.GetInstalledEntries()); + Disposable.Create(installedEntries, e => e.Clear()).DisposeWith(d); + }); + } + + public ReadOnlyObservableCollection InstalledEntries { get; } + + public string? SearchEntryInput + { + get => _searchEntryInput; + set => RaiseAndSetIfChanged(ref _searchEntryInput, value); + } + + private Func CreatePredicate(string? text) + { + if (string.IsNullOrWhiteSpace(text)) + return _ => true; + + return data => data.Name.Contains(text, StringComparison.InvariantCultureIgnoreCase) || + data.Summary.Contains(text, StringComparison.InvariantCultureIgnoreCase); + } } \ No newline at end of file diff --git a/src/Artemis.WebClient.Workshop/Services/Interfaces/IWorkshopService.cs b/src/Artemis.WebClient.Workshop/Services/Interfaces/IWorkshopService.cs index d7c0f8764..bda524109 100644 --- a/src/Artemis.WebClient.Workshop/Services/Interfaces/IWorkshopService.cs +++ b/src/Artemis.WebClient.Workshop/Services/Interfaces/IWorkshopService.cs @@ -10,6 +10,8 @@ public interface IWorkshopService Task GetWorkshopStatus(CancellationToken cancellationToken); Task ValidateWorkshopStatus(CancellationToken cancellationToken); Task NavigateToEntry(Guid entryId, EntryType entryType); + + List GetInstalledEntries(); InstalledEntry? GetInstalledEntry(IGetEntryById_Entry entry); InstalledEntry CreateInstalledEntry(IGetEntryById_Entry entry); void SaveInstalledEntry(InstalledEntry entry); diff --git a/src/Artemis.WebClient.Workshop/Services/WorkshopService.cs b/src/Artemis.WebClient.Workshop/Services/WorkshopService.cs index af29c9bb0..41924b8e1 100644 --- a/src/Artemis.WebClient.Workshop/Services/WorkshopService.cs +++ b/src/Artemis.WebClient.Workshop/Services/WorkshopService.cs @@ -103,6 +103,11 @@ public class WorkshopService : IWorkshopService } } + public List GetInstalledEntries() + { + return _entryRepository.GetAll().Select(e => new InstalledEntry(e)).ToList(); + } + /// public InstalledEntry? GetInstalledEntry(IGetEntryById_Entry entry) { From 3d1e53e395d2c5e116780c2b882827799979c788 Mon Sep 17 00:00:00 2001 From: Robert Date: Fri, 8 Sep 2023 17:06:48 +0200 Subject: [PATCH 32/37] Workshop Library - Finished basic implementation of installed tab --- .../ProfileEditor/ProfileEditorViewModel.cs | 4 +- .../Workshop/Layout/LayoutDetailsViewModel.cs | 1 - .../Library/SubmissionDetailViewModel.cs | 2 +- .../Library/Tabs/InstalledTabItemView.axaml | 45 ++++++----- .../Library/Tabs/InstalledTabItemViewModel.cs | 58 +++++++++++++- .../Library/Tabs/InstalledTabView.axaml | 39 +++++++--- .../Library/Tabs/InstalledTabViewModel.cs | 28 ++++--- .../Library/Tabs/SubmissionsTabItemView.axaml | 76 +++++++++++++++++++ .../Tabs/SubmissionsTabItemView.axaml.cs | 13 ++++ .../Tabs/SubmissionsTabItemViewModel.cs | 30 ++++++++ .../Library/Tabs/SubmissionsTabView.axaml | 76 +------------------ .../Library/Tabs/SubmissionsTabViewModel.cs | 23 +++--- .../Profile/ProfileDetailsViewModel.cs | 6 +- .../Steps/UploadStepViewModel.cs | 2 +- .../Artemis.WebClient.Workshop.csproj | 4 + .../DownloadHandlers/EntryUploadResult.cs | 28 ------- .../IEntryInstallationHandler.cs | 7 -- .../DryIoc/ContainerExtensions.cs | 4 +- .../EntryInstallResult.cs | 26 +++++++ .../EntryInstallationHandlerFactory.cs | 23 ++++++ .../EntryUninstallResult.cs | 24 ++++++ .../IEntryInstallationHandler.cs | 10 +++ .../ProfileEntryInstallationHandler.cs | 38 ++++++++-- .../EntryUploadHandlerFactory.cs | 4 +- .../UploadHandlers/EntryUploadResult.cs | 2 +- .../UploadHandlers/IEntryUploadHandler.cs | 2 +- .../UploadHandlers/ImageUploadResult.cs | 2 +- .../LayoutEntryUploadHandler.cs | 2 +- .../ProfileEntryUploadHandler.cs | 3 +- .../Services/InstalledEntry.cs | 4 - .../Services/Interfaces/IWorkshopService.cs | 4 +- .../Services/WorkshopService.cs | 8 +- 32 files changed, 407 insertions(+), 191 deletions(-) create mode 100644 src/Artemis.UI/Screens/Workshop/Library/Tabs/SubmissionsTabItemView.axaml create mode 100644 src/Artemis.UI/Screens/Workshop/Library/Tabs/SubmissionsTabItemView.axaml.cs create mode 100644 src/Artemis.UI/Screens/Workshop/Library/Tabs/SubmissionsTabItemViewModel.cs delete mode 100644 src/Artemis.WebClient.Workshop/DownloadHandlers/EntryUploadResult.cs delete mode 100644 src/Artemis.WebClient.Workshop/DownloadHandlers/IEntryInstallationHandler.cs create mode 100644 src/Artemis.WebClient.Workshop/Handlers/InstallationHandlers/EntryInstallResult.cs create mode 100644 src/Artemis.WebClient.Workshop/Handlers/InstallationHandlers/EntryInstallationHandlerFactory.cs create mode 100644 src/Artemis.WebClient.Workshop/Handlers/InstallationHandlers/EntryUninstallResult.cs create mode 100644 src/Artemis.WebClient.Workshop/Handlers/InstallationHandlers/IEntryInstallationHandler.cs rename src/Artemis.WebClient.Workshop/{DownloadHandlers => Handlers/InstallationHandlers}/Implementations/ProfileEntryInstallationHandler.cs (66%) rename src/Artemis.WebClient.Workshop/{ => Handlers}/UploadHandlers/EntryUploadHandlerFactory.cs (81%) rename src/Artemis.WebClient.Workshop/{ => Handlers}/UploadHandlers/EntryUploadResult.cs (90%) rename src/Artemis.WebClient.Workshop/{ => Handlers}/UploadHandlers/IEntryUploadHandler.cs (78%) rename src/Artemis.WebClient.Workshop/{ => Handlers}/UploadHandlers/ImageUploadResult.cs (86%) rename src/Artemis.WebClient.Workshop/{ => Handlers}/UploadHandlers/Implementations/LayoutEntryUploadHandler.cs (81%) rename src/Artemis.WebClient.Workshop/{ => Handlers}/UploadHandlers/Implementations/ProfileEntryUploadHandler.cs (94%) diff --git a/src/Artemis.UI/Screens/ProfileEditor/ProfileEditorViewModel.cs b/src/Artemis.UI/Screens/ProfileEditor/ProfileEditorViewModel.cs index b3da24ce2..5184c3c94 100644 --- a/src/Artemis.UI/Screens/ProfileEditor/ProfileEditorViewModel.cs +++ b/src/Artemis.UI/Screens/ProfileEditor/ProfileEditorViewModel.cs @@ -160,10 +160,10 @@ public class ProfileEditorViewModel : RoutableScreen c.ProfileId == parameters.ProfileId); - // If the profile doesn't exist, navigate home for lack of some kind of 404 :p + // If the profile doesn't exist, cancel navigation if (profileConfiguration == null) { - await args.Router.Navigate("home"); + args.Cancel(); return; } diff --git a/src/Artemis.UI/Screens/Workshop/Layout/LayoutDetailsViewModel.cs b/src/Artemis.UI/Screens/Workshop/Layout/LayoutDetailsViewModel.cs index e559e7e09..45de1c801 100644 --- a/src/Artemis.UI/Screens/Workshop/Layout/LayoutDetailsViewModel.cs +++ b/src/Artemis.UI/Screens/Workshop/Layout/LayoutDetailsViewModel.cs @@ -10,7 +10,6 @@ using Artemis.UI.Shared.Services; using Artemis.UI.Shared.Services.Builders; using Artemis.UI.Shared.Utilities; using Artemis.WebClient.Workshop; -using Artemis.WebClient.Workshop.DownloadHandlers; using ReactiveUI; using StrawberryShake; diff --git a/src/Artemis.UI/Screens/Workshop/Library/SubmissionDetailViewModel.cs b/src/Artemis.UI/Screens/Workshop/Library/SubmissionDetailViewModel.cs index 2b05edc76..61270df28 100644 --- a/src/Artemis.UI/Screens/Workshop/Library/SubmissionDetailViewModel.cs +++ b/src/Artemis.UI/Screens/Workshop/Library/SubmissionDetailViewModel.cs @@ -15,8 +15,8 @@ using Artemis.UI.Shared.Services; using Artemis.UI.Shared.Utilities; using Artemis.WebClient.Workshop; using Artemis.WebClient.Workshop.Exceptions; +using Artemis.WebClient.Workshop.Handlers.UploadHandlers; using Artemis.WebClient.Workshop.Services; -using Artemis.WebClient.Workshop.UploadHandlers; using Avalonia.Media.Imaging; using ReactiveUI; using StrawberryShake; diff --git a/src/Artemis.UI/Screens/Workshop/Library/Tabs/InstalledTabItemView.axaml b/src/Artemis.UI/Screens/Workshop/Library/Tabs/InstalledTabItemView.axaml index 4d01b32b0..3631f30ef 100644 --- a/src/Artemis.UI/Screens/Workshop/Library/Tabs/InstalledTabItemView.axaml +++ b/src/Artemis.UI/Screens/Workshop/Library/Tabs/InstalledTabItemView.axaml @@ -5,6 +5,7 @@ xmlns:tabs="clr-namespace:Artemis.UI.Screens.Workshop.Library.Tabs" xmlns:asyncImageLoader="clr-namespace:AsyncImageLoader;assembly=AsyncImageLoader.Avalonia" xmlns:converters="clr-namespace:Artemis.UI.Converters" + xmlns:avalonia="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia" mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" x:Class="Artemis.UI.Screens.Workshop.Library.Tabs.InstalledTabItemView" x:DataType="tabs:InstalledTabItemViewModel"> @@ -12,14 +13,14 @@ - - - + HorizontalAlignment="Stretch" + HorizontalContentAlignment="Stretch" + Command="{CompiledBinding ViewWorkshopPage}"> + - - - + - + Text="{CompiledBinding InstalledEntry.Author, FallbackValue=Summary}"> - - - - - + + + + + Installed + + + + + + - + \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/Library/Tabs/InstalledTabItemViewModel.cs b/src/Artemis.UI/Screens/Workshop/Library/Tabs/InstalledTabItemViewModel.cs index 54f171c52..1b22e37aa 100644 --- a/src/Artemis.UI/Screens/Workshop/Library/Tabs/InstalledTabItemViewModel.cs +++ b/src/Artemis.UI/Screens/Workshop/Library/Tabs/InstalledTabItemViewModel.cs @@ -1,14 +1,70 @@ +using System; +using System.Reactive; +using System.Threading; +using System.Threading.Tasks; using Artemis.UI.Shared; +using Artemis.UI.Shared.Routing; +using Artemis.UI.Shared.Services; +using Artemis.WebClient.Workshop; +using Artemis.WebClient.Workshop.Handlers.InstallationHandlers; using Artemis.WebClient.Workshop.Services; +using ReactiveUI; namespace Artemis.UI.Screens.Workshop.Library.Tabs; public class InstalledTabItemViewModel : ViewModelBase { - public InstalledTabItemViewModel(InstalledEntry installedEntry) + private readonly IWorkshopService _workshopService; + private readonly IRouter _router; + private readonly EntryInstallationHandlerFactory _factory; + private readonly IWindowService _windowService; + private bool _isRemoved; + + public InstalledTabItemViewModel(InstalledEntry installedEntry, IWorkshopService workshopService, IRouter router, EntryInstallationHandlerFactory factory, IWindowService windowService) { + _workshopService = workshopService; + _router = router; + _factory = factory; + _windowService = windowService; InstalledEntry = installedEntry; + + ViewWorkshopPage = ReactiveCommand.CreateFromTask(ExecuteViewWorkshopPage); + ViewLocal = ReactiveCommand.CreateFromTask(ExecuteViewLocal); + Uninstall = ReactiveCommand.CreateFromTask(ExecuteUninstall); + } + + public bool IsRemoved + { + get => _isRemoved; + private set => RaiseAndSetIfChanged(ref _isRemoved, value); } public InstalledEntry InstalledEntry { get; } + public ReactiveCommand ViewWorkshopPage { get; } + public ReactiveCommand ViewLocal { get; } + public ReactiveCommand Uninstall { get; } + + private async Task ExecuteViewWorkshopPage() + { + await _workshopService.NavigateToEntry(InstalledEntry.EntryId, InstalledEntry.EntryType); + } + + private async Task ExecuteViewLocal(CancellationToken cancellationToken) + { + if (InstalledEntry.EntryType == EntryType.Profile && Guid.TryParse(InstalledEntry.LocalReference, out Guid profileId)) + { + await _router.Navigate($"profile-editor/{profileId}"); + } + } + + private async Task ExecuteUninstall(CancellationToken cancellationToken) + { + bool confirmed = await _windowService.ShowConfirmContentDialog("Do you want to uninstall this entry?", "Both the entry and its contents will be removed."); + if (!confirmed) + return; + + IEntryInstallationHandler handler = _factory.CreateHandler(InstalledEntry.EntryType); + await handler.UninstallAsync(InstalledEntry, cancellationToken); + IsRemoved = true; + } } \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/Library/Tabs/InstalledTabView.axaml b/src/Artemis.UI/Screens/Workshop/Library/Tabs/InstalledTabView.axaml index 9689f09bf..fe1fef9e2 100644 --- a/src/Artemis.UI/Screens/Workshop/Library/Tabs/InstalledTabView.axaml +++ b/src/Artemis.UI/Screens/Workshop/Library/Tabs/InstalledTabView.axaml @@ -6,13 +6,34 @@ mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" x:Class="Artemis.UI.Screens.Workshop.Library.Tabs.InstalledTabView" x:DataType="tabs:InstalledTabViewModel"> - - - - - - - - - + + + + + + + + + Not much here yet, huh! + + Any entries you download from the workshop you can later manage here + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/Library/Tabs/InstalledTabViewModel.cs b/src/Artemis.UI/Screens/Workshop/Library/Tabs/InstalledTabViewModel.cs index 9e02249f7..7267c6e25 100644 --- a/src/Artemis.UI/Screens/Workshop/Library/Tabs/InstalledTabViewModel.cs +++ b/src/Artemis.UI/Screens/Workshop/Library/Tabs/InstalledTabViewModel.cs @@ -1,10 +1,10 @@ using System; +using System.Collections.Generic; using System.Collections.ObjectModel; -using System.Reactive.Disposables; +using System.Reactive; using System.Reactive.Linq; using Artemis.UI.Shared.Routing; using Artemis.WebClient.Workshop.Services; -using Avalonia.ReactiveUI; using DynamicData; using DynamicData.Binding; using ReactiveUI; @@ -15,27 +15,32 @@ public class InstalledTabViewModel : RoutableScreen { private string? _searchEntryInput; - public InstalledTabViewModel(IWorkshopService workshopService, Func getInstalledTabItemViewModel) + public InstalledTabViewModel(IWorkshopService workshopService, IRouter router, Func getInstalledTabItemViewModel) { SourceList installedEntries = new(); IObservable> pluginFilter = this.WhenAnyValue(vm => vm.SearchEntryInput).Throttle(TimeSpan.FromMilliseconds(100)).Select(CreatePredicate); installedEntries.Connect() .Filter(pluginFilter) - .Sort(SortExpressionComparer.Ascending(p => p.Name)) + .Sort(SortExpressionComparer.Descending(p => p.InstalledAt)) .Transform(getInstalledTabItemViewModel) - .ObserveOn(AvaloniaScheduler.Instance) + .AutoRefresh(vm => vm.IsRemoved) + .Filter(vm => !vm.IsRemoved) .Bind(out ReadOnlyObservableCollection installedEntryViewModels) .Subscribe(); + + List entries = workshopService.GetInstalledEntries(); + installedEntries.AddRange(entries); + + Empty = entries.Count == 0; InstalledEntries = installedEntryViewModels; - this.WhenActivated(d => - { - installedEntries.AddRange(workshopService.GetInstalledEntries()); - Disposable.Create(installedEntries, e => e.Clear()).DisposeWith(d); - }); + OpenWorkshop = ReactiveCommand.CreateFromTask(async () => await router.Navigate("workshop")); } + public bool Empty { get; } + public ReactiveCommand OpenWorkshop { get; } + public ReadOnlyObservableCollection InstalledEntries { get; } public string? SearchEntryInput @@ -49,7 +54,6 @@ public class InstalledTabViewModel : RoutableScreen if (string.IsNullOrWhiteSpace(text)) return _ => true; - return data => data.Name.Contains(text, StringComparison.InvariantCultureIgnoreCase) || - data.Summary.Contains(text, StringComparison.InvariantCultureIgnoreCase); + return data => data.Name.Contains(text, StringComparison.InvariantCultureIgnoreCase); } } \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/Library/Tabs/SubmissionsTabItemView.axaml b/src/Artemis.UI/Screens/Workshop/Library/Tabs/SubmissionsTabItemView.axaml new file mode 100644 index 000000000..583f14c4f --- /dev/null +++ b/src/Artemis.UI/Screens/Workshop/Library/Tabs/SubmissionsTabItemView.axaml @@ -0,0 +1,76 @@ + + + + + + + \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/Library/Tabs/SubmissionsTabItemView.axaml.cs b/src/Artemis.UI/Screens/Workshop/Library/Tabs/SubmissionsTabItemView.axaml.cs new file mode 100644 index 000000000..8bf14a3bf --- /dev/null +++ b/src/Artemis.UI/Screens/Workshop/Library/Tabs/SubmissionsTabItemView.axaml.cs @@ -0,0 +1,13 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Markup.Xaml; + +namespace Artemis.UI.Screens.Workshop.Library.Tabs; + +public partial class SubmissionsTabItemView : UserControl +{ + public SubmissionsTabItemView() + { + InitializeComponent(); + } +} \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/Library/Tabs/SubmissionsTabItemViewModel.cs b/src/Artemis.UI/Screens/Workshop/Library/Tabs/SubmissionsTabItemViewModel.cs new file mode 100644 index 000000000..21c8098ee --- /dev/null +++ b/src/Artemis.UI/Screens/Workshop/Library/Tabs/SubmissionsTabItemViewModel.cs @@ -0,0 +1,30 @@ +using System.Reactive; +using System.Threading; +using System.Threading.Tasks; +using Artemis.UI.Shared; +using Artemis.UI.Shared.Routing; +using Artemis.WebClient.Workshop; +using ReactiveUI; + +namespace Artemis.UI.Screens.Workshop.Library.Tabs; + +public class SubmissionsTabItemViewModel : ViewModelBase +{ + private readonly IRouter _router; + + public SubmissionsTabItemViewModel(IGetSubmittedEntries_SubmittedEntries entry, IRouter router) + { + _router = router; + Entry = entry; + + NavigateToEntry = ReactiveCommand.CreateFromTask(ExecuteNavigateToEntry); + } + + public IGetSubmittedEntries_SubmittedEntries Entry { get; } + public ReactiveCommand NavigateToEntry { get; } + + private async Task ExecuteNavigateToEntry(CancellationToken cancellationToken) + { + await _router.Navigate($"workshop/library/submissions/{Entry.Id}"); + } +} \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/Library/Tabs/SubmissionsTabView.axaml b/src/Artemis.UI/Screens/Workshop/Library/Tabs/SubmissionsTabView.axaml index 9fb0f3055..c52644447 100644 --- a/src/Artemis.UI/Screens/Workshop/Library/Tabs/SubmissionsTabView.axaml +++ b/src/Artemis.UI/Screens/Workshop/Library/Tabs/SubmissionsTabView.axaml @@ -3,17 +3,9 @@ xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:tabs="clr-namespace:Artemis.UI.Screens.Workshop.Library.Tabs" - xmlns:workshop="clr-namespace:Artemis.WebClient.Workshop;assembly=Artemis.WebClient.Workshop" - xmlns:avalonia="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia" - xmlns:asyncImageLoader="clr-namespace:AsyncImageLoader;assembly=AsyncImageLoader.Avalonia" - xmlns:converters="clr-namespace:Artemis.UI.Converters" mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="650" x:Class="Artemis.UI.Screens.Workshop.Library.Tabs.SubmissionsTabView" x:DataType="tabs:SubmissionsTabViewModel"> - - - - @@ -44,71 +36,11 @@ - - + + - - + + diff --git a/src/Artemis.UI/Screens/Workshop/Library/Tabs/SubmissionsTabViewModel.cs b/src/Artemis.UI/Screens/Workshop/Library/Tabs/SubmissionsTabViewModel.cs index 0b4a906f6..14ae89b70 100644 --- a/src/Artemis.UI/Screens/Workshop/Library/Tabs/SubmissionsTabViewModel.cs +++ b/src/Artemis.UI/Screens/Workshop/Library/Tabs/SubmissionsTabViewModel.cs @@ -21,21 +21,25 @@ public class SubmissionsTabViewModel : RoutableScreen private readonly IWorkshopClient _client; private readonly SourceCache _entries; private readonly IWindowService _windowService; - private readonly IRouter _router; private bool _isLoading = true; private bool _workshopReachable; - public SubmissionsTabViewModel(IWorkshopClient client, IAuthenticationService authenticationService, IWindowService windowService, IWorkshopService workshopService, IRouter router) + public SubmissionsTabViewModel(IWorkshopClient client, + IAuthenticationService authenticationService, + IWindowService windowService, + IWorkshopService workshopService, + Func getSubmissionsTabItemViewModel) { _client = client; _windowService = windowService; - _router = router; _entries = new SourceCache(e => e.Id); - _entries.Connect().Bind(out ReadOnlyObservableCollection entries).Subscribe(); + _entries.Connect() + .Transform(getSubmissionsTabItemViewModel) + .Bind(out ReadOnlyObservableCollection entries) + .Subscribe(); AddSubmission = ReactiveCommand.CreateFromTask(ExecuteAddSubmission, this.WhenAnyValue(vm => vm.WorkshopReachable)); Login = ReactiveCommand.CreateFromTask(ExecuteLogin, this.WhenAnyValue(vm => vm.WorkshopReachable)); - NavigateToEntry = ReactiveCommand.CreateFromTask(ExecuteNavigateToEntry); IsLoggedIn = authenticationService.IsLoggedIn; Entries = entries; @@ -53,7 +57,7 @@ public class SubmissionsTabViewModel : RoutableScreen public ReactiveCommand NavigateToEntry { get; } public IObservable IsLoggedIn { get; } - public ReadOnlyObservableCollection Entries { get; } + public ReadOnlyObservableCollection Entries { get; } public bool WorkshopReachable { @@ -77,12 +81,7 @@ public class SubmissionsTabViewModel : RoutableScreen { await _windowService.ShowDialogAsync(); } - - private async Task ExecuteNavigateToEntry(IGetSubmittedEntries_SubmittedEntries entry, CancellationToken cancellationToken) - { - await _router.Navigate($"workshop/library/submissions/{entry.Id}"); - } - + private async Task GetEntries(CancellationToken ct) { IsLoading = true; diff --git a/src/Artemis.UI/Screens/Workshop/Profile/ProfileDetailsViewModel.cs b/src/Artemis.UI/Screens/Workshop/Profile/ProfileDetailsViewModel.cs index 2d026efec..80a09c02e 100644 --- a/src/Artemis.UI/Screens/Workshop/Profile/ProfileDetailsViewModel.cs +++ b/src/Artemis.UI/Screens/Workshop/Profile/ProfileDetailsViewModel.cs @@ -10,8 +10,8 @@ using Artemis.UI.Shared.Services; using Artemis.UI.Shared.Services.Builders; using Artemis.UI.Shared.Utilities; using Artemis.WebClient.Workshop; -using Artemis.WebClient.Workshop.DownloadHandlers; -using Artemis.WebClient.Workshop.DownloadHandlers.Implementations; +using Artemis.WebClient.Workshop.Handlers.InstallationHandlers; +using Artemis.WebClient.Workshop.Handlers.InstallationHandlers.Implementations; using ReactiveUI; using StrawberryShake; @@ -70,7 +70,7 @@ public class ProfileDetailsViewModel : RoutableScreen if (!confirm) return; - EntryInstallResult result = await _installationHandler.InstallProfileAsync(Entry, Entry.LatestRelease.Id, new Progress(), cancellationToken); + EntryInstallResult result = await _installationHandler.InstallAsync(Entry, Entry.LatestRelease.Id, new Progress(), cancellationToken); if (result.IsSuccess) _notificationService.CreateNotification().WithTitle("Profile installed").WithSeverity(NotificationSeverity.Success).Show(); else diff --git a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/UploadStepViewModel.cs b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/UploadStepViewModel.cs index 7d0413714..689f80c4e 100644 --- a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/UploadStepViewModel.cs +++ b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/UploadStepViewModel.cs @@ -8,8 +8,8 @@ using Artemis.UI.Shared.Services; using Artemis.UI.Shared.Utilities; using Artemis.WebClient.Workshop; using Artemis.WebClient.Workshop.Exceptions; +using Artemis.WebClient.Workshop.Handlers.UploadHandlers; using Artemis.WebClient.Workshop.Services; -using Artemis.WebClient.Workshop.UploadHandlers; using ReactiveUI; using StrawberryShake; diff --git a/src/Artemis.WebClient.Workshop/Artemis.WebClient.Workshop.csproj b/src/Artemis.WebClient.Workshop/Artemis.WebClient.Workshop.csproj index 389fa75e5..391d5c4b5 100644 --- a/src/Artemis.WebClient.Workshop/Artemis.WebClient.Workshop.csproj +++ b/src/Artemis.WebClient.Workshop/Artemis.WebClient.Workshop.csproj @@ -48,4 +48,8 @@ MSBuild:GenerateGraphQLCode + + + + diff --git a/src/Artemis.WebClient.Workshop/DownloadHandlers/EntryUploadResult.cs b/src/Artemis.WebClient.Workshop/DownloadHandlers/EntryUploadResult.cs deleted file mode 100644 index d1162bee8..000000000 --- a/src/Artemis.WebClient.Workshop/DownloadHandlers/EntryUploadResult.cs +++ /dev/null @@ -1,28 +0,0 @@ -using Artemis.Web.Workshop.Entities; - -namespace Artemis.WebClient.Workshop.DownloadHandlers; - -public class EntryInstallResult -{ - public bool IsSuccess { get; set; } - public string? Message { get; set; } - public T? Result { get; set; } - - public static EntryInstallResult FromFailure(string? message) - { - return new EntryInstallResult - { - IsSuccess = false, - Message = message - }; - } - - public static EntryInstallResult FromSuccess(T installationResult) - { - return new EntryInstallResult - { - IsSuccess = true, - Result = installationResult - }; - } -} \ No newline at end of file diff --git a/src/Artemis.WebClient.Workshop/DownloadHandlers/IEntryInstallationHandler.cs b/src/Artemis.WebClient.Workshop/DownloadHandlers/IEntryInstallationHandler.cs deleted file mode 100644 index 435e17e82..000000000 --- a/src/Artemis.WebClient.Workshop/DownloadHandlers/IEntryInstallationHandler.cs +++ /dev/null @@ -1,7 +0,0 @@ -using Artemis.UI.Shared.Utilities; - -namespace Artemis.WebClient.Workshop.DownloadHandlers; - -public interface IEntryInstallationHandler -{ -} \ No newline at end of file diff --git a/src/Artemis.WebClient.Workshop/DryIoc/ContainerExtensions.cs b/src/Artemis.WebClient.Workshop/DryIoc/ContainerExtensions.cs index 5a4a29e6e..ddadc110e 100644 --- a/src/Artemis.WebClient.Workshop/DryIoc/ContainerExtensions.cs +++ b/src/Artemis.WebClient.Workshop/DryIoc/ContainerExtensions.cs @@ -1,10 +1,10 @@ using System.Reflection; -using Artemis.WebClient.Workshop.DownloadHandlers; using Artemis.WebClient.Workshop.Extensions; +using Artemis.WebClient.Workshop.Handlers.InstallationHandlers; +using Artemis.WebClient.Workshop.Handlers.UploadHandlers; using Artemis.WebClient.Workshop.Repositories; using Artemis.WebClient.Workshop.Services; using Artemis.WebClient.Workshop.State; -using Artemis.WebClient.Workshop.UploadHandlers; using DryIoc; using DryIoc.Microsoft.DependencyInjection; using IdentityModel.Client; diff --git a/src/Artemis.WebClient.Workshop/Handlers/InstallationHandlers/EntryInstallResult.cs b/src/Artemis.WebClient.Workshop/Handlers/InstallationHandlers/EntryInstallResult.cs new file mode 100644 index 000000000..aaf56859e --- /dev/null +++ b/src/Artemis.WebClient.Workshop/Handlers/InstallationHandlers/EntryInstallResult.cs @@ -0,0 +1,26 @@ +namespace Artemis.WebClient.Workshop.Handlers.InstallationHandlers; + +public class EntryInstallResult +{ + public bool IsSuccess { get; set; } + public string? Message { get; set; } + public object? Result { get; set; } + + public static EntryInstallResult FromFailure(string? message) + { + return new EntryInstallResult + { + IsSuccess = false, + Message = message + }; + } + + public static EntryInstallResult FromSuccess(object installationResult) + { + return new EntryInstallResult + { + IsSuccess = true, + Result = installationResult + }; + } +} \ No newline at end of file diff --git a/src/Artemis.WebClient.Workshop/Handlers/InstallationHandlers/EntryInstallationHandlerFactory.cs b/src/Artemis.WebClient.Workshop/Handlers/InstallationHandlers/EntryInstallationHandlerFactory.cs new file mode 100644 index 000000000..ec9c63566 --- /dev/null +++ b/src/Artemis.WebClient.Workshop/Handlers/InstallationHandlers/EntryInstallationHandlerFactory.cs @@ -0,0 +1,23 @@ +using Artemis.WebClient.Workshop.Handlers.InstallationHandlers.Implementations; +using DryIoc; + +namespace Artemis.WebClient.Workshop.Handlers.InstallationHandlers; + +public class EntryInstallationHandlerFactory +{ + private readonly IContainer _container; + + public EntryInstallationHandlerFactory(IContainer container) + { + _container = container; + } + + public IEntryInstallationHandler CreateHandler(EntryType entryType) + { + return entryType switch + { + EntryType.Profile => _container.Resolve(), + _ => throw new NotSupportedException($"EntryType '{entryType}' is not supported.") + }; + } +} \ No newline at end of file diff --git a/src/Artemis.WebClient.Workshop/Handlers/InstallationHandlers/EntryUninstallResult.cs b/src/Artemis.WebClient.Workshop/Handlers/InstallationHandlers/EntryUninstallResult.cs new file mode 100644 index 000000000..6d28edf9a --- /dev/null +++ b/src/Artemis.WebClient.Workshop/Handlers/InstallationHandlers/EntryUninstallResult.cs @@ -0,0 +1,24 @@ +namespace Artemis.WebClient.Workshop.Handlers.InstallationHandlers; + +public class EntryUninstallResult +{ + public bool IsSuccess { get; set; } + public string? Message { get; set; } + + public static EntryUninstallResult FromFailure(string? message) + { + return new EntryUninstallResult + { + IsSuccess = false, + Message = message + }; + } + + public static EntryUninstallResult FromSuccess() + { + return new EntryUninstallResult + { + IsSuccess = true + }; + } +} \ No newline at end of file diff --git a/src/Artemis.WebClient.Workshop/Handlers/InstallationHandlers/IEntryInstallationHandler.cs b/src/Artemis.WebClient.Workshop/Handlers/InstallationHandlers/IEntryInstallationHandler.cs new file mode 100644 index 000000000..91b3fb7f8 --- /dev/null +++ b/src/Artemis.WebClient.Workshop/Handlers/InstallationHandlers/IEntryInstallationHandler.cs @@ -0,0 +1,10 @@ +using Artemis.UI.Shared.Utilities; +using Artemis.WebClient.Workshop.Services; + +namespace Artemis.WebClient.Workshop.Handlers.InstallationHandlers; + +public interface IEntryInstallationHandler +{ + Task InstallAsync(IGetEntryById_Entry entry, Guid releaseId, Progress progress, CancellationToken cancellationToken); + Task UninstallAsync(InstalledEntry installedEntry, CancellationToken cancellationToken); +} \ No newline at end of file diff --git a/src/Artemis.WebClient.Workshop/DownloadHandlers/Implementations/ProfileEntryInstallationHandler.cs b/src/Artemis.WebClient.Workshop/Handlers/InstallationHandlers/Implementations/ProfileEntryInstallationHandler.cs similarity index 66% rename from src/Artemis.WebClient.Workshop/DownloadHandlers/Implementations/ProfileEntryInstallationHandler.cs rename to src/Artemis.WebClient.Workshop/Handlers/InstallationHandlers/Implementations/ProfileEntryInstallationHandler.cs index 83d057e61..c61921b06 100644 --- a/src/Artemis.WebClient.Workshop/DownloadHandlers/Implementations/ProfileEntryInstallationHandler.cs +++ b/src/Artemis.WebClient.Workshop/Handlers/InstallationHandlers/Implementations/ProfileEntryInstallationHandler.cs @@ -4,7 +4,7 @@ using Artemis.UI.Shared.Extensions; using Artemis.UI.Shared.Utilities; using Artemis.WebClient.Workshop.Services; -namespace Artemis.WebClient.Workshop.DownloadHandlers.Implementations; +namespace Artemis.WebClient.Workshop.Handlers.InstallationHandlers.Implementations; public class ProfileEntryInstallationHandler : IEntryInstallationHandler { @@ -19,7 +19,7 @@ public class ProfileEntryInstallationHandler : IEntryInstallationHandler _workshopService = workshopService; } - public async Task> InstallProfileAsync(IGetEntryById_Entry entry, Guid releaseId, Progress progress, CancellationToken cancellationToken) + public async Task InstallAsync(IGetEntryById_Entry entry, Guid releaseId, Progress progress, CancellationToken cancellationToken) { using MemoryStream stream = new(); @@ -31,7 +31,7 @@ public class ProfileEntryInstallationHandler : IEntryInstallationHandler } catch (Exception e) { - return EntryInstallResult.FromFailure(e.Message); + return EntryInstallResult.FromFailure(e.Message); } // Find existing installation to potentially replace the profile @@ -43,10 +43,10 @@ public class ProfileEntryInstallationHandler : IEntryInstallationHandler { ProfileConfiguration overwritten = await _profileService.OverwriteProfile(stream, existing); installedEntry.LocalReference = overwritten.ProfileId.ToString(); - + // Update the release and return the profile configuration UpdateRelease(releaseId, installedEntry); - return EntryInstallResult.FromSuccess(overwritten); + return EntryInstallResult.FromSuccess(overwritten); } } @@ -60,7 +60,33 @@ public class ProfileEntryInstallationHandler : IEntryInstallationHandler // Update the release and return the profile configuration UpdateRelease(releaseId, installedEntry); - return EntryInstallResult.FromSuccess(imported); + return EntryInstallResult.FromSuccess(imported); + } + + public async Task UninstallAsync(InstalledEntry installedEntry, CancellationToken cancellationToken) + { + if (!Guid.TryParse(installedEntry.LocalReference, out Guid profileId)) + return EntryUninstallResult.FromFailure("Local reference does not contain a GUID"); + + return await Task.Run(() => + { + try + { + // Find the profile if still there + ProfileConfiguration? profile = _profileService.ProfileConfigurations.FirstOrDefault(c => c.ProfileId == profileId); + if (profile != null) + _profileService.DeleteProfile(profile); + + // Remove the release + _workshopService.RemoveInstalledEntry(installedEntry); + } + catch (Exception e) + { + return EntryUninstallResult.FromFailure(e.Message); + } + + return EntryUninstallResult.FromSuccess(); + }, cancellationToken); } private void UpdateRelease(Guid releaseId, InstalledEntry installedEntry) diff --git a/src/Artemis.WebClient.Workshop/UploadHandlers/EntryUploadHandlerFactory.cs b/src/Artemis.WebClient.Workshop/Handlers/UploadHandlers/EntryUploadHandlerFactory.cs similarity index 81% rename from src/Artemis.WebClient.Workshop/UploadHandlers/EntryUploadHandlerFactory.cs rename to src/Artemis.WebClient.Workshop/Handlers/UploadHandlers/EntryUploadHandlerFactory.cs index ea0155e4f..17dc5245c 100644 --- a/src/Artemis.WebClient.Workshop/UploadHandlers/EntryUploadHandlerFactory.cs +++ b/src/Artemis.WebClient.Workshop/Handlers/UploadHandlers/EntryUploadHandlerFactory.cs @@ -1,7 +1,7 @@ -using Artemis.WebClient.Workshop.UploadHandlers.Implementations; +using Artemis.WebClient.Workshop.Handlers.UploadHandlers.Implementations; using DryIoc; -namespace Artemis.WebClient.Workshop.UploadHandlers; +namespace Artemis.WebClient.Workshop.Handlers.UploadHandlers; public class EntryUploadHandlerFactory { diff --git a/src/Artemis.WebClient.Workshop/UploadHandlers/EntryUploadResult.cs b/src/Artemis.WebClient.Workshop/Handlers/UploadHandlers/EntryUploadResult.cs similarity index 90% rename from src/Artemis.WebClient.Workshop/UploadHandlers/EntryUploadResult.cs rename to src/Artemis.WebClient.Workshop/Handlers/UploadHandlers/EntryUploadResult.cs index 040b99413..477201b24 100644 --- a/src/Artemis.WebClient.Workshop/UploadHandlers/EntryUploadResult.cs +++ b/src/Artemis.WebClient.Workshop/Handlers/UploadHandlers/EntryUploadResult.cs @@ -1,6 +1,6 @@ using Artemis.Web.Workshop.Entities; -namespace Artemis.WebClient.Workshop.UploadHandlers; +namespace Artemis.WebClient.Workshop.Handlers.UploadHandlers; public class EntryUploadResult { diff --git a/src/Artemis.WebClient.Workshop/UploadHandlers/IEntryUploadHandler.cs b/src/Artemis.WebClient.Workshop/Handlers/UploadHandlers/IEntryUploadHandler.cs similarity index 78% rename from src/Artemis.WebClient.Workshop/UploadHandlers/IEntryUploadHandler.cs rename to src/Artemis.WebClient.Workshop/Handlers/UploadHandlers/IEntryUploadHandler.cs index 21cdc2d5a..85a316600 100644 --- a/src/Artemis.WebClient.Workshop/UploadHandlers/IEntryUploadHandler.cs +++ b/src/Artemis.WebClient.Workshop/Handlers/UploadHandlers/IEntryUploadHandler.cs @@ -1,6 +1,6 @@ using Artemis.UI.Shared.Utilities; -namespace Artemis.WebClient.Workshop.UploadHandlers; +namespace Artemis.WebClient.Workshop.Handlers.UploadHandlers; public interface IEntryUploadHandler { diff --git a/src/Artemis.WebClient.Workshop/UploadHandlers/ImageUploadResult.cs b/src/Artemis.WebClient.Workshop/Handlers/UploadHandlers/ImageUploadResult.cs similarity index 86% rename from src/Artemis.WebClient.Workshop/UploadHandlers/ImageUploadResult.cs rename to src/Artemis.WebClient.Workshop/Handlers/UploadHandlers/ImageUploadResult.cs index 97f8e861c..7ebc08d2f 100644 --- a/src/Artemis.WebClient.Workshop/UploadHandlers/ImageUploadResult.cs +++ b/src/Artemis.WebClient.Workshop/Handlers/UploadHandlers/ImageUploadResult.cs @@ -1,4 +1,4 @@ -namespace Artemis.WebClient.Workshop.UploadHandlers; +namespace Artemis.WebClient.Workshop.Handlers.UploadHandlers; public class ImageUploadResult { diff --git a/src/Artemis.WebClient.Workshop/UploadHandlers/Implementations/LayoutEntryUploadHandler.cs b/src/Artemis.WebClient.Workshop/Handlers/UploadHandlers/Implementations/LayoutEntryUploadHandler.cs similarity index 81% rename from src/Artemis.WebClient.Workshop/UploadHandlers/Implementations/LayoutEntryUploadHandler.cs rename to src/Artemis.WebClient.Workshop/Handlers/UploadHandlers/Implementations/LayoutEntryUploadHandler.cs index 57f2f664c..ac3d0e739 100644 --- a/src/Artemis.WebClient.Workshop/UploadHandlers/Implementations/LayoutEntryUploadHandler.cs +++ b/src/Artemis.WebClient.Workshop/Handlers/UploadHandlers/Implementations/LayoutEntryUploadHandler.cs @@ -1,6 +1,6 @@ using Artemis.UI.Shared.Utilities; -namespace Artemis.WebClient.Workshop.UploadHandlers.Implementations; +namespace Artemis.WebClient.Workshop.Handlers.UploadHandlers.Implementations; public class LayoutEntryUploadHandler : IEntryUploadHandler { diff --git a/src/Artemis.WebClient.Workshop/UploadHandlers/Implementations/ProfileEntryUploadHandler.cs b/src/Artemis.WebClient.Workshop/Handlers/UploadHandlers/Implementations/ProfileEntryUploadHandler.cs similarity index 94% rename from src/Artemis.WebClient.Workshop/UploadHandlers/Implementations/ProfileEntryUploadHandler.cs rename to src/Artemis.WebClient.Workshop/Handlers/UploadHandlers/Implementations/ProfileEntryUploadHandler.cs index 9b4287625..b5ecc03c2 100644 --- a/src/Artemis.WebClient.Workshop/UploadHandlers/Implementations/ProfileEntryUploadHandler.cs +++ b/src/Artemis.WebClient.Workshop/Handlers/UploadHandlers/Implementations/ProfileEntryUploadHandler.cs @@ -1,12 +1,11 @@ using System.Net.Http.Headers; using Artemis.Core; using Artemis.Core.Services; -using Artemis.Storage.Repositories.Interfaces; using Artemis.UI.Shared.Utilities; using Artemis.Web.Workshop.Entities; using Newtonsoft.Json; -namespace Artemis.WebClient.Workshop.UploadHandlers.Implementations; +namespace Artemis.WebClient.Workshop.Handlers.UploadHandlers.Implementations; public class ProfileEntryUploadHandler : IEntryUploadHandler { diff --git a/src/Artemis.WebClient.Workshop/Services/InstalledEntry.cs b/src/Artemis.WebClient.Workshop/Services/InstalledEntry.cs index 8ad0b50ab..073c74584 100644 --- a/src/Artemis.WebClient.Workshop/Services/InstalledEntry.cs +++ b/src/Artemis.WebClient.Workshop/Services/InstalledEntry.cs @@ -20,7 +20,6 @@ public class InstalledEntry Author = entry.Author; Name = entry.Name; - Summary = entry.Summary; } public Guid EntryId { get; set; } @@ -28,7 +27,6 @@ public class InstalledEntry public string Author { get; set; } = string.Empty; public string Name { get; set; } = string.Empty; - public string Summary { get; set; } = string.Empty; public Guid ReleaseId { get; set; } public string ReleaseVersion { get; set; } = string.Empty; @@ -45,7 +43,6 @@ public class InstalledEntry Author = Entity.Author; Name = Entity.Name; - Summary = Entity.Summary; ReleaseId = Entity.ReleaseId; ReleaseVersion = Entity.ReleaseVersion; @@ -61,7 +58,6 @@ public class InstalledEntry Entity.Author = Author; Entity.Name = Name; - Entity.Summary = Summary; Entity.ReleaseId = ReleaseId; Entity.ReleaseVersion = ReleaseVersion; diff --git a/src/Artemis.WebClient.Workshop/Services/Interfaces/IWorkshopService.cs b/src/Artemis.WebClient.Workshop/Services/Interfaces/IWorkshopService.cs index bda524109..576b72b37 100644 --- a/src/Artemis.WebClient.Workshop/Services/Interfaces/IWorkshopService.cs +++ b/src/Artemis.WebClient.Workshop/Services/Interfaces/IWorkshopService.cs @@ -1,5 +1,5 @@ using Artemis.UI.Shared.Utilities; -using Artemis.WebClient.Workshop.UploadHandlers; +using Artemis.WebClient.Workshop.Handlers.UploadHandlers; namespace Artemis.WebClient.Workshop.Services; @@ -14,7 +14,9 @@ public interface IWorkshopService List GetInstalledEntries(); InstalledEntry? GetInstalledEntry(IGetEntryById_Entry entry); InstalledEntry CreateInstalledEntry(IGetEntryById_Entry entry); + void RemoveInstalledEntry(InstalledEntry installedEntry); void SaveInstalledEntry(InstalledEntry entry); + public record WorkshopStatus(bool IsReachable, string Message); } \ No newline at end of file diff --git a/src/Artemis.WebClient.Workshop/Services/WorkshopService.cs b/src/Artemis.WebClient.Workshop/Services/WorkshopService.cs index 41924b8e1..db3a15ae7 100644 --- a/src/Artemis.WebClient.Workshop/Services/WorkshopService.cs +++ b/src/Artemis.WebClient.Workshop/Services/WorkshopService.cs @@ -3,7 +3,7 @@ using Artemis.Storage.Entities.Workshop; using Artemis.Storage.Repositories.Interfaces; using Artemis.UI.Shared.Routing; using Artemis.UI.Shared.Utilities; -using Artemis.WebClient.Workshop.UploadHandlers; +using Artemis.WebClient.Workshop.Handlers.UploadHandlers; namespace Artemis.WebClient.Workshop.Services; @@ -124,6 +124,12 @@ public class WorkshopService : IWorkshopService return new InstalledEntry(entry); } + /// + public void RemoveInstalledEntry(InstalledEntry installedEntry) + { + _entryRepository.Remove(installedEntry.Entity); + } + /// public void SaveInstalledEntry(InstalledEntry entry) { From 0ac973d4bce132e7ccf798cba42e5a9d00ca4599 Mon Sep 17 00:00:00 2001 From: Robert Date: Sat, 9 Sep 2023 00:11:45 +0200 Subject: [PATCH 33/37] Workshop - Use production APIs --- src/Artemis.WebClient.Workshop/WorkshopConstants.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Artemis.WebClient.Workshop/WorkshopConstants.cs b/src/Artemis.WebClient.Workshop/WorkshopConstants.cs index 1dcfa737d..192a92b56 100644 --- a/src/Artemis.WebClient.Workshop/WorkshopConstants.cs +++ b/src/Artemis.WebClient.Workshop/WorkshopConstants.cs @@ -2,7 +2,7 @@ namespace Artemis.WebClient.Workshop; public static class WorkshopConstants { - public const string AUTHORITY_URL = "https://localhost:5001"; - public const string WORKSHOP_URL = "https://localhost:7281"; + public const string AUTHORITY_URL = "https://identity.artemis-rgb.com"; + public const string WORKSHOP_URL = "https://workshop.artemis-rgb.com"; public const string WORKSHOP_CLIENT_NAME = "WorkshopApiClient"; } \ No newline at end of file From a798980eecee1fa3c118e9646a3f14e23031a189 Mon Sep 17 00:00:00 2001 From: Robert Date: Sat, 9 Sep 2023 00:32:58 +0200 Subject: [PATCH 34/37] Workshop - Feature flagged out layouts Settings - Use Fluent tabs --- src/Artemis.UI.Linux/App.axaml | 2 +- src/Artemis.UI.MacOS/App.axaml | 2 +- src/Artemis.UI.Windows/App.axaml | 2 +- src/Artemis.UI/Routing/Routes.cs | 4 +- .../Screens/Settings/SettingsView.axaml | 37 +++++++++++-------- .../Screens/Settings/SettingsView.axaml.cs | 10 +++-- .../Screens/Settings/SettingsViewModel.cs | 5 +++ .../Settings/Tabs/PluginsTabView.axaml | 17 ++++++--- .../Screens/Sidebar/SidebarViewModel.cs | 7 ++-- .../Workshop/Entries/EntriesViewModel.cs | 2 + .../Workshop/Home/WorkshopHomeView.axaml | 2 +- .../Workshop/Home/WorkshopHomeViewModel.cs | 6 +++ .../Steps/EntryTypeStepView.axaml | 3 +- .../Steps/EntryTypeStepViewModel.cs | 6 +++ 14 files changed, 70 insertions(+), 35 deletions(-) diff --git a/src/Artemis.UI.Linux/App.axaml b/src/Artemis.UI.Linux/App.axaml index a63d617b9..d2109667a 100644 --- a/src/Artemis.UI.Linux/App.axaml +++ b/src/Artemis.UI.Linux/App.axaml @@ -18,7 +18,7 @@ - + diff --git a/src/Artemis.UI.MacOS/App.axaml b/src/Artemis.UI.MacOS/App.axaml index 6ef3efd97..5a000915e 100644 --- a/src/Artemis.UI.MacOS/App.axaml +++ b/src/Artemis.UI.MacOS/App.axaml @@ -18,7 +18,7 @@ - + diff --git a/src/Artemis.UI.Windows/App.axaml b/src/Artemis.UI.Windows/App.axaml index 836d6a11f..63ff0b8a1 100644 --- a/src/Artemis.UI.Windows/App.axaml +++ b/src/Artemis.UI.Windows/App.axaml @@ -18,7 +18,7 @@ - + diff --git a/src/Artemis.UI/Routing/Routes.cs b/src/Artemis.UI/Routing/Routes.cs index 7e04219ee..209c89dea 100644 --- a/src/Artemis.UI/Routing/Routes.cs +++ b/src/Artemis.UI/Routing/Routes.cs @@ -23,7 +23,6 @@ public static class Routes { new RouteRegistration("blank"), new RouteRegistration("home"), -#if DEBUG new RouteRegistration("workshop") { Children = new List @@ -35,8 +34,10 @@ public static class Routes { new RouteRegistration("profiles/{page:int}"), new RouteRegistration("profiles/details/{entryId:guid}"), +#if DEBUG new RouteRegistration("layouts/{page:int}"), new RouteRegistration("layouts/details/{entryId:guid}"), +#endif } }, new RouteRegistration("library") @@ -50,7 +51,6 @@ public static class Routes } } }, -#endif new RouteRegistration("surface-editor"), new RouteRegistration("settings") { diff --git a/src/Artemis.UI/Screens/Settings/SettingsView.axaml b/src/Artemis.UI/Screens/Settings/SettingsView.axaml index 0d50ad12b..47a6e7384 100644 --- a/src/Artemis.UI/Screens/Settings/SettingsView.axaml +++ b/src/Artemis.UI/Screens/Settings/SettingsView.axaml @@ -8,20 +8,25 @@ mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" x:Class="Artemis.UI.Screens.Settings.SettingsView" x:DataType="settings:SettingsViewModel"> - - - - - - - - - - - - - - - - + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Settings/SettingsView.axaml.cs b/src/Artemis.UI/Screens/Settings/SettingsView.axaml.cs index 75d3d5b12..6e04c8c28 100644 --- a/src/Artemis.UI/Screens/Settings/SettingsView.axaml.cs +++ b/src/Artemis.UI/Screens/Settings/SettingsView.axaml.cs @@ -3,8 +3,7 @@ using System.Reactive.Disposables; using Artemis.UI.Shared; using Avalonia.ReactiveUI; using Avalonia.Threading; -using FluentAvalonia.UI.Media.Animation; -using FluentAvalonia.UI.Navigation; +using FluentAvalonia.UI.Controls; using ReactiveUI; namespace Artemis.UI.Screens.Settings; @@ -19,6 +18,11 @@ public partial class SettingsView : ReactiveUserControl private void Navigate(ViewModelBase viewModel) { - Dispatcher.UIThread.Invoke(() => TabFrame.NavigateFromObject(viewModel, new FrameNavigationOptions {TransitionInfoOverride = new SlideNavigationTransitionInfo()})); + Dispatcher.UIThread.Invoke(() => TabFrame.NavigateFromObject(viewModel)); + } + + private void NavigationView_OnBackRequested(object? sender, NavigationViewBackRequestedEventArgs e) + { + ViewModel?.GoBack(); } } \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Settings/SettingsViewModel.cs b/src/Artemis.UI/Screens/Settings/SettingsViewModel.cs index 26e8edcd2..3391aeab8 100644 --- a/src/Artemis.UI/Screens/Settings/SettingsViewModel.cs +++ b/src/Artemis.UI/Screens/Settings/SettingsViewModel.cs @@ -55,4 +55,9 @@ public class SettingsViewModel : RoutableHostScreen, IMainScreen if (SelectedTab == null) await _router.Navigate(SettingTabs.First().Path); } + + public void GoBack() + { + _router.Navigate("workshop"); + } } \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Settings/Tabs/PluginsTabView.axaml b/src/Artemis.UI/Screens/Settings/Tabs/PluginsTabView.axaml index 5e1b9c246..b9f28f4a6 100644 --- a/src/Artemis.UI/Screens/Settings/Tabs/PluginsTabView.axaml +++ b/src/Artemis.UI/Screens/Settings/Tabs/PluginsTabView.axaml @@ -25,14 +25,19 @@ - - + + + + + + + - + - - - + + + \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Sidebar/SidebarViewModel.cs b/src/Artemis.UI/Screens/Sidebar/SidebarViewModel.cs index 0210d557f..f49cc0ec3 100644 --- a/src/Artemis.UI/Screens/Sidebar/SidebarViewModel.cs +++ b/src/Artemis.UI/Screens/Sidebar/SidebarViewModel.cs @@ -38,14 +38,15 @@ public class SidebarViewModel : ActivatableViewModelBase SidebarScreen = new SidebarScreenViewModel(MaterialIconKind.Abacus, ROOT_SCREEN, "", null, new ObservableCollection() { new(MaterialIconKind.HomeOutline, "Home", "home"), - #if DEBUG new(MaterialIconKind.TestTube, "Workshop", "workshop", null, new ObservableCollection { new(MaterialIconKind.FolderVideo, "Profiles", "workshop/entries/profiles/1", "workshop/entries/profiles"), +#if DEBUG new(MaterialIconKind.KeyboardVariant, "Layouts", "workshop/entries/layouts/1", "workshop/entries/layouts"), +#endif new(MaterialIconKind.Bookshelf, "Library", "workshop/library"), }), - #endif + new(MaterialIconKind.Devices, "Surface Editor", "surface-editor"), new(MaterialIconKind.SettingsOutline, "Settings", "settings") }); @@ -120,7 +121,7 @@ public class SidebarViewModel : ActivatableViewModelBase { if (_updating) return; - + Dispatcher.UIThread.Invoke(async () => { try diff --git a/src/Artemis.UI/Screens/Workshop/Entries/EntriesViewModel.cs b/src/Artemis.UI/Screens/Workshop/Entries/EntriesViewModel.cs index 0d966ba89..4ebd77ecc 100644 --- a/src/Artemis.UI/Screens/Workshop/Entries/EntriesViewModel.cs +++ b/src/Artemis.UI/Screens/Workshop/Entries/EntriesViewModel.cs @@ -24,7 +24,9 @@ public class EntriesViewModel : RoutableHostScreen Tabs = new ObservableCollection { new("Profiles", "workshop/entries/profiles/1", "workshop/entries/profiles"), +#if DEBUG new("Layouts", "workshop/entries/layouts/1", "workshop/entries/layouts") +#endif }; this.WhenActivated(d => diff --git a/src/Artemis.UI/Screens/Workshop/Home/WorkshopHomeView.axaml b/src/Artemis.UI/Screens/Workshop/Home/WorkshopHomeView.axaml index 433f87c50..c97d51ee0 100644 --- a/src/Artemis.UI/Screens/Workshop/Home/WorkshopHomeView.axaml +++ b/src/Artemis.UI/Screens/Workshop/Home/WorkshopHomeView.axaml @@ -49,7 +49,7 @@ -