From e52faf1c9f0d119de49ac71720f98defb2b061ea Mon Sep 17 00:00:00 2001 From: Robert Date: Sun, 13 Aug 2023 10:42:51 +0200 Subject: [PATCH] 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