From 7a69c87b56140ee95f0648552ce18a60f3561c4e Mon Sep 17 00:00:00 2001
From: Oleksii Holub <1935960+Tyrrrz@users.noreply.github.com>
Date: Mon, 13 May 2024 23:56:21 +0300
Subject: [PATCH] Use a 3-way theme switcher instead of a 2-way switcher
 (#1233)

---
 .../DiscordChatExporter.Core.csproj           |   2 +-
 DiscordChatExporter.Gui/App.axaml             |   9 +-
 DiscordChatExporter.Gui/App.axaml.cs          | 102 +++++++++++-------
 .../LocaleToDisplayNameStringConverter.cs     |   2 +-
 .../DiscordChatExporter.Gui.csproj            |   2 +-
 .../Framework/ThemeVariant.cs                 |   8 ++
 .../Services/SettingsService.cs               |  19 +---
 .../Utils/Extensions/AvaloniaExtensions.cs    |   3 +-
 .../ViewModels/Dialogs/SettingsViewModel.cs   |  15 +--
 .../ViewModels/MainViewModel.cs               |  12 ---
 .../Views/Dialogs/SettingsView.axaml          |  26 ++---
 .../Views/Dialogs/SettingsView.axaml.cs       |  20 +---
 12 files changed, 105 insertions(+), 115 deletions(-)
 create mode 100644 DiscordChatExporter.Gui/Framework/ThemeVariant.cs

diff --git a/DiscordChatExporter.Core/DiscordChatExporter.Core.csproj b/DiscordChatExporter.Core/DiscordChatExporter.Core.csproj
index eaa04cb..c0a434c 100644
--- a/DiscordChatExporter.Core/DiscordChatExporter.Core.csproj
+++ b/DiscordChatExporter.Core/DiscordChatExporter.Core.csproj
@@ -5,7 +5,7 @@
     <PackageReference Include="CSharpier.MsBuild" Version="0.28.2" PrivateAssets="all" />
     <PackageReference Include="Gress" Version="2.1.1" />
     <PackageReference Include="JsonExtensions" Version="1.2.0" />
-    <PackageReference Include="Polly" Version="8.3.1" />
+    <PackageReference Include="Polly" Version="8.4.0" />
     <PackageReference Include="RazorBlade" Version="0.6.0" />
     <PackageReference Include="Superpower" Version="3.0.0" />
     <PackageReference Include="WebMarkupMin.Core" Version="2.16.0" />
diff --git a/DiscordChatExporter.Gui/App.axaml b/DiscordChatExporter.Gui/App.axaml
index 52f0d27..66e4388 100644
--- a/DiscordChatExporter.Gui/App.axaml
+++ b/DiscordChatExporter.Gui/App.axaml
@@ -7,13 +7,18 @@
     xmlns:materialAssists="clr-namespace:Material.Styles.Assists;assembly=Material.Styles"
     xmlns:materialControls="clr-namespace:Material.Styles.Controls;assembly=Material.Styles"
     xmlns:materialIcons="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia"
-    xmlns:materialStyles="clr-namespace:Material.Styles.Themes;assembly=Material.Styles">
+    xmlns:materialStyles="clr-namespace:Material.Styles.Themes;assembly=Material.Styles"
+    ActualThemeVariantChanged="Application_OnActualThemeVariantChanged">
     <Application.DataTemplates>
         <framework:ViewManager />
     </Application.DataTemplates>
 
     <Application.Styles>
-        <materialStyles:MaterialTheme />
+        <!--  This theme is used as a stub to pre-load default resources, the actual colors are set through code  -->
+        <materialStyles:MaterialTheme
+            BaseTheme="Light"
+            PrimaryColor="Grey"
+            SecondaryColor="DeepOrange" />
         <materialIcons:MaterialIconStyles />
         <dialogHostAvalonia:DialogHostStyles />
 
diff --git a/DiscordChatExporter.Gui/App.axaml.cs b/DiscordChatExporter.Gui/App.axaml.cs
index 5032d8d..05eb9c0 100644
--- a/DiscordChatExporter.Gui/App.axaml.cs
+++ b/DiscordChatExporter.Gui/App.axaml.cs
@@ -7,6 +7,8 @@ using Avalonia.Media;
 using Avalonia.Platform;
 using DiscordChatExporter.Gui.Framework;
 using DiscordChatExporter.Gui.Services;
+using DiscordChatExporter.Gui.Utils;
+using DiscordChatExporter.Gui.Utils.Extensions;
 using DiscordChatExporter.Gui.ViewModels;
 using DiscordChatExporter.Gui.ViewModels.Components;
 using DiscordChatExporter.Gui.ViewModels.Dialogs;
@@ -16,11 +18,14 @@ using Microsoft.Extensions.DependencyInjection;
 
 namespace DiscordChatExporter.Gui;
 
-public partial class App : Application, IDisposable
+public class App : Application, IDisposable
 {
     private readonly ServiceProvider _services;
+    private readonly SettingsService _settingsService;
     private readonly MainViewModel _mainViewModel;
 
+    private readonly DisposableCollector _eventRoot = new();
+
     public App()
     {
         var services = new ServiceCollection();
@@ -43,17 +48,62 @@ public partial class App : Application, IDisposable
         services.AddTransient<SettingsViewModel>();
 
         _services = services.BuildServiceProvider(true);
+        _settingsService = _services.GetRequiredService<SettingsService>();
         _mainViewModel = _services.GetRequiredService<ViewModelManager>().CreateMainViewModel();
+
+        // Re-initialize the theme when the user changes it
+        _eventRoot.Add(
+            _settingsService.WatchProperty(
+                o => o.Theme,
+                () =>
+                {
+                    RequestedThemeVariant = _settingsService.Theme switch
+                    {
+                        ThemeVariant.System => Avalonia.Styling.ThemeVariant.Default,
+                        ThemeVariant.Light => Avalonia.Styling.ThemeVariant.Light,
+                        ThemeVariant.Dark => Avalonia.Styling.ThemeVariant.Dark,
+                        _
+                            => throw new InvalidOperationException(
+                                $"Unknown theme '{_settingsService.Theme}'."
+                            )
+                    };
+
+                    InitializeTheme();
+                },
+                false
+            )
+        );
     }
 
     public override void Initialize()
     {
+        base.Initialize();
+
         // Increase maximum concurrent connections
         ServicePointManager.DefaultConnectionLimit = 20;
 
         AvaloniaXamlLoader.Load(this);
     }
 
+    private void InitializeTheme()
+    {
+        var actualTheme = RequestedThemeVariant?.Key switch
+        {
+            "Light" => PlatformThemeVariant.Light,
+            "Dark" => PlatformThemeVariant.Dark,
+            _ => PlatformSettings?.GetColorValues().ThemeVariant
+        };
+
+        this.LocateMaterialTheme<MaterialThemeBase>().CurrentTheme = actualTheme switch
+        {
+            PlatformThemeVariant.Light
+                => Theme.Create(Theme.Light, Color.Parse("#343838"), Color.Parse("#F9A825")),
+            PlatformThemeVariant.Dark
+                => Theme.Create(Theme.Dark, Color.Parse("#E8E8E8"), Color.Parse("#F9A825")),
+            _ => throw new InvalidOperationException($"Unknown theme '{actualTheme}'.")
+        };
+    }
+
     public override void OnFrameworkInitializationCompleted()
     {
         if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
@@ -61,50 +111,20 @@ public partial class App : Application, IDisposable
 
         base.OnFrameworkInitializationCompleted();
 
-        // Set custom theme colors
-        SetDefaultTheme();
-    }
-
-    public void Dispose() => _services.Dispose();
-}
-
-public partial class App
-{
-    public static void SetLightTheme()
-    {
-        if (Current is null)
-            return;
+        // Set up custom theme colors
+        InitializeTheme();
 
-        Current.LocateMaterialTheme<MaterialThemeBase>().CurrentTheme = Theme.Create(
-            Theme.Light,
-            Color.Parse("#343838"),
-            Color.Parse("#F9A825")
-        );
+        // Load settings
+        _settingsService.Load();
     }
 
-    public static void SetDarkTheme()
-    {
-        if (Current is null)
-            return;
-
-        Current.LocateMaterialTheme<MaterialThemeBase>().CurrentTheme = Theme.Create(
-            Theme.Dark,
-            Color.Parse("#E8E8E8"),
-            Color.Parse("#F9A825")
-        );
-    }
+    private void Application_OnActualThemeVariantChanged(object? sender, EventArgs args) =>
+        // Re-initialize the theme when the system theme changes
+        InitializeTheme();
 
-    public static void SetDefaultTheme()
+    public void Dispose()
     {
-        if (Current is null)
-            return;
-
-        var isDarkModeEnabledByDefault =
-            Current.PlatformSettings?.GetColorValues().ThemeVariant == PlatformThemeVariant.Dark;
-
-        if (isDarkModeEnabledByDefault)
-            SetDarkTheme();
-        else
-            SetLightTheme();
+        _eventRoot.Dispose();
+        _services.Dispose();
     }
 }
diff --git a/DiscordChatExporter.Gui/Converters/LocaleToDisplayNameStringConverter.cs b/DiscordChatExporter.Gui/Converters/LocaleToDisplayNameStringConverter.cs
index 5ec65d9..af1c213 100644
--- a/DiscordChatExporter.Gui/Converters/LocaleToDisplayNameStringConverter.cs
+++ b/DiscordChatExporter.Gui/Converters/LocaleToDisplayNameStringConverter.cs
@@ -11,7 +11,7 @@ public class LocaleToDisplayNameStringConverter : IValueConverter
     public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) =>
         value is string locale && !string.IsNullOrWhiteSpace(locale)
             ? CultureInfo.GetCultureInfo(locale).DisplayName
-            : "System default";
+            : "System";
 
     public object ConvertBack(
         object? value,
diff --git a/DiscordChatExporter.Gui/DiscordChatExporter.Gui.csproj b/DiscordChatExporter.Gui/DiscordChatExporter.Gui.csproj
index 140d150..5574454 100644
--- a/DiscordChatExporter.Gui/DiscordChatExporter.Gui.csproj
+++ b/DiscordChatExporter.Gui/DiscordChatExporter.Gui.csproj
@@ -22,7 +22,7 @@
     <PackageReference Include="DialogHost.Avalonia" Version="0.7.7" />
     <PackageReference Include="DotnetRuntimeBootstrapper" Version="2.5.4" PrivateAssets="all" />
     <PackageReference Include="Gress" Version="2.1.1" />
-    <PackageReference Include="Material.Avalonia" Version="3.5.0" />
+    <PackageReference Include="Material.Avalonia" Version="3.6.0" />
     <PackageReference Include="Material.Icons.Avalonia" Version="2.1.9" />
     <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.0" />
     <PackageReference Include="Onova" Version="2.6.11" />
diff --git a/DiscordChatExporter.Gui/Framework/ThemeVariant.cs b/DiscordChatExporter.Gui/Framework/ThemeVariant.cs
new file mode 100644
index 0000000..e89751d
--- /dev/null
+++ b/DiscordChatExporter.Gui/Framework/ThemeVariant.cs
@@ -0,0 +1,8 @@
+namespace DiscordChatExporter.Gui.Framework;
+
+public enum ThemeVariant
+{
+    System,
+    Light,
+    Dark
+}
diff --git a/DiscordChatExporter.Gui/Services/SettingsService.cs b/DiscordChatExporter.Gui/Services/SettingsService.cs
index a7c27c3..04e40a4 100644
--- a/DiscordChatExporter.Gui/Services/SettingsService.cs
+++ b/DiscordChatExporter.Gui/Services/SettingsService.cs
@@ -1,12 +1,10 @@
 using System;
 using System.IO;
-using Avalonia;
-using Avalonia.Platform;
 using Cogwheel;
 using CommunityToolkit.Mvvm.ComponentModel;
 using DiscordChatExporter.Core.Exporting;
+using DiscordChatExporter.Gui.Framework;
 using DiscordChatExporter.Gui.Models;
-using Microsoft.Win32;
 
 namespace DiscordChatExporter.Gui.Services;
 
@@ -18,10 +16,10 @@ public partial class SettingsService()
     private bool _isUkraineSupportMessageEnabled = true;
 
     [ObservableProperty]
-    private bool _isAutoUpdateEnabled = true;
+    private ThemeVariant _theme;
 
     [ObservableProperty]
-    private bool _isDarkModeEnabled;
+    private bool _isAutoUpdateEnabled = true;
 
     [ObservableProperty]
     private bool _isTokenPersisted = true;
@@ -62,17 +60,6 @@ public partial class SettingsService()
     [ObservableProperty]
     private string? _lastAssetsDirPath;
 
-    public override void Reset()
-    {
-        base.Reset();
-
-        // Reset the dark mode setting separately because its default value is evaluated dynamically
-        // and cannot be set by the field initializer.
-        IsDarkModeEnabled =
-            Application.Current?.PlatformSettings?.GetColorValues().ThemeVariant
-            == PlatformThemeVariant.Dark;
-    }
-
     public override void Save()
     {
         // Clear the token if it's not supposed to be persisted
diff --git a/DiscordChatExporter.Gui/Utils/Extensions/AvaloniaExtensions.cs b/DiscordChatExporter.Gui/Utils/Extensions/AvaloniaExtensions.cs
index 15df033..9d339a1 100644
--- a/DiscordChatExporter.Gui/Utils/Extensions/AvaloniaExtensions.cs
+++ b/DiscordChatExporter.Gui/Utils/Extensions/AvaloniaExtensions.cs
@@ -1,5 +1,4 @@
-using System;
-using Avalonia.Controls;
+using Avalonia.Controls;
 using Avalonia.Controls.ApplicationLifetimes;
 using Avalonia.VisualTree;
 
diff --git a/DiscordChatExporter.Gui/ViewModels/Dialogs/SettingsViewModel.cs b/DiscordChatExporter.Gui/ViewModels/Dialogs/SettingsViewModel.cs
index 712da14..4193e6a 100644
--- a/DiscordChatExporter.Gui/ViewModels/Dialogs/SettingsViewModel.cs
+++ b/DiscordChatExporter.Gui/ViewModels/Dialogs/SettingsViewModel.cs
@@ -1,6 +1,5 @@
 using System;
 using System.Collections.Generic;
-using System.Linq;
 using DiscordChatExporter.Core.Utils.Extensions;
 using DiscordChatExporter.Gui.Framework;
 using DiscordChatExporter.Gui.Models;
@@ -23,16 +22,18 @@ public class SettingsViewModel : DialogViewModelBase
         _eventRoot.Add(_settingsService.WatchAllProperties(OnAllPropertiesChanged));
     }
 
-    public bool IsAutoUpdateEnabled
+    public IReadOnlyList<ThemeVariant> AvailableThemes { get; } = Enum.GetValues<ThemeVariant>();
+
+    public ThemeVariant Theme
     {
-        get => _settingsService.IsAutoUpdateEnabled;
-        set => _settingsService.IsAutoUpdateEnabled = value;
+        get => _settingsService.Theme;
+        set => _settingsService.Theme = value;
     }
 
-    public bool IsDarkModeEnabled
+    public bool IsAutoUpdateEnabled
     {
-        get => _settingsService.IsDarkModeEnabled;
-        set => _settingsService.IsDarkModeEnabled = value;
+        get => _settingsService.IsAutoUpdateEnabled;
+        set => _settingsService.IsAutoUpdateEnabled = value;
     }
 
     public bool IsTokenPersisted
diff --git a/DiscordChatExporter.Gui/ViewModels/MainViewModel.cs b/DiscordChatExporter.Gui/ViewModels/MainViewModel.cs
index c6e85eb..deced76 100644
--- a/DiscordChatExporter.Gui/ViewModels/MainViewModel.cs
+++ b/DiscordChatExporter.Gui/ViewModels/MainViewModel.cs
@@ -79,18 +79,6 @@ public partial class MainViewModel(
     [RelayCommand]
     private async Task InitializeAsync()
     {
-        // Reset settings (needed to resolve the default dark mode setting)
-        settingsService.Reset();
-
-        // Load settings
-        settingsService.Load();
-
-        // Set the correct theme
-        if (settingsService.IsDarkModeEnabled)
-            App.SetDarkTheme();
-        else
-            App.SetLightTheme();
-
         await ShowUkraineSupportMessageAsync();
         await CheckForUpdatesAsync();
     }
diff --git a/DiscordChatExporter.Gui/Views/Dialogs/SettingsView.axaml b/DiscordChatExporter.Gui/Views/Dialogs/SettingsView.axaml
index 8cf0e9a..31b06db 100644
--- a/DiscordChatExporter.Gui/Views/Dialogs/SettingsView.axaml
+++ b/DiscordChatExporter.Gui/Views/Dialogs/SettingsView.axaml
@@ -24,6 +24,19 @@
             BorderThickness="0,1">
             <ScrollViewer HorizontalScrollBarVisibility="Disabled" VerticalScrollBarVisibility="Auto">
                 <StackPanel Orientation="Vertical">
+                    <!--  Theme  -->
+                    <DockPanel
+                        Margin="16,8"
+                        LastChildFill="False"
+                        ToolTip.Tip="Preferred user interface theme">
+                        <TextBlock DockPanel.Dock="Left" Text="Theme" />
+                        <ComboBox
+                            Width="150"
+                            DockPanel.Dock="Right"
+                            ItemsSource="{Binding AvailableThemes}"
+                            SelectedItem="{Binding Theme}" />
+                    </DockPanel>
+
                     <!--  Auto-updates  -->
                     <DockPanel
                         Margin="16,8"
@@ -35,19 +48,6 @@
                         <ToggleSwitch DockPanel.Dock="Right" IsChecked="{Binding IsAutoUpdateEnabled}" />
                     </DockPanel>
 
-                    <!--  Dark mode  -->
-                    <DockPanel
-                        Margin="16,8"
-                        LastChildFill="False"
-                        ToolTip.Tip="Use darker colors in the UI">
-                        <TextBlock DockPanel.Dock="Left" Text="Dark mode" />
-                        <ToggleSwitch
-                            x:Name="DarkModeToggleSwitch"
-                            DockPanel.Dock="Right"
-                            IsChecked="{Binding IsDarkModeEnabled}"
-                            IsCheckedChanged="DarkModeToggleSwitch_OnIsCheckedChanged" />
-                    </DockPanel>
-
                     <!--  Persist token  -->
                     <DockPanel
                         Margin="16,8"
diff --git a/DiscordChatExporter.Gui/Views/Dialogs/SettingsView.axaml.cs b/DiscordChatExporter.Gui/Views/Dialogs/SettingsView.axaml.cs
index 4eb9dc7..316aa8d 100644
--- a/DiscordChatExporter.Gui/Views/Dialogs/SettingsView.axaml.cs
+++ b/DiscordChatExporter.Gui/Views/Dialogs/SettingsView.axaml.cs
@@ -1,6 +1,4 @@
-using System.Windows;
-using Avalonia.Interactivity;
-using DiscordChatExporter.Gui.Framework;
+using DiscordChatExporter.Gui.Framework;
 using DiscordChatExporter.Gui.ViewModels.Dialogs;
 
 namespace DiscordChatExporter.Gui.Views.Dialogs;
@@ -8,20 +6,4 @@ namespace DiscordChatExporter.Gui.Views.Dialogs;
 public partial class SettingsView : UserControl<SettingsViewModel>
 {
     public SettingsView() => InitializeComponent();
-
-    private void DarkModeToggleSwitch_OnIsCheckedChanged(object? sender, RoutedEventArgs args)
-    {
-        if (DarkModeToggleSwitch.IsChecked is true)
-        {
-            App.SetDarkTheme();
-        }
-        else if (DarkModeToggleSwitch.IsChecked is false)
-        {
-            App.SetLightTheme();
-        }
-        else
-        {
-            App.SetDefaultTheme();
-        }
-    }
 }