From f786172c5a15b808b4b76433924ba6b471ca781d Mon Sep 17 00:00:00 2001 From: Ionite Date: Wed, 12 Jul 2023 23:27:36 -0400 Subject: [PATCH] Add RefreshBadge and SelectDataDirectoryDialog --- StabilityMatrix.Avalonia/App.axaml.cs | 40 ++++- .../Controls/RefreshBadge.axaml | 39 +++++ .../Controls/RefreshBadge.axaml.cs | 16 ++ .../DesignData/DesignData.cs | 7 + .../Styles/ThemeColors.cs | 10 ++ .../ViewModels/CheckpointFolder.cs | 39 +++++ .../Dialogs/SelectDataDirectoryViewModel.cs | 156 ++++++++++++++++++ .../ViewModels/MainWindowViewModel.cs | 58 +++++++ .../ViewModels/RefreshBadgeViewModel.cs | 96 +++++++++++ .../Dialogs/SelectDataDirectoryDialog.axaml | 100 +++++++++++ .../SelectDataDirectoryDialog.axaml.cs | 17 ++ 11 files changed, 574 insertions(+), 4 deletions(-) create mode 100644 StabilityMatrix.Avalonia/Controls/RefreshBadge.axaml create mode 100644 StabilityMatrix.Avalonia/Controls/RefreshBadge.axaml.cs create mode 100644 StabilityMatrix.Avalonia/Styles/ThemeColors.cs create mode 100644 StabilityMatrix.Avalonia/ViewModels/Dialogs/SelectDataDirectoryViewModel.cs create mode 100644 StabilityMatrix.Avalonia/ViewModels/RefreshBadgeViewModel.cs create mode 100644 StabilityMatrix.Avalonia/Views/Dialogs/SelectDataDirectoryDialog.axaml create mode 100644 StabilityMatrix.Avalonia/Views/Dialogs/SelectDataDirectoryDialog.axaml.cs diff --git a/StabilityMatrix.Avalonia/App.axaml.cs b/StabilityMatrix.Avalonia/App.axaml.cs index f4349265..ab3f691a 100644 --- a/StabilityMatrix.Avalonia/App.axaml.cs +++ b/StabilityMatrix.Avalonia/App.axaml.cs @@ -12,6 +12,7 @@ using Avalonia; using Avalonia.Controls; using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Markup.Xaml; +using Avalonia.Platform.Storage; using Avalonia.Styling; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -25,11 +26,13 @@ using Polly.Contrib.WaitAndRetry; using Polly.Extensions.Http; using Polly.Timeout; using Refit; +using StabilityMatrix.Avalonia.Controls; using StabilityMatrix.Avalonia.DesignData; using StabilityMatrix.Avalonia.Services; using StabilityMatrix.Avalonia.ViewModels; using StabilityMatrix.Avalonia.ViewModels.Dialogs; using StabilityMatrix.Avalonia.Views; +using StabilityMatrix.Avalonia.Views.Dialogs; using StabilityMatrix.Core.Api; using StabilityMatrix.Core.Converters.Json; using StabilityMatrix.Core.Database; @@ -52,6 +55,9 @@ public partial class App : Application [NotNull] public static Visual? VisualRoot { get; private set; } + + [NotNull] + public static IStorageProvider? StorageProvider { get; private set; } public override void Initialize() { @@ -63,7 +69,7 @@ public partial class App : Application RequestedThemeVariant = ThemeVariant.Dark; } } - + public override void OnFrameworkInitializationCompleted() { ConfigureServiceProvider(); @@ -74,12 +80,14 @@ public partial class App : Application if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) { var mainWindow = Services.GetRequiredService(); - VisualRoot = mainWindow; mainWindow.DataContext = mainViewModel; mainWindow.NotificationService = notificationService; + + VisualRoot = mainWindow; + StorageProvider = mainWindow.StorageProvider; + desktop.MainWindow = mainWindow; desktop.Exit += OnExit; - Debug.WriteLine($"Shutdown mode: {desktop.ShutdownMode}"); } base.OnFrameworkInitializationCompleted(); @@ -112,28 +120,43 @@ public partial class App : Application services.AddTransient(); services.AddTransient(); services.AddTransient(); + services.AddTransient(); // Other transients (usually sub view models) services.AddTransient(); services.AddTransient(); + // Controls + services.AddTransient(); + // Dialog factory services.AddSingleton>(provider => new ServiceManager() .Register(provider.GetRequiredService) .Register(provider.GetRequiredService) .Register(provider.GetRequiredService) + .Register(provider.GetRequiredService) .Register(provider.GetRequiredService) - .Register(provider.GetRequiredService)); + .Register(provider.GetRequiredService) + .Register(provider.GetRequiredService)); } internal static void ConfigureViews(IServiceCollection services) { + // Pages services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); + + // Dialogs + services.AddTransient(); + + // Controls + services.AddTransient(); + + // Window services.AddSingleton(); } @@ -287,6 +310,15 @@ public partial class App : Application return services; } + public static void Shutdown() + { + if (Current is null) throw new InvalidOperationException("Current Application is not defined"); + if (Current.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime lifetime) + { + lifetime.Shutdown(); + } + } + private void OnExit(object? sender, ControlledApplicationLifetimeExitEventArgs args) { Debug.WriteLine("Start OnExit"); diff --git a/StabilityMatrix.Avalonia/Controls/RefreshBadge.axaml b/StabilityMatrix.Avalonia/Controls/RefreshBadge.axaml new file mode 100644 index 00000000..4a83417a --- /dev/null +++ b/StabilityMatrix.Avalonia/Controls/RefreshBadge.axaml @@ -0,0 +1,39 @@ + + + + + + + diff --git a/StabilityMatrix.Avalonia/Controls/RefreshBadge.axaml.cs b/StabilityMatrix.Avalonia/Controls/RefreshBadge.axaml.cs new file mode 100644 index 00000000..f5620f12 --- /dev/null +++ b/StabilityMatrix.Avalonia/Controls/RefreshBadge.axaml.cs @@ -0,0 +1,16 @@ +using Avalonia.Markup.Xaml; + +namespace StabilityMatrix.Avalonia.Controls; + +public partial class RefreshBadge : UserControlBase +{ + public RefreshBadge() + { + InitializeComponent(); + } + + private void InitializeComponent() + { + AvaloniaXamlLoader.Load(this); + } +} diff --git a/StabilityMatrix.Avalonia/DesignData/DesignData.cs b/StabilityMatrix.Avalonia/DesignData/DesignData.cs index 9d8f520f..65b4538b 100644 --- a/StabilityMatrix.Avalonia/DesignData/DesignData.cs +++ b/StabilityMatrix.Avalonia/DesignData/DesignData.cs @@ -14,6 +14,7 @@ using StabilityMatrix.Core.Helper.Factory; using StabilityMatrix.Core.Models; using StabilityMatrix.Core.Models.Api; using StabilityMatrix.Core.Models.Packages; +using StabilityMatrix.Core.Models.Progress; using StabilityMatrix.Core.Python; using StabilityMatrix.Core.Services; @@ -178,4 +179,10 @@ public static class DesignData public static SelectModelVersionViewModel SelectModelVersionViewModel => Services.GetRequiredService(); public static OneClickInstallViewModel OneClickInstallViewModel => Services.GetRequiredService(); public static InstallerViewModel InstallerViewModel => Services.GetRequiredService(); + public static SelectDataDirectoryViewModel SelectDataDirectoryViewModel => Services.GetRequiredService(); + + public static RefreshBadgeViewModel RefreshBadgeViewModel => new() + { + State = ProgressState.Success + }; } diff --git a/StabilityMatrix.Avalonia/Styles/ThemeColors.cs b/StabilityMatrix.Avalonia/Styles/ThemeColors.cs new file mode 100644 index 00000000..f77232d7 --- /dev/null +++ b/StabilityMatrix.Avalonia/Styles/ThemeColors.cs @@ -0,0 +1,10 @@ +using Avalonia.Media; + +namespace StabilityMatrix.Avalonia.Styles; + +public static class ThemeColors +{ + public static readonly SolidColorBrush ThemeGreen = SolidColorBrush.Parse("#4caf50"); + public static readonly SolidColorBrush ThemeRed = SolidColorBrush.Parse("#f44336"); + public static readonly SolidColorBrush ThemeYellow = SolidColorBrush.Parse("#ffeb3b"); +} diff --git a/StabilityMatrix.Avalonia/ViewModels/CheckpointFolder.cs b/StabilityMatrix.Avalonia/ViewModels/CheckpointFolder.cs index c312f817..b2aab55b 100644 --- a/StabilityMatrix.Avalonia/ViewModels/CheckpointFolder.cs +++ b/StabilityMatrix.Avalonia/ViewModels/CheckpointFolder.cs @@ -6,12 +6,14 @@ using System.Diagnostics; using System.IO; using System.Linq; using System.Threading.Tasks; +using Avalonia.Controls; using Avalonia.Input; using Avalonia.Platform.Storage; using Avalonia.Threading; using AvaloniaEdit.Utils; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; +using FluentAvalonia.UI.Controls; using StabilityMatrix.Core.Extensions; using StabilityMatrix.Core.Helper; using StabilityMatrix.Core.Models; @@ -162,6 +164,43 @@ public partial class CheckpointFolder : ViewModelBase Process.Start("explorer.exe", path); } + [RelayCommand] + private async Task CreateSubFolder() + { + Dispatcher.UIThread.VerifyAccess(); + + var textBox = new TextBox(); + var dialog = new ContentDialog + { + Content = textBox, + DefaultButton = ContentDialogButton.Primary, + PrimaryButtonText = "OK", + IsPrimaryButtonEnabled = true, + }; + + var result = await dialog.ShowAsync(); + if (result == ContentDialogResult.Primary) + { + var targetName = textBox.Text; + if (string.IsNullOrWhiteSpace(targetName)) return; + + var subFolderPath = Path.Combine(DirectoryPath, targetName); + + Directory.CreateDirectory(subFolderPath); + + SubFolders.Add(new CheckpointFolder(settingsManager, + downloadService, modelFinder, + useCategoryVisibility: false) + { + Title = Path.GetFileName(subFolderPath), + DirectoryPath = subFolderPath, + FolderType = FolderType, + ParentFolder = this, + IsExpanded = false, + }); + } + } + /// /// Imports files to the folder. Reports progress to instance properties. /// diff --git a/StabilityMatrix.Avalonia/ViewModels/Dialogs/SelectDataDirectoryViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/Dialogs/SelectDataDirectoryViewModel.cs new file mode 100644 index 00000000..a8040df7 --- /dev/null +++ b/StabilityMatrix.Avalonia/ViewModels/Dialogs/SelectDataDirectoryViewModel.cs @@ -0,0 +1,156 @@ +using System; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading.Tasks; +using AsyncAwaitBestPractices; +using Avalonia.Controls; +using Avalonia.Platform.Storage; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using NLog; +using StabilityMatrix.Avalonia.Views.Dialogs; +using StabilityMatrix.Core.Attributes; +using StabilityMatrix.Core.Helper; +using StabilityMatrix.Core.Models.Progress; +using StabilityMatrix.Core.Models.Settings; +using StabilityMatrix.Core.Services; + +namespace StabilityMatrix.Avalonia.ViewModels.Dialogs; + +[View(typeof(SelectDataDirectoryDialog))] +public partial class SelectDataDirectoryViewModel : ViewModelBase +{ + private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); + private static string DefaultInstallLocation => Compat.AppDataHome; + + private readonly ISettingsManager settingsManager; + + private const string ValidExistingDirectoryText = "Valid existing data directory found"; + private const string InvalidDirectoryText = + "Directory must be empty or have a valid settings.json file"; + private const string NotEnoughFreeSpaceText = "Not enough free space on the selected drive"; + + [ObservableProperty] private string dataDirectory = DefaultInstallLocation; + [ObservableProperty] private bool isPortableMode; + + [ObservableProperty] private string directoryStatusText = string.Empty; + [ObservableProperty] private bool isStatusBadgeVisible; + [ObservableProperty] private bool isDirectoryValid; + + public RefreshBadgeViewModel ValidatorRefreshBadge { get; } = new() + { + State = ProgressState.Inactive, + SuccessToolTipText = ValidExistingDirectoryText, + FailToolTipText = InvalidDirectoryText + }; + + public bool HasOldData => settingsManager.GetOldInstalledPackages().Any(); + + public SelectDataDirectoryViewModel(ISettingsManager settingsManager) + { + this.settingsManager = settingsManager; + ValidatorRefreshBadge.RefreshFunc = ValidateDataDirectory; + } + + public override void OnLoaded() + { + ValidatorRefreshBadge.RefreshCommand.ExecuteAsync(null).SafeFireAndForget(); + } + + // Revalidate on data directory change + partial void OnDataDirectoryChanged(string value) + { + ValidatorRefreshBadge.RefreshCommand.ExecuteAsync(null).SafeFireAndForget(); + } + + private async Task ValidateDataDirectory() + { + await using var delay = new MinimumDelay(100, 200); + + // Doesn't exist, this is fine as a new install, hide badge + if (!Directory.Exists(DataDirectory)) + { + IsStatusBadgeVisible = false; + IsDirectoryValid = true; + return true; + } + // Otherwise check that a settings.json exists + var settingsPath = Path.Combine(DataDirectory, "settings.json"); + + // settings.json exists: Try deserializing it + if (File.Exists(settingsPath)) + { + try + { + var jsonText = await File.ReadAllTextAsync(settingsPath); + var _ = JsonSerializer.Deserialize(jsonText, new JsonSerializerOptions + { + Converters = { new JsonStringEnumConverter() } + }); + // If successful, show existing badge + IsStatusBadgeVisible = true; + IsDirectoryValid = true; + DirectoryStatusText = ValidExistingDirectoryText; + return true; + } + catch (Exception e) + { + Logger.Info("Failed to deserialize settings.json: {Msg}", e.Message); + // If not, show error badge, and set directory to invalid to prevent continuing + IsStatusBadgeVisible = true; + IsDirectoryValid = false; + DirectoryStatusText = InvalidDirectoryText; + return false; + } + } + + // No settings.json + + // Check if the directory is %APPDATA%\StabilityMatrix: hide badge and set directory valid + if (DataDirectory == DefaultInstallLocation) + { + IsStatusBadgeVisible = false; + IsDirectoryValid = true; + return true; + } + + // Check if the directory is empty: hide badge and set directory to valid + var isEmpty = !Directory.EnumerateFileSystemEntries(DataDirectory).Any(); + if (isEmpty) + { + IsStatusBadgeVisible = false; + IsDirectoryValid = true; + return true; + } + + // Not empty and not appdata: show error badge, and set directory to invalid + IsStatusBadgeVisible = true; + IsDirectoryValid = false; + DirectoryStatusText = InvalidDirectoryText; + return false; + } + + private bool CanPickFolder => App.StorageProvider.CanPickFolder; + + [RelayCommand(CanExecute = nameof(CanPickFolder))] + private async Task ShowFolderBrowserDialog() + { + var provider = App.StorageProvider; + var result = await provider.OpenFolderPickerAsync(new FolderPickerOpenOptions + { + Title = "Select Data Folder", + AllowMultiple = false + }); + + if (result.Count != 1) return; + + DataDirectory = result[0].Path.LocalPath; + } + + partial void OnIsPortableModeChanged(bool value) + { + DataDirectory = value ? Compat.AppCurrentDir + "Data" : DefaultInstallLocation; + } +} diff --git a/StabilityMatrix.Avalonia/ViewModels/MainWindowViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/MainWindowViewModel.cs index 662aa942..69a98568 100644 --- a/StabilityMatrix.Avalonia/ViewModels/MainWindowViewModel.cs +++ b/StabilityMatrix.Avalonia/ViewModels/MainWindowViewModel.cs @@ -1,9 +1,12 @@ using System; using System.Collections.Generic; +using System.IO; using System.Linq; using System.Threading.Tasks; +using Avalonia; using CommunityToolkit.Mvvm.ComponentModel; using FluentAvalonia.UI.Controls; +using StabilityMatrix.Avalonia.Controls; using StabilityMatrix.Avalonia.Services; using StabilityMatrix.Avalonia.ViewModels.Dialogs; using StabilityMatrix.Avalonia.Views; @@ -44,6 +47,8 @@ public partial class MainWindowViewModel : ViewModelBase CurrentPage = Pages.FirstOrDefault(); SelectedCategory = Pages.FirstOrDefault(); EventManager.Instance.PageChangeRequested += OnPageChangeRequested; + + await EnsureDataDirectory(); if (!settingsManager.Settings.InstalledPackages.Any()) { @@ -70,6 +75,59 @@ public partial class MainWindowViewModel : ViewModelBase } } + /// + /// Check if the data directory exists, if not, show the select data directory dialog. + /// + private async Task EnsureDataDirectory() + { + // Show dialog if not set + if (!settingsManager.TryFindLibrary()) + { + await ShowSelectDataDirectoryDialog(); + } + + // Try to find library again, should be found now + if (!settingsManager.TryFindLibrary()) + { + throw new Exception("Could not find library after setting path"); + } + + // Check if there are old packages, if so show migration dialog + // TODO: Migration dialog + } + + private async Task ShowSelectDataDirectoryDialog() + { + var viewModel = dialogFactory.Get(); + var dialog = new BetterContentDialog + { + IsPrimaryButtonEnabled = false, + IsSecondaryButtonEnabled = false, + IsFooterVisible = false, + Content = new SelectDataDirectoryDialog + { + DataContext = viewModel + } + }; + + var result = await dialog.ShowAsync(); + if (result != ContentDialogResult.Primary) + { + App.Shutdown(); + } + + // 1. For portable mode, call settings.SetPortableMode() + if (viewModel.IsPortableMode) + { + settingsManager.SetPortableMode(); + } + // 2. For custom path, call settings.SetLibraryPath(path) + else + { + settingsManager.SetLibraryPath(viewModel.DataDirectory); + } + } + private void OnPageChangeRequested(object? sender, Type e) { CurrentPage = Pages.FirstOrDefault(p => p.GetType() == e); diff --git a/StabilityMatrix.Avalonia/ViewModels/RefreshBadgeViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/RefreshBadgeViewModel.cs new file mode 100644 index 00000000..6305ebbb --- /dev/null +++ b/StabilityMatrix.Avalonia/ViewModels/RefreshBadgeViewModel.cs @@ -0,0 +1,96 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using System.Threading.Tasks; +using Avalonia.Media; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using FluentAvalonia.UI.Controls; +using NLog; +using StabilityMatrix.Avalonia.Controls; +using StabilityMatrix.Avalonia.Styles; +using StabilityMatrix.Core.Attributes; +using StabilityMatrix.Core.Models.Progress; + +namespace StabilityMatrix.Avalonia.ViewModels; + +[View(typeof(RefreshBadge))] +[SuppressMessage("ReSharper", "MemberCanBePrivate.Global")] +public partial class RefreshBadgeViewModel : ViewModelBase +{ + private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); + + public string WorkingToolTipText { get; set; } = "Loading..."; + public string SuccessToolTipText { get; set; } = "Success"; + public string InactiveToolTipText { get; set; } = ""; + public string FailToolTipText { get; set; } = "Failed"; + + public Symbol InactiveIcon { get; set; } = Symbol.Clear; + public Symbol SuccessIcon { get; set; } = Symbol.Checkmark; + public Symbol FailIcon { get; set; } = Symbol.AlertUrgent; + + public IBrush SuccessColorBrush { get; set; } = ThemeColors.ThemeGreen; + public IBrush InactiveColorBrush { get; set; } = ThemeColors.ThemeYellow; + public IBrush FailColorBrush { get; set; } = ThemeColors.ThemeYellow; + + public Func>? RefreshFunc { get; set; } + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsWorking))] + [NotifyPropertyChangedFor(nameof(ColorBrush))] + [NotifyPropertyChangedFor(nameof(CurrentToolTip))] + [NotifyPropertyChangedFor(nameof(Icon))] + private ProgressState state; + + public bool IsWorking => State == ProgressState.Working; + + /*public ControlAppearance Appearance => State switch + { + ProgressState.Working => ControlAppearance.Info, + ProgressState.Success => ControlAppearance.Success, + ProgressState.Failed => ControlAppearance.Danger, + _ => ControlAppearance.Secondary + };*/ + + public IBrush ColorBrush => State switch + { + ProgressState.Success => SuccessColorBrush, + ProgressState.Inactive => InactiveColorBrush, + ProgressState.Failed => FailColorBrush, + _ => Brushes.Gray + }; + + public string CurrentToolTip => State switch + { + ProgressState.Working => WorkingToolTipText, + ProgressState.Success => SuccessToolTipText, + ProgressState.Inactive => InactiveToolTipText, + ProgressState.Failed => FailToolTipText, + _ => "" + }; + + public Symbol Icon => State switch + { + ProgressState.Success => SuccessIcon, + ProgressState.Failed => FailIcon, + _ => InactiveIcon + }; + + [RelayCommand] + private async Task Refresh() + { + Logger.Info("Running refresh command..."); + if (RefreshFunc == null) return; + + State = ProgressState.Working; + try + { + var result = await RefreshFunc.Invoke(); + State = result ? ProgressState.Success : ProgressState.Failed; + } + catch (Exception ex) + { + State = ProgressState.Failed; + Logger.Error(ex, "Refresh command failed: {Ex}", ex.Message); + } + } +} diff --git a/StabilityMatrix.Avalonia/Views/Dialogs/SelectDataDirectoryDialog.axaml b/StabilityMatrix.Avalonia/Views/Dialogs/SelectDataDirectoryDialog.axaml new file mode 100644 index 00000000..5bf58681 --- /dev/null +++ b/StabilityMatrix.Avalonia/Views/Dialogs/SelectDataDirectoryDialog.axaml @@ -0,0 +1,100 @@ + + + + + + + + + + + + + + + + +