From c1e0dadce8b0f38b27dce49342a02f6a2e455f11 Mon Sep 17 00:00:00 2001 From: Robert Date: Sun, 29 Oct 2023 20:43:30 +0100 Subject: [PATCH] Workshop - Layout info and images WIP --- .../Services/Builders/ContentDialogBuilder.cs | 10 ++ .../Builders/FileDialogFilterBuilder.cs | 24 ++++ .../Tabs/DeviceLogicalLayoutDialogView.axaml | 16 ++- .../ProfileConfigurationEditViewModel.cs | 2 +- .../Workshop/Image/ImageSubmissionView.axaml | 41 +++++++ .../Image/ImageSubmissionView.axaml.cs | 11 ++ .../Image/ImageSubmissionViewModel.cs | 42 +++++++ .../DeviceProviderPickerDialogView.axaml | 62 ++++++++++ .../DeviceProviderPickerDialogView.axaml.cs | 24 ++++ .../DeviceProviderPickerDialogViewModel.cs | 24 ++++ .../Workshop/Layout/LayoutInfoView.axaml | 51 ++++++++- .../Workshop/Layout/LayoutInfoView.axaml.cs | 6 +- .../Workshop/Layout/LayoutInfoViewModel.cs | 70 +++++++++--- .../Models/LayoutEntrySource.cs | 29 ++--- .../Models/SubmissionWizardState.cs | 9 +- .../Steps/ImagesStepView.axaml | 34 ++++++ .../Steps/ImagesStepView.axaml.cs | 14 +++ .../Steps/ImagesStepViewModel.cs | 68 +++++++++++ .../Steps/Layout/LayoutInfoStepView.axaml | 29 ++++- .../Steps/Layout/LayoutInfoStepViewModel.cs | 108 ++++++++++++++++-- .../Layout/LayoutSelectionStepViewModel.cs | 68 +++++++---- .../Steps/SpecificationsStepViewModel.cs | 2 +- .../SubmissionWizard/SubmissionViewModel.cs | 2 + .../SubmissionWizardView.axaml | 18 ++- .../SubmissionWizardViewModel.cs | 6 +- 25 files changed, 680 insertions(+), 90 deletions(-) create mode 100644 src/Artemis.UI/Screens/Workshop/Image/ImageSubmissionView.axaml create mode 100644 src/Artemis.UI/Screens/Workshop/Image/ImageSubmissionView.axaml.cs create mode 100644 src/Artemis.UI/Screens/Workshop/Image/ImageSubmissionViewModel.cs create mode 100644 src/Artemis.UI/Screens/Workshop/Layout/Dialogs/DeviceProviderPickerDialogView.axaml create mode 100644 src/Artemis.UI/Screens/Workshop/Layout/Dialogs/DeviceProviderPickerDialogView.axaml.cs create mode 100644 src/Artemis.UI/Screens/Workshop/Layout/Dialogs/DeviceProviderPickerDialogViewModel.cs create mode 100644 src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/ImagesStepView.axaml create mode 100644 src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/ImagesStepView.axaml.cs create mode 100644 src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/ImagesStepViewModel.cs diff --git a/src/Artemis.UI.Shared/Services/Builders/ContentDialogBuilder.cs b/src/Artemis.UI.Shared/Services/Builders/ContentDialogBuilder.cs index 92e681433..c5a9f3e34 100644 --- a/src/Artemis.UI.Shared/Services/Builders/ContentDialogBuilder.cs +++ b/src/Artemis.UI.Shared/Services/Builders/ContentDialogBuilder.cs @@ -148,6 +148,16 @@ public class ContentDialogBuilder _contentDialog.FullSizeDesired = true; return this; } + + /// + /// Changes the dialog to be full screen. + /// + /// The builder that can be used to further build the dialog. + public ContentDialogBuilder WithFullScreen() + { + _contentDialog.Classes.Add("fullscreen"); + return this; + } /// /// Asynchronously shows the content dialog. diff --git a/src/Artemis.UI.Shared/Services/Builders/FileDialogFilterBuilder.cs b/src/Artemis.UI.Shared/Services/Builders/FileDialogFilterBuilder.cs index e3c946804..e0b6532fc 100644 --- a/src/Artemis.UI.Shared/Services/Builders/FileDialogFilterBuilder.cs +++ b/src/Artemis.UI.Shared/Services/Builders/FileDialogFilterBuilder.cs @@ -2,6 +2,7 @@ using System.Linq; using Avalonia.Controls; using Avalonia.Platform.Storage; +using SkiaSharp; namespace Artemis.UI.Shared.Services.Builders; @@ -37,6 +38,29 @@ public class FileDialogFilterBuilder return this; } + /// + /// Adds all supported bitmap types to the filter. + /// + public FileDialogFilterBuilder WithBitmaps() + { + // Formats from SKEncodedImageFormat + return WithExtension("astc") + .WithExtension("avif") + .WithExtension("bmp") + .WithExtension("dng") + .WithExtension("gif") + .WithExtension("heif") + .WithExtension("ico") + .WithExtension("jpg") + .WithExtension("jpeg") + .WithExtension("ktx") + .WithExtension("pkm") + .WithExtension("png") + .WithExtension("wbmp") + .WithExtension("webp") + .WithName("Bitmap image"); + } + internal FilePickerFileType Build() { return new FilePickerFileType(_name) diff --git a/src/Artemis.UI/Screens/Device/Tabs/DeviceLogicalLayoutDialogView.axaml b/src/Artemis.UI/Screens/Device/Tabs/DeviceLogicalLayoutDialogView.axaml index 213762d55..4a86455ad 100644 --- a/src/Artemis.UI/Screens/Device/Tabs/DeviceLogicalLayoutDialogView.axaml +++ b/src/Artemis.UI/Screens/Device/Tabs/DeviceLogicalLayoutDialogView.axaml @@ -10,13 +10,13 @@ Artemis couldn't automatically determine the logical layout of your - + While not as important as the physical layout, setting the correct logical layout will allow Artemis to show the right keycaps (if a matching layout file is present) - - - - - - - + + + + diff --git a/src/Artemis.UI/Screens/Sidebar/Dialogs/ProfileConfigurationEditViewModel.cs b/src/Artemis.UI/Screens/Sidebar/Dialogs/ProfileConfigurationEditViewModel.cs index 4edaa16a0..a7ba504ee 100644 --- a/src/Artemis.UI/Screens/Sidebar/Dialogs/ProfileConfigurationEditViewModel.cs +++ b/src/Artemis.UI/Screens/Sidebar/Dialogs/ProfileConfigurationEditViewModel.cs @@ -181,7 +181,7 @@ public partial class ProfileConfigurationEditViewModel : DialogViewModelBase f.WithExtension("png").WithExtension("jpg").WithExtension("bmp").WithName("Bitmap image")) + .HavingFilter(f => f.WithBitmaps()) .ShowAsync(); if (result == null) diff --git a/src/Artemis.UI/Screens/Workshop/Image/ImageSubmissionView.axaml b/src/Artemis.UI/Screens/Workshop/Image/ImageSubmissionView.axaml new file mode 100644 index 000000000..ca6ec4cdd --- /dev/null +++ b/src/Artemis.UI/Screens/Workshop/Image/ImageSubmissionView.axaml @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/src/Artemis.UI/Screens/Workshop/Image/ImageSubmissionView.axaml.cs b/src/Artemis.UI/Screens/Workshop/Image/ImageSubmissionView.axaml.cs new file mode 100644 index 000000000..5fa82a6c2 --- /dev/null +++ b/src/Artemis.UI/Screens/Workshop/Image/ImageSubmissionView.axaml.cs @@ -0,0 +1,11 @@ +using Avalonia.ReactiveUI; + +namespace Artemis.UI.Screens.Workshop.Image; + +public partial class ImageSubmissionView : ReactiveUserControl +{ + public ImageSubmissionView() + { + InitializeComponent(); + } +} \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/Image/ImageSubmissionViewModel.cs b/src/Artemis.UI/Screens/Workshop/Image/ImageSubmissionViewModel.cs new file mode 100644 index 000000000..5b0c03cbe --- /dev/null +++ b/src/Artemis.UI/Screens/Workshop/Image/ImageSubmissionViewModel.cs @@ -0,0 +1,42 @@ +using System.IO; +using System.Reactive.Disposables; +using System.Threading.Tasks; +using System.Windows.Input; +using Artemis.UI.Shared; +using Artemis.UI.Shared.Services; +using Avalonia.Media.Imaging; +using Avalonia.Threading; +using PropertyChanged.SourceGenerator; +using ReactiveUI; + +namespace Artemis.UI.Screens.Workshop.Image; + +public partial class ImageSubmissionViewModel : ActivatableViewModelBase +{ + [Notify(Setter.Private)] private Bitmap? _bitmap; + [Notify(Setter.Private)] private string? _fileName; + [Notify(Setter.Private)] private string? _imageDimensions; + [Notify(Setter.Private)] private long _fileSize; + [Notify] private ICommand? _remove; + + public ImageSubmissionViewModel(Stream imageStream) + { + this.WhenActivated(d => + { + Dispatcher.UIThread.Invoke(() => + { + imageStream.Seek(0, SeekOrigin.Begin); + Bitmap = new Bitmap(imageStream); + FileSize = imageStream.Length; + ImageDimensions = Bitmap.Size.Width + "x" + Bitmap.Size.Height; + + if (imageStream is FileStream fileStream) + FileName = Path.GetFileName(fileStream.Name); + else + FileName = "Unnamed image"; + + Bitmap.DisposeWith(d); + }, DispatcherPriority.Background); + }); + } +} \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/Layout/Dialogs/DeviceProviderPickerDialogView.axaml b/src/Artemis.UI/Screens/Workshop/Layout/Dialogs/DeviceProviderPickerDialogView.axaml new file mode 100644 index 000000000..57a7d0fb4 --- /dev/null +++ b/src/Artemis.UI/Screens/Workshop/Layout/Dialogs/DeviceProviderPickerDialogView.axaml @@ -0,0 +1,62 @@ + + + + + + + + + + + + + + + + + + You do not have any device providers enabled + + + + + diff --git a/src/Artemis.UI/Screens/Workshop/Layout/Dialogs/DeviceProviderPickerDialogView.axaml.cs b/src/Artemis.UI/Screens/Workshop/Layout/Dialogs/DeviceProviderPickerDialogView.axaml.cs new file mode 100644 index 000000000..7758ce344 --- /dev/null +++ b/src/Artemis.UI/Screens/Workshop/Layout/Dialogs/DeviceProviderPickerDialogView.axaml.cs @@ -0,0 +1,24 @@ +using Artemis.Core.DeviceProviders; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Markup.Xaml; +using Avalonia.ReactiveUI; + +namespace Artemis.UI.Screens.Workshop.Layout.Dialogs; + +public partial class DeviceProviderPickerDialogView : ReactiveUserControl +{ + public DeviceProviderPickerDialogView() + { + InitializeComponent(); + } + + private void InputElement_OnPointerReleased(object? sender, PointerReleasedEventArgs e) + { + if (sender is not IDataContextProvider {DataContext: DeviceProvider deviceProvider} || ViewModel == null) + return; + + ViewModel?.SelectDeviceProvider(deviceProvider); + } +} \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/Layout/Dialogs/DeviceProviderPickerDialogViewModel.cs b/src/Artemis.UI/Screens/Workshop/Layout/Dialogs/DeviceProviderPickerDialogViewModel.cs new file mode 100644 index 000000000..6bc61fbb7 --- /dev/null +++ b/src/Artemis.UI/Screens/Workshop/Layout/Dialogs/DeviceProviderPickerDialogViewModel.cs @@ -0,0 +1,24 @@ +using System.Collections.ObjectModel; +using Artemis.Core.DeviceProviders; +using Artemis.Core.Services; +using Artemis.UI.Shared; + +namespace Artemis.UI.Screens.Workshop.Layout.Dialogs; + +public class DeviceProviderPickerDialogViewModel : ContentDialogViewModelBase +{ + public ObservableCollection DeviceProviders { get; } + + public DeviceProviderPickerDialogViewModel(IPluginManagementService pluginManagementService) + { + DeviceProviders = new ObservableCollection(pluginManagementService.GetFeaturesOfType()); + } + + public DeviceProvider? DeviceProvider { get; set; } + + public void SelectDeviceProvider(DeviceProvider deviceProvider) + { + DeviceProvider = deviceProvider; + ContentDialog?.Hide(); + } +} \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/Layout/LayoutInfoView.axaml b/src/Artemis.UI/Screens/Workshop/Layout/LayoutInfoView.axaml index 92d02d58f..8a505a1f8 100644 --- a/src/Artemis.UI/Screens/Workshop/Layout/LayoutInfoView.axaml +++ b/src/Artemis.UI/Screens/Workshop/Layout/LayoutInfoView.axaml @@ -3,8 +3,55 @@ xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:layout="clr-namespace:Artemis.UI.Screens.Workshop.Layout" + 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.Layout.LayoutInfoView" x:DataType="layout:LayoutInfoViewModel"> - Welcome to Avalonia! - + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/Layout/LayoutInfoView.axaml.cs b/src/Artemis.UI/Screens/Workshop/Layout/LayoutInfoView.axaml.cs index b7371d6fe..57092f2aa 100644 --- a/src/Artemis.UI/Screens/Workshop/Layout/LayoutInfoView.axaml.cs +++ b/src/Artemis.UI/Screens/Workshop/Layout/LayoutInfoView.axaml.cs @@ -1,10 +1,8 @@ -using Avalonia; -using Avalonia.Controls; -using Avalonia.Markup.Xaml; +using Avalonia.ReactiveUI; namespace Artemis.UI.Screens.Workshop.Layout; -public partial class LayoutInfoView : UserControl +public partial class LayoutInfoView : ReactiveUserControl { public LayoutInfoView() { diff --git a/src/Artemis.UI/Screens/Workshop/Layout/LayoutInfoViewModel.cs b/src/Artemis.UI/Screens/Workshop/Layout/LayoutInfoViewModel.cs index eea0f305b..22ee6a750 100644 --- a/src/Artemis.UI/Screens/Workshop/Layout/LayoutInfoViewModel.cs +++ b/src/Artemis.UI/Screens/Workshop/Layout/LayoutInfoViewModel.cs @@ -1,32 +1,68 @@ using System; +using System.Linq; +using System.Reactive.Linq; +using System.Threading.Tasks; +using System.Windows.Input; using Artemis.Core; -using Artemis.UI.Screens.Workshop.SubmissionWizard.Models; +using Artemis.Core.DeviceProviders; +using Artemis.Core.Services; +using Artemis.UI.Screens.Workshop.Layout.Dialogs; using Artemis.UI.Shared; +using Artemis.UI.Shared.Services; using PropertyChanged.SourceGenerator; -using RGB.NET.Core; -using KeyboardLayoutType = Artemis.Core.KeyboardLayoutType; +using ReactiveUI; +using ReactiveUI.Validation.Extensions; namespace Artemis.UI.Screens.Workshop.Layout; -public partial class LayoutInfoViewModel : ViewModelBase +public partial class LayoutInfoViewModel : ValidatableViewModelBase { - [Notify] private Guid _deviceProvider; + private readonly IWindowService _windowService; + private readonly ObservableAsPropertyHelper _deviceProviders; [Notify] private string? _vendor; [Notify] private string? _model; - [Notify] private KeyboardLayoutType? _physicalLayout; - [Notify] private string? _logicalLayout; - - /// - public LayoutInfoViewModel(ArtemisLayout layout) + [Notify] private Guid _deviceProviderId; + [Notify] private string? _deviceProviderIdInput; + [Notify] private ICommand? _remove; + + public LayoutInfoViewModel(ArtemisLayout layout, + IDeviceService deviceService, + IWindowService windowService, + IPluginManagementService pluginManagementService) { - DisplayKeyboardLayout = layout.RgbLayout.Type == RGBDeviceType.Keyboard; + _windowService = windowService; + _vendor = layout.RgbLayout.Vendor; + _model = layout.RgbLayout.Model; + + DeviceProvider? deviceProvider = deviceService.Devices.FirstOrDefault(d => d.Layout == layout)?.DeviceProvider; + if (deviceProvider != null) + _deviceProviderId = deviceProvider.Plugin.Guid; + + _deviceProviders = this.WhenAnyValue(vm => vm.DeviceProviderId) + .Select(id => pluginManagementService.GetAllPlugins().FirstOrDefault(p => p.Guid == id)?.Features.Select(f => f.Name)) + .Select(names => names != null ? string.Join(", ", names) : "") + .ToProperty(this, vm => vm.DeviceProviders); + + this.WhenAnyValue(vm => vm.DeviceProviderId).Subscribe(g => DeviceProviderIdInput = g.ToString()); + this.WhenAnyValue(vm => vm.DeviceProviderIdInput).Where(i => Guid.TryParse(i, out _)).Subscribe(i => DeviceProviderId = Guid.Parse(i!)); + + this.ValidationRule(vm => vm.Model, input => !string.IsNullOrWhiteSpace(input), "Device model is required"); + this.ValidationRule(vm => vm.Vendor, input => !string.IsNullOrWhiteSpace(input), "Device vendor is required"); + this.ValidationRule(vm => vm.DeviceProviderIdInput, input => Guid.TryParse(input, out _), "Must be a valid GUID formatted as: 00000000-0000-0000-0000-000000000000"); + this.ValidationRule(vm => vm.DeviceProviderIdInput, input => !string.IsNullOrWhiteSpace(input), "Device provider ID is required"); } - public LayoutInfoViewModel(ArtemisLayout layout, LayoutInfo layoutInfo) - { - DisplayKeyboardLayout = layout.RgbLayout.Type == RGBDeviceType.Keyboard; - - } + public string? DeviceProviders => _deviceProviders.Value; - public bool DisplayKeyboardLayout { get; } + public async Task BrowseDeviceProvider() + { + await _windowService.CreateContentDialog() + .WithTitle("Select device provider") + .WithViewModel(out DeviceProviderPickerDialogViewModel vm) + .ShowAsync(); + + DeviceProvider? deviceProvider = vm.DeviceProvider; + if (deviceProvider != null) + DeviceProviderId = deviceProvider.Plugin.Guid; + } } \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Models/LayoutEntrySource.cs b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Models/LayoutEntrySource.cs index 5445b34ab..c795a5b6b 100644 --- a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Models/LayoutEntrySource.cs +++ b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Models/LayoutEntrySource.cs @@ -1,8 +1,8 @@ -using System; -using System.Collections.Generic; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; using Artemis.Core; -using Artemis.WebClient.Workshop; -using KeyboardLayoutType = Artemis.WebClient.Workshop.KeyboardLayoutType; +using Artemis.UI.Screens.Workshop.Layout; namespace Artemis.UI.Screens.Workshop.SubmissionWizard.Models; @@ -14,15 +14,16 @@ public class LayoutEntrySource : IEntrySource } public ArtemisLayout Layout { get; set; } - public List LayoutInfo { get; } = new(); -} + public ObservableCollection LayoutInfo { get; } = new(); + public KeyboardLayoutType PhysicalLayout { get; set; } -public class LayoutInfo -{ - public Guid DeviceProvider { get; set; } - public RGBDeviceType DeviceType { get; set; } - public string Model { get; set; } - public string Vendor { get; set; } - public string? LogicalLayout { get; set; } - public KeyboardLayoutType? PhysicalLayout { get; set; } + private List GetLogicalLayouts() + { + return Layout.Leds + .Where(l => l.LayoutCustomLedData.LogicalLayouts != null) + .SelectMany(l => l.LayoutCustomLedData.LogicalLayouts!) + .Where(l => !string.IsNullOrWhiteSpace(l.Name)) + .DistinctBy(l => l.Name) + .ToList(); + } } \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Models/SubmissionWizardState.cs b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Models/SubmissionWizardState.cs index 15c6dca1d..c980a0895 100644 --- a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Models/SubmissionWizardState.cs +++ b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Models/SubmissionWizardState.cs @@ -9,7 +9,7 @@ using DryIoc; namespace Artemis.UI.Screens.Workshop.SubmissionWizard.Models; -public class SubmissionWizardState +public class SubmissionWizardState : IDisposable { private readonly IContainer _container; private readonly IWindowService _windowService; @@ -62,4 +62,11 @@ public class SubmissionWizardState else throw new NotImplementedException(); } + + public void Dispose() + { + Icon?.Dispose(); + foreach (Stream stream in Images) + stream.Dispose(); + } } \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/ImagesStepView.axaml b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/ImagesStepView.axaml new file mode 100644 index 000000000..d563f760e --- /dev/null +++ b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/ImagesStepView.axaml @@ -0,0 +1,34 @@ + + + + + + + + + Images + + Optionally provide some images of your submission. + + + + + + + + + + + + + + diff --git a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/ImagesStepView.axaml.cs b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/ImagesStepView.axaml.cs new file mode 100644 index 000000000..f8c499a2d --- /dev/null +++ b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/ImagesStepView.axaml.cs @@ -0,0 +1,14 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Markup.Xaml; +using Avalonia.ReactiveUI; + +namespace Artemis.UI.Screens.Workshop.SubmissionWizard.Steps; + +public partial class ImagesStepView : ReactiveUserControl +{ + public ImagesStepView() + { + InitializeComponent(); + } +} \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/ImagesStepViewModel.cs b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/ImagesStepViewModel.cs new file mode 100644 index 000000000..4e0d1e7c5 --- /dev/null +++ b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/ImagesStepViewModel.cs @@ -0,0 +1,68 @@ +using System; +using System.Collections.ObjectModel; +using System.IO; +using System.Linq; +using System.Reactive.Disposables; +using System.Threading; +using System.Threading.Tasks; +using Artemis.UI.Screens.Workshop.Image; +using Artemis.UI.Shared.Services; +using DynamicData; +using ReactiveUI; + +namespace Artemis.UI.Screens.Workshop.SubmissionWizard.Steps; + +public class ImagesStepViewModel : SubmissionViewModel +{ + private readonly IWindowService _windowService; + private readonly SourceList _imageStreams; + + public ImagesStepViewModel(IWindowService windowService, Func imageSubmissionViewModel) + { + _windowService = windowService; + + Continue = ReactiveCommand.Create(() => State.ChangeScreen()); + GoBack = ReactiveCommand.Create(() => State.ChangeScreen()); + Secondary = ReactiveCommand.CreateFromTask(ExecuteAddImage); + SecondaryText = "Add image"; + + _imageStreams = new SourceList(); + _imageStreams.Connect() + .Transform(p => CreateImageSubmissionViewModel(imageSubmissionViewModel, p)) + .Bind(out ReadOnlyObservableCollection images) + .Subscribe(); + Images = images; + + this.WhenActivated((CompositeDisposable d) => + { + _imageStreams.Clear(); + _imageStreams.AddRange(State.Images); + }); + } + + public ReadOnlyObservableCollection Images { get; } + + private ImageSubmissionViewModel CreateImageSubmissionViewModel(Func imageSubmissionViewModel, Stream stream) + { + ImageSubmissionViewModel viewModel = imageSubmissionViewModel(stream); + viewModel.Remove = ReactiveCommand.Create(() => _imageStreams.Remove(stream)); + return viewModel; + } + + private async Task ExecuteAddImage(CancellationToken arg) + { + string[]? result = await _windowService.CreateOpenFileDialog().WithAllowMultiple().HavingFilter(f => f.WithBitmaps()).ShowAsync(); + if (result == null) + return; + + foreach (string path in result) + { + if (_imageStreams.Items.Any(i => i is FileStream fs && fs.Name == path)) + continue; + + FileStream stream = new(path, FileMode.Open); + _imageStreams.Add(stream); + State.Images.Add(stream); + } + } +} \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/Layout/LayoutInfoStepView.axaml b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/Layout/LayoutInfoStepView.axaml index 62d3b1563..5b621ec63 100644 --- a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/Layout/LayoutInfoStepView.axaml +++ b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/Layout/LayoutInfoStepView.axaml @@ -3,10 +3,12 @@ xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:layout="clr-namespace:Artemis.UI.Screens.Workshop.SubmissionWizard.Steps.Layout" + xmlns:shared="clr-namespace:Artemis.UI.Shared;assembly=Artemis.UI.Shared" + xmlns:controls="clr-namespace:FluentAvalonia.UI.Controls;assembly=FluentAvalonia" mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" x:Class="Artemis.UI.Screens.Workshop.SubmissionWizard.Steps.Layout.LayoutInfoStepView" x:DataType="layout:LayoutInfoStepViewModel"> - + @@ -24,7 +26,25 @@ - + + + + + + Learn about physical layouts + + + - + + + + diff --git a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/Layout/LayoutInfoStepViewModel.cs b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/Layout/LayoutInfoStepViewModel.cs index 370000363..585e2f8b4 100644 --- a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/Layout/LayoutInfoStepViewModel.cs +++ b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/Layout/LayoutInfoStepViewModel.cs @@ -1,25 +1,117 @@ +using System; +using System.Collections.Generic; using System.Collections.ObjectModel; using System.Linq; using System.Reactive.Disposables; +using System.Reactive.Linq; +using Artemis.Core; using Artemis.UI.Screens.Workshop.Layout; using Artemis.UI.Screens.Workshop.SubmissionWizard.Models; -using DynamicData; +using PropertyChanged.SourceGenerator; using ReactiveUI; +using ReactiveUI.Validation.Extensions; +using RGB.NET.Core; +using KeyboardLayoutType = Artemis.Core.KeyboardLayoutType; namespace Artemis.UI.Screens.Workshop.SubmissionWizard.Steps.Layout; -public class LayoutInfoStepViewModel : SubmissionViewModel +public partial class LayoutInfoStepViewModel : SubmissionViewModel { - public LayoutInfoStepViewModel() + private readonly Func _getLayoutInfoViewModel; + private ArtemisLayout? _layout; + [Notify(Setter.Private)] private bool _isKeyboardLayout; + [Notify] private ObservableCollection _layoutInfo = new(); + [Notify] private KeyboardLayoutType _physicalLayout; + + public LayoutInfoStepViewModel(Func getLayoutInfoViewModel) { + _getLayoutInfoViewModel = getLayoutInfoViewModel; + GoBack = ReactiveCommand.Create(() => State.ChangeScreen()); - this.WhenActivated((CompositeDisposable _) => + Continue = ReactiveCommand.Create(ExecuteContinue, ValidationContext.Valid); + Secondary = ReactiveCommand.Create(ExecuteAddLayoutInfo); + SecondaryText = "Add layout info"; + + this.WhenActivated(d => { - LayoutInfo.Clear(); - if (State.EntrySource is LayoutEntrySource layoutEntrySource) - LayoutInfo.AddRange(layoutEntrySource.LayoutInfo.Select(i => new LayoutInfoViewModel(layoutEntrySource.Layout, i))); + if (State.EntrySource is not LayoutEntrySource layoutEntrySource) + return; + + _layout = layoutEntrySource.Layout; + IsKeyboardLayout = _layout.RgbLayout.Type == RGBDeviceType.Keyboard; + PhysicalLayout = layoutEntrySource.PhysicalLayout; + LayoutInfo = layoutEntrySource.LayoutInfo; + + if (!LayoutInfo.Any()) + ExecuteAddLayoutInfo(); + + this.ValidationRule( + vm => vm.PhysicalLayout, + this.WhenAnyValue(vm => vm.IsKeyboardLayout, vm => vm.PhysicalLayout, (isKeyboard, layout) => !isKeyboard || layout != KeyboardLayoutType.Unknown), + "A keyboard layout is required" + ).DisposeWith(d); + this.ValidationRule( + vm => vm.LayoutInfo, + this.WhenAnyValue(vm => vm.LayoutInfo.Count).Select(c => c != 0), + "At least one layout info is required" + ).DisposeWith(d); }); } - public ObservableCollection LayoutInfo { get; } = new(); + private void ExecuteAddLayoutInfo() + { + if (_layout == null) + return; + + LayoutInfoViewModel layoutInfo = _getLayoutInfoViewModel(_layout); + layoutInfo.Remove = ReactiveCommand.Create(() => LayoutInfo.Remove(layoutInfo)); + LayoutInfo.Add(layoutInfo); + } + + private void ExecuteContinue() + { + if (State.EntrySource is not LayoutEntrySource layoutEntrySource) + return; + + layoutEntrySource.PhysicalLayout = PhysicalLayout; + + if (string.IsNullOrWhiteSpace(State.Name)) + State.Name = layoutEntrySource.Layout.RgbLayout.Name ?? ""; + if (string.IsNullOrWhiteSpace(State.Summary)) + { + State.Summary = !string.IsNullOrWhiteSpace(layoutEntrySource.Layout.RgbLayout.Vendor) + ? $"{layoutEntrySource.Layout.RgbLayout.Vendor} {layoutEntrySource.Layout.RgbLayout.Type} device layout" + : $"{layoutEntrySource.Layout.RgbLayout.Type} device layout"; + } + + if (string.IsNullOrWhiteSpace(State.Description)) + { + State.Description = $@"### Layout properties +**Name** +{layoutEntrySource.Layout.RgbLayout.Name ?? "N/A"} +**Description** +{layoutEntrySource.Layout.RgbLayout.Description ?? "N/A"} +**Author** +{layoutEntrySource.Layout.RgbLayout.Author ?? "N/A"} +**Type** +{layoutEntrySource.Layout.RgbLayout.Type} +**Vendor** +{layoutEntrySource.Layout.RgbLayout.Vendor ?? "N/A"} +**Model** +{layoutEntrySource.Layout.RgbLayout.Model ?? "N/A"} +**Shape** +{layoutEntrySource.Layout.RgbLayout.Shape} +**Width** +{layoutEntrySource.Layout.RgbLayout.Width}mm +**Height** +{layoutEntrySource.Layout.RgbLayout.Height}mm"; + } + + State.Categories = new List {8}; // Device category, yes this could change but why would it + + 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/Layout/LayoutSelectionStepViewModel.cs b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/Layout/LayoutSelectionStepViewModel.cs index 5c365fe6a..83c788e68 100644 --- a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/Layout/LayoutSelectionStepViewModel.cs +++ b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/Layout/LayoutSelectionStepViewModel.cs @@ -5,7 +5,6 @@ using Artemis.Core.Services; using PropertyChanged.SourceGenerator; using ReactiveUI; using System; -using System.Collections.Generic; using System.IO; using System.Reactive.Disposables; using System.Reactive.Linq; @@ -14,6 +13,7 @@ using Artemis.UI.Screens.Workshop.SubmissionWizard.Models; using Artemis.UI.Shared.Extensions; using Artemis.UI.Shared.Services; using Avalonia.Media.Imaging; +using Avalonia.Threading; using SkiaSharp; namespace Artemis.UI.Screens.Workshop.SubmissionWizard.Steps.Layout; @@ -36,11 +36,11 @@ public partial class LayoutSelectionStepViewModel : SubmissionViewModel ); GoBack = ReactiveCommand.Create(() => State.ChangeScreen()); - Continue = ReactiveCommand.Create(ExecuteContinue, this.WhenAnyValue(vm => vm.Layout).Select(p => p != null)); + Continue = ReactiveCommand.CreateFromTask(ExecuteContinue, this.WhenAnyValue(vm => vm.Layout).Select(p => p != null)); this.WhenAnyValue(vm => vm.SelectedDevice).WhereNotNull().Subscribe(d => Layout = d.Layout); this.WhenAnyValue(vm => vm.Layout).Subscribe(CreatePreviewDevice); - + this.WhenActivated((CompositeDisposable _) => { ShowGoBack = State.EntryId == null; @@ -82,44 +82,64 @@ public partial class LayoutSelectionStepViewModel : SubmissionViewModel Layout = layout; } - private void ExecuteContinue() + private async Task ExecuteContinue() { if (Layout == null) return; State.EntrySource = new LayoutEntrySource(Layout); - State.Name = Layout.RgbLayout.Name ?? ""; - State.Summary = !string.IsNullOrWhiteSpace(Layout.RgbLayout.Vendor) - ? $"{Layout.RgbLayout.Vendor} {Layout.RgbLayout.Type} device layout" - : $"{Layout.RgbLayout.Type} device layout"; - - State.Categories = new List {8}; // Device category, yes this could change but why would it - - State.Icon?.Dispose(); - State.Icon = GetDeviceIcon(); - + await Dispatcher.UIThread.InvokeAsync(SetDeviceImages, DispatcherPriority.Background); State.ChangeScreen(); } - private Stream GetDeviceIcon() + private void SetDeviceImages() { - // Go through the hassle of resizing the image to 128x128 without losing aspect ratio, padding is added for this - using RenderTargetBitmap image = Layout.RenderLayout(false); - using MemoryStream stream = new(); - image.Save(stream); - stream.Seek(0, SeekOrigin.Begin); + if (Layout == null) + return; + + MemoryStream deviceWithoutLeds = new(); + MemoryStream deviceWithLeds = new(); + + using (RenderTargetBitmap image = Layout.RenderLayout(false)) + { + image.Save(deviceWithoutLeds); + deviceWithoutLeds.Seek(0, SeekOrigin.Begin); + } + using (RenderTargetBitmap image = Layout.RenderLayout(true)) + { + image.Save(deviceWithLeds); + deviceWithLeds.Seek(0, SeekOrigin.Begin); + } + State.Icon?.Dispose(); + foreach (Stream stateImage in State.Images) + stateImage.Dispose(); + State.Images.Clear(); + + // Go through the hassle of resizing the image to 128x128 without losing aspect ratio, padding is added for this + State.Icon = ResizeImage(deviceWithoutLeds, 128); + State.Images.Add(deviceWithoutLeds); + State.Images.Add(deviceWithLeds); + } + + private Stream ResizeImage(Stream image, int size) + { MemoryStream output = new(); - using SKBitmap? sourceBitmap = SKBitmap.Decode(stream); + using MemoryStream input = new(); + + image.CopyTo(input); + input.Seek(0, SeekOrigin.Begin); + + using SKBitmap? sourceBitmap = SKBitmap.Decode(input); int sourceWidth = sourceBitmap.Width; int sourceHeight = sourceBitmap.Height; - float scale = Math.Min((float) 128 / sourceWidth, (float) 128 / sourceHeight); + float scale = Math.Min((float) size / sourceWidth, (float) size / sourceHeight); SKSizeI scaledDimensions = new((int) Math.Floor(sourceWidth * scale), (int) Math.Floor(sourceHeight * scale)); - SKPointI offset = new((128 - scaledDimensions.Width) / 2, (128 - scaledDimensions.Height) / 2); + SKPointI offset = new((size - scaledDimensions.Width) / 2, (size - scaledDimensions.Height) / 2); using SKBitmap? scaleBitmap = sourceBitmap.Resize(scaledDimensions, SKFilterQuality.High); - using SKBitmap targetBitmap = new(128, 128); + using SKBitmap targetBitmap = new(size, size); using SKCanvas canvas = new(targetBitmap); canvas.Clear(SKColors.Transparent); canvas.DrawBitmap(scaleBitmap, offset.X, offset.Y); diff --git a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/SpecificationsStepViewModel.cs b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/SpecificationsStepViewModel.cs index 729f5b352..0f577e1fd 100644 --- a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/SpecificationsStepViewModel.cs +++ b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/SpecificationsStepViewModel.cs @@ -57,7 +57,7 @@ public partial class SpecificationsStepViewModel : SubmissionViewModel return; ApplyToState(); - State.ChangeScreen(); + State.ChangeScreen(); } private void ApplyFromState() diff --git a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/SubmissionViewModel.cs b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/SubmissionViewModel.cs index 5a118bae9..cbd398049 100644 --- a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/SubmissionViewModel.cs +++ b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/SubmissionViewModel.cs @@ -8,9 +8,11 @@ namespace Artemis.UI.Screens.Workshop.SubmissionWizard; public abstract partial class SubmissionViewModel : ValidatableViewModelBase { + [Notify] private ReactiveCommand? _secondary; [Notify] private ReactiveCommand? _continue; [Notify] private ReactiveCommand? _goBack; [Notify] private string _continueText = "Continue"; + [Notify] private string? _secondaryText; [Notify] private bool _showFinish; [Notify] private bool _showGoBack = true; [Notify] private bool _showHeader = true; diff --git a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/SubmissionWizardView.axaml b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/SubmissionWizardView.axaml index d64ed0199..448e4420f 100644 --- a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/SubmissionWizardView.axaml +++ b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/SubmissionWizardView.axaml @@ -16,9 +16,10 @@ WindowStartupLocation="CenterOwner"> - - - + + + @@ -36,16 +37,23 @@ - + + - diff --git a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/SubmissionWizardViewModel.cs b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/SubmissionWizardViewModel.cs index 787e9bdfc..efb87b34d 100644 --- a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/SubmissionWizardViewModel.cs +++ b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/SubmissionWizardViewModel.cs @@ -1,10 +1,12 @@ -using Artemis.UI.Screens.Workshop.CurrentUser; +using System.Reactive.Disposables; +using Artemis.UI.Screens.Workshop.CurrentUser; using Artemis.UI.Screens.Workshop.SubmissionWizard.Models; using Artemis.UI.Screens.Workshop.SubmissionWizard.Steps; using Artemis.UI.Shared; using Artemis.UI.Shared.Services; using DryIoc; using PropertyChanged.SourceGenerator; +using ReactiveUI; namespace Artemis.UI.Screens.Workshop.SubmissionWizard; @@ -26,6 +28,8 @@ public partial class SubmissionWizardViewModel : ActivatableViewModelBase, IWork WindowService = windowService; CurrentUserViewModel = currentUserViewModel; CurrentUserViewModel.AllowLogout = false; + + this.WhenActivated(d => _state.DisposeWith(d)); } public IWindowService WindowService { get; }