diff --git a/.gitignore b/.gitignore index bc78471d..1c87ff33 100644 --- a/.gitignore +++ b/.gitignore @@ -393,6 +393,9 @@ FodyWeavers.xsd # Local History for Visual Studio Code .history/ +# Avalonia build task artifacts +**/.avalonia-build-tasks/ + # Windows Installer files from build outputs *.cab *.msi @@ -482,3 +485,4 @@ $RECYCLE.BIN/ # Vim temporary swap files *.swp +AGENTS.md diff --git a/Flatpak/io.github.TeamWheelWizard.WheelWizard.metainfo.xml b/Flatpak/io.github.TeamWheelWizard.WheelWizard.metainfo.xml index a85ae40d..fa34b9e5 100644 --- a/Flatpak/io.github.TeamWheelWizard.WheelWizard.metainfo.xml +++ b/Flatpak/io.github.TeamWheelWizard.WheelWizard.metainfo.xml @@ -55,6 +55,7 @@ + diff --git a/WheelWizard.Test/Features/LinuxDolphinInstallerTests.cs b/WheelWizard.Test/Features/LinuxDolphinInstallerTests.cs new file mode 100644 index 00000000..8cef2545 --- /dev/null +++ b/WheelWizard.Test/Features/LinuxDolphinInstallerTests.cs @@ -0,0 +1,126 @@ +using WheelWizard.DolphinInstaller; +using WheelWizard.Shared; + +namespace WheelWizard.Test.Features; + +public class LinuxDolphinInstallerTests +{ + private readonly ILinuxCommandEnvironment _commandEnvironment; + private readonly ILinuxProcessService _processService; + private readonly LinuxDolphinInstaller _installer; + + public LinuxDolphinInstallerTests() + { + _commandEnvironment = Substitute.For(); + _processService = Substitute.For(); + _installer = new LinuxDolphinInstaller(_commandEnvironment, _processService); + } + + [Fact] + public void IsDolphinInstalledInFlatpak_ReturnsTrue_WhenFlatpakInfoExitCodeIsZero() + { + _processService.Run("flatpak", "info org.DolphinEmu.dolphin-emu").Returns(Ok(0)); + + var result = _installer.IsDolphinInstalledInFlatpak(); + + Assert.True(result); + } + + [Fact] + public void IsDolphinInstalledInFlatpak_ReturnsFalse_WhenFlatpakInfoExitCodeIsNonZero() + { + _processService.Run("flatpak", "info org.DolphinEmu.dolphin-emu").Returns(Ok(1)); + + var result = _installer.IsDolphinInstalledInFlatpak(); + + Assert.False(result); + } + + [Fact] + public async Task InstallFlatpak_ReturnsFailure_WhenPackageManagerCannotBeDetected() + { + _commandEnvironment.IsCommandAvailable("flatpak").Returns(false); + _commandEnvironment.DetectPackageManagerInstallCommand().Returns(string.Empty); + + var result = await _installer.InstallFlatpak(); + + Assert.True(result.IsFailure); + Assert.Contains("Unsupported Linux distribution", result.Error.Message); + } + + [Fact] + public async Task InstallFlatpak_ReturnsFailure_WhenPkexecIsUnauthorized() + { + _commandEnvironment.IsCommandAvailable("flatpak").Returns(false); + _commandEnvironment.DetectPackageManagerInstallCommand().Returns("apt-get install -y"); + _processService + .RunWithProgressAsync("pkexec", "apt-get install -y flatpak", Arg.Any?>()) + .Returns(Task.FromResult>(Ok(126))); + + var result = await _installer.InstallFlatpak(); + + Assert.True(result.IsFailure); + Assert.Contains("administrator", result.Error.Message); + } + + [Fact] + public async Task InstallFlatpak_ReturnsSuccess_WhenInstallCompletesAndCommandBecomesAvailable() + { + _commandEnvironment.IsCommandAvailable("flatpak").Returns(false, true); + _commandEnvironment.DetectPackageManagerInstallCommand().Returns("apt-get install -y"); + _processService + .RunWithProgressAsync("pkexec", "apt-get install -y flatpak", Arg.Any?>()) + .Returns(Task.FromResult>(Ok(0))); + + var result = await _installer.InstallFlatpak(); + + Assert.True(result.IsSuccess); + } + + [Fact] + public async Task InstallFlatpakDolphin_ReturnsFailure_WhenDolphinInstallCommandFails() + { + _commandEnvironment.IsCommandAvailable("flatpak").Returns(true); + _processService + .RunWithProgressAsync("pkexec", "flatpak --system install -y org.DolphinEmu.dolphin-emu", Arg.Any?>()) + .Returns(Task.FromResult>(Ok(1))); + + var result = await _installer.InstallFlatpakDolphin(); + + Assert.True(result.IsFailure); + Assert.Contains("exit code 1", result.Error.Message); + } + + [Fact] + public async Task InstallFlatpakDolphin_ReturnsFailure_WhenWarmupLaunchFails() + { + _commandEnvironment.IsCommandAvailable("flatpak").Returns(true); + _processService + .RunWithProgressAsync("pkexec", "flatpak --system install -y org.DolphinEmu.dolphin-emu", Arg.Any?>()) + .Returns(Task.FromResult>(Ok(0))); + _processService + .LaunchAndStopAsync("flatpak", "run org.DolphinEmu.dolphin-emu", TimeSpan.FromSeconds(4)) + .Returns(Task.FromResult(Fail("Launch failed"))); + + var result = await _installer.InstallFlatpakDolphin(); + + Assert.True(result.IsFailure); + Assert.Equal("Launch failed", result.Error.Message); + } + + [Fact] + public async Task InstallFlatpakDolphin_ReturnsSuccess_WhenInstallAndWarmupSucceed() + { + _commandEnvironment.IsCommandAvailable("flatpak").Returns(true); + _processService + .RunWithProgressAsync("pkexec", "flatpak --system install -y org.DolphinEmu.dolphin-emu", Arg.Any?>()) + .Returns(Task.FromResult>(Ok(0))); + _processService + .LaunchAndStopAsync("flatpak", "run org.DolphinEmu.dolphin-emu", TimeSpan.FromSeconds(4)) + .Returns(Task.FromResult(Ok())); + + var result = await _installer.InstallFlatpakDolphin(); + + Assert.True(result.IsSuccess); + } +} diff --git a/WheelWizard.Test/Features/Settings/DolphinSettingsTests.cs b/WheelWizard.Test/Features/Settings/DolphinSettingsTests.cs new file mode 100644 index 00000000..d1647f84 --- /dev/null +++ b/WheelWizard.Test/Features/Settings/DolphinSettingsTests.cs @@ -0,0 +1,149 @@ +using Testably.Abstractions.Testing; +using WheelWizard.Services; +using WheelWizard.Settings; +using WheelWizard.Settings.Types; + +namespace WheelWizard.Test.Features.Settings; + +[Collection("SettingsFeature")] +public class DolphinSettingTests +{ + [Fact] + public void Constructor_Throws_WhenFileNameIsNotIni() + { + var action = () => new DolphinSetting(typeof(string), ("Dolphin.cfg", "General", "NANDRootPath"), "value"); + + Assert.Throws(action); + } + + [Fact] + public void SetFromString_ParsesEnumAndFormatsAsIntegerString() + { + var setting = new DolphinSetting( + typeof(DolphinShaderCompilationMode), + ("GFX.ini", "Settings", "ShaderCompilationMode"), + DolphinShaderCompilationMode.Default + ); + + var result = setting.SetFromString("2", skipSave: true); + + Assert.True(result); + Assert.Equal(DolphinShaderCompilationMode.HybridUberShaders, Assert.IsType(setting.Get())); + Assert.Equal("2", setting.GetStringValue()); + } + + [Fact] + public void Set_ReturnsFalseAndKeepsOldValue_WhenValidationFails() + { + var setting = new DolphinSetting(typeof(int), ("GFX.ini", "Settings", "InternalResolution"), 1).SetValidation(value => + (int)value! >= 0 + ); + setting.Set(2); + + var result = setting.Set(-1); + + Assert.False(result); + Assert.Equal(2, Assert.IsType(setting.Get())); + } + + [Fact] + public void SetFromString_Throws_WhenTypeIsUnsupported() + { + var setting = new DolphinSetting(typeof(decimal), ("GFX.ini", "Settings", "Price"), 1m); + + Assert.Throws(() => setting.SetFromString("3.14")); + } +} + +[Collection("SettingsFeature")] +public class DolphinSettingManagerTests : IDisposable +{ + [Fact] + public void LoadSettings_ReadsExistingValue_FromIniFile() + { + var fileSystem = new MockFileSystem(); + var userFolderPath = $"/wheelwizard-user-{Guid.NewGuid():N}"; + SettingsTestUtils.InitializeSettingsRuntime(userFolderPath); + var configFolderPath = PathManager.ConfigFolderPath; + var iniPath = Path.Combine(configFolderPath, "Dolphin.ini"); + fileSystem.Directory.CreateDirectory(configFolderPath); + fileSystem.File.WriteAllLines(iniPath, ["[General]", "NANDRootPath = /persisted"]); + var manager = new DolphinSettingManager(fileSystem); + var setting = new DolphinSetting(typeof(string), ("Dolphin.ini", "General", "NANDRootPath"), "/default"); + + manager.RegisterSetting(setting); + manager.LoadSettings(); + + Assert.Equal("/persisted", Assert.IsType(setting.Get())); + } + + [Fact] + public void LoadSettings_WritesDefaultValue_WhenIniEntryIsMissing() + { + var fileSystem = new MockFileSystem(); + var userFolderPath = $"/wheelwizard-user-{Guid.NewGuid():N}"; + SettingsTestUtils.InitializeSettingsRuntime(userFolderPath); + var configFolderPath = PathManager.ConfigFolderPath; + var iniPath = Path.Combine(configFolderPath, "Dolphin.ini"); + fileSystem.Directory.CreateDirectory(configFolderPath); + fileSystem.File.WriteAllLines(iniPath, ["[General]", "OtherSetting = 1"]); + var manager = new DolphinSettingManager(fileSystem); + var setting = new DolphinSetting(typeof(string), ("Dolphin.ini", "General", "NANDRootPath"), "/default"); + + manager.RegisterSetting(setting); + manager.LoadSettings(); + + var updatedFile = fileSystem.File.ReadAllText(iniPath); + Assert.Contains("NANDRootPath = /default", updatedFile); + } + + [Fact] + public void SaveSettings_UpdatesExistingSettingLine_InIniFile() + { + var fileSystem = new MockFileSystem(); + var userFolderPath = $"/wheelwizard-user-{Guid.NewGuid():N}"; + SettingsTestUtils.InitializeSettingsRuntime(userFolderPath); + var configFolderPath = PathManager.ConfigFolderPath; + var iniPath = Path.Combine(configFolderPath, "Dolphin.ini"); + fileSystem.Directory.CreateDirectory(configFolderPath); + fileSystem.File.WriteAllLines(iniPath, ["[General]", "NANDRootPath = /old"]); + var manager = new DolphinSettingManager(fileSystem); + var setting = new DolphinSetting(typeof(string), ("Dolphin.ini", "General", "NANDRootPath"), "/default"); + + manager.RegisterSetting(setting); + manager.LoadSettings(); + setting.Set("/new", skipSave: true); + manager.SaveSettings(setting); + + var updatedFile = fileSystem.File.ReadAllText(iniPath); + Assert.Contains("NANDRootPath = /new", updatedFile); + Assert.DoesNotContain("NANDRootPath = /old", updatedFile); + } + + [Fact] + public void ReloadSettings_ReReadsFile_AfterItChangesOnDisk() + { + var fileSystem = new MockFileSystem(); + var userFolderPath = $"/wheelwizard-user-{Guid.NewGuid():N}"; + SettingsTestUtils.InitializeSettingsRuntime(userFolderPath); + var configFolderPath = PathManager.ConfigFolderPath; + var iniPath = Path.Combine(configFolderPath, "Dolphin.ini"); + fileSystem.Directory.CreateDirectory(configFolderPath); + fileSystem.File.WriteAllLines(iniPath, ["[General]", "NANDRootPath = /first"]); + var manager = new DolphinSettingManager(fileSystem); + var setting = new DolphinSetting(typeof(string), ("Dolphin.ini", "General", "NANDRootPath"), "/default"); + + manager.RegisterSetting(setting); + manager.LoadSettings(); + fileSystem.File.WriteAllLines(iniPath, ["[General]", "NANDRootPath = /second"]); + manager.ReloadSettings(); + + Assert.Equal("/second", Assert.IsType(setting.Get())); + } + + public void Dispose() + { + SettingsTestUtils.ResetSettingsRuntime(); + SettingsTestUtils.ResetSignalRuntime(); + } +} diff --git a/WheelWizard.Test/Features/Settings/SettingsTests.cs b/WheelWizard.Test/Features/Settings/SettingsTests.cs new file mode 100644 index 00000000..3b974b37 --- /dev/null +++ b/WheelWizard.Test/Features/Settings/SettingsTests.cs @@ -0,0 +1,334 @@ +using System.Globalization; +using System.IO.Abstractions; +using System.Reflection; +using Microsoft.Extensions.Logging; +using Testably.Abstractions; +using Testably.Abstractions.Testing; +using WheelWizard.DolphinInstaller; +using WheelWizard.Settings; +using WheelWizard.Settings.Types; + +namespace WheelWizard.Test.Features.Settings; + +[CollectionDefinition("SettingsFeature", DisableParallelization = true)] +public sealed class SettingsFeatureCollection; + +[Collection("SettingsFeature")] +public class SettingsManagerTests +{ + [Fact] + public void Get_Throws_WhenRequestedTypeDoesNotMatchSettingType() + { + var manager = CreateManager(new MockFileSystem(), out _, out _, out _); + + Assert.Throws(() => manager.Get(manager.WW_LANGUAGE)); + } + + [Fact] + public void Set_Throws_WhenProvidedValueIsNull() + { + var manager = CreateManager(new MockFileSystem(), out _, out _, out _); + + Assert.Throws(() => manager.Set(manager.WW_LANGUAGE, null!)); + } + + [Fact] + public void Set_ReturnsFalse_WhenValidationFails() + { + var manager = CreateManager(new MockFileSystem(), out _, out _, out _); + + var result = manager.Set(manager.FOCUSED_USER, 99, skipSave: true); + + Assert.False(result); + Assert.Equal(0, manager.Get(manager.FOCUSED_USER)); + } + + [Fact] + public void ValidateCorePathSettings_ReturnsAllExpectedIssues_WhenDefaultsAreInvalid() + { + var manager = CreateManager(new RealFileSystem(), out _, out _, out _); +#pragma warning disable CS0618 + SettingsRuntime.Initialize(manager); +#pragma warning restore CS0618 + + var result = manager.ValidateCorePathSettings(); + + Assert.True(result.IsSuccess); + Assert.False(result.Value.IsValid); + Assert.Contains(result.Value.Issues, issue => issue.Code == SettingsValidationCode.InvalidUserFolderPath); + Assert.Contains(result.Value.Issues, issue => issue.Code == SettingsValidationCode.InvalidDolphinLocation); + Assert.Contains(result.Value.Issues, issue => issue.Code == SettingsValidationCode.InvalidGameLocation); + } + + [Fact] + public void PathsSetupCorrectly_ReturnsTrue_WhenCorePathsAreValid() + { + var fileSystem = new MockFileSystem(); + var manager = CreateManager(fileSystem, out _, out _, out _); + var userFolderPath = $"/wheelwizard-user-{Guid.NewGuid():N}"; + var gameFilePath = Path.Combine(userFolderPath, "game.iso"); + var dolphinLocation = SettingsTestUtils.GetValidDolphinLocation(fileSystem); + fileSystem.Directory.CreateDirectory(userFolderPath); + fileSystem.File.WriteAllText(gameFilePath, "iso"); +#pragma warning disable CS0618 + SettingsRuntime.Initialize(manager); +#pragma warning restore CS0618 + + Assert.True(manager.Set(manager.USER_FOLDER_PATH, userFolderPath, skipSave: true)); + Assert.True(manager.Set(manager.GAME_LOCATION, gameFilePath, skipSave: true)); + Assert.True(manager.Set(manager.DOLPHIN_LOCATION, dolphinLocation, skipSave: true)); + Assert.True(manager.PathsSetupCorrectly()); + } + + [Fact] + public void LoadSettings_CallsUnderlyingManagersOnlyOnce() + { + var manager = CreateManager(new MockFileSystem(), out var whWzManager, out var dolphinManager, out _); + + manager.LoadSettings(); + manager.LoadSettings(); + + whWzManager.Received(1).LoadSettings(); + dolphinManager.Received(1).LoadSettings(); + } + + private static SettingsManager CreateManager( + IFileSystem fileSystem, + out IWhWzSettingManager whWzSettingManager, + out IDolphinSettingManager dolphinSettingManager, + out ILinuxDolphinInstaller linuxDolphinInstaller + ) + { + whWzSettingManager = Substitute.For(); + dolphinSettingManager = Substitute.For(); + linuxDolphinInstaller = Substitute.For(); + linuxDolphinInstaller.IsDolphinInstalledInFlatpak().Returns(true); + + return new SettingsManager(whWzSettingManager, dolphinSettingManager, linuxDolphinInstaller, fileSystem); + } +} + +[Collection("SettingsFeature")] +public class SettingsSignalBusTests +{ + [Fact] + public void Publish_NotifiesActiveSubscribers() + { + var signalBus = SettingsTestUtils.CreateSettingsSignalBus(); + var setting = new WhWzSetting(typeof(int), "Volume", 10); + SettingChangedSignal? receivedSignal = null; + using var _ = signalBus.Subscribe(signal => receivedSignal = signal); + + signalBus.Publish(setting); + + Assert.True(receivedSignal.HasValue); + Assert.Same(setting, receivedSignal.Value.Setting); + } + + [Fact] + public void DisposeSubscription_StopsReceivingSignals() + { + var signalBus = SettingsTestUtils.CreateSettingsSignalBus(); + var setting = new WhWzSetting(typeof(int), "Volume", 10); + var receiveCount = 0; + var subscription = signalBus.Subscribe(_ => receiveCount++); + + signalBus.Publish(setting); + subscription.Dispose(); + signalBus.Publish(setting); + + Assert.Equal(1, receiveCount); + } + + [Fact] + public void Subscribe_Throws_WhenHandlerIsNull() + { + var signalBus = SettingsTestUtils.CreateSettingsSignalBus(); + + Assert.Throws(() => signalBus.Subscribe(null!)); + } +} + +[Collection("SettingsFeature")] +public class SettingsLocalizationServiceTests +{ + [Fact] + public void Initialize_SetsCurrentCulture_FromLanguageSetting() + { + var originalCulture = CultureInfo.CurrentCulture; + var originalUiCulture = CultureInfo.CurrentUICulture; + var signalBus = SettingsTestUtils.CreateSettingsSignalBus(); + var settingsManager = Substitute.For(); + var languageSetting = new WhWzSetting(typeof(string), "WW_Language", "fr"); + settingsManager.WW_LANGUAGE.Returns(languageSetting); + settingsManager.Get(Arg.Any()).Returns(_ => (string)languageSetting.Get()); + var localizationService = new SettingsLocalizationService(settingsManager, signalBus); + + try + { + localizationService.Initialize(); + + Assert.Equal("fr", CultureInfo.CurrentCulture.TwoLetterISOLanguageName); + Assert.Equal("fr", CultureInfo.CurrentUICulture.TwoLetterISOLanguageName); + } + finally + { + CultureInfo.CurrentCulture = originalCulture; + CultureInfo.CurrentUICulture = originalUiCulture; + } + } + + [Fact] + public void PublishLanguageSignal_UpdatesCulture_WhenLanguageChanges() + { + var originalCulture = CultureInfo.CurrentCulture; + var originalUiCulture = CultureInfo.CurrentUICulture; + var signalBus = SettingsTestUtils.CreateSettingsSignalBus(); + var settingsManager = Substitute.For(); + var languageSetting = new WhWzSetting(typeof(string), "WW_Language", "en"); + settingsManager.WW_LANGUAGE.Returns(languageSetting); + settingsManager.Get(Arg.Any()).Returns(_ => (string)languageSetting.Get()); + var localizationService = new SettingsLocalizationService(settingsManager, signalBus); + + try + { + localizationService.Initialize(); + languageSetting.Set("de", skipSave: true); + signalBus.Publish(languageSetting); + + Assert.Equal("de", CultureInfo.CurrentCulture.TwoLetterISOLanguageName); + Assert.Equal("de", CultureInfo.CurrentUICulture.TwoLetterISOLanguageName); + } + finally + { + CultureInfo.CurrentCulture = originalCulture; + CultureInfo.CurrentUICulture = originalUiCulture; + } + } +} + +[Collection("SettingsFeature")] +public class SettingsStartupInitializerTests +{ + [Fact] + public void Initialize_LoadsSettings_InitializesLocalization_AndSetsRuntimes() + { + var settingsManager = Substitute.For(); + var signalBus = SettingsTestUtils.CreateSettingsSignalBus(); + var localizationService = Substitute.For(); + var logger = Substitute.For>(); + settingsManager.ValidateCorePathSettings().Returns(Ok(new SettingsValidationReport([]))); + var initializer = new SettingsStartupInitializer(settingsManager, signalBus, localizationService, logger); + + initializer.Initialize(); + + settingsManager.Received(1).LoadSettings(); + localizationService.Received(1).Initialize(); +#pragma warning disable CS0618 + Assert.Same(settingsManager, SettingsRuntime.Current); +#pragma warning restore CS0618 + } + + [Fact] + public void Initialize_DoesNotThrow_WhenValidationFails() + { + var settingsManager = Substitute.For(); + var signalBus = SettingsTestUtils.CreateSettingsSignalBus(); + var localizationService = Substitute.For(); + var logger = Substitute.For>(); + settingsManager.ValidateCorePathSettings().Returns(Fail("validation failed")); + var initializer = new SettingsStartupInitializer(settingsManager, signalBus, localizationService, logger); + + var exception = Record.Exception(initializer.Initialize); + + Assert.Null(exception); + settingsManager.Received(1).LoadSettings(); + localizationService.Received(1).Initialize(); + } +} + +internal static class SettingsTestUtils +{ + public static ISettingsManager InitializeSettingsRuntime(string userFolderPath, string dolphinLocation = "dolphin-emu") + { + var settings = CreateRuntimeSettingsStub(userFolderPath, dolphinLocation); +#pragma warning disable CS0618 + SettingsRuntime.Initialize(settings); +#pragma warning restore CS0618 + return settings; + } + + public static void InitializeSignalRuntime(ISettingsSignalBus? signalBus = null) + { +#pragma warning disable CS0618 + SettingsSignalRuntime.Initialize(signalBus ?? CreateSettingsSignalBus()); +#pragma warning restore CS0618 + } + + public static ISettingsSignalBus CreateSettingsSignalBus() + { + var logger = Substitute.For>(); + return new SettingsSignalBus(logger); + } + + public static void ResetSettingsRuntime() + { +#pragma warning disable CS0618 + SetPrivateStaticFieldValue(typeof(SettingsRuntime), "_current", null); +#pragma warning restore CS0618 + } + + public static void ResetSignalRuntime() + { +#pragma warning disable CS0618 + SetPrivateStaticFieldValue(typeof(SettingsSignalRuntime), "_current", null); + var pendingInitializersField = + typeof(SettingsSignalRuntime).GetField("PendingInitializers", BindingFlags.NonPublic | BindingFlags.Static) + ?? throw new InvalidOperationException("SettingsSignalRuntime pending initializers field was not found."); +#pragma warning restore CS0618 + if (pendingInitializersField.GetValue(null) is not System.Collections.IList pendingInitializers) + throw new InvalidOperationException("SettingsSignalRuntime pending initializers storage has an unexpected type."); + pendingInitializers.Clear(); + } + + public static string GetValidDolphinLocation(IFileSystem fileSystem) + { + if (!OperatingSystem.IsWindows()) + return "/usr/bin/env"; + + const string exePath = @"C:\WheelWizardTests\Dolphin.exe"; + var directoryPath = fileSystem.Path.GetDirectoryName(exePath); + if (!string.IsNullOrWhiteSpace(directoryPath)) + fileSystem.Directory.CreateDirectory(directoryPath); + + fileSystem.File.WriteAllText(exePath, "test"); + return exePath; + } + + private static ISettingsManager CreateRuntimeSettingsStub(string userFolderPath, string dolphinLocation) + { + var settings = Substitute.For(); + var userFolderSetting = new WhWzSetting(typeof(string), "UserFolderPath", userFolderPath); + var dolphinLocationSetting = new WhWzSetting(typeof(string), "DolphinLocation", dolphinLocation); + + settings.USER_FOLDER_PATH.Returns(userFolderSetting); + settings.DOLPHIN_LOCATION.Returns(dolphinLocationSetting); + + settings + .Get(Arg.Is(setting => ReferenceEquals(setting, userFolderSetting))) + .Returns(_ => (string)userFolderSetting.Get()); + settings + .Get(Arg.Is(setting => ReferenceEquals(setting, dolphinLocationSetting))) + .Returns(_ => (string)dolphinLocationSetting.Get()); + + return settings; + } + + private static void SetPrivateStaticFieldValue(Type targetType, string fieldName, object? value) + { + var field = + targetType.GetField(fieldName, BindingFlags.NonPublic | BindingFlags.Static) + ?? throw new InvalidOperationException($"{targetType.Name}.{fieldName} field was not found."); + field.SetValue(null, value); + } +} diff --git a/WheelWizard.Test/Features/Settings/VirtualSettingsTests.cs b/WheelWizard.Test/Features/Settings/VirtualSettingsTests.cs new file mode 100644 index 00000000..c9c77c8c --- /dev/null +++ b/WheelWizard.Test/Features/Settings/VirtualSettingsTests.cs @@ -0,0 +1,57 @@ +using WheelWizard.Settings; +using WheelWizard.Settings.Types; + +namespace WheelWizard.Test.Features.Settings; + +[Collection("SettingsFeature")] +public class VirtualSettingTests +{ + [Fact] + public void Set_StoresValueAndInvokesSetter_WhenValueIsValid() + { + var backingValue = 1; + var setting = new VirtualSetting(typeof(int), value => backingValue = (int)value, () => backingValue); + + var result = setting.Set(5); + + Assert.True(result); + Assert.Equal(5, backingValue); + Assert.Equal(5, Assert.IsType(setting.Get())); + } + + [Fact] + public void Set_ReturnsFalseAndKeepsOldValue_WhenValidationFails() + { + var backingValue = 2; + var setting = new VirtualSetting(typeof(int), value => backingValue = (int)value, () => backingValue).SetValidation(value => + (int)value! >= 0 + ); + + var result = setting.Set(-1); + + Assert.False(result); + Assert.Equal(2, backingValue); + Assert.Equal(2, Assert.IsType(setting.Get())); + } + + [Fact] + public void SetDependencies_RecalculatesValue_WhenDependencySignalsChange() + { + SettingsTestUtils.InitializeSignalRuntime(SettingsTestUtils.CreateSettingsSignalBus()); + var dependency = new WhWzSetting(typeof(int), "Dependency", 1); + var setting = new VirtualSetting(typeof(int), _ => { }, () => (int)dependency.Get()).SetDependencies(dependency); + + dependency.Set(7, skipSave: true); + + Assert.Equal(7, Assert.IsType(setting.Get())); + } + + [Fact] + public void SetDependencies_Throws_WhenCalledTwice() + { + var dependency = new WhWzSetting(typeof(int), "Dependency", 1); + var setting = new VirtualSetting(typeof(int), _ => { }, () => 1).SetDependencies(dependency); + + Assert.Throws(() => setting.SetDependencies(dependency)); + } +} diff --git a/WheelWizard.Test/Features/Settings/WhWzSettingsTests.cs b/WheelWizard.Test/Features/Settings/WhWzSettingsTests.cs new file mode 100644 index 00000000..9a82b942 --- /dev/null +++ b/WheelWizard.Test/Features/Settings/WhWzSettingsTests.cs @@ -0,0 +1,159 @@ +using System.Text.Json; +using Microsoft.Extensions.Logging; +using Testably.Abstractions.Testing; +using WheelWizard.Services; +using WheelWizard.Settings; +using WheelWizard.Settings.Types; + +namespace WheelWizard.Test.Features.Settings; + +[Collection("SettingsFeature")] +public class WhWzSettingTests +{ + [Fact] + public void Set_StoresValueAndCallsSaveAction_WhenValueIsValid() + { + var saveCalls = 0; + var setting = new WhWzSetting(typeof(int), "Volume", 10, _ => saveCalls++); + + var result = setting.Set(20); + + Assert.True(result); + Assert.Equal(20, Assert.IsType(setting.Get())); + Assert.Equal(1, saveCalls); + } + + [Fact] + public void Set_ReturnsFalseAndKeepsOldValue_WhenValidationFails() + { + var setting = new WhWzSetting(typeof(int), "Volume", 10).SetValidation(value => (int)value! >= 0); + setting.Set(5); + + var result = setting.Set(-1); + + Assert.False(result); + Assert.Equal(5, Assert.IsType(setting.Get())); + } + + [Fact] + public void Reset_AppliesDefaultValue_EvenIfDefaultDoesNotPassValidation() + { + var saveCalls = 0; + var setting = new WhWzSetting(typeof(int), "Threshold", 5, _ => saveCalls++).SetValidation(value => (int)value! >= 10); + setting.Set(12); + + setting.Reset(); + + Assert.Equal(5, Assert.IsType(setting.Get())); + Assert.Equal(2, saveCalls); + } + + [Fact] + public void SetFromJson_ParsesEnumAndArrayValues() + { + var enumSetting = new WhWzSetting(typeof(DayOfWeek), "Day", DayOfWeek.Monday); + var arraySetting = new WhWzSetting(typeof(string[]), "Names", Array.Empty()); + using var enumDocument = JsonDocument.Parse("2"); + using var arrayDocument = JsonDocument.Parse("[\"A\", \"B\"]"); + + var enumResult = enumSetting.SetFromJson(enumDocument.RootElement, skipSave: true); + var arrayResult = arraySetting.SetFromJson(arrayDocument.RootElement, skipSave: true); + + Assert.True(enumResult); + Assert.True(arrayResult); + Assert.Equal(DayOfWeek.Tuesday, Assert.IsType(enumSetting.Get())); + Assert.Equal(["A", "B"], Assert.IsType(arraySetting.Get())); + } + + [Fact] + public void SetFromJson_Throws_WhenTypeIsUnsupported() + { + var setting = new WhWzSetting(typeof(decimal), "Price", 1m); + using var document = JsonDocument.Parse("2"); + + Assert.Throws(() => setting.SetFromJson(document.RootElement, skipSave: true)); + } +} + +[Collection("SettingsFeature")] +public class WhWzSettingManagerTests +{ + [Fact] + public void LoadSettings_AppliesPersistedValues_ToRegisteredSettings() + { + var fileSystem = new MockFileSystem(); + var logger = Substitute.For>(); + var manager = new WhWzSettingManager(logger, fileSystem); + var volume = new WhWzSetting(typeof(int), "Volume", 5).SetValidation(value => (int)value! >= 0); + var language = new WhWzSetting(typeof(string), "Language", "en"); + var configPath = PathManager.WheelWizardConfigFilePath; + var configFolderPath = fileSystem.Path.GetDirectoryName(configPath)!; + fileSystem.Directory.CreateDirectory(configFolderPath); + fileSystem.File.WriteAllText(configPath, "{\"Volume\":12,\"Language\":\"de\",\"Unknown\":true}"); + + manager.RegisterSetting(volume); + manager.RegisterSetting(language); + manager.LoadSettings(); + + Assert.Equal(12, Assert.IsType(volume.Get())); + Assert.Equal("de", Assert.IsType(language.Get())); + } + + [Fact] + public void LoadSettings_ResetsInvalidPersistedValues_ToDefaults() + { + var fileSystem = new MockFileSystem(); + var logger = Substitute.For>(); + var manager = new WhWzSettingManager(logger, fileSystem); + var volume = new WhWzSetting(typeof(int), "Volume", 5).SetValidation(value => (int)value! >= 0); + var configPath = PathManager.WheelWizardConfigFilePath; + var configFolderPath = fileSystem.Path.GetDirectoryName(configPath)!; + fileSystem.Directory.CreateDirectory(configFolderPath); + fileSystem.File.WriteAllText(configPath, "{\"Volume\":-1}"); + + manager.RegisterSetting(volume); + manager.LoadSettings(); + + Assert.Equal(5, Assert.IsType(volume.Get())); + } + + [Fact] + public void SaveSettings_PersistsRegisteredValues_AfterLoad() + { + var fileSystem = new MockFileSystem(); + var logger = Substitute.For>(); + var manager = new WhWzSettingManager(logger, fileSystem); + var volume = new WhWzSetting(typeof(int), "Volume", 5); + var configPath = PathManager.WheelWizardConfigFilePath; + + manager.RegisterSetting(volume); + manager.LoadSettings(); + volume.Set(9, skipSave: true); + manager.SaveSettings(volume); + + var savedJson = fileSystem.File.ReadAllText(configPath); + Assert.Contains("\"Volume\": 9", savedJson); + } + + [Fact] + public void RegisterSetting_IsIgnoredAfterLoad() + { + var fileSystem = new MockFileSystem(); + var logger = Substitute.For>(); + var manager = new WhWzSettingManager(logger, fileSystem); + var registeredBeforeLoad = new WhWzSetting(typeof(int), "Volume", 1); + var ignoredAfterLoad = new WhWzSetting(typeof(string), "Future", "initial"); + var configPath = PathManager.WheelWizardConfigFilePath; + + manager.RegisterSetting(registeredBeforeLoad); + manager.LoadSettings(); + manager.RegisterSetting(ignoredAfterLoad); + registeredBeforeLoad.Set(2, skipSave: true); + ignoredAfterLoad.Set("changed", skipSave: true); + manager.SaveSettings(registeredBeforeLoad); + + var savedJson = fileSystem.File.ReadAllText(configPath); + Assert.Contains("\"Volume\": 2", savedJson); + Assert.DoesNotContain("Future", savedJson); + } +} diff --git a/WheelWizard/Features/CustomDistributions/CustomDistributionSingletonService.cs b/WheelWizard/Features/CustomDistributions/CustomDistributionSingletonService.cs index a65a8acf..304c66bb 100644 --- a/WheelWizard/Features/CustomDistributions/CustomDistributionSingletonService.cs +++ b/WheelWizard/Features/CustomDistributions/CustomDistributionSingletonService.cs @@ -1,6 +1,7 @@ using System.IO.Abstractions; using Microsoft.Extensions.Logging; using WheelWizard.CustomDistributions.Domain; +using WheelWizard.Settings; using WheelWizard.Shared.Services; namespace WheelWizard.CustomDistributions; @@ -21,10 +22,15 @@ public class CustomDistributionSingletonService : ICustomDistributionSingletonSe public RetroRewind RetroRewind { get; } public RetroRewindBeta RetroRewindBeta { get; } - public CustomDistributionSingletonService(IFileSystem fileSystem, IApiCaller api, ILogger logger) + public CustomDistributionSingletonService( + IFileSystem fileSystem, + IApiCaller api, + ILogger logger, + ISettingsManager settingsManager + ) { - RetroRewind = new RetroRewind(fileSystem, api, logger); - RetroRewindBeta = new RetroRewindBeta(fileSystem, logger); + RetroRewind = new RetroRewind(fileSystem, api, logger, settingsManager); + RetroRewindBeta = new RetroRewindBeta(fileSystem, logger, settingsManager); } public List GetAllDistributions() diff --git a/WheelWizard/Features/CustomDistributions/RetroRewind.cs b/WheelWizard/Features/CustomDistributions/RetroRewind.cs index f47c39b2..c63e34ca 100644 --- a/WheelWizard/Features/CustomDistributions/RetroRewind.cs +++ b/WheelWizard/Features/CustomDistributions/RetroRewind.cs @@ -9,7 +9,7 @@ using WheelWizard.Models.Enums; using WheelWizard.Resources.Languages; using WheelWizard.Services; -using WheelWizard.Services.Settings; +using WheelWizard.Settings; using WheelWizard.Shared.Services; using WheelWizard.Views.Popups.Generic; @@ -20,12 +20,19 @@ public class RetroRewind : IDistribution private readonly IFileSystem _fileSystem; private readonly IApiCaller _api; private readonly ILogger _logger; + private readonly ISettingsManager _settingsManager; - public RetroRewind(IFileSystem fileSystem, IApiCaller api, ILogger logger) + public RetroRewind( + IFileSystem fileSystem, + IApiCaller api, + ILogger logger, + ISettingsManager settingsManager + ) { _api = api; _fileSystem = fileSystem; _logger = logger; + _settingsManager = settingsManager; } public string Title => "Retro Rewind"; @@ -566,7 +573,7 @@ public async Task ReinstallAsync(ProgressWindow progressWindow) public async Task> GetCurrentStatusAsync() { - if (!SettingsHelper.PathsSetupCorrectly()) + if (!_settingsManager.PathsSetupCorrectly()) return WheelWizardStatus.ConfigNotFinished; var serverEnabled = await _api.CallApiAsync(api => api.Ping()); diff --git a/WheelWizard/Features/CustomDistributions/RetroRewindBeta.cs b/WheelWizard/Features/CustomDistributions/RetroRewindBeta.cs index 75166dd8..de759416 100644 --- a/WheelWizard/Features/CustomDistributions/RetroRewindBeta.cs +++ b/WheelWizard/Features/CustomDistributions/RetroRewindBeta.cs @@ -10,7 +10,7 @@ using WheelWizard.Models.Enums; using WheelWizard.Resources.Languages; using WheelWizard.Services; -using WheelWizard.Services.Settings; +using WheelWizard.Settings; using WheelWizard.Views.Popups.Generic; namespace WheelWizard.CustomDistributions; @@ -19,11 +19,13 @@ public class RetroRewindBeta : IDistribution { private readonly IFileSystem _fileSystem; private readonly ILogger _logger; + private readonly ISettingsManager _settingsManager; - public RetroRewindBeta(IFileSystem fileSystem, ILogger logger) + public RetroRewindBeta(IFileSystem fileSystem, ILogger logger, ISettingsManager settingsManager) { _fileSystem = fileSystem; _logger = logger; + _settingsManager = settingsManager; } public string Title => "Retro Rewind Beta"; @@ -162,7 +164,7 @@ public async Task ReinstallAsync(ProgressWindow progressWindow) public Task> GetCurrentStatusAsync() { - if (!SettingsHelper.PathsSetupCorrectly()) + if (!_settingsManager.PathsSetupCorrectly()) return Task.FromResult(Ok(WheelWizardStatus.ConfigNotFinished)); var isInstalled = diff --git a/WheelWizard/Features/DolphinInstaller/DolphinInstallerExtensions.cs b/WheelWizard/Features/DolphinInstaller/DolphinInstallerExtensions.cs new file mode 100644 index 00000000..56fa6cde --- /dev/null +++ b/WheelWizard/Features/DolphinInstaller/DolphinInstallerExtensions.cs @@ -0,0 +1,18 @@ +namespace WheelWizard.DolphinInstaller; + +public static class DolphinInstallerExtensions +{ + public static IServiceCollection AddDolphinInstaller(this IServiceCollection services) + { + // TODO: Reorganize this feature boundary. + // Right now this registers 3 service concerns: + // 1) Linux command environment, 2) Linux process execution, 3) Dolphin installer orchestration. + // Consider either: + // - moving Linux command/process services into a shared Linux feature/module, or + // - using a strategy-based installer (like AutoUpdater) with platform/version-specific installer implementations. + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + return services; + } +} diff --git a/WheelWizard/Features/DolphinInstaller/LinuxCommandEnvironment.cs b/WheelWizard/Features/DolphinInstaller/LinuxCommandEnvironment.cs new file mode 100644 index 00000000..8803026a --- /dev/null +++ b/WheelWizard/Features/DolphinInstaller/LinuxCommandEnvironment.cs @@ -0,0 +1,22 @@ +using WheelWizard.Helpers; + +namespace WheelWizard.DolphinInstaller; + +public interface ILinuxCommandEnvironment +{ + bool IsCommandAvailable(string command); + string DetectPackageManagerInstallCommand(); +} + +public sealed class LinuxCommandEnvironment : ILinuxCommandEnvironment +{ + public bool IsCommandAvailable(string command) + { + return EnvHelper.IsValidUnixCommand(command); + } + + public string DetectPackageManagerInstallCommand() + { + return EnvHelper.DetectLinuxPackageManagerInstallCommand(); + } +} diff --git a/WheelWizard/Features/DolphinInstaller/LinuxDolphinInstaller.cs b/WheelWizard/Features/DolphinInstaller/LinuxDolphinInstaller.cs new file mode 100644 index 00000000..af2f5725 --- /dev/null +++ b/WheelWizard/Features/DolphinInstaller/LinuxDolphinInstaller.cs @@ -0,0 +1,76 @@ +namespace WheelWizard.DolphinInstaller; + +public interface ILinuxDolphinInstaller +{ + bool IsDolphinInstalledInFlatpak(); + bool IsFlatpakInstalled(); + Task InstallFlatpak(IProgress? progress = null); + Task InstallFlatpakDolphin(IProgress? progress = null); +} + +public sealed class LinuxDolphinInstaller(ILinuxCommandEnvironment commandEnvironment, ILinuxProcessService processService) + : ILinuxDolphinInstaller +{ + public bool IsDolphinInstalledInFlatpak() + { + var processResult = processService.Run("flatpak", "info org.DolphinEmu.dolphin-emu"); + return processResult.IsSuccess && processResult.Value == 0; + } + + public bool IsFlatpakInstalled() + { + return commandEnvironment.IsCommandAvailable("flatpak"); + } + + public async Task InstallFlatpak(IProgress? progress = null) + { + if (IsFlatpakInstalled()) + return Ok(); + + var packageManagerCommand = commandEnvironment.DetectPackageManagerInstallCommand(); + if (string.IsNullOrWhiteSpace(packageManagerCommand)) + return Fail("Unsupported Linux distribution. Could not detect a package manager command."); + + var installResult = await processService.RunWithProgressAsync("pkexec", $"{packageManagerCommand} flatpak", progress); + if (installResult.IsFailure) + return installResult.Error; + + if (installResult.Value is 126 or 127) + return Fail("You need to be an administrator to install Flatpak."); + + if (installResult.Value != 0) + return Fail($"Flatpak installation failed with exit code {installResult.Value}."); + + if (!IsFlatpakInstalled()) + return Fail("Flatpak installation completed, but Flatpak is still unavailable."); + + return Ok(); + } + + public async Task InstallFlatpakDolphin(IProgress? progress = null) + { + if (!IsFlatpakInstalled()) + { + var installFlatpakResult = await InstallFlatpak(progress); + if (installFlatpakResult.IsFailure) + return installFlatpakResult; + } + + var installDolphinResult = await processService.RunWithProgressAsync( + "pkexec", + "flatpak --system install -y org.DolphinEmu.dolphin-emu", + progress + ); + if (installDolphinResult.IsFailure) + return installDolphinResult.Error; + + if (installDolphinResult.Value is 126 or 127) + return Fail("You need to be an administrator to install Dolphin via Flatpak."); + + if (installDolphinResult.Value != 0) + return Fail($"Dolphin installation failed with exit code {installDolphinResult.Value}."); + + var launchResult = await processService.LaunchAndStopAsync("flatpak", "run org.DolphinEmu.dolphin-emu", TimeSpan.FromSeconds(4)); + return launchResult.IsFailure ? launchResult.Error : Ok(); + } +} diff --git a/WheelWizard/Features/DolphinInstaller/LinuxProcessService.cs b/WheelWizard/Features/DolphinInstaller/LinuxProcessService.cs new file mode 100644 index 00000000..f1c53112 --- /dev/null +++ b/WheelWizard/Features/DolphinInstaller/LinuxProcessService.cs @@ -0,0 +1,109 @@ +using System.Diagnostics; +using System.Text.RegularExpressions; + +namespace WheelWizard.DolphinInstaller; + +public interface ILinuxProcessService +{ + OperationResult Run(string fileName, string arguments); + Task> RunWithProgressAsync(string fileName, string arguments, IProgress? progress = null); + Task LaunchAndStopAsync(string fileName, string arguments, TimeSpan duration); +} + +public sealed class LinuxProcessService : ILinuxProcessService +{ + public OperationResult Run(string fileName, string arguments) + { + return TryCatch( + () => + { + var processInfo = new ProcessStartInfo + { + FileName = fileName, + Arguments = arguments, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }; + + using var process = Process.Start(processInfo); + if (process == null) + return -1; + + process.WaitForExit(); + return process.ExitCode; + }, + $"Failed to run process: {fileName} {arguments}" + ); + } + + public async Task> RunWithProgressAsync(string fileName, string arguments, IProgress? progress = null) + { + return await TryCatch( + async () => + { + var processInfo = new ProcessStartInfo + { + FileName = fileName, + Arguments = arguments, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }; + + using var process = Process.Start(processInfo); + if (process == null) + return -1; + + process.OutputDataReceived += (_, eventArgs) => ReportProgress(eventArgs.Data, progress); + process.ErrorDataReceived += (_, eventArgs) => ReportProgress(eventArgs.Data, progress); + + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + await process.WaitForExitAsync(); + return process.ExitCode; + }, + $"Failed to run process: {fileName} {arguments}" + ); + } + + public async Task LaunchAndStopAsync(string fileName, string arguments, TimeSpan duration) + { + return await TryCatch( + async () => + { + using var process = new Process + { + StartInfo = new() + { + FileName = fileName, + Arguments = arguments, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }, + }; + + process.Start(); + await Task.Delay(duration); + + if (!process.HasExited) + process.Kill(); + }, + $"Failed to run process: {fileName} {arguments}" + ); + } + + private static void ReportProgress(string? output, IProgress? progress) + { + if (string.IsNullOrWhiteSpace(output)) + return; + + var match = Regex.Match(output, @"(\d+)%"); + if (match.Success && int.TryParse(match.Groups[1].Value, out var percent)) + progress?.Report(percent); + } +} diff --git a/WheelWizard/Features/RrRooms/Domain/RwfcLeaderboardEntry.cs b/WheelWizard/Features/RrRooms/Domain/RwfcLeaderboardEntry.cs index c72a4fcf..dae9630a 100644 --- a/WheelWizard/Features/RrRooms/Domain/RwfcLeaderboardEntry.cs +++ b/WheelWizard/Features/RrRooms/Domain/RwfcLeaderboardEntry.cs @@ -16,5 +16,5 @@ public sealed class RwfcLeaderboardEntry public RwfcLeaderboardVrStats? VrStats { get; set; } - public string? MiiImageBase64 { get; set; } + public string? MiiData { get; set; } } diff --git a/WheelWizard/Features/RrRooms/RrLeaderboardSingletonService.cs b/WheelWizard/Features/RrRooms/RrLeaderboardSingletonService.cs index bedddc6c..72576dce 100644 --- a/WheelWizard/Features/RrRooms/RrLeaderboardSingletonService.cs +++ b/WheelWizard/Features/RrRooms/RrLeaderboardSingletonService.cs @@ -1,3 +1,5 @@ +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging; using WheelWizard.Shared.Services; namespace WheelWizard.RrRooms; @@ -7,11 +9,59 @@ public interface IRrLeaderboardSingletonService Task>> GetTopPlayersAsync(int limit = 50); } -public class RrLeaderboardSingletonService(IApiCaller apiCaller) : IRrLeaderboardSingletonService +public class RrLeaderboardSingletonService( + IApiCaller apiCaller, + IMemoryCache cache, + ILogger logger +) : IRrLeaderboardSingletonService { + private const string FreshCacheKey = "rrrooms:leaderboard:fresh"; + private const string StaleCacheKey = "rrrooms:leaderboard:stale"; + private static readonly TimeSpan CacheLifetime = TimeSpan.FromSeconds(90); + public async Task>> GetTopPlayersAsync(int limit = 50) { var boundedLimit = Math.Clamp(limit, 1, 200); - return await apiCaller.CallApiAsync(api => api.GetTopLeaderboardAsync(boundedLimit)); + + if (TryGetCached(FreshCacheKey, boundedLimit, out var freshCache)) + return freshCache; + + var fetchResult = await apiCaller.CallApiAsync(api => api.GetTopLeaderboardAsync(boundedLimit)); + if (fetchResult.IsFailure) + { + if (!TryGetCached(StaleCacheKey, boundedLimit, out var staleCache)) + return fetchResult; + + logger.LogWarning("RWFC leaderboard fetch failed; returning stale cached leaderboard for top {Limit}.", boundedLimit); + return staleCache; + } + + var fetchedEntries = fetchResult.Value.ToList(); + + cache.Set(FreshCacheKey, fetchedEntries, new MemoryCacheEntryOptions { AbsoluteExpirationRelativeToNow = CacheLifetime }); + cache.Set(StaleCacheKey, fetchedEntries); + + return TrimToLimit(fetchedEntries, boundedLimit); + } + + private bool TryGetCached(string cacheKey, int limit, out OperationResult> result) + { + if ( + !cache.TryGetValue(cacheKey, out List? cachedEntries) + || cachedEntries == null + || cachedEntries.Count < limit + ) + { + result = default!; + return false; + } + + result = TrimToLimit(cachedEntries, limit); + return true; + } + + private static OperationResult> TrimToLimit(IEnumerable entries, int limit) + { + return entries.Take(limit).ToList(); } } diff --git a/WheelWizard/Features/Settings/ADDING_SETTINGS.md b/WheelWizard/Features/Settings/ADDING_SETTINGS.md new file mode 100644 index 00000000..8f296e55 --- /dev/null +++ b/WheelWizard/Features/Settings/ADDING_SETTINGS.md @@ -0,0 +1,57 @@ +# Adding a Setting in WheelWizard + +## Setting Types +- **WheelWizard:** Our own settings, we save them in a JSON file. +- **Dolphin:** Settings from the Dolphin emulator. they store them in INI files. this implementation allows us to also modify them= +- **Virtual:** Settings that are not saved. These are used for managing for computing state and managing side effect. For instance, if you want to control 3 settings with 1 toggle, virtual settings is perfect for that. + +## Adding settings +You first always define the setting in the `ISettingsServices.cs` file in the `ISettingsProperties` class +```csharp +Setting MY_NEW_SETTING { get; } +``` +then you also define this setting in the `SettingsManager.cs` as a property +```csharp +public Setting MY_NEW_SETTING { get; } +``` + +after that you have to register the setting. This depends on the type of setting you want to add. + +### Wheel Wizard +```csharp +MY_NEW_SETTING = RegisterWhWz( + "MyNewSetting", + false, + value => value is bool +); +``` + +### Dolphin +```csharp +MY_DOLPHIN_SETTING = RegisterDolphin( + ("GFX.ini", "Settings", "MyDolphinKey"), + 0, + value => (int)(value ?? -1) >= 0 +); +``` + +### Virtual +```csharp +MY_VIRTUAL_SETTING = new VirtualSetting( + typeof(bool), + value => { /* apply side-effects */ }, + () => { /* compute value */ return true; } +).SetDependencies(SETTING_A, SETTING_B); +``` +Usually, you create virtual settings that reference one or more real settings. +The value of the virtual setting is cached. However, if the value relies on, for example, SETTING_A, then once SETTING_A changes, your cache is incorrect. +For that reason, you have to set dependencies. That way, if SETTING_A changes, the virtual setting gets a signal to recompute its value. + +## Reading/Writing settings +Use type-safe manager methods in callers: +```csharp +// reading +bool value = SettingsManager.Get(SettingsManager.MY_NEW_SETTING); +// writeing +SettingsManager.Set(SettingsManager.MY_NEW_SETTING, true); +``` diff --git a/WheelWizard/Features/Settings/DolphinSettingManager.cs b/WheelWizard/Features/Settings/DolphinSettingManager.cs new file mode 100644 index 00000000..0a1f7090 --- /dev/null +++ b/WheelWizard/Features/Settings/DolphinSettingManager.cs @@ -0,0 +1,180 @@ +using System.IO.Abstractions; +using WheelWizard.Services; +using WheelWizard.Settings.Types; + +namespace WheelWizard.Settings; + +public class DolphinSettingManager(IFileSystem fileSystem) : IDolphinSettingManager +{ + private string ConfigFolderPath(string fileName) => fileSystem.Path.Combine(PathManager.ConfigFolderPath, fileName); + + // LOCKS: + // We use locks to keep the settings state and file IO consistent. + // Even though we do not manually create threads in this class, work can still happen concurrently + // (for example the Avalonia UI thread + Task/thread-pool execution), so synchronization is still required. + + // Sync Root: Responsible for synchronizing access to the _settings list and the _loaded flag. + // It ensures that multiple threads don't modify the settings list or the loaded state at the same time + // File IO Sync: Responsible for reading and writing the INI files. It ensures that multiple threads don't read/write at the same time + private readonly object _syncRoot = new(); + private readonly object _fileIoSync = new(); + private bool _loaded; + private readonly List _settings = []; + + public void RegisterSetting(DolphinSetting setting) + { + lock (_syncRoot) + { + if (_loaded) + return; + + _settings.Add(setting); + } + } + + public void SaveSettings(DolphinSetting invokingSetting) + { + List settingsSnapshot; + lock (_syncRoot) + { + // TODO: This method definitely has to be optimized + if (!_loaded) + return; + + settingsSnapshot = [.. _settings]; + } + + lock (_fileIoSync) + { + foreach (var setting in settingsSnapshot) + { + ChangeIniSettings(setting.FileName, setting.Section, setting.Name, setting.GetStringValue()); + } + } + } + + public void ReloadSettings() + { + lock (_syncRoot) + { + // TODO: this method could also be optimized by checking if the previously loaded directory + // is still the current ConfigFolderPath and if so, just not run the LoadSettings method again + _loaded = false; + } + + LoadSettings(); + } + + public void LoadSettings() + { + List settingsSnapshot; + if (_loaded || !fileSystem.Directory.Exists(PathManager.ConfigFolderPath)) + return; + + lock (_syncRoot) + { + // Since we are working with concurrency here, we have to check loaded again since it might be changed while we were waiting + // for the lock to open + if (_loaded) + return; + + _loaded = true; + settingsSnapshot = [.. _settings]; + } + + // TODO: This method can maybe be optimized in the future, since now it reads the file for every setting + // and on top of that for reach setting it loops over each line and section and stuff like that. + lock (_fileIoSync) + { + foreach (var setting in settingsSnapshot) + { + var value = ReadIniSetting(setting.FileName, setting.Section, setting.Name); + if (value == null) + ChangeIniSettings(setting.FileName, setting.Section, setting.Name, setting.GetStringValue()); + else + setting.SetFromString(value, true); // we read it, which means there is no purpose in saving it again + } + } + } + + private string[]? ReadIniFile(string fileName) + { + var filePath = ConfigFolderPath(fileName); + if (!fileSystem.File.Exists(filePath)) + return null; + + try + { + return fileSystem.File.ReadAllLines(filePath); + } + catch + { + return null; + } + } + + private string? ReadIniSetting(string fileName, string section, string settingToRead) + { + var lines = ReadIniFile(fileName); + if (lines == null) + return null; + + var sectionIndex = Array.IndexOf(lines, $"[{section}]"); + if (sectionIndex == -1) + return null; + + // find all the settings related to this section, we dont want to read/influence other sections + var nextSectionName = lines.Skip(sectionIndex + 1).FirstOrDefault(x => x.Trim().StartsWith("[") && x.Trim().EndsWith("]")); + var nextSectionIndex = Array.IndexOf(lines, nextSectionName); + var sectionLines = lines.Skip(sectionIndex + 1); + if (nextSectionIndex != -1) + sectionLines = sectionLines.Take(nextSectionIndex - sectionIndex - 1); + + // finally we can read the setting + foreach (var line in sectionLines) + { + if (!line.StartsWith($"{settingToRead}=") && !line.StartsWith($"{settingToRead} =")) + continue; + //we found the setting, now we need to return the value + var setting = line.Split("="); + return setting[1].Trim(); + } + + return null; + } + + // TODO: find out when to use `setting=value` and when to use `setting = value` + private void ChangeIniSettings(string fileName, string section, string settingToChange, string value) + { + var lines = ReadIniFile(fileName)?.ToList(); + if (lines == null) + return; + + var sectionIndex = lines.IndexOf($"[{section}]"); + if (sectionIndex == -1) + { + lines.Add($"[{section}]"); + lines.Add($"{settingToChange} = {value}"); + fileSystem.File.WriteAllLines(ConfigFolderPath(fileName), lines); + return; + } + + for (var i = sectionIndex + 1; i < lines.Count; i++) + { + // + if (lines[i].Trim().StartsWith("[") && lines[i].Trim().EndsWith("]")) + break; // Setting was not found in this section, so we have to append it to the section + + if (!lines[i].StartsWith($"{settingToChange}=") && !lines[i].StartsWith($"{settingToChange} =")) + continue; + + lines[i] = $"{settingToChange} = {value}"; + fileSystem.File.WriteAllLines(ConfigFolderPath(fileName), lines); + return; + } + // you only get here if the setting was not found in the section + + lines.Insert(sectionIndex + 1, $"{settingToChange} = {value}"); + fileSystem.File.WriteAllLines(ConfigFolderPath(fileName), lines); + } +} diff --git a/WheelWizard/Features/Settings/ISettingsServices.cs b/WheelWizard/Features/Settings/ISettingsServices.cs new file mode 100644 index 00000000..063518a3 --- /dev/null +++ b/WheelWizard/Features/Settings/ISettingsServices.cs @@ -0,0 +1,64 @@ +using WheelWizard.Settings.Types; + +namespace WheelWizard.Settings; + +public interface IWhWzSettingManager +{ + void RegisterSetting(WhWzSetting setting); + void SaveSettings(WhWzSetting invokingSetting); + void LoadSettings(); +} + +public interface IDolphinSettingManager +{ + void RegisterSetting(DolphinSetting setting); + void SaveSettings(DolphinSetting invokingSetting); + void ReloadSettings(); + void LoadSettings(); +} + +public interface ISettingsProperties +{ + Setting USER_FOLDER_PATH { get; } + Setting DOLPHIN_LOCATION { get; } + Setting GAME_LOCATION { get; } + Setting FORCE_WIIMOTE { get; } + Setting LAUNCH_WITH_DOLPHIN { get; } + Setting PREFERS_MODS_ROW_VIEW { get; } + Setting FOCUSED_USER { get; } + Setting ENABLE_ANIMATIONS { get; } + Setting TESTING_MODE_ENABLED { get; } + Setting SAVED_WINDOW_SCALE { get; } + Setting REMOVE_BLUR { get; } + Setting RR_REGION { get; } + Setting WW_LANGUAGE { get; } + Setting NAND_ROOT_PATH { get; } + Setting LOAD_PATH { get; } + Setting VSYNC { get; } + Setting INTERNAL_RESOLUTION { get; } + Setting SHOW_FPS { get; } + Setting GFX_BACKEND { get; } + Setting MACADDRESS { get; } + Setting WINDOW_SCALE { get; } + Setting RECOMMENDED_SETTINGS { get; } +} + +public interface ISettingsManager : ISettingsProperties +{ + OperationResult ValidateCorePathSettings(); + + T Get(Setting setting); + bool Set(Setting setting, T value, bool skipSave = false); + bool PathsSetupCorrectly(); + void LoadSettings(); +} + +public interface ISettingsStartupInitializer +{ + void Initialize(); +} + +public interface ISettingsLocalizationService +{ + void Initialize(); +} diff --git a/WheelWizard/Features/Settings/SettingsExtensions.cs b/WheelWizard/Features/Settings/SettingsExtensions.cs new file mode 100644 index 00000000..eb8e8a0b --- /dev/null +++ b/WheelWizard/Features/Settings/SettingsExtensions.cs @@ -0,0 +1,23 @@ +namespace WheelWizard.Settings; + +public static class SettingsExtensions +{ + public static IServiceCollection AddSettings(this IServiceCollection services) + { + // TODO(naming-cleanup): + // - Prefix casing is inconsistent: `WhWz*` vs `WW_*` (example: `WhWzSettingManager`, `WW_LANGUAGE`). + // - Some setting identifiers use all-caps while others use PascalCase (example: `MACADDRESS` vs `GAME_LOCATION`). + // - Domain type naming is mixed between generic and feature-specific terms (`Setting`, `WhWzSetting`, `DolphinSetting`). + + // TODO: Investigate / migrate to IOptions: https://learn.microsoft.com/en-us/dotnet/core/extensions/options + + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + return services; + } +} diff --git a/WheelWizard/Features/Settings/SettingsLocalizationService.cs b/WheelWizard/Features/Settings/SettingsLocalizationService.cs new file mode 100644 index 00000000..5017e00a --- /dev/null +++ b/WheelWizard/Features/Settings/SettingsLocalizationService.cs @@ -0,0 +1,33 @@ +using System.Globalization; + +namespace WheelWizard.Settings; + +public sealed class SettingsLocalizationService(ISettingsManager settingsManager, ISettingsSignalBus settingsSignalBus) + : ISettingsLocalizationService +{ + private bool _initialized; + private IDisposable? _subscription; + + public void Initialize() + { + if (_initialized) + return; + + _subscription = settingsSignalBus.Subscribe(OnSignal); + ApplyCulture(); + _initialized = true; + } + + private void OnSignal(SettingChangedSignal signal) + { + if (signal.Setting == settingsManager.WW_LANGUAGE) + ApplyCulture(); + } + + private void ApplyCulture() + { + var newCulture = new CultureInfo(settingsManager.Get(settingsManager.WW_LANGUAGE)); + CultureInfo.CurrentCulture = newCulture; + CultureInfo.CurrentUICulture = newCulture; + } +} diff --git a/WheelWizard/Features/Settings/SettingsManager.cs b/WheelWizard/Features/Settings/SettingsManager.cs new file mode 100644 index 00000000..d9ae7adf --- /dev/null +++ b/WheelWizard/Features/Settings/SettingsManager.cs @@ -0,0 +1,299 @@ +using System.IO.Abstractions; +using System.Runtime.InteropServices; +using WheelWizard.DolphinInstaller; +using WheelWizard.Helpers; +using WheelWizard.Models.Enums; +using WheelWizard.Services; +using WheelWizard.Settings.Types; + +namespace WheelWizard.Settings; + +public class SettingsManager : ISettingsManager +{ + private readonly IWhWzSettingManager _whWzSettingManager; + private readonly IDolphinSettingManager _dolphinSettingManager; + private readonly ILinuxDolphinInstaller _linuxDolphinInstaller; + private readonly IFileSystem _fileSystem; + + private readonly Setting _dolphinCompilationMode; + private readonly Setting _dolphinCompileShadersAtStart; + private readonly Setting _dolphinSsaa; + private readonly Setting _dolphinMsaa; + + private bool _hasLoadedSettings; + private double _internalScale = -1.0; + + #region Constructor + public SettingsManager( + IWhWzSettingManager whWzSettingManager, + IDolphinSettingManager dolphinSettingManager, + ILinuxDolphinInstaller linuxDolphinInstaller, + IFileSystem fileSystem + ) + { + _whWzSettingManager = whWzSettingManager; + _dolphinSettingManager = dolphinSettingManager; + _linuxDolphinInstaller = linuxDolphinInstaller; + _fileSystem = fileSystem; + + #region WhWz settings + DOLPHIN_LOCATION = RegisterWhWz( + "DolphinLocation", + "", + value => + { + var pathOrCommand = value as string ?? string.Empty; + if (string.IsNullOrWhiteSpace(pathOrCommand)) + return false; + + if (Environment.OSVersion.Platform == PlatformID.Unix || Environment.OSVersion.Platform == PlatformID.MacOSX) + { + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + return EnvHelper.IsValidUnixCommand(pathOrCommand); + + if (PathManager.IsFlatpakDolphinFilePath(pathOrCommand) && !_linuxDolphinInstaller.IsDolphinInstalledInFlatpak()) + return false; + + return EnvHelper.IsValidUnixCommand(pathOrCommand); + } + + return _fileSystem.File.Exists(pathOrCommand); + } + ); + + USER_FOLDER_PATH = RegisterWhWz( + "UserFolderPath", + "", + value => + { + var userFolderPath = value as string ?? string.Empty; + if (!_fileSystem.Directory.Exists(userFolderPath)) + return false; + + var dolphinLocation = Get(DOLPHIN_LOCATION); + + // We cannot determine the validity of the user folder path in that case + if (string.IsNullOrWhiteSpace(dolphinLocation)) + return true; + + // If we want to use a split XDG dolphin config, + // this only really works as expected if certain conditions are met. + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Linux) || !PathManager.IsLinuxDolphinConfigSplit()) + return true; + + // In this case, Dolphin would use `EMBEDDED_USER_DIR` (portable `user` directory). + if (_fileSystem.Directory.Exists("user")) + return false; + + // The Dolphin executable directory with `portable.txt` case + if (_fileSystem.File.Exists(Path.Combine(PathManager.GetDolphinExeDirectory(), "portable.txt"))) + return false; + + // The value of this environment variable would be used instead if it was somehow set + const string environmentVariableToAvoid = "DOLPHIN_EMU_USERPATH"; + + if (!string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable(environmentVariableToAvoid))) + return false; + + if (dolphinLocation.Contains(environmentVariableToAvoid, StringComparison.Ordinal)) + return false; + + // `~/.dolphin-emu` would be used if it exists + if (!PathManager.IsFlatpakDolphinFilePath() && _fileSystem.Directory.Exists(PathManager.LinuxDolphinLegacyFolderPath)) + return false; + + return true; + } + ); + + GAME_LOCATION = RegisterWhWz("GameLocation", "", value => _fileSystem.File.Exists(value as string ?? string.Empty)); + FORCE_WIIMOTE = RegisterWhWz("ForceWiimote", false); + LAUNCH_WITH_DOLPHIN = RegisterWhWz("LaunchWithDolphin", false); + PREFERS_MODS_ROW_VIEW = RegisterWhWz("PrefersModsRowView", true); + FOCUSED_USER = RegisterWhWz("FavoriteUser", 0, value => (int)(value ?? -1) >= 0 && (int)(value ?? -1) < 4); + + ENABLE_ANIMATIONS = RegisterWhWz("EnableAnimations", true); + TESTING_MODE_ENABLED = RegisterWhWz("TestingModeEnabled", false); + SAVED_WINDOW_SCALE = RegisterWhWz("WindowScale", 1.0, value => (double)(value ?? -1) >= 0.5 && (double)(value ?? -1) <= 2.0); + REMOVE_BLUR = RegisterWhWz("REMOVE_BLUR", true); + RR_REGION = RegisterWhWz("RR_Region", MarioKartWiiEnums.Regions.None); + WW_LANGUAGE = RegisterWhWz("WW_Language", "en", value => SettingValues.WhWzLanguages.ContainsKey((string)value!)); + #endregion + + #region Dolphin settings + NAND_ROOT_PATH = RegisterDolphin( + ("Dolphin.ini", "General", "NANDRootPath"), + "", + value => _fileSystem.Directory.Exists(value as string ?? string.Empty) + ); + + LOAD_PATH = RegisterDolphin( + ("Dolphin.ini", "General", "LoadPath"), + "", + value => _fileSystem.Directory.Exists(value as string ?? string.Empty) + ); + + VSYNC = RegisterDolphin(("GFX.ini", "Hardware", "VSync"), false); + INTERNAL_RESOLUTION = RegisterDolphin(("GFX.ini", "Settings", "InternalResolution"), 1, value => (int)(value ?? -1) >= 0); + SHOW_FPS = RegisterDolphin(("GFX.ini", "Settings", "ShowFPS"), false); + GFX_BACKEND = RegisterDolphin(("Dolphin.ini", "Core", "GFXBackend"), SettingValues.GFXRenderers.Values.First()); + + // recommended settings + _dolphinCompilationMode = RegisterDolphin(("GFX.ini", "Settings", "ShaderCompilationMode"), DolphinShaderCompilationMode.Default); + _dolphinCompileShadersAtStart = RegisterDolphin(("GFX.ini", "Settings", "WaitForShadersBeforeStarting"), false); + _dolphinSsaa = RegisterDolphin(("GFX.ini", "Settings", "SSAA"), false); + _dolphinMsaa = RegisterDolphin( + ("GFX.ini", "Settings", "MSAA"), + "0x00000001", + value => (value?.ToString() ?? "") is "0x00000001" or "0x00000002" or "0x00000004" or "0x00000008" + ); + + // Readonly settings + MACADDRESS = RegisterDolphin(("Dolphin.ini", "General", "WirelessMac"), "02:01:02:03:04:05"); + #endregion + + #region Virtual settings + WINDOW_SCALE = new VirtualSetting( + typeof(double), + value => _internalScale = (double)value!, + () => _internalScale == -1.0 ? SAVED_WINDOW_SCALE.Get() : _internalScale + ).SetDependencies(SAVED_WINDOW_SCALE); + + RECOMMENDED_SETTINGS = new VirtualSetting( + typeof(bool), + value => + { + var newValue = (bool)value!; + _dolphinCompilationMode.Set( + newValue ? DolphinShaderCompilationMode.HybridUberShaders : DolphinShaderCompilationMode.Default + ); +#if WINDOWS + _dolphinCompileShadersAtStart.Set(newValue); +#endif + _dolphinMsaa.Set(newValue ? "0x00000002" : "0x00000001"); + _dolphinSsaa.Set(false); + }, + () => + { + var value1 = (DolphinShaderCompilationMode)_dolphinCompilationMode.Get(); + var value2 = true; +#if WINDOWS + value2 = (bool)_dolphinCompileShadersAtStart.Get(); +#endif + var value3 = (string)_dolphinMsaa.Get(); + var value4 = (bool)_dolphinSsaa.Get(); + return !value4 && value2 && value3 == "0x00000002" && value1 == DolphinShaderCompilationMode.HybridUberShaders; + } + ).SetDependencies(_dolphinCompilationMode, _dolphinCompileShadersAtStart, _dolphinMsaa, _dolphinSsaa); + #endregion + } + #endregion + + #region Settings Properties + public Setting USER_FOLDER_PATH { get; } + public Setting DOLPHIN_LOCATION { get; } + public Setting GAME_LOCATION { get; } + public Setting FORCE_WIIMOTE { get; } + public Setting LAUNCH_WITH_DOLPHIN { get; } + public Setting PREFERS_MODS_ROW_VIEW { get; } + public Setting FOCUSED_USER { get; } + public Setting ENABLE_ANIMATIONS { get; } + public Setting TESTING_MODE_ENABLED { get; } + public Setting SAVED_WINDOW_SCALE { get; } + public Setting REMOVE_BLUR { get; } + public Setting RR_REGION { get; } + public Setting WW_LANGUAGE { get; } + + public Setting NAND_ROOT_PATH { get; } + public Setting LOAD_PATH { get; } + public Setting VSYNC { get; } + public Setting INTERNAL_RESOLUTION { get; } + public Setting SHOW_FPS { get; } + public Setting GFX_BACKEND { get; } + public Setting MACADDRESS { get; } + public Setting WINDOW_SCALE { get; } + public Setting RECOMMENDED_SETTINGS { get; } + #endregion + + #region Public API + public T Get(Setting setting) + { + var value = setting.Get(); + if (value is not T typedValue) + throw new InvalidOperationException($"Setting '{setting.Name}' does not match expected type '{typeof(T).Name}'."); + + return typedValue; + } + + public bool Set(Setting setting, T value, bool skipSave = false) + { + if (value == null) + throw new ArgumentNullException(nameof(value)); + + return setting.Set(value, skipSave); + } + + public bool PathsSetupCorrectly() + { + var reportResult = ValidateCorePathSettings(); + return reportResult.IsSuccess && reportResult.Value.IsValid; + } + + public OperationResult ValidateCorePathSettings() + { + try + { + var issues = new List(); + + if (!USER_FOLDER_PATH.IsValid()) + issues.Add(new(SettingsValidationCode.InvalidUserFolderPath, USER_FOLDER_PATH.Name, "User folder path is invalid.")); + + if (!DOLPHIN_LOCATION.IsValid()) + issues.Add( + new(SettingsValidationCode.InvalidDolphinLocation, DOLPHIN_LOCATION.Name, "Dolphin path or command is invalid.") + ); + + if (!GAME_LOCATION.IsValid()) + issues.Add(new(SettingsValidationCode.InvalidGameLocation, GAME_LOCATION.Name, "Game file path is invalid.")); + + return Ok(new SettingsValidationReport(issues)); + } + catch (Exception ex) + { + return Fail(ex); + } + } + + public void LoadSettings() + { + if (_hasLoadedSettings) + return; + + _whWzSettingManager.LoadSettings(); + _dolphinSettingManager.LoadSettings(); + _hasLoadedSettings = true; + } + #endregion + + #region Registration Helpers + private WhWzSetting RegisterWhWz(string name, T defaultValue, Func? validation = null) + { + var setting = new WhWzSetting(typeof(T), name, defaultValue!, _whWzSettingManager.SaveSettings); + if (validation != null) + setting.SetValidation(validation); + + _whWzSettingManager.RegisterSetting(setting); + return setting; + } + + private DolphinSetting RegisterDolphin((string, string, string) location, T defaultValue, Func? validation = null) + { + var setting = new DolphinSetting(typeof(T), location, defaultValue!, _dolphinSettingManager.SaveSettings); + if (validation != null) + setting.SetValidation(validation); + + _dolphinSettingManager.RegisterSetting(setting); + return setting; + } + #endregion +} diff --git a/WheelWizard/Features/Settings/SettingsRuntime.cs b/WheelWizard/Features/Settings/SettingsRuntime.cs new file mode 100644 index 00000000..2692494c --- /dev/null +++ b/WheelWizard/Features/Settings/SettingsRuntime.cs @@ -0,0 +1,64 @@ +using WheelWizard.Settings.Types; + +namespace WheelWizard.Settings; + +// Legacy runtime bridge for static callers that cannot use constructor injection yet. +// Replace usage with injected services: +// 1) Inject `ISettingsManager` into classes that currently read `SettingsRuntime.Current`. +// 2) Inject `ISettingsSignalBus` for signal subscription/publish usage. +// 3) Remove runtime initialization from `SettingsStartupInitializer` after all static callers are gone. +[Obsolete("SettingsRuntime is deprecated. Use constructor injection for ISettingsManager instead.")] +public static class SettingsRuntime +{ + private static ISettingsManager? _current; + + public static ISettingsManager Current + { + get { return _current ?? throw new InvalidOperationException("Settings runtime has not been initialized yet."); } + } + + public static void Initialize(ISettingsManager settingsManager) + { + _current = settingsManager; + } +} + +[Obsolete("SettingsSignalRuntime is deprecated. Use constructor injection for ISettingsSignalBus instead.")] +public static class SettingsSignalRuntime +{ + private static ISettingsSignalBus? _current; + private static readonly List> PendingInitializers = []; + + public static void Initialize(ISettingsSignalBus signalBus) + { + ArgumentNullException.ThrowIfNull(signalBus); + + _current = signalBus; + var callbacksToRun = PendingInitializers.ToArray(); + PendingInitializers.Clear(); + + foreach (var callback in callbacksToRun) + { + callback(signalBus); + } + } + + public static void OnInitialized(Action callback) + { + ArgumentNullException.ThrowIfNull(callback); + + var signalBus = _current; + if (signalBus == null) + { + PendingInitializers.Add(callback); + return; + } + + callback(signalBus); + } + + public static void Publish(Setting setting) + { + _current?.Publish(setting); + } +} diff --git a/WheelWizard/Features/Settings/SettingsSignalBus.cs b/WheelWizard/Features/Settings/SettingsSignalBus.cs new file mode 100644 index 00000000..460001ec --- /dev/null +++ b/WheelWizard/Features/Settings/SettingsSignalBus.cs @@ -0,0 +1,82 @@ +using Microsoft.Extensions.Logging; +using WheelWizard.Settings.Types; + +namespace WheelWizard.Settings; + +public readonly record struct SettingChangedSignal(Setting Setting); + +public interface ISettingsSignalBus +{ + IDisposable Subscribe(Action handler); + void Publish(Setting setting); +} + +public sealed class SettingsSignalBus(ILogger logger) : ISettingsSignalBus +{ + // LOCKS: + // We are working with locks. This is to ensure that we always have accurate information in our settings / application. + // We do not create multiple threads. However, some of our features run through Tasks. Those are executed asynchronously, therefore still require locks. + + private readonly object _syncSubscribers = new(); + private readonly Dictionary> _subscribers = []; + private long _nextSubscriberId; + + public IDisposable Subscribe(Action handler) + { + ArgumentNullException.ThrowIfNull(handler); + + long id; + lock (_syncSubscribers) + { + id = _nextSubscriberId++; + _subscribers[id] = handler; + } + + return new Subscription(this, id); + } + + public void Publish(Setting setting) + { + Action[] handlers; + + // You could use a lock for reading the subscribes. But let's minimize the lock usage to where it is important. + // If the handlers list is slightly outdated it is not a problem (unlike when this happens when modifying this list) + handlers = [.. _subscribers.Values]; + + var signal = new SettingChangedSignal(setting); + foreach (var handler in handlers) + { + try + { + handler(signal); + } + catch + { + // Exceptions from subscribers should not affect the publisher or other subscribers, so we catch and log them. + logger.LogError("A subscriber threw an exception while handling a setting changed signal."); + } + } + } + + private void Unsubscribe(long subscriberId) + { + lock (_syncSubscribers) + { + _subscribers.Remove(subscriberId); + } + } + + private sealed class Subscription(SettingsSignalBus bus, long subscriberId) : IDisposable + { + private bool _disposed; + + public void Dispose() + { + if (_disposed) + return; + + bus.Unsubscribe(subscriberId); + _disposed = true; + } + } +} diff --git a/WheelWizard/Features/Settings/SettingsStartupInitializer.cs b/WheelWizard/Features/Settings/SettingsStartupInitializer.cs new file mode 100644 index 00000000..b07757aa --- /dev/null +++ b/WheelWizard/Features/Settings/SettingsStartupInitializer.cs @@ -0,0 +1,40 @@ +using Microsoft.Extensions.Logging; + +namespace WheelWizard.Settings; + +public sealed class SettingsStartupInitializer( + ISettingsManager settingsManager, + ISettingsSignalBus settingsSignalBus, + ISettingsLocalizationService localizationService, + ILogger logger +) : ISettingsStartupInitializer +{ + public void Initialize() + { + SettingsSignalRuntime.Initialize(settingsSignalBus); + SettingsRuntime.Initialize(settingsManager); + settingsManager.LoadSettings(); + localizationService.Initialize(); + + var reportResult = settingsManager.ValidateCorePathSettings(); + if (reportResult.IsFailure) + { + logger.LogError(reportResult.Error.Exception, "Failed to validate startup settings: {Message}", reportResult.Error.Message); + return; + } + + var report = reportResult.Value; + if (report.IsValid) + return; + + foreach (var issue in report.Issues) + { + logger.LogWarning( + "Settings validation warning: {Code} ({SettingName}) {Message}", + issue.Code, + issue.SettingName, + issue.Message + ); + } + } +} diff --git a/WheelWizard/Features/Settings/SettingsValidationReport.cs b/WheelWizard/Features/Settings/SettingsValidationReport.cs new file mode 100644 index 00000000..30496068 --- /dev/null +++ b/WheelWizard/Features/Settings/SettingsValidationReport.cs @@ -0,0 +1,31 @@ +namespace WheelWizard.Settings; + +public enum SettingsValidationCode +{ + InvalidUserFolderPath, + InvalidDolphinLocation, + InvalidGameLocation, +} + +public sealed class SettingsValidationIssue(SettingsValidationCode code, string settingName, string message) +{ + public SettingsValidationCode Code { get; } = code; + public string SettingName { get; } = settingName; + public string Message { get; } = message; + + public override string ToString() => $"[{Code}] {SettingName}: {Message}"; +} + +public sealed class SettingsValidationReport(IReadOnlyList issues) +{ + public IReadOnlyList Issues { get; } = issues; + public bool IsValid => Issues.Count == 0; + + public string ToSummaryText() + { + if (IsValid) + return "All required settings are valid."; + + return string.Join("; ", Issues.Select(issue => issue.ToString())); + } +} diff --git a/WheelWizard/Models/Settings/DolphinSetting.cs b/WheelWizard/Features/Settings/Types/DolphinSetting.cs similarity index 76% rename from WheelWizard/Models/Settings/DolphinSetting.cs rename to WheelWizard/Features/Settings/Types/DolphinSetting.cs index 84a5b0ad..4bc67365 100644 --- a/WheelWizard/Models/Settings/DolphinSetting.cs +++ b/WheelWizard/Features/Settings/Types/DolphinSetting.cs @@ -1,15 +1,19 @@ -using WheelWizard.Services.Settings; - -namespace WheelWizard.Models.Settings; +namespace WheelWizard.Settings.Types; public class DolphinSetting : Setting { + private readonly Action _saveAction; + public string FileName { get; private set; } public string Section { get; private set; } public DolphinSetting(Type type, (string, string, string) location, object defaultValue) + : this(type, location, defaultValue, _ => { }) { } + + public DolphinSetting(Type type, (string, string, string) location, object defaultValue, Action saveAction) : base(type, location.Item3, defaultValue) { + _saveAction = saveAction ?? throw new ArgumentNullException(nameof(saveAction)); FileName = location.Item1; Section = location.Item2; // name/key = location.Item3 @@ -19,8 +23,6 @@ public DolphinSetting(Type type, (string, string, string) location, object defau throw new ArgumentException( $"FileName for dolphin setting '[{Section}]{Name}' must end with .ini (given file is '{FileName}')" ); - - DolphinSettingManager.Instance.RegisterSetting(this); } protected override bool SetInternal(object newValue, bool skipSave = false) @@ -31,7 +33,7 @@ protected override bool SetInternal(object newValue, bool skipSave = false) if (newIsValid) { if (!skipSave) - DolphinSettingManager.Instance.SaveSettings(this); + _saveAction(this); } else Value = oldValue; @@ -43,6 +45,18 @@ protected override bool SetInternal(object newValue, bool skipSave = false) public override bool IsValid() => ValidationFunc == null || ValidationFunc(Value); + public new DolphinSetting SetValidation(Func validationFunc) + { + base.SetValidation(validationFunc); + return this; + } + + public new DolphinSetting SetForceSave(bool saveEvenIfNotValid) + { + base.SetForceSave(saveEvenIfNotValid); + return this; + } + public string GetStringValue() { if (ValueType.IsEnum) diff --git a/WheelWizard/Models/Settings/Setting.cs b/WheelWizard/Features/Settings/Types/Setting.cs similarity index 72% rename from WheelWizard/Models/Settings/Setting.cs rename to WheelWizard/Features/Settings/Types/Setting.cs index 183bf060..09c58f88 100644 --- a/WheelWizard/Models/Settings/Setting.cs +++ b/WheelWizard/Features/Settings/Types/Setting.cs @@ -1,4 +1,6 @@ -namespace WheelWizard.Models.Settings; +using WheelWizard.Settings; + +namespace WheelWizard.Settings.Types; public abstract class Setting { @@ -10,7 +12,6 @@ protected Setting(Type type, string name, object defaultValue) ValueType = type; } - protected readonly List DependentVirtualSettings = []; public string Name { get; protected set; } public object DefaultValue { get; protected set; } protected object Value { get; set; } @@ -59,19 +60,5 @@ public Setting SetForceSave(bool saveEvenIfNotValid) return this; } - public bool Unsubscribe(ISettingListener dependent) => DependentVirtualSettings.Remove(dependent); - - public void Subscribe(ISettingListener dependent) - { - if (!DependentVirtualSettings.Contains(dependent)) - DependentVirtualSettings.Add(dependent); - } - - protected void SignalChange() - { - foreach (var dependent in DependentVirtualSettings) - { - dependent.OnSettingChanged(this); - } - } + protected void SignalChange() => SettingsSignalRuntime.Publish(this); } diff --git a/WheelWizard/Models/Settings/SettingConstants.cs b/WheelWizard/Features/Settings/Types/SettingConstants.cs similarity index 97% rename from WheelWizard/Models/Settings/SettingConstants.cs rename to WheelWizard/Features/Settings/Types/SettingConstants.cs index bd1de544..73508520 100644 --- a/WheelWizard/Models/Settings/SettingConstants.cs +++ b/WheelWizard/Features/Settings/Types/SettingConstants.cs @@ -1,4 +1,4 @@ -namespace WheelWizard.Models.Settings; +namespace WheelWizard.Settings.Types; public enum DolphinShaderCompilationMode { diff --git a/WheelWizard/Models/Settings/VirtualSetting.cs b/WheelWizard/Features/Settings/Types/VirtualSetting.cs similarity index 71% rename from WheelWizard/Models/Settings/VirtualSetting.cs rename to WheelWizard/Features/Settings/Types/VirtualSetting.cs index 03b20ec5..770ab8ae 100644 --- a/WheelWizard/Models/Settings/VirtualSetting.cs +++ b/WheelWizard/Features/Settings/Types/VirtualSetting.cs @@ -1,18 +1,21 @@ -namespace WheelWizard.Models.Settings; +using WheelWizard.Settings; -public class VirtualSetting : Setting, ISettingListener +namespace WheelWizard.Settings.Types; + +public class VirtualSetting : Setting { private Setting[] _dependencies; - private Action Setter; - private Func Getter; + private readonly Action _setter; + private readonly Func _getter; private bool _acceptsSignals = true; + private IDisposable? _signalSubscription; public VirtualSetting(Type type, Action setter, Func getter) : base(type, "virtual", getter()) { _dependencies = []; - Setter = setter; - Getter = getter; + _setter = setter; + _getter = getter; } protected override bool SetInternal(object newValue, bool skipSave = false) @@ -25,7 +28,7 @@ protected override bool SetInternal(object newValue, bool skipSave = false) var succeeded = false; if (newIsValid) { - Setter(newValue); + _setter(newValue); succeeded = true; } else @@ -48,25 +51,29 @@ public VirtualSetting SetDependencies(params Setting[] dependencies) throw new ArgumentException("Dependencies have already been set once"); _dependencies = dependencies; - foreach (var dependency in dependencies) + SettingsSignalRuntime.OnInitialized(signalBus => { - dependency.Subscribe(this); - } + _signalSubscription?.Dispose(); + _signalSubscription = signalBus.Subscribe(OnSignal); + }); return this; } public void Recalculate() { - Value = Getter(); + Value = _getter(); } - public void OnSettingChanged(Setting changedSetting) + private void OnSignal(SettingChangedSignal signal) { if (!_acceptsSignals) return; - SignalChange(); + if (!_dependencies.Contains(signal.Setting)) + return; + Recalculate(); + SignalChange(); } } diff --git a/WheelWizard/Models/Settings/WhWzSetting.cs b/WheelWizard/Features/Settings/Types/WhWzSetting.cs similarity index 75% rename from WheelWizard/Models/Settings/WhWzSetting.cs rename to WheelWizard/Features/Settings/Types/WhWzSetting.cs index 7a76c884..43a4c582 100644 --- a/WheelWizard/Models/Settings/WhWzSetting.cs +++ b/WheelWizard/Features/Settings/Types/WhWzSetting.cs @@ -1,14 +1,18 @@ using System.Text.Json; -using WheelWizard.Services.Settings; -namespace WheelWizard.Models.Settings; +namespace WheelWizard.Settings.Types; public class WhWzSetting : Setting { + private readonly Action _saveAction; + public WhWzSetting(Type type, string name, object defaultValue) + : this(type, name, defaultValue, _ => { }) { } + + public WhWzSetting(Type type, string name, object defaultValue, Action saveAction) : base(type, name, defaultValue) { - WhWzSettingManager.Instance.RegisterSetting(this); + _saveAction = saveAction ?? throw new ArgumentNullException(nameof(saveAction)); } protected override bool SetInternal(object newValue, bool skipSave = false) @@ -19,7 +23,7 @@ protected override bool SetInternal(object newValue, bool skipSave = false) if (newIsValid) { if (!skipSave) - WhWzSettingManager.Instance.SaveSettings(this); + _saveAction(this); } else Value = oldValue; @@ -31,6 +35,18 @@ protected override bool SetInternal(object newValue, bool skipSave = false) public override bool IsValid() => ValidationFunc == null || ValidationFunc(Value); + public new WhWzSetting SetValidation(Func validationFunc) + { + base.SetValidation(validationFunc); + return this; + } + + public new WhWzSetting SetForceSave(bool saveEvenIfNotValid) + { + base.SetForceSave(saveEvenIfNotValid); + return this; + } + public bool SetFromJson(JsonElement newValue, bool skipSave = false) { // Feel free to add more types if you find them diff --git a/WheelWizard/Features/Settings/WhWzSettingManager.cs b/WheelWizard/Features/Settings/WhWzSettingManager.cs new file mode 100644 index 00000000..4a7accdc --- /dev/null +++ b/WheelWizard/Features/Settings/WhWzSettingManager.cs @@ -0,0 +1,131 @@ +using System.IO.Abstractions; +using System.Text.Json; +using Microsoft.Extensions.Logging; +using WheelWizard.Services; +using WheelWizard.Settings.Types; + +namespace WheelWizard.Settings; + +public class WhWzSettingManager(ILogger logger, IFileSystem fileSystem) : IWhWzSettingManager +{ + // LOCKS: + // We are working with locks. This is to ensure that we always have accurate information in our settings / application. + // We do not create multiple threads. However, some of our features run through Tasks. Those are executed asynchronously, therefore still require locks. + + // Sync Root: Responsible for synchronizing access to the _settings list and the _loaded flag. + // It ensures that multiple threads don't modify the settings list or the loaded state at the same time + // File IO Sync: Responsible for reading and writing the INI files. It ensures that multiple threads don't read/write at the same time + private readonly object _syncRoot = new(); + private readonly object _fileIoSync = new(); + private bool _loaded; + private readonly Dictionary _settings = new(); + + public void RegisterSetting(WhWzSetting setting) + { + lock (_syncRoot) + { + if (_loaded) + return; + + _settings[setting.Name] = setting; + } + } + + public void SaveSettings(WhWzSetting invokingSetting) + { + Dictionary settingsSnapshot; + lock (_syncRoot) + { + if (!_loaded) + return; + + settingsSnapshot = new(_settings); + } + + var settingsToSave = new Dictionary(); + + foreach (var (name, setting) in settingsSnapshot) + { + settingsToSave[name] = setting.Get(); + } + + var jsonString = JsonSerializer.Serialize(settingsToSave, new JsonSerializerOptions { WriteIndented = true }); + lock (_fileIoSync) + { + var configPath = PathManager.WheelWizardConfigFilePath; + try + { + var directoryPath = fileSystem.Path.GetDirectoryName(configPath); + if (!string.IsNullOrWhiteSpace(directoryPath) && !fileSystem.Directory.Exists(directoryPath)) + fileSystem.Directory.CreateDirectory(directoryPath); + + fileSystem.File.WriteAllText(configPath, jsonString); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to save settings file: {Path}", configPath); + } + } + } + + public void LoadSettings() + { + Dictionary settingsSnapshot; + lock (_syncRoot) + { + if (_loaded) + return; + + _loaded = true; + settingsSnapshot = new(_settings); + } + + // Even if it now returns early, loading has been considered complete. + string? jsonString; + lock (_fileIoSync) + { + var configPath = PathManager.WheelWizardConfigFilePath; + try + { + jsonString = fileSystem.File.Exists(configPath) ? fileSystem.File.ReadAllText(configPath) : null; + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to read settings file: {Path}", configPath); + jsonString = null; + } + } + + if (jsonString == null) + return; + + try + { + var loadedSettings = JsonSerializer.Deserialize>(jsonString); + if (loadedSettings == null) + return; + + foreach (var kvp in loadedSettings) + { + if (!settingsSnapshot.TryGetValue(kvp.Key, out var setting)) + continue; + + try + { + var success = setting.SetFromJson(kvp.Value, skipSave: true); + if (!success) + setting.Set(setting.DefaultValue, skipSave: true); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Invalid value for setting {SettingName}; resetting to default.", setting.Name); + setting.Set(setting.DefaultValue, skipSave: true); + } + } + } + catch (JsonException e) + { + logger.LogError(e, "Failed to deserialize the JSON config"); + } + } +} diff --git a/WheelWizard/Features/WiiManagement/GameLicense/GameLicenseService.cs b/WheelWizard/Features/WiiManagement/GameLicense/GameLicenseService.cs index 267bd13e..9bcbfb25 100644 --- a/WheelWizard/Features/WiiManagement/GameLicense/GameLicenseService.cs +++ b/WheelWizard/Features/WiiManagement/GameLicense/GameLicenseService.cs @@ -3,11 +3,11 @@ using System.Text.RegularExpressions; using WheelWizard.Helpers; using WheelWizard.Models.Enums; -using WheelWizard.Models.Settings; using WheelWizard.Services; using WheelWizard.Services.LiveData; using WheelWizard.Services.Other; -using WheelWizard.Services.Settings; +using WheelWizard.Settings; +using WheelWizard.Settings.Types; using WheelWizard.Utilities.Generators; using WheelWizard.Utilities.RepeatedTasks; using WheelWizard.WheelWizardData; @@ -89,6 +89,7 @@ public class GameLicenseSingletonService : RepeatedTaskManager, IGameLicenseSing private readonly IFileSystem _fileSystem; private readonly IWhWzDataSingletonService _whWzDataSingletonService; private readonly IRRratingReader _rrratingReader; + private readonly ISettingsManager _settingsManager; private LicenseCollection Licenses { get; } private byte[]? _rksysData; @@ -96,7 +97,8 @@ public GameLicenseSingletonService( IMiiDbService miiService, IFileSystem fileSystem, IWhWzDataSingletonService whWzDataSingletonService, - IRRratingReader rrratingReader + IRRratingReader rrratingReader, + ISettingsManager settingsManager ) : base(40) { @@ -104,6 +106,7 @@ IRRratingReader rrratingReader _fileSystem = fileSystem; _whWzDataSingletonService = whWzDataSingletonService; _rrratingReader = rrratingReader; + _settingsManager = settingsManager; Licenses = new(); } @@ -131,9 +134,9 @@ IRRratingReader rrratingReader /// /// Returns the "focused" or currently active license/user as determined by the Settings. /// - public LicenseProfile ActiveUser => Licenses.Users[(int)SettingsManager.FOCUSSED_USER.Get()]; + public LicenseProfile ActiveUser => Licenses.Users[_settingsManager.Get(_settingsManager.FOCUSED_USER)]; - public List ActiveCurrentFriends => Licenses.Users[(int)SettingsManager.FOCUSSED_USER.Get()].Friends; + public List ActiveCurrentFriends => Licenses.Users[_settingsManager.Get(_settingsManager.FOCUSED_USER)].Friends; public LicenseCollection LicenseCollection => Licenses; @@ -635,7 +638,7 @@ private OperationResult ReadRksys() if (!_fileSystem.Directory.Exists(PathManager.SaveFolderPath)) return Fail("Save folder not found"); - var currentRegion = (MarioKartWiiEnums.Regions)SettingsManager.RR_REGION.Get(); + var currentRegion = _settingsManager.Get(_settingsManager.RR_REGION); if (currentRegion == MarioKartWiiEnums.Regions.None) { // Double check if there's at least one valid region @@ -643,7 +646,7 @@ private OperationResult ReadRksys() if (validRegions.First() != MarioKartWiiEnums.Regions.None) { currentRegion = validRegions.First(); - SettingsManager.RR_REGION.Set(currentRegion); + _settingsManager.Set(_settingsManager.RR_REGION, currentRegion); } else { @@ -753,10 +756,10 @@ private OperationResult WriteLicenseNameToSaveData(int userIndex, string newName private OperationResult SaveRksysToFile() { - if (_rksysData == null || !SettingsHelper.PathsSetupCorrectly()) + if (_rksysData == null || !_settingsManager.PathsSetupCorrectly()) return Fail("Invalid save data or config is not setup properly."); FixRksysCrc(_rksysData); - var currentRegion = (MarioKartWiiEnums.Regions)SettingsManager.RR_REGION.Get(); + var currentRegion = _settingsManager.Get(_settingsManager.RR_REGION); var saveFolder = _fileSystem.Path.Combine(PathManager.SaveFolderPath, RRRegionManager.ConvertRegionToGameId(currentRegion)); var trySaveRksys = TryCatch(() => { diff --git a/WheelWizard/Features/WiiManagement/MiiManagement/MiiExtensions.cs b/WheelWizard/Features/WiiManagement/MiiManagement/MiiExtensions.cs index 0ef4fff4..82e85d46 100644 --- a/WheelWizard/Features/WiiManagement/MiiManagement/MiiExtensions.cs +++ b/WheelWizard/Features/WiiManagement/MiiManagement/MiiExtensions.cs @@ -1,10 +1,12 @@ -using WheelWizard.Services.Settings; +using WheelWizard.Settings; using WheelWizard.WiiManagement.MiiManagement.Domain.Mii; namespace WheelWizard.WiiManagement.MiiManagement; public static class MiiExtensions { + private static ISettingsManager Settings => SettingsRuntime.Current; + private static readonly DateTime MiiIdEpochUtc = new(2006, 1, 1, 0, 0, 0, DateTimeKind.Utc); private const uint MiiIdCounterMask = 0x1FFFFFFF; private const int MiiIdTickResolutionSeconds = 4; @@ -70,7 +72,7 @@ public static bool IsGlobal(this Mii self) return true; // But it can also be global if the mac address is not the same as your own address - var macAddressString = (string)SettingsManager.MACADDRESS.Get(); + var macAddressString = Settings.Get(Settings.MACADDRESS); var macParts = macAddressString.Split(':'); var macBytes = new byte[6]; for (var i = 0; i < 6; i++) diff --git a/WheelWizard/Helpers/FileHelper.cs b/WheelWizard/Helpers/FileHelper.cs index 934ffdb5..2a2c8700 100644 --- a/WheelWizard/Helpers/FileHelper.cs +++ b/WheelWizard/Helpers/FileHelper.cs @@ -57,6 +57,7 @@ public DirectoryMoveContentsResult( // From now on we to have this FileHelper as a middle man whenever we do anything file related. This makes // it easier to create helper methods, mock data, and most importantly, easy to make it multi-platform later on +[Obsolete("FileHelper is deprecated. Use IFileSystem and feature-specific services instead.")] public static class FileHelper { public static bool FileExists(string path) => File.Exists(path); diff --git a/WheelWizard/Models/Settings/Mod.cs b/WheelWizard/Models/Mods/Mod.cs similarity index 98% rename from WheelWizard/Models/Settings/Mod.cs rename to WheelWizard/Models/Mods/Mod.cs index df9e65f1..975b6fc1 100644 --- a/WheelWizard/Models/Settings/Mod.cs +++ b/WheelWizard/Models/Mods/Mod.cs @@ -3,7 +3,7 @@ using IniParser; using IniParser.Model; -namespace WheelWizard.Models.Settings; +namespace WheelWizard.Models.Mods; public class Mod : INotifyPropertyChanged { diff --git a/WheelWizard/Models/RRInfo/RrPlayer.cs b/WheelWizard/Models/RRInfo/RrPlayer.cs index cadd5c18..7a12bca0 100644 --- a/WheelWizard/Models/RRInfo/RrPlayer.cs +++ b/WheelWizard/Models/RRInfo/RrPlayer.cs @@ -14,6 +14,7 @@ public class RrPlayer : IEquatable public bool IsOpenHost { get; set; } public bool IsSuspended { get; set; } + public bool IsFriend { get; set; } public int? LeaderboardRank { get; set; } public bool IsTopLeaderboardPlayer => LeaderboardRank.HasValue; diff --git a/WheelWizard/Models/Settings/ISettingListener.cs b/WheelWizard/Models/Settings/ISettingListener.cs deleted file mode 100644 index 2ee17443..00000000 --- a/WheelWizard/Models/Settings/ISettingListener.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace WheelWizard.Models.Settings; - -public interface ISettingListener -{ - public void OnSettingChanged(Setting setting); -} diff --git a/WheelWizard/Models/Settings/ListedSetting.cs b/WheelWizard/Models/Settings/ListedSetting.cs deleted file mode 100644 index 7b6a4256..00000000 --- a/WheelWizard/Models/Settings/ListedSetting.cs +++ /dev/null @@ -1,22 +0,0 @@ -namespace WheelWizard.Models.Settings; - -public class ListedSetting -{ - public readonly Dictionary Mapping = new(); - public readonly List AllKeys = []; - public readonly List AllValues = []; - public T DefaultValue { get; set; } - - public ListedSetting(string defaultKey, params (string, T)[] values) - { - foreach (var (key, value) in values) - { - Mapping[key] = value; - } - AllKeys.AddRange(Mapping.Keys); - AllValues.AddRange(Mapping.Values); - DefaultValue = Mapping[defaultKey]; - } - - public T Get(string key) => Mapping[key]; -} diff --git a/WheelWizard/Program.cs b/WheelWizard/Program.cs index 6eaa931c..4f0f8e36 100644 --- a/WheelWizard/Program.cs +++ b/WheelWizard/Program.cs @@ -3,8 +3,8 @@ using Avalonia.Logging; using Serilog; using WheelWizard.Helpers; -using WheelWizard.Services.Settings; using WheelWizard.Services.UrlProtocol; +using WheelWizard.Settings; using WheelWizard.Shared.Services; using WheelWizard.Views; @@ -69,7 +69,7 @@ private static AppBuilder CreateWheelWizardApp(bool isDesigner) // Make sure this comes AFTER setting the service provider // of the `App` instance! Otherwise, things like logging will not work // in `Setup`. - Setup(); + Setup(serviceProvider); }); return builder; @@ -106,9 +106,9 @@ private static void SetupWorkingDirectory() } } - private static void Setup() + private static void Setup(IServiceProvider serviceProvider) { - SettingsManager.Instance.LoadSettings(); + serviceProvider.GetRequiredService().Initialize(); UrlProtocolManager.SetWhWzScheme(); } } diff --git a/WheelWizard/Resources/Languages/Common.Designer.cs b/WheelWizard/Resources/Languages/Common.Designer.cs index 47190ee5..b11771e9 100644 --- a/WheelWizard/Resources/Languages/Common.Designer.cs +++ b/WheelWizard/Resources/Languages/Common.Designer.cs @@ -1165,6 +1165,15 @@ public static string PageTitle_Friends { return ResourceManager.GetString("PageTitle_Friends", resourceCulture); } } + + /// + /// Looks up a localized string similar to Leaderboard. + /// + public static string PageTitle_Leaderboard { + get { + return ResourceManager.GetString("PageTitle_Leaderboard", resourceCulture); + } + } /// /// Looks up a localized string similar to Home. diff --git a/WheelWizard/Resources/Languages/Common.resx b/WheelWizard/Resources/Languages/Common.resx index 32b1de77..4a0c130c 100644 --- a/WheelWizard/Resources/Languages/Common.resx +++ b/WheelWizard/Resources/Languages/Common.resx @@ -41,6 +41,9 @@ Friends + + Leaderboard + Online diff --git a/WheelWizard/Services/Installation/ModInstallation.cs b/WheelWizard/Services/Installation/ModInstallation.cs index e4a25ec6..35250358 100644 --- a/WheelWizard/Services/Installation/ModInstallation.cs +++ b/WheelWizard/Services/Installation/ModInstallation.cs @@ -2,7 +2,7 @@ using System.Text.Json; using Avalonia.Threading; using SharpCompress.Archives; -using WheelWizard.Models.Settings; +using WheelWizard.Models.Mods; using WheelWizard.Views.Popups.Generic; namespace WheelWizard.Services.Installation; diff --git a/WheelWizard/Services/Launcher/Helpers/DolphinLaunchHelper.cs b/WheelWizard/Services/Launcher/Helpers/DolphinLaunchHelper.cs index bb4989fb..12dca25a 100644 --- a/WheelWizard/Services/Launcher/Helpers/DolphinLaunchHelper.cs +++ b/WheelWizard/Services/Launcher/Helpers/DolphinLaunchHelper.cs @@ -2,13 +2,15 @@ using System.Runtime.InteropServices; using System.Text.RegularExpressions; using WheelWizard.Helpers; -using WheelWizard.Services.Settings; +using WheelWizard.Settings; using WheelWizard.Views.Popups.Generic; namespace WheelWizard.Services.Launcher.Helpers; public static class DolphinLaunchHelper { + private static ISettingsManager Settings => SettingsRuntime.Current; + public static void KillDolphin() //dont tell PETA { var dolphinLocation = PathManager.DolphinFilePath; @@ -130,7 +132,7 @@ public static void LaunchDolphin(string arguments = "", bool shellExecute = fals var userFolderArgument = cannotPassUserFolder ? "" : $"-u {EnvHelper.QuotePath(Path.GetFullPath(PathManager.UserFolderPath))}"; var dolphinLaunchArguments = $"{arguments} {userFolderArgument}"; - var dolphinLocation = (string)SettingsManager.DOLPHIN_LOCATION.Get(); + var dolphinLocation = Settings.Get(Settings.DOLPHIN_LOCATION); if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { // Windows builds diff --git a/WheelWizard/Services/Launcher/RrBetaLauncher.cs b/WheelWizard/Services/Launcher/RrBetaLauncher.cs index 1bcba6b9..c7c0066d 100644 --- a/WheelWizard/Services/Launcher/RrBetaLauncher.cs +++ b/WheelWizard/Services/Launcher/RrBetaLauncher.cs @@ -4,9 +4,8 @@ using WheelWizard.Models.Enums; using WheelWizard.Resources.Languages; using WheelWizard.Services.Launcher.Helpers; -using WheelWizard.Services.Settings; using WheelWizard.Services.WiiManagement; -using WheelWizard.Shared.DependencyInjection; +using WheelWizard.Settings; using WheelWizard.Views; using WheelWizard.Views.Popups.Generic; @@ -16,10 +15,14 @@ public class RrBetaLauncher : ILauncher { public string GameTitle { get; } = "Retro Rewind Beta"; private static string RrLaunchJsonFilePath => PathManager.RrLaunchJsonFilePath; + private readonly ICustomDistributionSingletonService _customDistributionSingletonService; + private readonly ISettingsManager _settingsManager; - [Inject] - private ICustomDistributionSingletonService CustomDistributionSingletonService { get; set; } = - App.Services.GetRequiredService(); + public RrBetaLauncher(ICustomDistributionSingletonService customDistributionSingletonService, ISettingsManager settingsManager) + { + _customDistributionSingletonService = customDistributionSingletonService; + _settingsManager = settingsManager; + } public async Task Launch() { @@ -43,7 +46,7 @@ public async Task Launch() } RetroRewindLaunchHelper.GenerateLaunchJson(PathManager.RrBetaXmlFilePath); - var dolphinLaunchType = (bool)SettingsManager.LAUNCH_WITH_DOLPHIN.Get() ? "" : "-b"; + var dolphinLaunchType = _settingsManager.Get(_settingsManager.LAUNCH_WITH_DOLPHIN) ? "" : "-b"; DolphinLaunchHelper.LaunchDolphin( $"{dolphinLaunchType} -e {EnvHelper.QuotePath(Path.GetFullPath(RrLaunchJsonFilePath))} --config=Dolphin.Core.EnableCheats=False --config=Achievements.Achievements.Enabled=False" ); @@ -65,7 +68,7 @@ public async Task Install() { var progressWindow = new ProgressWindow("Installing test build"); progressWindow.Show(); - var installResult = await CustomDistributionSingletonService.RetroRewindBeta.InstallAsync(progressWindow); + var installResult = await _customDistributionSingletonService.RetroRewindBeta.InstallAsync(progressWindow); progressWindow.Close(); if (installResult.IsFailure) { @@ -81,17 +84,13 @@ public async Task Update() { var progressWindow = new ProgressWindow("Updating test build"); progressWindow.Show(); - await CustomDistributionSingletonService.RetroRewindBeta.UpdateAsync(progressWindow); + await _customDistributionSingletonService.RetroRewindBeta.UpdateAsync(progressWindow); progressWindow.Close(); } public async Task GetCurrentStatus() { - if (CustomDistributionSingletonService == null) - { - return WheelWizardStatus.NotInstalled; - } - var statusResult = await CustomDistributionSingletonService.RetroRewindBeta.GetCurrentStatusAsync(); + var statusResult = await _customDistributionSingletonService.RetroRewindBeta.GetCurrentStatusAsync(); if (statusResult.IsFailure) return WheelWizardStatus.NotInstalled; return statusResult.Value; diff --git a/WheelWizard/Services/Launcher/RrLauncher.cs b/WheelWizard/Services/Launcher/RrLauncher.cs index 9a18eb36..ec08959a 100644 --- a/WheelWizard/Services/Launcher/RrLauncher.cs +++ b/WheelWizard/Services/Launcher/RrLauncher.cs @@ -5,9 +5,8 @@ using WheelWizard.Resources.Languages; using WheelWizard.Services.Installation; using WheelWizard.Services.Launcher.Helpers; -using WheelWizard.Services.Settings; using WheelWizard.Services.WiiManagement; -using WheelWizard.Shared.DependencyInjection; +using WheelWizard.Settings; using WheelWizard.Views; using WheelWizard.Views.Popups.Generic; @@ -17,10 +16,14 @@ public class RrLauncher : ILauncher { public string GameTitle { get; } = "Retro Rewind"; private static string RrLaunchJsonFilePath => PathManager.RrLaunchJsonFilePath; + private readonly ICustomDistributionSingletonService _customDistributionSingletonService; + private readonly ISettingsManager _settingsManager; - [Inject] - private ICustomDistributionSingletonService CustomDistributionSingletonService { get; set; } = - App.Services.GetRequiredService(); + public RrLauncher(ICustomDistributionSingletonService customDistributionSingletonService, ISettingsManager settingsManager) + { + _customDistributionSingletonService = customDistributionSingletonService; + _settingsManager = settingsManager; + } public async Task Launch() { @@ -44,7 +47,7 @@ public async Task Launch() } RetroRewindLaunchHelper.GenerateLaunchJson(); - var dolphinLaunchType = (bool)SettingsManager.LAUNCH_WITH_DOLPHIN.Get() ? "" : "-b"; + var dolphinLaunchType = _settingsManager.Get(_settingsManager.LAUNCH_WITH_DOLPHIN) ? "" : "-b"; DolphinLaunchHelper.LaunchDolphin( $"{dolphinLaunchType} -e {EnvHelper.QuotePath(Path.GetFullPath(RrLaunchJsonFilePath))} --config=Dolphin.Core.EnableCheats=False --config=Achievements.Achievements.Enabled=False" ); @@ -66,7 +69,7 @@ public async Task Install() { var progressWindow = new ProgressWindow(); progressWindow.Show(); - var installResult = await CustomDistributionSingletonService.RetroRewind.InstallAsync(progressWindow); + var installResult = await _customDistributionSingletonService.RetroRewind.InstallAsync(progressWindow); progressWindow.Close(); if (installResult.IsFailure) { @@ -82,17 +85,13 @@ public async Task Update() { var progressWindow = new ProgressWindow(); progressWindow.Show(); - await CustomDistributionSingletonService.RetroRewind.UpdateAsync(progressWindow); + await _customDistributionSingletonService.RetroRewind.UpdateAsync(progressWindow); progressWindow.Close(); } public async Task GetCurrentStatus() { - if (CustomDistributionSingletonService == null) - { - return WheelWizardStatus.NotInstalled; - } - var statusResult = await CustomDistributionSingletonService.RetroRewind.GetCurrentStatusAsync(); + var statusResult = await _customDistributionSingletonService.RetroRewind.GetCurrentStatusAsync(); if (statusResult.IsFailure) return WheelWizardStatus.NotInstalled; return statusResult.Value; diff --git a/WheelWizard/Services/LiveData/RRLiveRooms.cs b/WheelWizard/Services/LiveData/RRLiveRooms.cs index 99aef535..bc33368d 100644 --- a/WheelWizard/Services/LiveData/RRLiveRooms.cs +++ b/WheelWizard/Services/LiveData/RRLiveRooms.cs @@ -1,9 +1,11 @@ using WheelWizard.Models.RRInfo; using WheelWizard.RrRooms; +using WheelWizard.Utilities.Generators; using WheelWizard.Utilities.RepeatedTasks; using WheelWizard.Views; using WheelWizard.WheelWizardData; using WheelWizard.WiiManagement; +using WheelWizard.WiiManagement.GameLicense; using WheelWizard.WiiManagement.MiiManagement; using WheelWizard.WiiManagement.MiiManagement.Domain.Mii; @@ -11,24 +13,35 @@ namespace WheelWizard.Services.LiveData; public class RRLiveRooms : RepeatedTaskManager { + private readonly IWhWzDataSingletonService _whWzService; + private readonly IRrRoomsSingletonService _roomsService; + private readonly IRrLeaderboardSingletonService _leaderboardService; + private readonly IGameLicenseSingletonService _gameLicenseService; + public List CurrentRooms { get; private set; } = []; public int PlayerCount => CurrentRooms.Sum(room => room.PlayerCount); public int RoomCount => CurrentRooms.Count; - private static RRLiveRooms? _instance; - public static RRLiveRooms Instance => _instance ??= new(); + public static RRLiveRooms Instance => App.Services.GetRequiredService(); - private RRLiveRooms() - : base(40) { } + public RRLiveRooms( + IWhWzDataSingletonService whWzService, + IRrRoomsSingletonService roomsService, + IRrLeaderboardSingletonService leaderboardService, + IGameLicenseSingletonService gameLicenseService + ) + : base(40) + { + _whWzService = whWzService; + _roomsService = roomsService; + _leaderboardService = leaderboardService; + _gameLicenseService = gameLicenseService; + } protected override async Task ExecuteTaskAsync() { - var whWzService = App.Services.GetRequiredService(); - var roomsService = App.Services.GetRequiredService(); - var leaderboardService = App.Services.GetRequiredService(); - - var roomsTask = roomsService.GetRoomsAsync(); - var leaderboardTask = leaderboardService.GetTopPlayersAsync(50); + var roomsTask = _roomsService.GetRoomsAsync(); + var leaderboardTask = _leaderboardService.GetTopPlayersAsync(50); await Task.WhenAll(roomsTask, leaderboardTask); @@ -59,7 +72,14 @@ protected override async Task ExecuteTaskAsync() var raw = roomsResult.Value; var splitRaw = SplitMergedRooms(raw); - var rrRooms = splitRaw.Select(room => MapRoom(room, whWzService, leaderboardByPid, leaderboardByFriendCode)).ToList(); + var friendProfileIds = _gameLicenseService + .ActiveCurrentFriends.Select(friend => FriendCodeGenerator.FriendCodeToProfileId(friend.FriendCode)) + .Where(profileId => profileId != 0) + .ToHashSet(); + + var rrRooms = splitRaw + .Select(room => MapRoom(room, _whWzService, leaderboardByPid, leaderboardByFriendCode, friendProfileIds)) + .ToList(); CurrentRooms = rrRooms; } @@ -68,7 +88,8 @@ private static RrRoom MapRoom( RwfcRoomStatusRoom room, IWhWzDataSingletonService whWzService, IReadOnlyDictionary leaderboardByPid, - IReadOnlyDictionary leaderboardByFriendCode + IReadOnlyDictionary leaderboardByFriendCode, + IReadOnlySet friendProfileIds ) { return new() @@ -78,7 +99,9 @@ IReadOnlyDictionary leaderboardByFriendCode Type = room.Type, Suspend = room.Suspend, Rk = room.Rk, - Players = room.Players.Select(p => MapPlayer(p, whWzService, leaderboardByPid, leaderboardByFriendCode)).ToList(), + Players = room + .Players.Select(p => MapPlayer(p, whWzService, leaderboardByPid, leaderboardByFriendCode, friendProfileIds)) + .ToList(), }; } @@ -86,7 +109,8 @@ private static RrPlayer MapPlayer( RwfcRoomStatusPlayer p, IWhWzDataSingletonService whWzService, IReadOnlyDictionary leaderboardByPid, - IReadOnlyDictionary leaderboardByFriendCode + IReadOnlyDictionary leaderboardByFriendCode, + IReadOnlySet friendProfileIds ) { Mii? mii = null; @@ -106,6 +130,7 @@ IReadOnlyDictionary leaderboardByFriendCode } var friendCode = p.FriendCode ?? string.Empty; + var profileId = FriendCodeGenerator.FriendCodeToProfileId(friendCode); var leaderboardEntry = GetLeaderboardEntry(p, friendCode, leaderboardByPid, leaderboardByFriendCode); @@ -122,6 +147,7 @@ IReadOnlyDictionary leaderboardByFriendCode Mii = mii, BadgeVariants = whWzService.GetBadges(friendCode), LeaderboardRank = leaderboardEntry?.Rank ?? leaderboardEntry?.ActiveRank, + IsFriend = profileId != 0 && friendProfileIds.Contains(profileId), }; } diff --git a/WheelWizard/Services/LiveData/WhWzStatusManager.cs b/WheelWizard/Services/LiveData/WhWzStatusManager.cs index 9e59ff03..e8078368 100644 --- a/WheelWizard/Services/LiveData/WhWzStatusManager.cs +++ b/WheelWizard/Services/LiveData/WhWzStatusManager.cs @@ -8,18 +8,23 @@ namespace WheelWizard.Services.LiveData; public class WhWzStatusManager : RepeatedTaskManager { + private readonly IWhWzDataSingletonService _whWzDataService; + private readonly ILogger _logger; + public WhWzStatus? Status { get; private set; } - private static WhWzStatusManager? _instance; - public static WhWzStatusManager Instance => _instance ??= new(); + public static WhWzStatusManager Instance => App.Services.GetRequiredService(); - private WhWzStatusManager() - : base(90) { } + public WhWzStatusManager(IWhWzDataSingletonService whWzDataService, ILogger logger) + : base(90) + { + _whWzDataService = whWzDataService; + _logger = logger; + } protected override async Task ExecuteTaskAsync() { - var whWzDataService = App.Services.GetRequiredService(); - var statusResult = await whWzDataService.GetStatusAsync(); + var statusResult = await _whWzDataService.GetStatusAsync(); if (statusResult.IsSuccess) { @@ -27,8 +32,7 @@ protected override async Task ExecuteTaskAsync() return; } - App.Services.GetRequiredService>() - .LogError(statusResult.Error.Exception, "Failed to retrieve WhWz Status: {Message}", statusResult.Error.Message); + _logger.LogError(statusResult.Error.Exception, "Failed to retrieve WhWz Status: {Message}", statusResult.Error.Message); Status = new() { Variant = WhWzStatusVariant.Error, Message = "Failed to retrieve Wheel Wizard status" }; } } diff --git a/WheelWizard/Services/Settings/ModConfigManager.cs b/WheelWizard/Services/ModConfigManager.cs similarity index 100% rename from WheelWizard/Services/Settings/ModConfigManager.cs rename to WheelWizard/Services/ModConfigManager.cs diff --git a/WheelWizard/Services/ModManager.cs b/WheelWizard/Services/ModManager.cs index 534a8ac3..b51cb587 100644 --- a/WheelWizard/Services/ModManager.cs +++ b/WheelWizard/Services/ModManager.cs @@ -5,7 +5,7 @@ using Avalonia.Threading; using IniParser; using WheelWizard.Helpers; -using WheelWizard.Models.Settings; +using WheelWizard.Models.Mods; using WheelWizard.Resources.Languages; using WheelWizard.Services.Installation; using WheelWizard.Views.Popups.Generic; @@ -30,7 +30,7 @@ private set } } - private bool _isProcessing; + private bool _isBatchUpdating; private ModManager() { @@ -103,17 +103,26 @@ public void RemoveMod(Mod mod) private void Mod_PropertyChanged(object sender, PropertyChangedEventArgs e) { - if ( - e.PropertyName != nameof(Mod.IsEnabled) - && e.PropertyName != nameof(Mod.Title) - && e.PropertyName != nameof(Mod.Author) - && e.PropertyName != nameof(Mod.ModID) - && e.PropertyName != nameof(Mod.Priority) - ) + if (_isBatchUpdating) return; - SaveModsAsync(); - SortModsByPriority(); + if (e.PropertyName == nameof(Mod.Priority)) + { + SaveModsAsync(); + SortModsByPriority(); + return; + } + + if ( + e.PropertyName == nameof(Mod.IsEnabled) + || e.PropertyName == nameof(Mod.Title) + || e.PropertyName == nameof(Mod.Author) + || e.PropertyName == nameof(Mod.ModID) + ) + { + SaveModsAsync(); + OnPropertyChanged(nameof(Mods)); + } } private void SortModsByPriority() @@ -203,12 +212,18 @@ await Task.Run(() => public void ToggleAllMods(bool enable) { - foreach (var mod in Mods) + _isBatchUpdating = true; + try { - mod.IsEnabled = enable; + foreach (var mod in Mods) + mod.IsEnabled = enable; + } + finally + { + _isBatchUpdating = false; } - _isProcessing = !_isProcessing; + SaveModsAsync(); OnPropertyChanged(nameof(Mods)); } @@ -490,4 +505,48 @@ public void IncreasePriority(Mod mod) public int GetLowestActivePriority() => Mods.Min(m => m.Priority); public int GetHighestActivePriority() => Mods.Max(m => m.Priority); + + /// + /// Moves a mod to a new position in the list using gap-based indexing. + /// Gap 0 = before first item, gap Count = after last item. + /// + public void MoveModToIndex(Mod mod, int gapIndex) + { + var sortedMods = Mods.OrderBy(m => m.Priority).ToList(); + var currentIndex = sortedMods.IndexOf(mod); + + if (currentIndex == -1) + return; + + // Convert gap index to target index after removal + int targetIndex; + if (gapIndex <= currentIndex) + targetIndex = gapIndex; + else if (gapIndex > currentIndex + 1) + targetIndex = gapIndex - 1; + else + return; // No change needed (dropped in same position) + + targetIndex = Math.Clamp(targetIndex, 0, sortedMods.Count - 1); + + if (currentIndex == targetIndex) + return; + + _isBatchUpdating = true; + try + { + sortedMods.RemoveAt(currentIndex); + sortedMods.Insert(targetIndex, mod); + + for (var i = 0; i < sortedMods.Count; i++) + sortedMods[i].Priority = i; + } + finally + { + _isBatchUpdating = false; + } + + SortModsByPriority(); + SaveModsAsync(); + } } diff --git a/WheelWizard/Services/PathManager.cs b/WheelWizard/Services/PathManager.cs index 4417cd1f..1719332b 100644 --- a/WheelWizard/Services/PathManager.cs +++ b/WheelWizard/Services/PathManager.cs @@ -1,6 +1,6 @@ using System.Runtime.InteropServices; using WheelWizard.Helpers; -using WheelWizard.Services.Settings; +using WheelWizard.Settings; #if WINDOWS using Microsoft.Win32; #endif @@ -9,6 +9,8 @@ namespace WheelWizard.Services; public static class PathManager { + private static ISettingsManager Settings => SettingsRuntime.Current; + // IMPORTANT: To keep things consistent all paths should be Attrib expressions, // and either end with `FilePath` or `FolderPath` @@ -34,9 +36,9 @@ static PathManager() public static string HomeFolderPath => Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); // Paths set by the user - public static string GameFilePath => (string)SettingsManager.GAME_LOCATION.Get(); - public static string DolphinFilePath => (string)SettingsManager.DOLPHIN_LOCATION.Get(); - public static string UserFolderPath => (string)SettingsManager.USER_FOLDER_PATH.Get(); + public static string GameFilePath => Settings.Get(Settings.GAME_LOCATION); + public static string DolphinFilePath => Settings.Get(Settings.DOLPHIN_LOCATION); + public static string UserFolderPath => Settings.Get(Settings.USER_FOLDER_PATH); private static string AppDataFolder => Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData); private static string LocalAppDataFolder => Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); @@ -603,9 +605,9 @@ public static string LoadFolderPath { get { - if (SettingsManager.LOAD_PATH.IsValid()) + if (Settings.LOAD_PATH.IsValid()) { - return (string)SettingsManager.LOAD_PATH.Get(); + return Settings.Get(Settings.LOAD_PATH); } return Path.Combine(UserFolderPath, "Load"); } @@ -637,9 +639,9 @@ public static string WiiFolderPath { get { - if (SettingsManager.NAND_ROOT_PATH.IsValid()) + if (Settings.NAND_ROOT_PATH.IsValid()) { - return (string)SettingsManager.NAND_ROOT_PATH.Get(); + return Settings.Get(Settings.NAND_ROOT_PATH); } return Path.Combine(UserFolderPath, "Wii"); } diff --git a/WheelWizard/Services/Settings/DolphinSettingManager.cs b/WheelWizard/Services/Settings/DolphinSettingManager.cs deleted file mode 100644 index 9e11bffd..00000000 --- a/WheelWizard/Services/Settings/DolphinSettingManager.cs +++ /dev/null @@ -1,138 +0,0 @@ -using WheelWizard.Helpers; -using WheelWizard.Models.Settings; - -namespace WheelWizard.Services.Settings; - -public class DolphinSettingManager -{ - private static string ConfigFolderPath(string fileName) => Path.Combine(PathManager.ConfigFolderPath, fileName); - - private bool _loaded; - private readonly List _settings = []; - - public static DolphinSettingManager Instance { get; } = new(); - - private DolphinSettingManager() { } - - public void RegisterSetting(DolphinSetting setting) - { - if (_loaded) - return; - - _settings.Add(setting); - } - - public void SaveSettings(DolphinSetting invokingSetting) - { - // TODO: This method definitely has to be optimized - if (!_loaded) - return; - - foreach (var setting in _settings) - { - ChangeIniSettings(setting.FileName, setting.Section, setting.Name, setting.GetStringValue()); - } - } - - public void ReloadSettings() - { - // TODO: this method could also be optimized by checking if the previously loaded directory - // is still the current ConfigFolderPath and if so, just not run the LoadSettings method again - _loaded = false; - LoadSettings(); - } - - public void LoadSettings() - { - if (_loaded) - return; - - if (!FileHelper.DirectoryExists(PathManager.ConfigFolderPath)) - return; - - // TODO: This method can maybe be optimized in the future, since now it reads the file for every setting - // and on top of that for reach setting it loops over each line and section and stuff like that. - foreach (var setting in _settings) - { - var value = ReadIniSetting(setting.FileName, setting.Section, setting.Name); - if (value == null) - ChangeIniSettings(setting.FileName, setting.Section, setting.Name, setting.GetStringValue()); - else - setting.SetFromString(value, true); // we read it, which means there is no purpose in saving it again - } - - _loaded = true; - } - - private static string[]? ReadIniFile(string fileName) - { - var filePath = ConfigFolderPath(fileName); - var lines = FileHelper.ReadAllLinesSafe(filePath); - return lines; - } - - private static string? ReadIniSetting(string fileName, string section, string settingToRead) - { - var lines = ReadIniFile(fileName); - if (lines == null) - return null; - - var sectionIndex = Array.IndexOf(lines, $"[{section}]"); - if (sectionIndex == -1) - return null; - - // find all the settings related to this section, we dont want to read/influence other sections - var nextSectionName = lines.Skip(sectionIndex + 1).FirstOrDefault(x => x.Trim().StartsWith("[") && x.Trim().EndsWith("]")); - var nextSectionIndex = Array.IndexOf(lines, nextSectionName); - var sectionLines = lines.Skip(sectionIndex + 1); - if (nextSectionIndex != -1) - sectionLines = sectionLines.Take(nextSectionIndex - sectionIndex - 1); - - // finally we can read the setting - foreach (var line in sectionLines) - { - if (!line.StartsWith($"{settingToRead}=") && !line.StartsWith($"{settingToRead} =")) - continue; - //we found the setting, now we need to return the value - var setting = line.Split("="); - return setting[1].Trim(); - } - - return null; - } - - // TODO: find out when to use `setting=value` and when to use `setting = value` - private static void ChangeIniSettings(string fileName, string section, string settingToChange, string value) - { - var lines = ReadIniFile(fileName)?.ToList(); - if (lines == null) - return; - - var sectionIndex = lines.IndexOf($"[{section}]"); - if (sectionIndex == -1) - { - lines.Add($"[{section}]"); - lines.Add($"{settingToChange} = {value}"); - FileHelper.WriteAllLines(ConfigFolderPath(fileName), lines); - return; - } - - for (var i = sectionIndex + 1; i < lines.Count; i++) - { - // - if (lines[i].Trim().StartsWith("[") && lines[i].Trim().EndsWith("]")) - break; // Setting was not found in this section, so we have to append it to the section - - if (!lines[i].StartsWith($"{settingToChange}=") && !lines[i].StartsWith($"{settingToChange} =")) - continue; - - lines[i] = $"{settingToChange} = {value}"; - FileHelper.WriteAllLines(ConfigFolderPath(fileName), lines); - return; - } - // you only get here if the setting was not found in the section - - lines.Insert(sectionIndex + 1, $"{settingToChange} = {value}"); - FileHelper.WriteAllLines(ConfigFolderPath(fileName), lines); - } -} diff --git a/WheelWizard/Services/Settings/LinuxDolphinInstaller.cs b/WheelWizard/Services/Settings/LinuxDolphinInstaller.cs deleted file mode 100644 index e8e02aad..00000000 --- a/WheelWizard/Services/Settings/LinuxDolphinInstaller.cs +++ /dev/null @@ -1,179 +0,0 @@ -using System.Diagnostics; -using System.Text.RegularExpressions; -using Avalonia.Threading; -using WheelWizard.Helpers; -using WheelWizard.Views.Popups.Generic; - -namespace WheelWizard.Services.Settings; - -public static class LinuxDolphinInstaller -{ - public static bool IsDolphinInstalledInFlatpak() - { - try - { - var processInfo = new ProcessStartInfo - { - FileName = "flatpak", - Arguments = "info org.DolphinEmu.dolphin-emu", - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true, - }; - - using var process = Process.Start(processInfo); - process.WaitForExit(); - return process.ExitCode == 0; - } - catch - { - return false; - } - } - - // Helper method to run a process asynchronously with progress reporting. - private static async Task RunProcessWithProgressAsync(string fileName, string arguments, IProgress progress = null) - { - var processInfo = new ProcessStartInfo - { - FileName = fileName, - Arguments = arguments, - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true, - }; - - using var process = Process.Start(processInfo); - - // Listen for output data to parse progress. - process.OutputDataReceived += (sender, e) => - { - if (string.IsNullOrWhiteSpace(e.Data)) - return; - - var match = Regex.Match(e.Data, @"(\d+)%"); - if (match.Success && int.TryParse(match.Groups[1].Value, out int percent)) - { - progress?.Report(percent); - } - }; - - process.ErrorDataReceived += (sender, e) => - { - if (string.IsNullOrWhiteSpace(e.Data)) - return; - - var match = Regex.Match(e.Data, @"(\d+)%"); - if (match.Success && int.TryParse(match.Groups[1].Value, out int percent)) - { - progress?.Report(percent); - } - }; - - process.BeginOutputReadLine(); - process.BeginErrorReadLine(); - - await process.WaitForExitAsync(); - return process.ExitCode; - } - - /// - /// Checks if Flatpak is installed by verifying the command exists. - /// - /// True if Flatpak is available; otherwise, false. - public static bool isFlatPakInstalled() - { - return EnvHelper.IsValidUnixCommand("flatpak"); - } - - /// - /// Installs Flatpak - /// Reports progress via the provided IProgress callback. - /// - /// True if installation succeeded; otherwise, false. - public static async Task InstallFlatpak(IProgress progress = null) - { - if (isFlatPakInstalled()) - return true; - - // Detect the package manager - var packageManagerCommand = EnvHelper.DetectLinuxPackageManagerInstallCommand(); - if (string.IsNullOrEmpty(packageManagerCommand)) - return false; // Unsupported distro - - // Install Flatpak - var exitCode = await RunProcessWithProgressAsync("pkexec", $"{packageManagerCommand} flatpak", progress); - if (exitCode is 127 or 126) //this is error unauthorized - { - Dispatcher.UIThread.InvokeAsync(() => - { - new MessageBoxWindow().SetTitleText("Error").SetTitleText("You need to be an administrator to install Flatpak").Show(); - }); - } - - return exitCode == 0 && isFlatPakInstalled(); - } - - /// - /// Installs Dolphin via Flatpak. - /// Ensures that Flatpak is installed and reports progress via the provided IProgress callback. - /// - /// True if Dolphin was successfully installed; otherwise, false. - public static async Task InstallFlatpakDolphin(IProgress progress = null) - { - // Ensure Flatpak is installed; if not, attempt installation. - if (!isFlatPakInstalled()) - { - var flatpakInstalled = await InstallFlatpak(progress); - if (!flatpakInstalled) - return false; - } - - // Install Dolphin using Flatpak and report progress. - var exitCode = await RunProcessWithProgressAsync("pkexec", "flatpak --system install -y org.DolphinEmu.dolphin-emu", progress); - if (exitCode is 127 or 126) //this is error unauthorized - { - Dispatcher.UIThread.InvokeAsync(() => - { - new MessageBoxWindow() - .SetTitleText("Error") - .SetTitleText("You need to be an administrator to install Dolphin via Flatpak") - .Show(); - }); - return false; - } - - if (exitCode != 0) - return false; - try - { - var dolphinProcess = new Process - { - StartInfo = new() - { - FileName = "flatpak", - Arguments = "run org.DolphinEmu.dolphin-emu", - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true, - }, - }; - - dolphinProcess.Start(); - await Task.Delay(4000); - dolphinProcess.Kill(); - } - catch (Exception ex) - { - Dispatcher.UIThread.InvokeAsync(() => - { - new MessageBoxWindow().SetTitleText("Error").SetTitleText($"Failed to launch Dolphin").SetInfoText(ex.Message).Show(); - }); - } - - return true; - } -} diff --git a/WheelWizard/Services/Settings/SettingsHelper.cs b/WheelWizard/Services/Settings/SettingsHelper.cs deleted file mode 100644 index 8780bb38..00000000 --- a/WheelWizard/Services/Settings/SettingsHelper.cs +++ /dev/null @@ -1,39 +0,0 @@ -using System.Globalization; -using WheelWizard.Models.Settings; - -namespace WheelWizard.Services.Settings; - -// This class is meant for all the loose little helper methods regarding settings. -public class SettingsHelper : ISettingListener -{ - private SettingsHelper() { } - - private static readonly SettingsHelper Instance = new(); - - public static void LoadExtraStuff() - { - SettingsManager.WW_LANGUAGE.Subscribe(Instance); - Instance.OnWheelWizardLanguageChange(); - } - - public static bool PathsSetupCorrectly() - { - return SettingsManager.USER_FOLDER_PATH.IsValid() - && SettingsManager.DOLPHIN_LOCATION.IsValid() - && SettingsManager.GAME_LOCATION.IsValid(); - } - - public void OnSettingChanged(Setting setting) - { - if (setting == SettingsManager.WW_LANGUAGE) - OnWheelWizardLanguageChange(); - } - - private void OnWheelWizardLanguageChange() - { - var lang = (string)SettingsManager.WW_LANGUAGE.Get(); - var newCulture = new CultureInfo(lang); - CultureInfo.CurrentCulture = newCulture; - CultureInfo.CurrentUICulture = newCulture; - } -} diff --git a/WheelWizard/Services/Settings/SettingsManager.cs b/WheelWizard/Services/Settings/SettingsManager.cs deleted file mode 100644 index bbdc61e6..00000000 --- a/WheelWizard/Services/Settings/SettingsManager.cs +++ /dev/null @@ -1,196 +0,0 @@ -using System.Runtime.InteropServices; -using WheelWizard.Helpers; -using WheelWizard.Models.Enums; -using WheelWizard.Models.Settings; - -namespace WheelWizard.Services.Settings; - -public class SettingsManager -{ - #region Wheel Wizard Settings - public static Setting USER_FOLDER_PATH = new WhWzSetting(typeof(string), "UserFolderPath", "").SetValidation(value => - { - var userFolderPath = value as string ?? string.Empty; - if (!FileHelper.DirectoryExists(userFolderPath)) - return false; - - string dolphinLocation = DOLPHIN_LOCATION.Get() as string ?? string.Empty; - - // We cannot determine the validity of the user folder path in that case - if (string.IsNullOrWhiteSpace(dolphinLocation)) - return true; - - // If we want to use a split XDG dolphin config, - // this only really works as expected if certain conditions are met - // (we cannot simply pass `-u` to Dolphin since that would put the `Config` directory - // inside the data directory and not use the XDG config directory, leading to two different configs). - if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux) && PathManager.IsLinuxDolphinConfigSplit()) - { - // In this case, Dolphin would use `EMBEDDED_USER_DIR` which is the portable `user` directory - // in the current directory (the directory of the WheelWizard executable). - // This means a split dolphin user folder and config cannot work... - if (FileHelper.DirectoryExists("user")) - return false; - - // The Dolphin executable directory with `portable.txt` case - if (FileHelper.FileExists(Path.Combine(PathManager.GetDolphinExeDirectory(), "portable.txt"))) - return false; - - // The value of this environment variable would be used instead if it was somehow set - string environmentVariableToAvoid = "DOLPHIN_EMU_USERPATH"; - - if (!string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable(environmentVariableToAvoid))) - return false; - - if (dolphinLocation.Contains(environmentVariableToAvoid, StringComparison.Ordinal)) - return false; - - // `~/.dolphin-emu` would be used if it exists - if (!PathManager.IsFlatpakDolphinFilePath() && FileHelper.DirectoryExists(PathManager.LinuxDolphinLegacyFolderPath)) - return false; - } - - return true; - }); - - public static Setting DOLPHIN_LOCATION = new WhWzSetting(typeof(string), "DolphinLocation", "").SetValidation(value => - { - var pathOrCommand = value as string ?? string.Empty; - if (string.IsNullOrWhiteSpace(pathOrCommand)) - return false; - - if (Environment.OSVersion.Platform == PlatformID.Unix || Environment.OSVersion.Platform == PlatformID.MacOSX) - { - if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) - { - if (PathManager.IsFlatpakDolphinFilePath(pathOrCommand) && !LinuxDolphinInstaller.IsDolphinInstalledInFlatpak()) - { - return false; - } - } - return EnvHelper.IsValidUnixCommand(pathOrCommand); - } - - return FileHelper.FileExists(pathOrCommand); - }); - - public static Setting GAME_LOCATION = new WhWzSetting(typeof(string), "GameLocation", "").SetValidation(value => - FileHelper.FileExists(value as string ?? string.Empty) - ); - public static Setting FORCE_WIIMOTE = new WhWzSetting(typeof(bool), "ForceWiimote", false); - public static Setting LAUNCH_WITH_DOLPHIN = new WhWzSetting(typeof(bool), "LaunchWithDolphin", false); - public static Setting PREFERS_MODS_ROW_VIEW = new WhWzSetting(typeof(bool), "PrefersModsRowView", true); - public static Setting FOCUSSED_USER = new WhWzSetting(typeof(int), "FavoriteUser", 0).SetValidation(value => - (int)(value ?? -1) >= 0 && (int)(value ?? -1) < 4 - ); - - public static Setting ENABLE_ANIMATIONS = new WhWzSetting(typeof(bool), "EnableAnimations", true); - public static Setting TESTING_MODE_ENABLED = new WhWzSetting(typeof(bool), "TestingModeEnabled", false); - public static Setting SAVED_WINDOW_SCALE = new WhWzSetting(typeof(double), "WindowScale", 1.0).SetValidation(value => - (double)(value ?? -1) >= 0.5 && (double)(value ?? -1) <= 2.0 - ); - public static Setting REMOVE_BLUR = new WhWzSetting(typeof(bool), "REMOVE_BLUR", true); - public static Setting RR_REGION = new WhWzSetting(typeof(MarioKartWiiEnums.Regions), "RR_Region", MarioKartWiiEnums.Regions.None); - public static Setting WW_LANGUAGE = new WhWzSetting(typeof(string), "WW_Language", "en").SetValidation(value => - SettingValues.WhWzLanguages.ContainsKey((string)value!) - ); - #endregion - - #region Dolphin Settings - public static Setting NAND_ROOT_PATH = new DolphinSetting(typeof(string), ("Dolphin.ini", "General", "NANDRootPath"), "").SetValidation( - value => Directory.Exists(value as string ?? string.Empty) - ); - - public static Setting LOAD_PATH = new DolphinSetting(typeof(string), ("Dolphin.ini", "General", "LoadPath"), "").SetValidation(value => - Directory.Exists(value as string ?? string.Empty) - ); - - public static Setting VSYNC = new DolphinSetting(typeof(bool), ("GFX.ini", "Hardware", "VSync"), false); - public static Setting INTERNAL_RESOLUTION = new DolphinSetting( - typeof(int), - ("GFX.ini", "Settings", "InternalResolution"), - 1 - ).SetValidation(value => (int)(value ?? -1) >= 0); - public static Setting SHOW_FPS = new DolphinSetting(typeof(bool), ("GFX.ini", "Settings", "ShowFPS"), false); - public static Setting GFX_BACKEND = new DolphinSetting( - typeof(string), - ("Dolphin.ini", "Core", "GFXBackend"), - SettingValues.GFXRenderers.Values.First() - ); - - //recommended settings - private static Setting DOLPHIN_COMPILATION_MODE = new DolphinSetting( - typeof(DolphinShaderCompilationMode), - ("GFX.ini", "Settings", "ShaderCompilationMode"), - DolphinShaderCompilationMode.Default - ); - private static Setting DOLPHIN_COMPILE_SHADERS_AT_START = new DolphinSetting( - typeof(bool), - ("GFX.ini", "Settings", "WaitForShadersBeforeStarting"), - false - ); - private static Setting DOLPHIN_SSAA = new DolphinSetting(typeof(bool), ("GFX.ini", "Settings", "SSAA"), false); - private static Setting DOLPHIN_MSAA = new DolphinSetting(typeof(string), ("GFX.ini", "Settings", "MSAA"), "0x00000001").SetValidation( - value => (value?.ToString() ?? "") is "0x00000001" or "0x00000002" or "0x00000004" or "0x00000008" - ); - - //Readonly settings - public static readonly Setting MACADDRESS = new DolphinSetting( - typeof(string), - ("Dolphin.ini", "General", "WirelessMac"), - "02:01:02:03:04:05" - ); - #endregion - - #region Virtual Settings - private static double _internalScale = -1.0; - public static Setting WINDOW_SCALE = new VirtualSetting( - typeof(double), - value => _internalScale = (double)value!, - () => _internalScale == -1.0 ? SAVED_WINDOW_SCALE.Get() : _internalScale - ).SetDependencies(SAVED_WINDOW_SCALE); - - public static Setting RECOMMENDED_SETTINGS = new VirtualSetting( - typeof(bool), - value => - { - var newValue = (bool)value!; - DOLPHIN_COMPILATION_MODE.Set(newValue ? DolphinShaderCompilationMode.HybridUberShaders : DolphinShaderCompilationMode.Default); -#if WINDOWS - DOLPHIN_COMPILE_SHADERS_AT_START.Set(newValue); -#endif - DOLPHIN_MSAA.Set(newValue ? "0x00000002" : "0x00000001"); - DOLPHIN_SSAA.Set(false); - }, - () => - { - var value1 = (DolphinShaderCompilationMode)DOLPHIN_COMPILATION_MODE.Get(); - var value2 = true; -#if WINDOWS - value2 = (bool)DOLPHIN_COMPILE_SHADERS_AT_START.Get(); -#endif - var value3 = (string)DOLPHIN_MSAA.Get(); - var value4 = (bool)DOLPHIN_SSAA.Get(); - return !value4 && value2 && value3 == "0x00000002" && value1 == DolphinShaderCompilationMode.HybridUberShaders; - } - ).SetDependencies(DOLPHIN_COMPILATION_MODE, DOLPHIN_COMPILE_SHADERS_AT_START, DOLPHIN_MSAA, DOLPHIN_SSAA); - - private static RrGameMode _internalRrGameMode = RrGameMode.RETRO_TRACKS; - #endregion - - - #region Base Settings Manager - // dont ever make this a static class, it is required to be an instance class to ensure all settings are loaded - public static SettingsManager Instance { get; } = new(); - - private SettingsManager() { } - - // dont make this a static method - public void LoadSettings() - { - WhWzSettingManager.Instance.LoadSettings(); - DolphinSettingManager.Instance.LoadSettings(); - SettingsHelper.LoadExtraStuff(); - } - #endregion -} diff --git a/WheelWizard/Services/Settings/WhWzSettingManager.cs b/WheelWizard/Services/Settings/WhWzSettingManager.cs deleted file mode 100644 index df6fa3d3..00000000 --- a/WheelWizard/Services/Settings/WhWzSettingManager.cs +++ /dev/null @@ -1,73 +0,0 @@ -using System.Text.Json; -using Microsoft.Extensions.Logging; -using WheelWizard.Helpers; -using WheelWizard.Models.Settings; -using WheelWizard.Views; -using JsonElement = System.Text.Json.JsonElement; -using JsonSerializerOptions = System.Text.Json.JsonSerializerOptions; - -namespace WheelWizard.Services.Settings; - -public class WhWzSettingManager -{ - private bool _loaded; - private readonly Dictionary _settings = new(); - - public static WhWzSettingManager Instance { get; } = new(); - - private WhWzSettingManager() { } - - public void RegisterSetting(WhWzSetting setting) - { - if (_loaded) - return; - - _settings.Add(setting.Name, setting); - } - - public void SaveSettings(WhWzSetting invokingSetting) - { - if (!_loaded) - return; - - var settingsToSave = new Dictionary(); - - foreach (var (name, setting) in _settings) - { - settingsToSave[name] = setting.Get(); - } - var jsonString = JsonSerializer.Serialize(settingsToSave, new JsonSerializerOptions { WriteIndented = true }); - FileHelper.WriteAllTextSafe(PathManager.WheelWizardConfigFilePath, jsonString); - } - - public void LoadSettings() - { - if (_loaded) - return; - - _loaded = true; - // even if it will now return early, that means its still done loading since then there is nothing to load - var jsonString = FileHelper.ReadAllTextSafe(PathManager.WheelWizardConfigFilePath); - if (jsonString == null) - return; - - try - { - var loadedSettings = JsonSerializer.Deserialize>(jsonString); - if (loadedSettings == null) - return; - - foreach (var kvp in loadedSettings) - { - if (!_settings.TryGetValue(kvp.Key, out var setting)) - continue; - - setting.SetFromJson(kvp.Value); - } - } - catch (JsonException e) - { - App.Services.GetRequiredService>().LogError(e, "Failed to deserialize the JSON config"); - } - } -} diff --git a/WheelWizard/Services/WiiManagement/WiiMoteSettings.cs b/WheelWizard/Services/WiiManagement/WiiMoteSettings.cs index 8a327cd5..0809c289 100644 --- a/WheelWizard/Services/WiiManagement/WiiMoteSettings.cs +++ b/WheelWizard/Services/WiiManagement/WiiMoteSettings.cs @@ -1,10 +1,12 @@ using WheelWizard.Helpers; -using WheelWizard.Services.Settings; +using WheelWizard.Settings; namespace WheelWizard.Services.WiiManagement; public static class WiiMoteSettings { + private static ISettingsManager Settings => SettingsRuntime.Current; + private const string WiimoteSection = "[Wiimote1]"; private const string SourceParameter = "Source"; @@ -12,7 +14,7 @@ public static class WiiMoteSettings public static void DisableVirtualWiiMote() => ModifyWiiMoteSource(0); - public static bool IsForceSettingsEnabled() => (bool)SettingsManager.FORCE_WIIMOTE.Get(); + public static bool IsForceSettingsEnabled() => Settings.Get(Settings.FORCE_WIIMOTE); private static string GetSavedWiiMoteLocation() { diff --git a/WheelWizard/SetupExtensions.cs b/WheelWizard/SetupExtensions.cs index 47f44225..88f5c6bc 100644 --- a/WheelWizard/SetupExtensions.cs +++ b/WheelWizard/SetupExtensions.cs @@ -6,10 +6,14 @@ using WheelWizard.Branding; using WheelWizard.CustomCharacters; using WheelWizard.CustomDistributions; +using WheelWizard.DolphinInstaller; using WheelWizard.GameBanana; using WheelWizard.GitHub; using WheelWizard.MiiImages; using WheelWizard.RrRooms; +using WheelWizard.Services.Launcher; +using WheelWizard.Services.LiveData; +using WheelWizard.Settings; using WheelWizard.Shared.Services; using WheelWizard.WheelWizardData; using WheelWizard.WiiManagement; @@ -25,6 +29,8 @@ public static class SetupExtensions public static void AddWheelWizardServices(this IServiceCollection services) { // Features + services.AddDolphinInstaller(); + services.AddSettings(); services.AddCustomCharacters(); services.AddAutoUpdating(); services.AddBranding(); @@ -48,5 +54,9 @@ public static void AddWheelWizardServices(this IServiceCollection services) // Dynamic API calls services.AddTransient(typeof(IApiCaller<>), typeof(ApiCaller<>)); + services.AddTransient(); + services.AddTransient(); + services.AddSingleton(); + services.AddSingleton(); } } diff --git a/WheelWizard/Views/App.axaml b/WheelWizard/Views/App.axaml index 65f76ef6..1b9991a0 100644 --- a/WheelWizard/Views/App.axaml +++ b/WheelWizard/Views/App.axaml @@ -49,26 +49,27 @@ - - - - - - - - - - - - - + + + + + + + + + + + + + - - - - - - + + + + + + + - \ No newline at end of file + diff --git a/WheelWizard/Views/App.axaml.cs b/WheelWizard/Views/App.axaml.cs index e1d16884..32ba582c 100644 --- a/WheelWizard/Views/App.axaml.cs +++ b/WheelWizard/Views/App.axaml.cs @@ -6,6 +6,7 @@ using WheelWizard.Services; using WheelWizard.Services.LiveData; using WheelWizard.Services.UrlProtocol; +using WheelWizard.Views.Behaviors; using WheelWizard.WheelWizardData; using WheelWizard.WiiManagement; using WheelWizard.WiiManagement.GameLicense; @@ -38,6 +39,13 @@ public void SetServiceProvider(IServiceProvider serviceProvider) public override void Initialize() { AvaloniaXamlLoader.Load(this); + InitializeBehaviorOverrides(); + } + + private void InitializeBehaviorOverrides() + { + //Behavior overrides are native components where we are overriding the behavior of + ToolTipBubbleBehavior.Initialize(); } private static void OpenGameBananaModWindow() diff --git a/WheelWizard/Views/Behaviors/ToolTipBubbleBehavior.cs b/WheelWizard/Views/Behaviors/ToolTipBubbleBehavior.cs new file mode 100644 index 00000000..80d17f1c --- /dev/null +++ b/WheelWizard/Views/Behaviors/ToolTipBubbleBehavior.cs @@ -0,0 +1,355 @@ +using System.Runtime.CompilerServices; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Diagnostics; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Threading; + +namespace WheelWizard.Views.Behaviors; + +public static class ToolTipBubbleBehavior +{ + private const string BubblePointerLeftClass = "BubblePointerLeft"; + private const string BubblePointerMiddleClass = "BubblePointerMiddle"; + private const string BubblePointerRightClass = "BubblePointerRight"; + private const string BubbleAnimateInClass = "BubbleAnimateIn"; + private const string BubbleAnimateOutClass = "BubbleAnimateOut"; + private const double TailCenterOffsetFromSide = 22d; + private const double TooltipVerticalOffset = -4d; + private static readonly TimeSpan HoverOpenDelay = TimeSpan.FromMilliseconds(200); + private static readonly TimeSpan MinimumVisibleTime = TimeSpan.FromSeconds(1); + private static readonly TimeSpan CloseAnimationDuration = TimeSpan.FromMilliseconds(40); + private static readonly ConditionalWeakTable ToolTipStates = new(); + private static bool _isInitialized; + + public static void Initialize() + { + if (_isInitialized) + return; + + _isInitialized = true; + ToolTip.TipProperty.Changed.AddClassHandler(OnTipChanged); + ToolTip.ToolTipOpeningEvent.AddClassHandler(OnToolTipOpening); + ToolTip.IsOpenProperty.Changed.AddClassHandler(OnIsOpenChanged); + InputElement.IsPointerOverProperty.Changed.AddClassHandler(OnIsPointerOverChanged); + } + + private static void OnTipChanged(Control control, AvaloniaPropertyChangedEventArgs args) + { + var newTip = args.GetNewValue(); + if (newTip == null || ReferenceEquals(newTip, AvaloniaProperty.UnsetValue)) + { + var state = GetState(control); + CancelPendingOpen(state); + CancelPendingClose(state); + ToolTip.SetIsOpen(control, false); + ToolTip.SetServiceEnabled(control, true); + return; + } + + var normalizedPlacement = NormalizePlacement(ToolTip.GetPlacement(control)); + if (ToolTip.GetPlacement(control) != normalizedPlacement) + ToolTip.SetPlacement(control, normalizedPlacement); + + ToolTip.SetServiceEnabled(control, false); + + if (newTip is ToolTip existingToolTip) + { + ApplyPointerClass(existingToolTip, normalizedPlacement); + return; + } + + var wrappedToolTip = new ToolTip { Content = newTip }; + ApplyPointerClass(wrappedToolTip, normalizedPlacement); + ToolTip.SetTip(control, wrappedToolTip); + } + + private static void OnToolTipOpening(Control control, CancelRoutedEventArgs _) => PrepareToolTip(control); + + private static void OnIsPointerOverChanged(Control control, AvaloniaPropertyChangedEventArgs args) + { + if (args.GetNewValue()) + { + OnPointerEntered(control); + return; + } + + OnPointerExited(control); + } + + private static void OnIsOpenChanged(Control control, AvaloniaPropertyChangedEventArgs args) + { + var wasOpen = args.GetOldValue(); + var isOpen = args.GetNewValue(); + if (wasOpen == isOpen) + return; + + var state = GetState(control); + + if (isOpen) + { + state.OpenedAt = DateTimeOffset.UtcNow; + CancelPendingOpen(state); + CancelPendingClose(state); + return; + } + + CancelPendingOpen(state); + CancelPendingClose(state); + ClearBubbleAnimationClasses(control); + } + + private static void OnPointerEntered(Control control) + { + if (!HasToolTip(control)) + return; + + var state = GetState(control); + var hadPendingClose = state.PendingCloseCts != null; + CancelPendingOpen(state); + CancelPendingClose(state); + + if (ToolTip.GetIsOpen(control)) + { + if (hadPendingClose && HasBubbleClass(control, BubbleAnimateOutClass)) + ApplyBubbleAnimationClass(control, animateIn: true); + return; + } + + var cts = new CancellationTokenSource(); + state.PendingOpenCts = cts; + _ = DeferredOpenAsync(control, state, HoverOpenDelay, cts.Token); + } + + private static void OnPointerExited(Control control) + { + if (!HasToolTip(control) && !ToolTip.GetIsOpen(control)) + return; + + var state = GetState(control); + CancelPendingOpen(state); + + var elapsed = DateTimeOffset.UtcNow - state.OpenedAt; + var remaining = MinimumVisibleTime - elapsed; + if (remaining < TimeSpan.Zero) + remaining = TimeSpan.Zero; + + CancelPendingClose(state); + var cts = new CancellationTokenSource(); + state.PendingCloseCts = cts; + _ = DeferredCloseAsync(control, state, remaining, cts.Token); + } + + private static void ApplyPointerClass(ToolTip toolTip, PlacementMode placement) + { + toolTip.Classes.Remove(BubblePointerLeftClass); + toolTip.Classes.Remove(BubblePointerMiddleClass); + toolTip.Classes.Remove(BubblePointerRightClass); + toolTip.Classes.Add(GetPointerClass(placement)); + } + + private static void ApplyPointerAnchorOffset(Control control, PlacementMode placement) + { + var controlCenterX = control.Bounds.Width / 2d; + var horizontalOffset = placement switch + { + PlacementMode.TopEdgeAlignedLeft => controlCenterX - TailCenterOffsetFromSide, + PlacementMode.TopEdgeAlignedRight => TailCenterOffsetFromSide - controlCenterX, + _ => 0d, + }; + + ToolTip.SetHorizontalOffset(control, horizontalOffset); + ToolTip.SetVerticalOffset(control, TooltipVerticalOffset); + } + + private static ToolTipState GetState(Control control) => ToolTipStates.GetOrCreateValue(control); + + private static bool HasToolTip(Control control) + { + var tip = ToolTip.GetTip(control); + return tip != null && !ReferenceEquals(tip, AvaloniaProperty.UnsetValue); + } + + private static void PrepareToolTip(Control control) + { + var normalizedPlacement = NormalizePlacement(ToolTip.GetPlacement(control)); + if (ToolTip.GetPlacement(control) != normalizedPlacement) + ToolTip.SetPlacement(control, normalizedPlacement); + + var toolTip = GetToolTipInstance(control); + if (toolTip == null) + return; + + ApplyPointerClass(toolTip, normalizedPlacement); + ApplyPointerAnchorOffset(control, normalizedPlacement); + } + + private static ToolTip? GetToolTipInstance(Control control) => + control.GetValue(ToolTipDiagnostics.ToolTipProperty) as ToolTip ?? ToolTip.GetTip(control) as ToolTip; + + private static bool HasBubbleClass(Control control, string className) + { + var toolTip = GetToolTipInstance(control); + return toolTip != null && toolTip.Classes.Contains(className); + } + + private static void ApplyBubbleAnimationClass(Control control, bool animateIn) + { + var toolTip = GetToolTipInstance(control); + if (toolTip == null) + return; + + toolTip.Classes.Remove(BubbleAnimateInClass); + toolTip.Classes.Remove(BubbleAnimateOutClass); + toolTip.Classes.Add(animateIn ? BubbleAnimateInClass : BubbleAnimateOutClass); + } + + private static void ClearBubbleAnimationClasses(Control control) + { + var toolTip = GetToolTipInstance(control); + if (toolTip == null) + return; + + toolTip.Classes.Remove(BubbleAnimateInClass); + toolTip.Classes.Remove(BubbleAnimateOutClass); + } + + private static void CancelPendingClose(ToolTipState state) + { + if (state.PendingCloseCts == null) + return; + + state.PendingCloseCts.Cancel(); + state.PendingCloseCts.Dispose(); + state.PendingCloseCts = null; + } + + private static void CancelPendingOpen(ToolTipState state) + { + if (state.PendingOpenCts == null) + return; + + state.PendingOpenCts.Cancel(); + state.PendingOpenCts.Dispose(); + state.PendingOpenCts = null; + } + + private static async Task DeferredOpenAsync(Control control, ToolTipState state, TimeSpan delay, CancellationToken cancellationToken) + { + try + { + await Task.Delay(delay, cancellationToken); + } + catch (TaskCanceledException) + { + return; + } + + await Dispatcher.UIThread.InvokeAsync(() => + { + if (cancellationToken.IsCancellationRequested) + return; + + state.PendingOpenCts?.Dispose(); + state.PendingOpenCts = null; + + if (!control.IsPointerOver || ToolTip.GetIsOpen(control) || !HasToolTip(control)) + return; + + PrepareToolTip(control); + ApplyBubbleAnimationClass(control, animateIn: true); + ToolTip.SetIsOpen(control, true); + }); + } + + private static async Task DeferredCloseAsync(Control control, ToolTipState state, TimeSpan delay, CancellationToken cancellationToken) + { + try + { + if (delay > TimeSpan.Zero) + await Task.Delay(delay, cancellationToken); + } + catch (TaskCanceledException) + { + return; + } + + var shouldAnimateOut = false; + await Dispatcher.UIThread.InvokeAsync(() => + { + if (cancellationToken.IsCancellationRequested) + return; + + if (control.IsPointerOver || !ToolTip.GetIsOpen(control)) + { + state.PendingCloseCts?.Dispose(); + state.PendingCloseCts = null; + return; + } + + shouldAnimateOut = true; + ApplyBubbleAnimationClass(control, animateIn: false); + }); + + if (!shouldAnimateOut) + return; + + try + { + await Task.Delay(CloseAnimationDuration, cancellationToken); + } + catch (TaskCanceledException) + { + return; + } + + await Dispatcher.UIThread.InvokeAsync(() => + { + if (cancellationToken.IsCancellationRequested) + return; + + state.PendingCloseCts?.Dispose(); + state.PendingCloseCts = null; + if (control.IsPointerOver) + { + if (HasBubbleClass(control, BubbleAnimateOutClass)) + ApplyBubbleAnimationClass(control, animateIn: true); + return; + } + + ToolTip.SetIsOpen(control, false); + }); + } + + private static string GetPointerClass(PlacementMode placement) => + placement switch + { + PlacementMode.TopEdgeAlignedLeft => BubblePointerLeftClass, + PlacementMode.TopEdgeAlignedRight => BubblePointerRightClass, + _ => BubblePointerMiddleClass, + }; + + private static PlacementMode NormalizePlacement(PlacementMode placement) => + placement switch + { + PlacementMode.Left => PlacementMode.TopEdgeAlignedLeft, + PlacementMode.LeftEdgeAlignedTop => PlacementMode.TopEdgeAlignedLeft, + PlacementMode.LeftEdgeAlignedBottom => PlacementMode.TopEdgeAlignedLeft, + PlacementMode.TopEdgeAlignedLeft => PlacementMode.TopEdgeAlignedLeft, + PlacementMode.BottomEdgeAlignedLeft => PlacementMode.TopEdgeAlignedLeft, + PlacementMode.Right => PlacementMode.TopEdgeAlignedRight, + PlacementMode.RightEdgeAlignedTop => PlacementMode.TopEdgeAlignedRight, + PlacementMode.RightEdgeAlignedBottom => PlacementMode.TopEdgeAlignedRight, + PlacementMode.TopEdgeAlignedRight => PlacementMode.TopEdgeAlignedRight, + PlacementMode.BottomEdgeAlignedRight => PlacementMode.TopEdgeAlignedRight, + _ => PlacementMode.Top, + }; + + private sealed class ToolTipState + { + public DateTimeOffset OpenedAt { get; set; } + public CancellationTokenSource? PendingOpenCts { get; set; } + public CancellationTokenSource? PendingCloseCts { get; set; } + } +} diff --git a/WheelWizard/Views/BehaviorComponent/AspectGrid.axaml b/WheelWizard/Views/Components/AspectGrid.axaml similarity index 86% rename from WheelWizard/Views/BehaviorComponent/AspectGrid.axaml rename to WheelWizard/Views/Components/AspectGrid.axaml index 8cf0eb5f..c5354c7c 100644 --- a/WheelWizard/Views/BehaviorComponent/AspectGrid.axaml +++ b/WheelWizard/Views/Components/AspectGrid.axaml @@ -3,7 +3,7 @@ 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="WheelWizard.Views.BehaviorComponent.AspectGrid" + x:Class="WheelWizard.Views.Components.AspectGrid" SizeChanged="OnSizeChanged"> - \ No newline at end of file + diff --git a/WheelWizard/Views/BehaviorComponent/AspectGrid.axaml.cs b/WheelWizard/Views/Components/AspectGrid.axaml.cs similarity index 97% rename from WheelWizard/Views/BehaviorComponent/AspectGrid.axaml.cs rename to WheelWizard/Views/Components/AspectGrid.axaml.cs index 93145b55..da51dc21 100644 --- a/WheelWizard/Views/BehaviorComponent/AspectGrid.axaml.cs +++ b/WheelWizard/Views/Components/AspectGrid.axaml.cs @@ -1,7 +1,7 @@ using Avalonia; using Avalonia.Controls; -namespace WheelWizard.Views.BehaviorComponent; +namespace WheelWizard.Views.Components; public partial class AspectGrid : Grid { diff --git a/WheelWizard/Views/Components/WhWzLibrary/Badge.axaml b/WheelWizard/Views/Components/Badge.axaml similarity index 100% rename from WheelWizard/Views/Components/WhWzLibrary/Badge.axaml rename to WheelWizard/Views/Components/Badge.axaml diff --git a/WheelWizard/Views/Components/WhWzLibrary/Badge.axaml.cs b/WheelWizard/Views/Components/Badge.axaml.cs similarity index 100% rename from WheelWizard/Views/Components/WhWzLibrary/Badge.axaml.cs rename to WheelWizard/Views/Components/Badge.axaml.cs diff --git a/WheelWizard/Views/Components/StandardLibrary/Button.axaml b/WheelWizard/Views/Components/Button.axaml similarity index 99% rename from WheelWizard/Views/Components/StandardLibrary/Button.axaml rename to WheelWizard/Views/Components/Button.axaml index 94418edc..56f5222e 100644 --- a/WheelWizard/Views/Components/StandardLibrary/Button.axaml +++ b/WheelWizard/Views/Components/Button.axaml @@ -24,7 +24,7 @@ IconData="{StaticResource WheelIcon}" IconSize="25" ToolTip.Tip="Launch Dolphin" - ToolTip.Placement="Bottom" + ToolTip.Placement="Top" ToolTip.ShowDelay="50" HorizontalAlignment="Center" Margin="0,6,0,0" /> @@ -140,4 +140,4 @@ - \ No newline at end of file + diff --git a/WheelWizard/Views/Components/StandardLibrary/Button.axaml.cs b/WheelWizard/Views/Components/Button.axaml.cs similarity index 100% rename from WheelWizard/Views/Components/StandardLibrary/Button.axaml.cs rename to WheelWizard/Views/Components/Button.axaml.cs diff --git a/WheelWizard/Views/Components/StandardLibrary/EmptyPageInfo.axaml b/WheelWizard/Views/Components/EmptyPageInfo.axaml similarity index 100% rename from WheelWizard/Views/Components/StandardLibrary/EmptyPageInfo.axaml rename to WheelWizard/Views/Components/EmptyPageInfo.axaml diff --git a/WheelWizard/Views/Components/StandardLibrary/EmptyPageInfo.axaml.cs b/WheelWizard/Views/Components/EmptyPageInfo.axaml.cs similarity index 100% rename from WheelWizard/Views/Components/StandardLibrary/EmptyPageInfo.axaml.cs rename to WheelWizard/Views/Components/EmptyPageInfo.axaml.cs diff --git a/WheelWizard/Views/Components/StandardLibrary/FormFieldLabel.axaml b/WheelWizard/Views/Components/FormFieldLabel.axaml similarity index 89% rename from WheelWizard/Views/Components/StandardLibrary/FormFieldLabel.axaml rename to WheelWizard/Views/Components/FormFieldLabel.axaml index 30239ad0..81ee970c 100644 --- a/WheelWizard/Views/Components/StandardLibrary/FormFieldLabel.axaml +++ b/WheelWizard/Views/Components/FormFieldLabel.axaml @@ -10,6 +10,7 @@ - \ No newline at end of file + diff --git a/WheelWizard/Views/Components/StandardLibrary/LoadingIcon.axaml.cs b/WheelWizard/Views/Components/LoadingIcon.axaml.cs similarity index 100% rename from WheelWizard/Views/Components/StandardLibrary/LoadingIcon.axaml.cs rename to WheelWizard/Views/Components/LoadingIcon.axaml.cs diff --git a/WheelWizard/Views/Components/StandardLibrary/MemeNumberState.axaml b/WheelWizard/Views/Components/MemeNumberState.axaml similarity index 100% rename from WheelWizard/Views/Components/StandardLibrary/MemeNumberState.axaml rename to WheelWizard/Views/Components/MemeNumberState.axaml diff --git a/WheelWizard/Views/Components/StandardLibrary/MemeNumberState.axaml.cs b/WheelWizard/Views/Components/MemeNumberState.axaml.cs similarity index 100% rename from WheelWizard/Views/Components/StandardLibrary/MemeNumberState.axaml.cs rename to WheelWizard/Views/Components/MemeNumberState.axaml.cs diff --git a/WheelWizard/Views/Components/StandardLibrary/MultiColoredIcon.axaml b/WheelWizard/Views/Components/MultiColoredIcon.axaml similarity index 100% rename from WheelWizard/Views/Components/StandardLibrary/MultiColoredIcon.axaml rename to WheelWizard/Views/Components/MultiColoredIcon.axaml diff --git a/WheelWizard/Views/Components/StandardLibrary/MultiColoredIcon.axaml.cs b/WheelWizard/Views/Components/MultiColoredIcon.axaml.cs similarity index 100% rename from WheelWizard/Views/Components/StandardLibrary/MultiColoredIcon.axaml.cs rename to WheelWizard/Views/Components/MultiColoredIcon.axaml.cs diff --git a/WheelWizard/Views/Components/StandardLibrary/MultiIconRadioButton.axaml b/WheelWizard/Views/Components/MultiIconRadioButton.axaml similarity index 100% rename from WheelWizard/Views/Components/StandardLibrary/MultiIconRadioButton.axaml rename to WheelWizard/Views/Components/MultiIconRadioButton.axaml diff --git a/WheelWizard/Views/Components/StandardLibrary/MultiIconRadioButton.axaml.cs b/WheelWizard/Views/Components/MultiIconRadioButton.axaml.cs similarity index 100% rename from WheelWizard/Views/Components/StandardLibrary/MultiIconRadioButton.axaml.cs rename to WheelWizard/Views/Components/MultiIconRadioButton.axaml.cs diff --git a/WheelWizard/Views/Components/StandardLibrary/OptionButton.axaml b/WheelWizard/Views/Components/OptionButton.axaml similarity index 97% rename from WheelWizard/Views/Components/StandardLibrary/OptionButton.axaml rename to WheelWizard/Views/Components/OptionButton.axaml index ef07671d..9356747a 100644 --- a/WheelWizard/Views/Components/StandardLibrary/OptionButton.axaml +++ b/WheelWizard/Views/Components/OptionButton.axaml @@ -1,7 +1,7 @@  + xmlns:behavior="clr-namespace:WheelWizard.Views.Patterns"> @@ -27,7 +27,7 @@ - + - + @@ -112,4 +112,4 @@ - \ No newline at end of file + diff --git a/WheelWizard/Views/Components/StandardLibrary/OptionButton.axaml.cs b/WheelWizard/Views/Components/OptionButton.axaml.cs similarity index 100% rename from WheelWizard/Views/Components/StandardLibrary/OptionButton.axaml.cs rename to WheelWizard/Views/Components/OptionButton.axaml.cs diff --git a/WheelWizard/Views/Components/StandardLibrary/PopupListButton.axaml b/WheelWizard/Views/Components/PopupListButton.axaml similarity index 100% rename from WheelWizard/Views/Components/StandardLibrary/PopupListButton.axaml rename to WheelWizard/Views/Components/PopupListButton.axaml diff --git a/WheelWizard/Views/Components/StandardLibrary/PopupListButton.axaml.cs b/WheelWizard/Views/Components/PopupListButton.axaml.cs similarity index 100% rename from WheelWizard/Views/Components/StandardLibrary/PopupListButton.axaml.cs rename to WheelWizard/Views/Components/PopupListButton.axaml.cs diff --git a/WheelWizard/Views/Components/StandardLibrary/FormFieldLabel.axaml.cs b/WheelWizard/Views/Components/StandardLibrary/FormFieldLabel.axaml.cs deleted file mode 100644 index 596f382c..00000000 --- a/WheelWizard/Views/Components/StandardLibrary/FormFieldLabel.axaml.cs +++ /dev/null @@ -1,23 +0,0 @@ -using Avalonia; -using Avalonia.Controls; - -namespace WheelWizard.Views.Components; - -public class FormFieldLabel : UserControl -{ - public static readonly StyledProperty TextProperty = AvaloniaProperty.Register(nameof(Text)); // Add a default value here - - public string Text - { - get => GetValue(TextProperty); - set => SetValue(TextProperty, value); - } - - public static readonly StyledProperty TipTextProperty = AvaloniaProperty.Register(nameof(TipText)); // Add a default value here - - public string TipText - { - get => GetValue(TipTextProperty); - set => SetValue(TipTextProperty, value); - } -} diff --git a/WheelWizard/Views/Components/StandardLibrary/StateBox.axaml b/WheelWizard/Views/Components/StateBox.axaml similarity index 100% rename from WheelWizard/Views/Components/StandardLibrary/StateBox.axaml rename to WheelWizard/Views/Components/StateBox.axaml diff --git a/WheelWizard/Views/Components/StandardLibrary/StateBox.axaml.cs b/WheelWizard/Views/Components/StateBox.axaml.cs similarity index 99% rename from WheelWizard/Views/Components/StandardLibrary/StateBox.axaml.cs rename to WheelWizard/Views/Components/StateBox.axaml.cs index 632ef21b..9e7d7e5a 100644 --- a/WheelWizard/Views/Components/StandardLibrary/StateBox.axaml.cs +++ b/WheelWizard/Views/Components/StateBox.axaml.cs @@ -78,7 +78,7 @@ public StateBoxVariantType Variant public static readonly StyledProperty TipPlacementProperty = AvaloniaProperty.Register( nameof(TipPlacement), - PlacementMode.Right + PlacementMode.Top ); public PlacementMode TipPlacement diff --git a/WheelWizard/Views/Components/WhWzLibrary/WheelTrail.axaml b/WheelWizard/Views/Components/WheelTrail.axaml similarity index 100% rename from WheelWizard/Views/Components/WhWzLibrary/WheelTrail.axaml rename to WheelWizard/Views/Components/WheelTrail.axaml diff --git a/WheelWizard/Views/Components/WhWzLibrary/WheelTrail.axaml.cs b/WheelWizard/Views/Components/WheelTrail.axaml.cs similarity index 100% rename from WheelWizard/Views/Components/WhWzLibrary/WheelTrail.axaml.cs rename to WheelWizard/Views/Components/WheelTrail.axaml.cs diff --git a/WheelWizard/Views/Layout.axaml b/WheelWizard/Views/Layout.axaml index e0512497..d2886b41 100644 --- a/WheelWizard/Views/Layout.axaml +++ b/WheelWizard/Views/Layout.axaml @@ -6,8 +6,8 @@ x:Class="WheelWizard.Views.Layout" xmlns:lang="clr-namespace:WheelWizard.Resources.Languages" xmlns:pages="clr-namespace:WheelWizard.Views.Pages" - xmlns:settings="clr-namespace:WheelWizard.Views.Pages.Settings" xmlns:components="clr-namespace:WheelWizard.Views.Components" + xmlns:patterns="clr-namespace:WheelWizard.Views.Patterns" Height="876" Width="656" WindowStartupLocation="CenterScreen" SystemDecorations="None" ExtendClientAreaToDecorationsHint="True" @@ -57,9 +57,11 @@ - - + + - - - - - - + - - - diff --git a/WheelWizard/Views/Layout.axaml.cs b/WheelWizard/Views/Layout.axaml.cs index 7a41c1fb..9c41e809 100644 --- a/WheelWizard/Views/Layout.axaml.cs +++ b/WheelWizard/Views/Layout.axaml.cs @@ -1,5 +1,6 @@ using System.Runtime.InteropServices; using Avalonia; +using Avalonia.Animation; using Avalonia.Controls; using Avalonia.Input; using Avalonia.Interactivity; @@ -7,14 +8,15 @@ using Avalonia.Platform; using WheelWizard.Branding; using WheelWizard.Helpers; -using WheelWizard.Models.Settings; using WheelWizard.Resources.Languages; using WheelWizard.Services.LiveData; -using WheelWizard.Services.Settings; +using WheelWizard.Settings; +using WheelWizard.Settings.Types; using WheelWizard.Shared.DependencyInjection; using WheelWizard.Utilities.RepeatedTasks; using WheelWizard.Views.Components; using WheelWizard.Views.Pages; +using WheelWizard.Views.Patterns; using WheelWizard.Views.Popups.Generic; using WheelWizard.WheelWizardData.Domain; using WheelWizard.WiiManagement; @@ -22,7 +24,7 @@ namespace WheelWizard.Views; -public partial class Layout : BaseWindow, IRepeatedTaskListener, ISettingListener +public partial class Layout : BaseWindow, IRepeatedTaskListener { protected override Control InteractionOverlay => DisabledDarkenEffect; protected override Control InteractionContent => CompleteGrid; @@ -37,8 +39,19 @@ public partial class Layout : BaseWindow, IRepeatedTaskListener, ISettingListene // testing builds since they are behind authentication walls. // but have fun with the beta button :) private const string TesterSecretPhrase = "WhenSonicInRR?"; + private static readonly TimeSpan PageSwapDuration = TimeSpan.FromMilliseconds(250); + private static readonly IPageTransition RoomsPageTransition = new CompositePageTransition + { + PageTransitions = + [ + new PageSlide { Duration = PageSwapDuration, Orientation = PageSlide.SlideAxis.Horizontal }, + new CrossFade { Duration = PageSwapDuration }, + ], + }; + private int _testerClickCount; private bool _testerPromptOpen; + private IDisposable? _settingsSignalSubscription; [Inject] private IBrandingSingletonService BrandingService { get; set; } = null!; @@ -46,15 +59,20 @@ public partial class Layout : BaseWindow, IRepeatedTaskListener, ISettingListene [Inject] private IGameLicenseSingletonService GameLicenseService { get; set; } = null!; + [Inject] + private ISettingsManager SettingsService { get; set; } = null!; + + [Inject] + private ISettingsSignalBus SettingsSignalBus { get; set; } = null!; + public Layout() { Instance = this; InitializeComponent(); AddLayer(); - OnSettingChanged(SettingsManager.SAVED_WINDOW_SCALE); - SettingsManager.WINDOW_SCALE.Subscribe(this); - SettingsManager.TESTING_MODE_ENABLED.Subscribe(this); + OnSettingChanged(SettingsService.SAVED_WINDOW_SCALE); + _settingsSignalSubscription = SettingsSignalBus.Subscribe(OnSettingSignal); UpdateTestingButtonVisibility(); var completeString = Humanizer.ReplaceDynamic(Phrases.Text_MadeByString, "Patchzy", "WantToBeeMe"); @@ -91,10 +109,19 @@ protected override void OnLoaded(RoutedEventArgs e) NavigationManager.NavigateTo(); } - public void OnSettingChanged(Setting setting) + protected override void OnClosed(EventArgs e) + { + _settingsSignalSubscription?.Dispose(); + _settingsSignalSubscription = null; + base.OnClosed(e); + } + + private void OnSettingSignal(SettingChangedSignal signal) => OnSettingChanged(signal.Setting); + + private void OnSettingChanged(Setting setting) { // Note that this method will also be called whenever the setting changes - if (setting == SettingsManager.WINDOW_SCALE || setting == SettingsManager.SAVED_WINDOW_SCALE) + if (setting == SettingsService.WINDOW_SCALE || setting == SettingsService.SAVED_WINDOW_SCALE) { var scaleFactor = (double)setting.Get(); Height = WindowHeight * scaleFactor; @@ -107,14 +134,24 @@ public void OnSettingChanged(Setting setting) return; } - if (setting == SettingsManager.TESTING_MODE_ENABLED) + if (setting == SettingsService.TESTING_MODE_ENABLED) UpdateTestingButtonVisibility(); } public void NavigateToPage(UserControl page) { + var oldPage = ContentArea.Content as Control; + var isRoomsToDetails = oldPage is RoomsPage && page is RoomDetailsPage; + var isDetailsToRooms = oldPage is RoomDetailsPage && page is RoomsPage; + + ContentArea.PageTransition = isRoomsToDetails || isDetailsToRooms ? RoomsPageTransition : null; + ContentArea.IsTransitionReversed = isDetailsToRooms; ContentArea.Content = page; + UpdateSidebarSelection(page); + } + private void UpdateSidebarSelection(UserControl page) + { // Update the IsChecked state of the SidebarRadioButtons foreach (var child in SidePanelButtons.Children) { @@ -235,7 +272,7 @@ private async void TitleLabel_OnPointerPressed(object? sender, PointerPressedEve e.Handled = true; - if ((bool)SettingsManager.TESTING_MODE_ENABLED.Get()) + if (SettingsService.Get(SettingsService.TESTING_MODE_ENABLED)) return; if (_testerPromptOpen) @@ -261,7 +298,7 @@ private async void TitleLabel_OnPointerPressed(object? sender, PointerPressedEve if (result == TesterSecretPhrase) { - SettingsManager.TESTING_MODE_ENABLED.Set(true); + SettingsService.Set(SettingsService.TESTING_MODE_ENABLED, true); ShowSnackbar("Testing mode enabled", ViewUtils.SnackbarType.Success); } else @@ -277,7 +314,7 @@ private async void TitleLabel_OnPointerPressed(object? sender, PointerPressedEve private void UpdateTestingButtonVisibility() { - TestingButton.IsVisible = (bool)SettingsManager.TESTING_MODE_ENABLED.Get(); + TestingButton.IsVisible = SettingsService.Get(SettingsService.TESTING_MODE_ENABLED); } private void CloseButton_Click(object? sender, RoutedEventArgs e) => Close(); diff --git a/WheelWizard/Views/NavigationManager.cs b/WheelWizard/Views/NavigationManager.cs index 931b8d34..066e831c 100644 --- a/WheelWizard/Views/NavigationManager.cs +++ b/WheelWizard/Views/NavigationManager.cs @@ -1,11 +1,13 @@ using System.Globalization; using Avalonia.Controls; -using WheelWizard.Services.Settings; +using WheelWizard.Settings; namespace WheelWizard.Views; public static class NavigationManager { + private static ISettingsManager Settings => SettingsRuntime.Current; + public static void NavigateTo(Type pageType, params object?[] args) { // TODO: Fix the language bug. for some reason when changing the language, it changes itself back to the language before @@ -13,11 +15,11 @@ public static void NavigateTo(Type pageType, params object?[] args) // still makes it so that the first page you enter after changing the language setting will always be the old language instead of the new one // when working on the translations again, this should be fixed. and in a solid way instead of this var itCurrentlyIs = CultureInfo.CurrentCulture.ToString(); - var itsSupposeToBe = (string)SettingsManager.WW_LANGUAGE.Get(); + var itsSupposeToBe = Settings.Get(Settings.WW_LANGUAGE); if (itCurrentlyIs != itsSupposeToBe) { - SettingsManager.WW_LANGUAGE.Set(itCurrentlyIs); - SettingsManager.WW_LANGUAGE.Set(itsSupposeToBe); + Settings.Set(Settings.WW_LANGUAGE, itCurrentlyIs); + Settings.Set(Settings.WW_LANGUAGE, itsSupposeToBe); } if (Activator.CreateInstance(pageType, args) is not UserControl instance) diff --git a/WheelWizard/Views/Pages/FriendsPage.axaml b/WheelWizard/Views/Pages/FriendsPage.axaml index 5560178f..53f197d2 100644 --- a/WheelWizard/Views/Pages/FriendsPage.axaml +++ b/WheelWizard/Views/Pages/FriendsPage.axaml @@ -5,7 +5,7 @@ mc:Ignorable="d" d:DesignWidth="656" d:DesignHeight="876" xmlns:lang="clr-namespace:WheelWizard.Resources.Languages" xmlns:components="clr-namespace:WheelWizard.Views.Components" - xmlns:behaviorComponent="clr-namespace:WheelWizard.Views.BehaviorComponent" + xmlns:patterns="clr-namespace:WheelWizard.Views.Patterns" xmlns:domain="clr-namespace:WheelWizard.WiiManagement.GameLicense.Domain" x:Class="WheelWizard.Views.Pages.FriendsPage"> @@ -35,17 +35,17 @@ ToolTip.Tip="Add Friend" ToolTip.Placement="Top" ToolTip.ShowDelay="20" /> - - - + @@ -74,7 +74,7 @@ ScrollViewer.VerticalScrollBarVisibility="Auto"> - ApiCaller { get; set; } = null!; + [Inject] + private ISettingsManager SettingsService { get; set; } = null!; + public ObservableCollection FriendList { get => _friendlist; @@ -140,7 +143,7 @@ private void SortByDropdown_OnSelectionChanged(object? sender, SelectionChangedE private async void AddFriend_OnClick(object? sender, RoutedEventArgs e) { - var focusedUserIndex = (int)SettingsManager.FOCUSSED_USER.Get(); + var focusedUserIndex = SettingsService.Get(SettingsService.FOCUSED_USER); if (focusedUserIndex is < 0 or > 3) { ViewUtils.ShowSnackbar("Invalid license selected.", ViewUtils.SnackbarType.Warning); @@ -166,6 +169,7 @@ private async void AddFriend_OnClick(object? sender, RoutedEventArgs e) .SetPlaceholderText("0000-0000-0000") .SetButtonText(Common.Action_Cancel, Common.Action_Submit) .SetValidation((_, newText) => ValidateFriendCodeInput(newText)) + .SetWarningValidation((_, newText) => ValidateFriendCodeWarning(newText)) .ShowDialog(); if (inputFriendCode == null) @@ -227,13 +231,23 @@ private OperationResult ValidateFriendCodeInput(string? rawFriendCode) if (currentProfileId != 0 && currentProfileId == friendProfileId) return Fail("You cannot add your own friend code."); + return Ok(); + } + + private string? ValidateFriendCodeWarning(string? rawFriendCode) + { + var normalizedFriendCodeResult = NormalizeFriendCode(rawFriendCode ?? string.Empty); + if (normalizedFriendCodeResult.IsFailure) + return null; + + var friendProfileId = FriendCodeGenerator.FriendCodeToProfileId(normalizedFriendCodeResult.Value); var duplicateFriend = GameLicenseService.ActiveCurrentFriends.Any(friend => { var existingPid = FriendCodeGenerator.FriendCodeToProfileId(friend.FriendCode); return existingPid != 0 && existingPid == friendProfileId; }); - return duplicateFriend ? Fail("This friend is already in your list.") : Ok(); + return duplicateFriend ? "This friend is already in your list." : null; } private static OperationResult NormalizeFriendCode(string friendCode) @@ -315,7 +329,7 @@ private void RemoveFriend_OnClick(object sender, RoutedEventArgs e) if (string.IsNullOrWhiteSpace(selectedPlayer.FriendCode)) return; - var focusedUserIndex = (int)SettingsManager.FOCUSSED_USER.Get(); + var focusedUserIndex = SettingsService.Get(SettingsService.FOCUSED_USER); if (focusedUserIndex is < 0 or > 3) { ViewUtils.ShowSnackbar("Invalid license selected.", ViewUtils.SnackbarType.Warning); diff --git a/WheelWizard/Views/Pages/HomePage.axaml b/WheelWizard/Views/Pages/HomePage.axaml index 59f0e2f7..62875b08 100644 --- a/WheelWizard/Views/Pages/HomePage.axaml +++ b/WheelWizard/Views/Pages/HomePage.axaml @@ -4,7 +4,7 @@ xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:lang="clr-namespace:WheelWizard.Resources.Languages" xmlns:components="clr-namespace:WheelWizard.Views.Components" - xmlns:behaviorComponent="clr-namespace:WheelWizard.Views.BehaviorComponent" + xmlns:behaviorComponent="clr-namespace:WheelWizard.Views.Patterns" mc:Ignorable="d" d:DesignWidth="490" d:DesignHeight="830" ClipToBounds="False" x:Class="WheelWizard.Views.Pages.HomePage"> @@ -173,12 +173,13 @@ IsEnabled="True" IconData="{StaticResource DolphinIcon}" IconSize="30" - ToolTip.Tip="{x:Static lang:Common.Action_LaunchDolphin}" - ToolTip.Placement="Bottom" - ToolTip.ShowDelay="50" Height="40" Click="DolphinButton_OnClick" Width="100" Margin="0,6,0,0" /> + + - \ No newline at end of file + diff --git a/WheelWizard/Views/Pages/HomePage.axaml.cs b/WheelWizard/Views/Pages/HomePage.axaml.cs index ca54024a..aa26a263 100644 --- a/WheelWizard/Views/Pages/HomePage.axaml.cs +++ b/WheelWizard/Views/Pages/HomePage.axaml.cs @@ -9,31 +9,36 @@ using WheelWizard.Resources.Languages; using WheelWizard.Services.Launcher; using WheelWizard.Services.Launcher.Helpers; -using WheelWizard.Services.Settings; +using WheelWizard.Settings; +using WheelWizard.Shared.DependencyInjection; using WheelWizard.Views.Components; -using WheelWizard.Views.Pages.Settings; using Button = WheelWizard.Views.Components.Button; namespace WheelWizard.Views.Pages; public partial class HomePage : UserControlBase { - private static ILauncher currentLauncher => _launcherTypes[_launcherIndex]; - private static int _launcherIndex = 0; // Make sure this index never goes over the list index + [Inject] + private ISettingsManager SettingsService { get; set; } = null!; + + [Inject] + private RrLauncher RrLauncher { get; set; } = null!; + + [Inject] + private IRandomSystem RandomSystem { get; set; } = null!; + + private ILauncher CurrentLauncher => _launcherTypes[_launcherIndex]; + private int _launcherIndex; // Make sure this index never goes over the list index private WheelTrail[] _trails; // also used as a lock private WheelTrailState _currentTrailState = WheelTrailState.Static_None; - private static List _launcherTypes = - [ - new RrLauncher(), - //GoogleLauncher.Instance - ]; + private readonly List _launcherTypes = []; private WheelWizardStatus _status; - private MainButtonState currentButtonState => GetButtonState(_status); + private MainButtonState CurrentButtonState => GetButtonState(_status); - private static MainButtonState GetButtonState(WheelWizardStatus status) => + private MainButtonState GetButtonState(WheelWizardStatus status) => status switch { WheelWizardStatus.Loading => new(Common.State_Loading, Button.ButtonsVariantType.Default, "Spinner", null, false), @@ -68,18 +73,17 @@ private static MainButtonState GetButtonState(WheelWizardStatus status) => public HomePage() { InitializeComponent(); + _launcherTypes.Add(RrLauncher); PopulateGameModeDropdown(); UpdatePage(); _trails = [HomeTrail1, HomeTrail2, HomeTrail3, HomeTrail4, HomeTrail5]; - App.Services.GetService()?.Random.Shared.Shuffle(_trails); - // We have to do it like `App.Service.GetService`. We cant make use of `private IRandomSystem Random { get; set; } = null!;` here - // This is because this HomePage is always loaded first + RandomSystem.Random.Shared.Shuffle(_trails); } private void UpdatePage() { - GameTitle.Text = currentLauncher.GameTitle; + GameTitle.Text = CurrentLauncher.GameTitle; UpdateActionButton(); } @@ -89,29 +93,29 @@ private void DolphinButton_OnClick(object? sender, RoutedEventArgs e) DisableAllButtonsTemporarily(); } - private static void LaunchGame() => currentLauncher.Launch(); + private void LaunchGame() => _ = CurrentLauncher.Launch(); - private static void NavigateToSettings() => NavigationManager.NavigateTo(); + private void NavigateToSettings() => NavigationManager.NavigateTo(); - private static async void Download() + private async void Download() { ViewUtils.GetLayout().SetInteractable(false); - await currentLauncher.Install(); + await CurrentLauncher.Install(); ViewUtils.GetLayout().SetInteractable(true); NavigationManager.NavigateTo(); } - private static async void Update() + private async void Update() { ViewUtils.GetLayout().SetInteractable(false); - await currentLauncher.Update(); + await CurrentLauncher.Update(); ViewUtils.GetLayout().SetInteractable(true); NavigationManager.NavigateTo(); } private void PlayButton_Click(object? sender, RoutedEventArgs e) { - currentButtonState?.OnClick?.Invoke(); + CurrentButtonState?.OnClick?.Invoke(); PlayActivateAnimation(); UpdateActionButton(); DisableAllButtonsTemporarily(); @@ -141,9 +145,9 @@ private void PopulateGameModeDropdown() private async void UpdateActionButton() { _status = WheelWizardStatus.Loading; - SetButtonState(currentButtonState); - _status = await currentLauncher.GetCurrentStatus(); - SetButtonState(currentButtonState); + SetButtonState(CurrentButtonState); + _status = await CurrentLauncher.GetCurrentStatus(); + SetButtonState(CurrentButtonState); } private void DisableAllButtonsTemporarily() @@ -155,7 +159,7 @@ private void DisableAllButtonsTemporarily() { Dispatcher.UIThread.InvokeAsync(() => { - SetButtonState(currentButtonState); + SetButtonState(CurrentButtonState); return CompleteGrid.IsEnabled = true; }); }); @@ -168,7 +172,7 @@ private void SetButtonState(MainButtonState state) PlayButton.IsEnabled = state.OnClick != null; if (Application.Current != null && Application.Current.FindResource(state.IconName) is Geometry geometry) PlayButton.IconData = geometry; - DolphinButton.IsEnabled = state.SubButtonsEnabled && SettingsHelper.PathsSetupCorrectly(); + DolphinButton.IsEnabled = state.SubButtonsEnabled && SettingsService.PathsSetupCorrectly(); if (_status == WheelWizardStatus.Ready) PlayEntranceAnimation(); @@ -188,7 +192,7 @@ private async void PlayEntranceAnimation() // If the animations are disabled, it will never play the entrance animation // The entrance animation is also the only one that makes the wheels visible, meaning hat if this one does not play // all the other animations are all also impossible to play - if (!(bool)SettingsManager.ENABLE_ANIMATIONS.Get()) + if (!SettingsService.Get(SettingsService.ENABLE_ANIMATIONS)) return; var allowedToRun = WaitForWheelTrailState( @@ -219,7 +223,7 @@ private async void PlayEntranceAnimation() private async void PlayActivateAnimation() { - if (!(bool)SettingsManager.ENABLE_ANIMATIONS.Get()) + if (!SettingsService.Get(SettingsService.ENABLE_ANIMATIONS)) return; var allowedToRun = WaitForWheelTrailState( diff --git a/WheelWizard/Views/Pages/KitchenSinkPage.axaml b/WheelWizard/Views/Pages/KitchenSinkPage.axaml new file mode 100644 index 00000000..d9d0743b --- /dev/null +++ b/WheelWizard/Views/Pages/KitchenSinkPage.axaml @@ -0,0 +1,116 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/WheelWizard/Views/Pages/KitchenSinkPage.axaml.cs b/WheelWizard/Views/Pages/KitchenSinkPage.axaml.cs new file mode 100644 index 00000000..78a48a41 --- /dev/null +++ b/WheelWizard/Views/Pages/KitchenSinkPage.axaml.cs @@ -0,0 +1,233 @@ +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Interactivity; +using Avalonia.Layout; +using WheelWizard.Views.Components; +using WheelWizard.Views.Pages.KitchenSink; + +namespace WheelWizard.Views.Pages; + +public partial class KitchenSinkPage : UserControlBase +{ + private readonly SectionDefinition[] _sections = + [ + SectionDefinition.Create(), + SectionDefinition.Create(), + SectionDefinition.Create(), + SectionDefinition.Create(), + SectionDefinition.Create(), + SectionDefinition.Create(), + SectionDefinition.Create(), + SectionDefinition.Create(), + ]; + + // Add more configurable section groups here. + private readonly SectionCollectionDefinition[] _sectionCollections = + [ + SectionCollectionDefinition.Create( + "Basic Components", + typeof(KitchenSinkTextStylesPage), + typeof(KitchenSinkToggleButtonsPage), + typeof(KitchenSinkInputFieldsPage), + typeof(KitchenSinkDropdownsPage), + typeof(KitchenSinkButtonsPage), + typeof(KitchenSinkIconLabelsPage), + typeof(KitchenSinkStateBoxesPage), + typeof(KitchenSinkIconsPage) + ), + ]; + + private readonly Dictionary _allSectionContainersById = []; + private readonly List _allSectionBorders = []; + private Border? _singleSectionBorder; + private bool _isInitializing; + private bool _useNeutral900Blocks = true; + + public KitchenSinkPage() + { + InitializeComponent(); + BuildAllSections(); + PopulateSections(); + ApplyBlockBackgroundMode(); + } + + private void BuildAllSections() + { + _allSectionContainersById.Clear(); + _allSectionBorders.Clear(); + AllSectionsContainer.Children.Clear(); + + foreach (var section in _sections) + { + var sectionView = section.CreatePage(); + var sectionContainer = CreateSectionContainer(section.Label, section.Tooltip, sectionView, out var sectionBorder); + _allSectionContainersById[section.Id] = sectionContainer; + _allSectionBorders.Add(sectionBorder); + AllSectionsContainer.Children.Add(sectionContainer); + } + } + + private static Border CreateSectionContainer( + string sectionName, + string? sectionTooltip, + Control sectionContent, + out Border sectionBorder + ) + { + var header = new FormFieldLabel { Text = sectionName, TipText = sectionTooltip ?? string.Empty }; + var divider = new Border(); + divider.Classes.Add("KitchenSinkSectionDivider"); + var body = new StackPanel { Spacing = 8 }; + body.Children.Add(header); + body.Children.Add(divider); + body.Children.Add(sectionContent); + + sectionBorder = new Border { Child = body }; + sectionBorder.Classes.Add("KitchenSinkSectionBlock"); + sectionBorder.VerticalAlignment = VerticalAlignment.Top; + sectionBorder.HorizontalAlignment = HorizontalAlignment.Stretch; + return sectionBorder; + } + + private void PopulateSections() + { + _isInitializing = true; + SectionDropdown.Items.Clear(); + + foreach (var collection in _sectionCollections) + SectionDropdown.Items.Add(SectionDropdownItem.ForCollection(collection)); + + foreach (var section in _sections) + SectionDropdown.Items.Add(SectionDropdownItem.ForSection(section)); + + SectionDropdown.SelectedIndex = SectionDropdown.Items.Count > 0 ? 0 : -1; + _isInitializing = false; + + ApplySelectedSection(); + } + + private void SectionDropdown_OnSelectionChanged(object? sender, SelectionChangedEventArgs e) + { + if (_isInitializing) + return; + + ApplySelectedSection(); + } + + private void BackgroundSwitch_OnIsCheckedChanged(object? sender, RoutedEventArgs e) + { + _useNeutral900Blocks = BackgroundSwitch.IsChecked == true; + ApplyBlockBackgroundMode(); + } + + private void ApplySelectedSection() + { + var selectedItem = SectionDropdown.SelectedItem as SectionDropdownItem; + if (selectedItem == null) + { + AllSectionsScrollViewer.IsVisible = false; + SectionContent.IsVisible = false; + SectionContent.Content = null; + _singleSectionBorder = null; + return; + } + + if (selectedItem.Collection is { } collection) + { + ShowCollection(collection); + return; + } + + if (string.IsNullOrWhiteSpace(selectedItem.SectionId)) + return; + + ShowSingleSection(selectedItem.SectionId); + } + + private void ShowCollection(SectionCollectionDefinition collection) + { + foreach (var section in _sections) + { + if (_allSectionContainersById.TryGetValue(section.Id, out var sectionContainer)) + sectionContainer.IsVisible = collection.Contains(section.Id); + } + + AllSectionsScrollViewer.IsVisible = true; + SectionContent.IsVisible = false; + _singleSectionBorder = null; + SectionContent.Content = null; + + ApplyBlockBackgroundMode(); + } + + private void ShowSingleSection(string sectionId) + { + var sectionIndex = Array.FindIndex(_sections, x => x.Id == sectionId); + if (sectionIndex < 0) + { + SectionContent.Content = null; + return; + } + + var section = _sections[sectionIndex]; + var sectionContainer = CreateSectionContainer(section.Label, section.Tooltip, section.CreatePage(), out var sectionBorder); + _singleSectionBorder = sectionBorder; + SectionContent.Content = new ScrollViewer + { + HorizontalScrollBarVisibility = ScrollBarVisibility.Disabled, + VerticalScrollBarVisibility = ScrollBarVisibility.Auto, + HorizontalContentAlignment = HorizontalAlignment.Stretch, + VerticalContentAlignment = VerticalAlignment.Top, + Content = sectionContainer, + }; + + AllSectionsScrollViewer.IsVisible = false; + SectionContent.IsVisible = true; + + ApplyBlockBackgroundMode(); + } + + private void ApplyBlockBackgroundMode() + { + foreach (var border in _allSectionBorders) + ApplyBackgroundClass(border); + + if (_singleSectionBorder != null) + ApplyBackgroundClass(_singleSectionBorder); + } + + private void ApplyBackgroundClass(Border border) + { + border.Classes.Set("BlockBackground900", _useNeutral900Blocks); + } + + private readonly record struct SectionDefinition(string Id, string Label, string? Tooltip, Func CreatePage) + { + public static SectionDefinition Create() + where T : KitchenSinkSectionPageBase, new() + { + var metadataInstance = new T(); + return new(typeof(T).Name, metadataInstance.SectionName, metadataInstance.SectionTooltip, () => new T()); + } + } + + private readonly record struct SectionCollectionDefinition(string Label, HashSet SectionIds) + { + public bool Contains(string sectionId) => SectionIds.Contains(sectionId); + + public static SectionCollectionDefinition Create(string label, params Type[] sectionTypes) + { + var sectionIds = sectionTypes.Select(static sectionType => sectionType.Name).ToHashSet(StringComparer.Ordinal); + return new(label, sectionIds); + } + } + + private sealed record SectionDropdownItem(string Label, string? SectionId, SectionCollectionDefinition? Collection) + { + public static SectionDropdownItem ForCollection(SectionCollectionDefinition collection) => new(collection.Label, null, collection); + + public static SectionDropdownItem ForSection(SectionDefinition section) => new(section.Label, section.Id, null); + + public override string ToString() => Label; + } +} diff --git a/WheelWizard/Views/Pages/LeaderboardPage.axaml b/WheelWizard/Views/Pages/LeaderboardPage.axaml new file mode 100644 index 00000000..38b94aa2 --- /dev/null +++ b/WheelWizard/Views/Pages/LeaderboardPage.axaml @@ -0,0 +1,316 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/WheelWizard/Views/Pages/LeaderboardPage.axaml.cs b/WheelWizard/Views/Pages/LeaderboardPage.axaml.cs new file mode 100644 index 00000000..acef0734 --- /dev/null +++ b/WheelWizard/Views/Pages/LeaderboardPage.axaml.cs @@ -0,0 +1,586 @@ +using System.Collections.ObjectModel; +using System.Collections.Specialized; +using System.ComponentModel; +using Avalonia.Controls; +using Avalonia.Interactivity; +using WheelWizard.Models; +using WheelWizard.Resources.Languages; +using WheelWizard.RrRooms; +using WheelWizard.Services.LiveData; +using WheelWizard.Settings; +using WheelWizard.Shared.DependencyInjection; +using WheelWizard.Utilities.Generators; +using WheelWizard.Views.Popups; +using WheelWizard.Views.Popups.MiiManagement; +using WheelWizard.WheelWizardData; +using WheelWizard.WheelWizardData.Domain; +using WheelWizard.WiiManagement.GameLicense; +using WheelWizard.WiiManagement.MiiManagement; +using WheelWizard.WiiManagement.MiiManagement.Domain.Mii; + +namespace WheelWizard.Views.Pages; + +public sealed record LeaderboardPlayerItem +{ + public required int Rank { get; init; } + public required string PlacementLabel { get; init; } + public required string Name { get; init; } + public required string FriendCode { get; init; } + public required string VrText { get; init; } + public Mii? Mii { get; init; } + public BadgeVariant PrimaryBadge { get; init; } + public bool HasBadge { get; init; } + public bool IsSuspicious { get; init; } + public bool IsEvenRow { get; init; } + public bool IsFriend { get; init; } + public bool IsOnline { get; init; } + + // Keep parity with RoomDetailsPage player template bindings. + public string VrDisplay => VrText; + public Mii? FirstMii => Mii; + public bool HasBadges => HasBadge; + public bool IsTopLeaderboardPlayer => true; + public string TopLabel => $"#{Rank}"; + public bool IsOpenHost => false; +} + +public partial class LeaderboardPage : UserControlBase, INotifyPropertyChanged +{ + private static readonly LeaderboardPlayerItem EmptyPodiumPlayer = new() + { + Rank = 0, + PlacementLabel = string.Empty, + Name = string.Empty, + FriendCode = string.Empty, + VrText = string.Empty, + PrimaryBadge = BadgeVariant.None, + HasBadge = false, + IsSuspicious = false, + IsEvenRow = true, + }; + + private CancellationTokenSource? _loadCts; + + [Inject] + private IRrLeaderboardSingletonService LeaderboardService { get; set; } = null!; + + [Inject] + private IWhWzDataSingletonService BadgeService { get; set; } = null!; + + [Inject] + private IGameLicenseSingletonService GameDataService { get; set; } = null!; + + [Inject] + private ISettingsManager SettingsManager { get; set; } = null!; + + private bool _hasLoadedOnce; + private bool _isLoading; + private bool _hasError; + private bool _hasNoData; + private bool _hasData; + private string _errorMessage = string.Empty; + private int _loadedPlayerCount; + private LeaderboardPlayerItem? _podiumFirst; + private LeaderboardPlayerItem? _podiumSecond; + private LeaderboardPlayerItem? _podiumThird; + + public ObservableCollection RemainingPlayers { get; } = []; + + public bool HasError + { + get => _hasError; + private set + { + if (_hasError == value) + return; + _hasError = value; + OnPropertyChanged(nameof(HasError)); + } + } + + public bool IsLoading + { + get => _isLoading; + private set + { + if (_isLoading == value) + return; + _isLoading = value; + OnPropertyChanged(nameof(IsLoading)); + } + } + + public bool HasNoData + { + get => _hasNoData; + private set + { + if (_hasNoData == value) + return; + _hasNoData = value; + OnPropertyChanged(nameof(HasNoData)); + } + } + + public bool HasData + { + get => _hasData; + private set + { + if (_hasData == value) + return; + _hasData = value; + OnPropertyChanged(nameof(HasData)); + } + } + + public string ErrorMessage + { + get => _errorMessage; + private set + { + if (_errorMessage == value) + return; + _errorMessage = value; + OnPropertyChanged(nameof(ErrorMessage)); + } + } + + public string TotalPlayerCountText => _loadedPlayerCount.ToString(); + + public string RemainingCountText => $"{RemainingPlayers.Count} players"; + + public LeaderboardPlayerItem? PodiumFirst + { + get => _podiumFirst ?? EmptyPodiumPlayer; + private set => SetPodiumPlayer(ref _podiumFirst, value, nameof(PodiumFirst), nameof(HasPodiumFirst)); + } + + public LeaderboardPlayerItem? PodiumSecond + { + get => _podiumSecond ?? EmptyPodiumPlayer; + private set => SetPodiumPlayer(ref _podiumSecond, value, nameof(PodiumSecond), nameof(HasPodiumSecond)); + } + + public LeaderboardPlayerItem? PodiumThird + { + get => _podiumThird ?? EmptyPodiumPlayer; + private set => SetPodiumPlayer(ref _podiumThird, value, nameof(PodiumThird), nameof(HasPodiumThird)); + } + + public bool HasPodiumFirst => _podiumFirst != null; + public bool HasPodiumSecond => _podiumSecond != null; + public bool HasPodiumThird => _podiumThird != null; + + public LeaderboardPage() + { + InitializeComponent(); + DataContext = this; + RemainingPlayers.CollectionChanged += RemainingPlayers_OnCollectionChanged; + + Loaded += LeaderboardPage_Loaded; + Unloaded += LeaderboardPage_Unloaded; + } + + private async void LeaderboardPage_Loaded(object? sender, RoutedEventArgs e) + { + if (_hasLoadedOnce) + return; + + _hasLoadedOnce = true; + await ReloadLeaderboardAsync(); + } + + private void LeaderboardPage_Unloaded(object? sender, RoutedEventArgs e) + { + CancelCurrentLoad(); + } + + private void RemainingPlayers_OnCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) + { + OnPropertyChanged(nameof(RemainingCountText)); + } + + private async void RetryButton_OnClick(object? sender, RoutedEventArgs e) + { + await ReloadLeaderboardAsync(); + } + + private async Task ReloadLeaderboardAsync() + { + CancelCurrentLoad(); + _loadCts = new(); + var cancellationToken = _loadCts.Token; + + SetLoadingState(); + ClearLeaderboardData(); + await Task.Yield(); + + var leaderboardResult = await LeaderboardService.GetTopPlayersAsync(50); + if (cancellationToken.IsCancellationRequested) + return; + + if (leaderboardResult.IsFailure) + { + SetErrorState(leaderboardResult.Error?.Message ?? "Unable to fetch leaderboard."); + return; + } + + var orderedEntries = leaderboardResult + .Value.Select((entry, index) => new { Entry = entry, Rank = ResolveRank(entry, index) }) + .OrderBy(entry => entry.Rank) + .Take(50) + .ToList(); + + var friendProfileIds = GameDataService + .ActiveCurrentFriends.Select(friend => FriendCodeGenerator.FriendCodeToProfileId(friend.FriendCode)) + .Where(profileId => profileId != 0) + .ToHashSet(); + var onlineProfileIds = RRLiveRooms + .Instance.CurrentRooms.SelectMany(room => room.Players) + .Select(player => FriendCodeGenerator.FriendCodeToProfileId(player.FriendCode)) + .Where(profileId => profileId != 0) + .ToHashSet(); + + if (orderedEntries.Count == 0) + { + SetEmptyState(); + return; + } + + List mappedPlayers; + try + { + mappedPlayers = await Task.Run( + () => + orderedEntries + .Select( + (entry, index) => CreateLeaderboardPlayer(entry.Entry, entry.Rank, index, friendProfileIds, onlineProfileIds) + ) + .ToList(), + cancellationToken + ); + } + catch (OperationCanceledException) + { + return; + } + + if (cancellationToken.IsCancellationRequested) + return; + + _loadedPlayerCount = mappedPlayers.Count; + OnPropertyChanged(nameof(TotalPlayerCountText)); + + PodiumFirst = mappedPlayers.ElementAtOrDefault(0); + PodiumSecond = mappedPlayers.ElementAtOrDefault(1); + PodiumThird = mappedPlayers.ElementAtOrDefault(2); + + if (cancellationToken.IsCancellationRequested) + return; + + foreach (var player in mappedPlayers.Skip(3)) + { + RemainingPlayers.Add(player); + } + + SetDataState(); + } + + private LeaderboardPlayerItem CreateLeaderboardPlayer( + RwfcLeaderboardEntry entry, + int rank, + int index, + IReadOnlySet friendProfileIds, + IReadOnlySet onlineProfileIds + ) + { + var friendCode = entry.FriendCode ?? string.Empty; + var profileId = FriendCodeGenerator.FriendCodeToProfileId(friendCode); + var badges = string.IsNullOrWhiteSpace(friendCode) ? [] : BadgeService.GetBadges(friendCode); + var primaryBadge = badges.FirstOrDefault(BadgeVariant.None); + + return new() + { + Rank = rank, + PlacementLabel = GetPlacementLabel(rank), + Name = string.IsNullOrWhiteSpace(entry.Name) ? "Unknown Player" : entry.Name, + FriendCode = friendCode, + VrText = entry.Vr?.ToString("N0") ?? "--", + Mii = DeserializeMii(entry.MiiData), + PrimaryBadge = primaryBadge, + HasBadge = primaryBadge != BadgeVariant.None, + IsSuspicious = entry.IsSuspicious, + IsEvenRow = index % 2 == 0, + IsFriend = profileId != 0 && friendProfileIds.Contains(profileId), + IsOnline = profileId != 0 && onlineProfileIds.Contains(profileId), + }; + } + + private static int ResolveRank(RwfcLeaderboardEntry entry, int index) + { + if (entry.Rank is > 0 and <= 50000) + return entry.Rank.Value; + + if (entry.ActiveRank is > 0 and <= 50000) + return entry.ActiveRank.Value; + + return index + 1; + } + + private static string GetPlacementLabel(int rank) => + rank switch + { + 1 => "Champion", + 2 => "2nd Place", + 3 => "3rd Place", + _ => $"#{rank}", + }; + + private static Mii? DeserializeMii(string? miiData) + { + if (string.IsNullOrWhiteSpace(miiData)) + return null; + + // Mii block payload should be ~100 base64 chars (74 bytes decoded). + // Guarding the size avoids expensive decode failures for large non-Mii payloads. + if (miiData.Length is < 90 or > 120) + return null; + + var buffer = new byte[MiiSerializer.MiiBlockSize]; + if (!Convert.TryFromBase64String(miiData, buffer, out var bytesWritten) || bytesWritten != MiiSerializer.MiiBlockSize) + return null; + + var result = MiiSerializer.Deserialize(buffer); + return result.IsSuccess ? result.Value : null; + } + + private void SetPodiumPlayer( + ref LeaderboardPlayerItem? field, + LeaderboardPlayerItem? value, + string propertyName, + string hasPropertyName + ) + { + if (field == value) + return; + + field = value; + OnPropertyChanged(propertyName); + OnPropertyChanged(hasPropertyName); + } + + private void SetLoadingState() + { + IsLoading = true; + HasError = false; + HasNoData = false; + HasData = false; + ErrorMessage = string.Empty; + } + + private void SetErrorState(string message) + { + IsLoading = false; + HasError = true; + HasNoData = false; + HasData = false; + ErrorMessage = string.IsNullOrWhiteSpace(message) ? "Failed to load leaderboard." : message; + } + + private void SetEmptyState() + { + IsLoading = false; + HasError = false; + HasNoData = true; + HasData = false; + } + + private void SetDataState() + { + IsLoading = false; + HasError = false; + HasNoData = false; + HasData = true; + } + + private void ClearLeaderboardData() + { + PodiumFirst = null; + PodiumSecond = null; + PodiumThird = null; + RemainingPlayers.Clear(); + _loadedPlayerCount = 0; + + OnPropertyChanged(nameof(TotalPlayerCountText)); + OnPropertyChanged(nameof(RemainingCountText)); + } + + private void CancelCurrentLoad() + { + if (_loadCts == null) + return; + + _loadCts.Cancel(); + _loadCts.Dispose(); + _loadCts = null; + } + + private void CopyFriendCode_OnClick(object sender, RoutedEventArgs e) + { + var player = GetContextPlayer(sender); + if (player == null || string.IsNullOrWhiteSpace(player.FriendCode)) + return; + + TopLevel.GetTopLevel(this)?.Clipboard?.SetTextAsync(player.FriendCode); + ViewUtils.ShowSnackbar(Phrases.SnackbarSuccess_CopiedFC); + } + + private void OpenCarousel_OnClick(object sender, RoutedEventArgs e) + { + var player = GetContextPlayer(sender); + if (player?.FirstMii == null) + return; + + new MiiCarouselWindow().SetMii(player.FirstMii).Show(); + } + + private void ViewProfile_OnClick(object sender, RoutedEventArgs e) + { + var player = GetContextPlayer(sender); + if (player == null || string.IsNullOrWhiteSpace(player.FriendCode)) + return; + + new PlayerProfileWindow(player.FriendCode).Show(); + } + + private async void AddFriend_OnClick(object sender, RoutedEventArgs e) + { + var player = GetContextPlayer(sender); + if (player == null) + return; + + if (player.FirstMii == null) + { + ViewUtils.ShowSnackbar("This player has no valid Mii data.", ViewUtils.SnackbarType.Warning); + return; + } + + var focusedUserIndex = SettingsManager.Get(SettingsManager.FOCUSED_USER); + if (focusedUserIndex is < 0 or > 3) + { + ViewUtils.ShowSnackbar("Invalid license selected.", ViewUtils.SnackbarType.Warning); + return; + } + + var activeUserPid = FriendCodeGenerator.FriendCodeToProfileId(GameDataService.ActiveUser.FriendCode); + if (activeUserPid == 0) + { + ViewUtils.ShowSnackbar("Select a valid license before adding friends.", ViewUtils.SnackbarType.Warning); + return; + } + + if (GameDataService.ActiveCurrentFriends.Count >= 30) + { + ViewUtils.ShowSnackbar("Your friend list is full.", ViewUtils.SnackbarType.Warning); + return; + } + + var normalizedFriendCodeResult = NormalizeFriendCode(player.FriendCode); + if (normalizedFriendCodeResult.IsFailure) + { + ViewUtils.ShowSnackbar(normalizedFriendCodeResult.Error.Message, ViewUtils.SnackbarType.Warning); + return; + } + + var normalizedFriendCode = normalizedFriendCodeResult.Value; + var friendProfileId = FriendCodeGenerator.FriendCodeToProfileId(normalizedFriendCode); + if (activeUserPid == friendProfileId) + { + ViewUtils.ShowSnackbar("You cannot add your own friend code.", ViewUtils.SnackbarType.Warning); + return; + } + + var duplicateFriend = GameDataService.ActiveCurrentFriends.Any(friend => + { + var existingPid = FriendCodeGenerator.FriendCodeToProfileId(friend.FriendCode); + return existingPid != 0 && existingPid == friendProfileId; + }); + + if (duplicateFriend) + { + ViewUtils.ShowSnackbar("This friend is already in your list.", ViewUtils.SnackbarType.Warning); + return; + } + + var profile = new PlayerProfileResponse + { + Name = player.Name, + FriendCode = normalizedFriendCode, + Vr = int.TryParse(player.VrText.Replace(",", string.Empty), out var vr) ? vr : 0, + }; + + var shouldAdd = await new AddFriendConfirmationWindow(profile, player.FirstMii).AwaitAnswer(); + if (!shouldAdd) + return; + + var addResult = GameDataService.AddFriend(focusedUserIndex, normalizedFriendCode, player.FirstMii, (uint)Math.Max(profile.Vr, 0)); + + if (addResult.IsFailure) + { + ViewUtils.ShowSnackbar(addResult.Error.Message, ViewUtils.SnackbarType.Warning); + return; + } + + ViewUtils.GetLayout().UpdateFriendCount(); + ViewUtils.ShowSnackbar($"Added {player.Name} to your friend list."); + } + + private void JoinRoom_OnClick(string friendCode) + { + if (string.IsNullOrWhiteSpace(friendCode)) + return; + + foreach (var room in RRLiveRooms.Instance.CurrentRooms) + { + if (room.Players.All(player => player.FriendCode != friendCode)) + continue; + + NavigationManager.NavigateTo(room); + return; + } + + ViewUtils.ShowSnackbar("Could not find an active room for this player.", ViewUtils.SnackbarType.Warning); + } + + private static LeaderboardPlayerItem? GetContextPlayer(object sender) + { + if (sender is not Control control) + return null; + return control.DataContext as LeaderboardPlayerItem; + } + + private static OperationResult NormalizeFriendCode(string friendCode) + { + if (string.IsNullOrWhiteSpace(friendCode)) + return Fail("Friend code cannot be empty."); + + var digits = new string(friendCode.Where(char.IsDigit).ToArray()); + if (digits.Length != 12 || !ulong.TryParse(digits, out _)) + return Fail("Friend code must be exactly 12 digits."); + + var formatted = $"{digits[..4]}-{digits.Substring(4, 4)}-{digits.Substring(8, 4)}"; + var profileId = FriendCodeGenerator.FriendCodeToProfileId(formatted); + if (profileId == 0) + return Fail("Invalid friend code."); + + return formatted; + } + + public new event PropertyChangedEventHandler? PropertyChanged; + + private void OnPropertyChanged(string propertyName) + { + PropertyChanged?.Invoke(this, new(propertyName)); + } +} diff --git a/WheelWizard/Views/Pages/MiiListPage.axaml b/WheelWizard/Views/Pages/MiiListPage.axaml index 0029360e..6240715a 100644 --- a/WheelWizard/Views/Pages/MiiListPage.axaml +++ b/WheelWizard/Views/Pages/MiiListPage.axaml @@ -3,7 +3,7 @@ xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:components="clr-namespace:WheelWizard.Views.Components" - xmlns:behaviorComponent="clr-namespace:WheelWizard.Views.BehaviorComponent" + xmlns:behaviorComponent="clr-namespace:WheelWizard.Views.Patterns" xmlns:lang="clr-namespace:WheelWizard.Resources.Languages" mc:Ignorable="d" d:DesignWidth="456" d:DesignHeight="776" x:Class="WheelWizard.Views.Pages.MiiListPage"> @@ -83,9 +83,9 @@ - + - \ No newline at end of file + diff --git a/WheelWizard/Views/Pages/MiiListPage.axaml.cs b/WheelWizard/Views/Pages/MiiListPage.axaml.cs index a216fd91..90b70267 100644 --- a/WheelWizard/Views/Pages/MiiListPage.axaml.cs +++ b/WheelWizard/Views/Pages/MiiListPage.axaml.cs @@ -9,10 +9,11 @@ using WheelWizard.Helpers; using WheelWizard.Resources.Languages; using WheelWizard.Services; -using WheelWizard.Services.Settings; +using WheelWizard.Settings; using WheelWizard.Shared.DependencyInjection; using WheelWizard.Shared.MessageTranslations; using WheelWizard.Views.Components; +using WheelWizard.Views.Patterns; using WheelWizard.Views.Popups.Generic; using WheelWizard.Views.Popups.MiiManagement; using WheelWizard.WiiManagement; @@ -38,6 +39,9 @@ public partial class MiiListPage : UserControlBase [Inject] private IRandomSystem Random { get; set; } = null!; + [Inject] + private ISettingsManager SettingsService { get; set; } = null!; + public MiiListPage() { InitializeComponent(); @@ -46,7 +50,7 @@ public MiiListPage() var miiDbExists = MiiDbService.Exists(); if (!miiDbExists) { - if (SettingsHelper.PathsSetupCorrectly()) + if (SettingsService.PathsSetupCorrectly()) { var creationResult = MiiRepositoryService.ForceCreateDatabase(); if (creationResult.IsFailure) @@ -418,7 +422,7 @@ private async void CreateNewMii() if (!save) return; - var result = MiiDbService.AddToDatabase(window.Mii, (string)SettingsManager.MACADDRESS.Get()); + var result = MiiDbService.AddToDatabase(window.Mii, SettingsService.Get(SettingsService.MACADDRESS)); if (result.IsFailure) { ViewUtils.ShowSnackbar( @@ -434,7 +438,7 @@ private async void CreateNewMii() private void DuplicateMii(Mii[] miis) { //assuming the mac address is already set correctly - var macAddress = (string)SettingsManager.MACADDRESS.Get(); + var macAddress = SettingsService.Get(SettingsService.MACADDRESS); foreach (var mii in miis) { var result = MiiDbService.AddToDatabase(mii, macAddress); diff --git a/WheelWizard/Views/Pages/ModsPage.axaml b/WheelWizard/Views/Pages/ModsPage.axaml index 040b6b4e..2dfc4105 100644 --- a/WheelWizard/Views/Pages/ModsPage.axaml +++ b/WheelWizard/Views/Pages/ModsPage.axaml @@ -4,6 +4,7 @@ xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:lang="clr-namespace:WheelWizard.Resources.Languages" xmlns:components="clr-namespace:WheelWizard.Views.Components" + xmlns:patterns="clr-namespace:WheelWizard.Views.Patterns" xmlns:pages="clr-namespace:WheelWizard.Views.Pages" mc:Ignorable="d" d:DesignWidth="656" d:DesignHeight="876" x:Class="WheelWizard.Views.Pages.ModsPage" @@ -93,28 +94,36 @@ VerticalAlignment="Bottom" /> - + + - + + + + + + Grid.Column="2" VerticalAlignment="Center" Margin="10,0,0,0" /> - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - \ No newline at end of file + diff --git a/WheelWizard/Views/Pages/ModsPage.axaml.cs b/WheelWizard/Views/Pages/ModsPage.axaml.cs index 5301d174..a051c7b2 100644 --- a/WheelWizard/Views/Pages/ModsPage.axaml.cs +++ b/WheelWizard/Views/Pages/ModsPage.axaml.cs @@ -1,11 +1,16 @@ using System.Collections.ObjectModel; using System.ComponentModel; +using Avalonia; using Avalonia.Controls; using Avalonia.Input; using Avalonia.Interactivity; -using WheelWizard.Models.Settings; +using Avalonia.Layout; +using Avalonia.Media; +using Avalonia.VisualTree; +using WheelWizard.Models.Mods; using WheelWizard.Services; -using WheelWizard.Services.Settings; +using WheelWizard.Settings; +using WheelWizard.Shared.DependencyInjection; using WheelWizard.Shared.MessageTranslations; using WheelWizard.Views.Popups.Generic; using WheelWizard.Views.Popups.ModManagement; @@ -16,6 +21,9 @@ public record ModListItem(Mod Mod, bool IsLowest, bool IsHighest); public partial class ModsPage : UserControlBase, INotifyPropertyChanged { + [Inject] + private ISettingsManager SettingsService { get; set; } = null!; + public ModManager ModManager => ModManager.Instance; public ObservableCollection Mods => @@ -42,26 +50,59 @@ public bool HasMods } } + // Drag-and-drop state + private bool _isDragPending; + private bool _isDragging; + private Point _dragStartPoint; + private ModListItem? _draggedItem; + private int _dragStartIndex; + private int _currentDropIndex; + private ListBoxItem? _draggedListBoxItem; + private double _dragOffsetY; + private Border? _dragAdorner; + private Border? _dropIndicatorLine; + private IPointer? _capturedPointer; + private const double DragThreshold = 5.0; + public ModsPage() { InitializeComponent(); DataContext = this; + Focusable = true; ModManager.PropertyChanged += OnModsChanged; ModManager.ReloadAsync(); SetModsViewVariant(); + + // Apply priority edits as soon as the user clicks anywhere outside the textbox. + AddHandler(PointerPressedEvent, OnPagePointerPressed, RoutingStrategies.Tunnel, true); + + // Wire up drag-and-drop pointer tracking + PointerMoved += OnDragPointerMoved; + PointerReleased += OnDragPointerReleased; + PointerCaptureLost += OnDragPointerCaptureLost; } private void OnModsChanged(object? sender, PropertyChangedEventArgs e) { if (e.PropertyName == nameof(ModManager.Mods)) OnModsChanged(); + else if (e.PropertyName == nameof(Mod.IsEnabled)) + UpdateEnableAllCheckboxState(); } private void OnModsChanged() { + if (_isDragging) + return; // Suppress UI updates during drag to prevent stale container references + ListItemCount.Text = ModManager.Mods.Count.ToString(); OnPropertyChanged(nameof(Mods)); - HasMods = Mods.Count > 0; + HasMods = ModManager.Mods.Count > 0; + UpdateEnableAllCheckboxState(); + } + + private void UpdateEnableAllCheckboxState() + { EnableAllCheckbox.IsChecked = !ModManager.Mods.Select(mod => mod.IsEnabled).Contains(false); } @@ -78,7 +119,8 @@ private void ImportMod_Click(object sender, RoutedEventArgs e) private void RenameMod_Click(object sender, RoutedEventArgs e) { - if (ModsListBox.SelectedItem is not ModListItem selectedMod) + var selectedMod = GetContextModListItem(sender); + if (selectedMod == null) return; ModManager.RenameMod(selectedMod.Mod); @@ -86,7 +128,8 @@ private void RenameMod_Click(object sender, RoutedEventArgs e) private void DeleteMod_Click(object sender, RoutedEventArgs e) { - if (ModsListBox.SelectedItem is not ModListItem selectedMod) + var selectedMod = GetContextModListItem(sender); + if (selectedMod == null) return; ModManager.DeleteMod(selectedMod.Mod); @@ -94,7 +137,8 @@ private void DeleteMod_Click(object sender, RoutedEventArgs e) private void OpenFolder_Click(object sender, RoutedEventArgs e) { - if (ModsListBox.SelectedItem is not ModListItem selectedMod) + var selectedMod = GetContextModListItem(sender); + if (selectedMod == null) return; ModManager.OpenModFolder(selectedMod.Mod); @@ -102,7 +146,8 @@ private void OpenFolder_Click(object sender, RoutedEventArgs e) private void ViewMod_Click(object sender, RoutedEventArgs e) { - if (ModsListBox.SelectedItem is not ModListItem selectedMod) + var selectedMod = GetContextModListItem(sender); + if (selectedMod == null) { // You actually never see this error, however, if for some unknown reason it happens, we don't want to disregard it MessageTranslationHelper.ShowMessage(MessageTranslation.Warning_CantViewMod_SomethingWrong); @@ -120,6 +165,16 @@ private void ViewMod_Click(object sender, RoutedEventArgs e) modPopup.ShowDialog(); } + /// + /// Resolves the ModListItem from either a grid context menu (DataContext) or ListBox selection. + /// + private ModListItem? GetContextModListItem(object? sender) + { + if (sender is MenuItem { DataContext: ModListItem item }) + return item; + return ModsListBox.SelectedItem as ModListItem; + } + private void ToggleButton_OnIsCheckedChanged(object? sender, RoutedEventArgs e) { ModManager.ToggleAllMods(EnableAllCheckbox.IsChecked == true); @@ -179,15 +234,15 @@ private void ButtonDown_OnClick(object? sender, RoutedEventArgs e) private void ToggleModsPageView_OnClick(object? sender, RoutedEventArgs e) { - var current = (bool)SettingsManager.PREFERS_MODS_ROW_VIEW.Get(); - SettingsManager.PREFERS_MODS_ROW_VIEW.Set(!current); + var current = SettingsService.Get(SettingsService.PREFERS_MODS_ROW_VIEW); + SettingsService.Set(SettingsService.PREFERS_MODS_ROW_VIEW, !current); SetModsViewVariant(); } private void SetModsViewVariant() { Control[] elementsToSwapClasses = [ToggleButton, ModsListBox]; - var asRows = (bool)SettingsManager.PREFERS_MODS_ROW_VIEW.Get(); + var asRows = SettingsService.Get(SettingsService.PREFERS_MODS_ROW_VIEW); foreach (var elementToSwapClass in elementsToSwapClasses) { @@ -201,6 +256,10 @@ private void SetModsViewVariant() else elementToSwapClass.Classes.Remove("Rows"); } + + // Toggle between list view (Blocks/arrows mode) and grid view (Rows/priority text mode) + ModsListBox.IsVisible = !asRows; + ModsGridView.IsVisible = asRows; } private void PriorityText_OnKeyDown(object? sender, KeyEventArgs e) @@ -210,6 +269,365 @@ private void PriorityText_OnKeyDown(object? sender, KeyEventArgs e) ViewUtils.FindParent(e.Source)?.Focus(); } + private void OnPagePointerPressed(object? sender, PointerPressedEventArgs e) + { + if (!e.GetCurrentPoint(this).Properties.IsLeftButtonPressed) + return; + + if (e.Source is TextBox || ViewUtils.FindParent(e.Source) != null) + return; + + var clickedControl = e.Source as Control ?? ViewUtils.FindParent(e.Source); + if (clickedControl?.Focusable == true) + clickedControl.Focus(NavigationMethod.Pointer, e.KeyModifiers); + else + Focus(NavigationMethod.Pointer, e.KeyModifiers); + } + + #region Drag and Drop + + private void DragHandle_PointerPressed(object? sender, PointerPressedEventArgs e) + { + if (!e.GetCurrentPoint(this).Properties.IsLeftButtonPressed) + return; + + var listBoxItem = ViewUtils.FindParent(sender); + if (listBoxItem?.Content is not ModListItem modItem) + return; + + if (Mods.Count <= 1) + return; + + ModsListBox.SelectedItem = modItem; + + _isDragPending = true; + _isDragging = false; + _dragStartPoint = e.GetPosition(this); + _draggedItem = modItem; + _draggedListBoxItem = listBoxItem; + _dragOffsetY = e.GetPosition(listBoxItem).Y; + + _capturedPointer = e.Pointer; + e.Pointer.Capture(this); + e.Handled = true; + } + + private void OnDragPointerMoved(object? sender, PointerEventArgs e) + { + if (!_isDragPending && !_isDragging) + return; + + var currentPos = e.GetPosition(this); + + if (_isDragPending && !_isDragging) + { + var delta = currentPos - _dragStartPoint; + if (Math.Abs(delta.Y) < DragThreshold && Math.Abs(delta.X) < DragThreshold) + return; + + StartDrag(currentPos); + } + + if (_isDragging) + UpdateDrag(e, currentPos); + } + + private void OnDragPointerReleased(object? sender, PointerReleasedEventArgs e) + { + if (_isDragging) + { + EndDrag(commit: true); + e.Handled = true; + } + else if (_isDragPending) + { + CancelDrag(); + } + } + + private void OnDragPointerCaptureLost(object? sender, PointerCaptureLostEventArgs e) + { + if (_isDragging || _isDragPending) + CancelDrag(); + } + + protected override void OnKeyDown(KeyEventArgs e) + { + base.OnKeyDown(e); + if (e.Key == Key.Escape && (_isDragging || _isDragPending)) + { + CancelDrag(); + e.Handled = true; + } + } + + private void StartDrag(Point currentPos) + { + _isDragPending = false; + _isDragging = true; + + _dragStartIndex = GetModIndex(_draggedItem!); + _currentDropIndex = _dragStartIndex; + + if (_draggedListBoxItem != null) + _draggedListBoxItem.Opacity = 0.3; + + CreateDragAdorner(currentPos); + CreateDropIndicator(); + } + + private void CreateDragAdorner(Point pos) + { + var title = _draggedItem?.Mod.Title ?? "Mod"; + + var content = new StackPanel + { + Orientation = Orientation.Horizontal, + VerticalAlignment = VerticalAlignment.Center, + Spacing = 10, + }; + + try + { + if (this.FindResource("Grip") is Geometry gripData) + { + content.Children.Add( + new PathIcon + { + Data = gripData, + Width = 12, + Height = 12, + Foreground = new SolidColorBrush(Color.Parse("#6C7389")), + } + ); + } + } + catch + { + // Resource not found, skip grip icon + } + + content.Children.Add( + new TextBlock + { + Text = title, + Foreground = new SolidColorBrush(Colors.White), + FontSize = 14, + VerticalAlignment = VerticalAlignment.Center, + } + ); + + _dragAdorner = new Border + { + Background = new SolidColorBrush(Color.Parse("#474B5D")), + CornerRadius = new CornerRadius(6), + Padding = new Thickness(15, 0), + Height = 50, + MinWidth = 250, + RenderTransform = new RotateTransform(1.5), + Opacity = 0.95, + BoxShadow = new BoxShadows( + new BoxShadow + { + Blur = 15, + Color = Color.Parse("#80000000"), + OffsetX = 0, + OffsetY = 4, + } + ), + Child = content, + }; + + Canvas.SetLeft(_dragAdorner, pos.X - 30); + Canvas.SetTop(_dragAdorner, pos.Y - _dragOffsetY); + + DragCanvas.Children.Add(_dragAdorner); + DragCanvas.IsVisible = true; + } + + private void CreateDropIndicator() + { + _dropIndicatorLine = new Border + { + Height = 3, + Background = new SolidColorBrush(Color.Parse("#34EAC5")), + CornerRadius = new CornerRadius(2), + IsVisible = false, + }; + + DragCanvas.Children.Add(_dropIndicatorLine); + } + + private void UpdateDrag(PointerEventArgs e, Point currentPos) + { + if (_dragAdorner != null) + { + Canvas.SetLeft(_dragAdorner, currentPos.X - 30); + Canvas.SetTop(_dragAdorner, currentPos.Y - _dragOffsetY); + } + + var dropIndex = CalculateDropIndex(e); + _currentDropIndex = dropIndex; + UpdateDropIndicator(dropIndex); + HandleAutoScroll(e); + } + + private int CalculateDropIndex(PointerEventArgs e) + { + var posInListBox = e.GetPosition(ModsListBox); + var items = GetListBoxItems(); + + if (items.Count == 0) + return 0; + + for (var i = 0; i < items.Count; i++) + { + var itemPos = items[i].TranslatePoint(new Point(0, 0), ModsListBox); + if (itemPos == null) + continue; + + var midY = itemPos.Value.Y + items[i].Bounds.Height / 2; + if (posInListBox.Y < midY) + return i; + } + + return items.Count; + } + + private void UpdateDropIndicator(int gapIndex) + { + if (_dropIndicatorLine == null) + return; + + var items = GetListBoxItems(); + if (items.Count == 0) + return; + + // Hide indicator when hovering over the no-op zone (same position) + if (gapIndex == _dragStartIndex || gapIndex == _dragStartIndex + 1) + { + _dropIndicatorLine.IsVisible = false; + return; + } + + _dropIndicatorLine.IsVisible = true; + + if (gapIndex < items.Count) + { + var item = items[gapIndex]; + var itemPos = item.TranslatePoint(new Point(0, -2), DragCanvas); + if (itemPos != null) + { + Canvas.SetLeft(_dropIndicatorLine, itemPos.Value.X); + Canvas.SetTop(_dropIndicatorLine, itemPos.Value.Y); + _dropIndicatorLine.Width = item.Bounds.Width; + } + } + else + { + var lastItem = items[^1]; + var itemPos = lastItem.TranslatePoint(new Point(0, lastItem.Bounds.Height + 2), DragCanvas); + if (itemPos != null) + { + Canvas.SetLeft(_dropIndicatorLine, itemPos.Value.X); + Canvas.SetTop(_dropIndicatorLine, itemPos.Value.Y); + _dropIndicatorLine.Width = lastItem.Bounds.Width; + } + } + } + + private void HandleAutoScroll(PointerEventArgs e) + { + var scrollViewer = ModsListBox.GetVisualDescendants().OfType().FirstOrDefault(); + if (scrollViewer == null) + return; + + var pos = e.GetPosition(scrollViewer); + const double scrollZone = 40.0; + const double scrollSpeed = 8.0; + + if (pos.Y < scrollZone && scrollViewer.Offset.Y > 0) + { + var factor = 1.0 - pos.Y / scrollZone; + scrollViewer.Offset = new Vector(scrollViewer.Offset.X, Math.Max(0, scrollViewer.Offset.Y - scrollSpeed * factor)); + } + else if (pos.Y > scrollViewer.Viewport.Height - scrollZone) + { + var maxScroll = scrollViewer.Extent.Height - scrollViewer.Viewport.Height; + if (scrollViewer.Offset.Y < maxScroll) + { + var factor = 1.0 - (scrollViewer.Viewport.Height - pos.Y) / scrollZone; + scrollViewer.Offset = new Vector(scrollViewer.Offset.X, Math.Min(maxScroll, scrollViewer.Offset.Y + scrollSpeed * factor)); + } + } + } + + private List GetListBoxItems() + { + var items = new List(); + for (var i = 0; i < Mods.Count; i++) + { + if (ModsListBox.ContainerFromIndex(i) is ListBoxItem lbi) + items.Add(lbi); + } + return items; + } + + private int GetModIndex(ModListItem modItem) + { + for (var i = 0; i < Mods.Count; i++) + { + if (Mods[i].Mod == modItem.Mod) + return i; + } + return -1; + } + + private void EndDrag(bool commit) + { + if (_draggedListBoxItem != null) + _draggedListBoxItem.Opacity = 1.0; + + var modToMove = _draggedItem?.Mod; + var targetGapIndex = _currentDropIndex; + var sourceIndex = _dragStartIndex; + var shouldCommit = commit && modToMove != null && targetGapIndex != sourceIndex && targetGapIndex != sourceIndex + 1; + + CleanupDrag(); + + if (shouldCommit) + ModManager.MoveModToIndex(modToMove!, targetGapIndex); + } + + private void CancelDrag() + { + if (!_isDragging && !_isDragPending) + return; + + if (_draggedListBoxItem != null) + _draggedListBoxItem.Opacity = 1.0; + + CleanupDrag(); + } + + private void CleanupDrag() + { + _isDragging = false; + _isDragPending = false; + _draggedItem = null; + _draggedListBoxItem = null; + _dragAdorner = null; + _dropIndicatorLine = null; + + DragCanvas.Children.Clear(); + DragCanvas.IsVisible = false; + + _capturedPointer?.Capture(null); + _capturedPointer = null; + } + + #endregion + #region PropertyChanged public new event PropertyChangedEventHandler? PropertyChanged; diff --git a/WheelWizard/Views/Pages/RoomDetailsPage.axaml b/WheelWizard/Views/Pages/RoomDetailsPage.axaml index 0c2a78e4..f1271c64 100644 --- a/WheelWizard/Views/Pages/RoomDetailsPage.axaml +++ b/WheelWizard/Views/Pages/RoomDetailsPage.axaml @@ -5,6 +5,7 @@ mc:Ignorable="d" d:DesignHeight="876" d:DesignWidth="456" xmlns:lang="clr-namespace:WheelWizard.Resources.Languages" xmlns:components="clr-namespace:WheelWizard.Views.Components" + xmlns:patterns="clr-namespace:WheelWizard.Views.Patterns" xmlns:pages="clr-namespace:WheelWizard.Views.Pages" x:Class="WheelWizard.Views.Pages.RoomDetailsPage" x:DataType="pages:RoomDetailsPage"> @@ -86,7 +87,7 @@ SelectionChanged="PlayerView_SelectionChanged"> - (SettingsService.FOCUSED_USER); if (focusedUserIndex is < 0 or > 3) { ViewUtils.ShowSnackbar("Invalid license selected.", ViewUtils.SnackbarType.Warning); diff --git a/WheelWizard/Views/Pages/RoomsPage.axaml b/WheelWizard/Views/Pages/RoomsPage.axaml index 2b0029b8..9c648242 100644 --- a/WheelWizard/Views/Pages/RoomsPage.axaml +++ b/WheelWizard/Views/Pages/RoomsPage.axaml @@ -5,6 +5,7 @@ mc:Ignorable="d" d:DesignWidth="656" d:DesignHeight="876" xmlns:lang="clr-namespace:WheelWizard.Resources.Languages" xmlns:components="clr-namespace:WheelWizard.Views.Components" + xmlns:patterns="clr-namespace:WheelWizard.Views.Patterns" xmlns:pages="clr-namespace:WheelWizard.Views.Pages" x:Class="WheelWizard.Views.Pages.RoomsPage" x:DataType="pages:RoomsPage"> @@ -23,11 +24,11 @@ - - @@ -139,7 +140,7 @@ SelectionChanged="PlayerView_SelectionChanged"> - - \ No newline at end of file + diff --git a/WheelWizard/Views/Pages/Settings/AppInfo.axaml.cs b/WheelWizard/Views/Pages/Settings/AppInfo.axaml.cs index ab668408..43597e1a 100644 --- a/WheelWizard/Views/Pages/Settings/AppInfo.axaml.cs +++ b/WheelWizard/Views/Pages/Settings/AppInfo.axaml.cs @@ -1,4 +1,3 @@ -using Avalonia.Controls; using Avalonia.Controls.Primitives; using WheelWizard.Branding; using WheelWizard.CustomDistributions; @@ -11,6 +10,9 @@ public partial class AppInfo : UserControlBase [Inject] private ICustomDistributionSingletonService CustomDistributionSingletonService { get; set; } = null!; + [Inject] + private IBrandingSingletonService BrandingSingletonService { get; set; } = null!; + public AppInfo() { InitializeComponent(); @@ -46,7 +48,7 @@ private void OpenLick_OnClick(object? sender, EventArgs e) protected override void OnInitialized() { - var branding = App.Services.GetRequiredService().Branding; + var branding = BrandingSingletonService.Branding; WhWzVersionText.Text = $"WhWz: v{branding.Version}"; base.OnInitialized(); } diff --git a/WheelWizard/Views/Pages/Settings/OtherSettings.axaml.cs b/WheelWizard/Views/Pages/Settings/OtherSettings.axaml.cs index 70ff8785..8eb6eea4 100644 --- a/WheelWizard/Views/Pages/Settings/OtherSettings.axaml.cs +++ b/WheelWizard/Views/Pages/Settings/OtherSettings.axaml.cs @@ -1,12 +1,7 @@ -using Avalonia.Controls; using Avalonia.Interactivity; using WheelWizard.CustomDistributions; -using WheelWizard.Helpers; -using WheelWizard.Models.Settings; -using WheelWizard.Resources.Languages; using WheelWizard.Services; -using WheelWizard.Services.Installation; -using WheelWizard.Services.Settings; +using WheelWizard.Settings; using WheelWizard.Shared.DependencyInjection; using WheelWizard.Views.Popups.Generic; @@ -19,10 +14,13 @@ public partial class OtherSettings : UserControlBase [Inject] private ICustomDistributionSingletonService CustomDistributionSingletonService { get; set; } = null!; + [Inject] + private ISettingsManager SettingsService { get; set; } = null!; + public OtherSettings() { InitializeComponent(); - _settingsAreDisabled = !SettingsHelper.PathsSetupCorrectly(); + _settingsAreDisabled = !SettingsService.PathsSetupCorrectly(); DisabledWarningText.IsVisible = _settingsAreDisabled; DolphinBorder.IsEnabled = !_settingsAreDisabled; @@ -38,8 +36,8 @@ public OtherSettings() private void LoadSettings() { // Only loads when the settings are not disabled (aka when the paths are set up correctly) - DisableForce.IsChecked = (bool)SettingsManager.FORCE_WIIMOTE.Get(); - LaunchWithDolphin.IsChecked = (bool)SettingsManager.LAUNCH_WITH_DOLPHIN.Get(); + DisableForce.IsChecked = SettingsService.Get(SettingsService.FORCE_WIIMOTE); + LaunchWithDolphin.IsChecked = SettingsService.Get(SettingsService.LAUNCH_WITH_DOLPHIN); OpenSaveFolderButton.IsEnabled = Directory.Exists(PathManager.SaveFolderPath); } @@ -50,12 +48,12 @@ private void ForceLoadSettings() private void ClickForceWiimote(object? sender, RoutedEventArgs e) { - SettingsManager.FORCE_WIIMOTE.Set(DisableForce.IsChecked == true); + SettingsService.Set(SettingsService.FORCE_WIIMOTE, DisableForce.IsChecked == true); } private void ClickLaunchWithDolphinWindow(object? sender, RoutedEventArgs e) { - SettingsManager.LAUNCH_WITH_DOLPHIN.Set(LaunchWithDolphin.IsChecked == true); + SettingsService.Set(SettingsService.LAUNCH_WITH_DOLPHIN, LaunchWithDolphin.IsChecked == true); } private async void Reinstall_RetroRewind(object sender, RoutedEventArgs e) diff --git a/WheelWizard/Views/Pages/Settings/VideoSettings.axaml.cs b/WheelWizard/Views/Pages/Settings/VideoSettings.axaml.cs index 74b65613..37ed1df3 100644 --- a/WheelWizard/Views/Pages/Settings/VideoSettings.axaml.cs +++ b/WheelWizard/Views/Pages/Settings/VideoSettings.axaml.cs @@ -1,20 +1,23 @@ using Avalonia.Controls; using Avalonia.Interactivity; -using WheelWizard.Models.Settings; -using WheelWizard.Services.Settings; +using WheelWizard.Settings; +using WheelWizard.Settings.Types; +using WheelWizard.Shared.DependencyInjection; using WheelWizard.Shared.MessageTranslations; -using WheelWizard.Views.Popups.Generic; namespace WheelWizard.Views.Pages.Settings; -public partial class VideoSettings : UserControl +public partial class VideoSettings : UserControlBase { private readonly bool _settingsAreDisabled; + [Inject] + private ISettingsManager SettingsService { get; set; } = null!; + public VideoSettings() { InitializeComponent(); - _settingsAreDisabled = !SettingsHelper.PathsSetupCorrectly(); + _settingsAreDisabled = !SettingsService.PathsSetupCorrectly(); DisabledWarningText.IsVisible = _settingsAreDisabled; VideoBorder.IsEnabled = !_settingsAreDisabled; @@ -38,12 +41,12 @@ public VideoSettings() private void LoadSettings() { // Load settings that are enabled for editing - VSyncButton.IsChecked = (bool)SettingsManager.VSYNC.Get(); - RecommendedButton.IsChecked = (bool)SettingsManager.RECOMMENDED_SETTINGS.Get(); - ShowFPSButton.IsChecked = (bool)SettingsManager.SHOW_FPS.Get(); - RemoveBlurButton.IsChecked = (bool)SettingsManager.REMOVE_BLUR.Get(); + VSyncButton.IsChecked = SettingsService.Get(SettingsService.VSYNC); + RecommendedButton.IsChecked = SettingsService.Get(SettingsService.RECOMMENDED_SETTINGS); + ShowFPSButton.IsChecked = SettingsService.Get(SettingsService.SHOW_FPS); + RemoveBlurButton.IsChecked = SettingsService.Get(SettingsService.REMOVE_BLUR); - var finalResolution = (int)SettingsManager.INTERNAL_RESOLUTION.Get(); + var finalResolution = SettingsService.Get(SettingsService.INTERNAL_RESOLUTION); foreach (RadioButton radioButton in ResolutionStackPanel.Children) { radioButton.IsChecked = (radioButton.Tag.ToString() == finalResolution.ToString()); @@ -58,7 +61,7 @@ private void ForceLoadSettings() RendererDropdown.Items.Add(renderer); } - var currentRenderer = (string)SettingsManager.GFX_BACKEND.Get(); + var currentRenderer = SettingsService.Get(SettingsService.GFX_BACKEND); var renderDisplayName = SettingValues.GFXRenderers.FirstOrDefault(x => x.Value == currentRenderer).Key; if (renderDisplayName != null) { @@ -70,28 +73,28 @@ private void UpdateResolution(object? sender, RoutedEventArgs e) { if (sender is RadioButton radioButton && radioButton.IsChecked == true) { - SettingsManager.INTERNAL_RESOLUTION.Set(int.Parse(radioButton.Tag.ToString()!)); + SettingsService.Set(SettingsService.INTERNAL_RESOLUTION, int.Parse(radioButton.Tag.ToString()!)); } } private void VSync_OnClick(object? sender, RoutedEventArgs e) { - SettingsManager.VSYNC.Set(VSyncButton.IsChecked == true); + SettingsService.Set(SettingsService.VSYNC, VSyncButton.IsChecked == true); } private void Recommended_OnClick(object? sender, RoutedEventArgs e) { - SettingsManager.RECOMMENDED_SETTINGS.Set(RecommendedButton.IsChecked == true); + SettingsService.Set(SettingsService.RECOMMENDED_SETTINGS, RecommendedButton.IsChecked == true); } private void ShowFPS_OnClick(object? sender, RoutedEventArgs e) { - SettingsManager.SHOW_FPS.Set(ShowFPSButton.IsChecked == true); + SettingsService.Set(SettingsService.SHOW_FPS, ShowFPSButton.IsChecked == true); } private void RemoveBlur_OnClick(object? sender, RoutedEventArgs e) { - SettingsManager.REMOVE_BLUR.Set(RemoveBlurButton.IsChecked == true); + SettingsService.Set(SettingsService.REMOVE_BLUR, RemoveBlurButton.IsChecked == true); } private void RendererDropdown_OnSelectionChanged(object? sender, SelectionChangedEventArgs e) @@ -99,7 +102,7 @@ private void RendererDropdown_OnSelectionChanged(object? sender, SelectionChange var selectedDisplayName = RendererDropdown.SelectedItem?.ToString(); if (SettingValues.GFXRenderers.TryGetValue(selectedDisplayName, out var actualValue)) { - SettingsManager.GFX_BACKEND.Set(actualValue); + SettingsService.Set(SettingsService.GFX_BACKEND, actualValue); } else { diff --git a/WheelWizard/Views/Pages/Settings/WhWzSettings.axaml.cs b/WheelWizard/Views/Pages/Settings/WhWzSettings.axaml.cs index fbf58c66..b3a87ad4 100644 --- a/WheelWizard/Views/Pages/Settings/WhWzSettings.axaml.cs +++ b/WheelWizard/Views/Pages/Settings/WhWzSettings.axaml.cs @@ -1,17 +1,17 @@ -using System.IO; using System.Runtime.InteropServices; using Avalonia.Controls; using Avalonia.Interactivity; using Avalonia.Media; using Avalonia.Platform.Storage; using Avalonia.Threading; -using HarfBuzzSharp; using Serilog; +using WheelWizard.DolphinInstaller; using WheelWizard.Helpers; -using WheelWizard.Models.Settings; using WheelWizard.Resources.Languages; using WheelWizard.Services; -using WheelWizard.Services.Settings; +using WheelWizard.Settings; +using WheelWizard.Settings.Types; +using WheelWizard.Shared.DependencyInjection; using WheelWizard.Shared.MessageTranslations; using WheelWizard.Views.Popups.Generic; using Button = WheelWizard.Views.Components.Button; @@ -19,12 +19,21 @@ namespace WheelWizard.Views.Pages.Settings; -public partial class WhWzSettings : UserControl +public partial class WhWzSettings : UserControlBase { private readonly bool _pageLoaded; private bool _editingScale; private bool _isMovingAppData; + [Inject] + private ISettingsManager SettingsService { get; set; } = null!; + + [Inject] + private IDolphinSettingManager DolphinSettingsService { get; set; } = null!; + + [Inject] + private ILinuxDolphinInstaller LinuxDolphinInstallerService { get; set; } = null!; + public WhWzSettings() { InitializeComponent(); @@ -49,7 +58,7 @@ private void LoadSettings() WhWzLanguageDropdown.Items.Add(lang()); } - var currentWhWzLanguage = (string)SettingsManager.WW_LANGUAGE.Get(); + var currentWhWzLanguage = (string)SettingsService.WW_LANGUAGE.Get(); var whWzLanguageDisplayName = SettingValues.WhWzLanguages[currentWhWzLanguage]; WhWzLanguageDropdown.SelectedItem = whWzLanguageDisplayName(); @@ -70,12 +79,12 @@ private void LoadSettings() WindowScaleDropdown.Items.Add(ScaleToString(scale)); } - var selectedItemText = ScaleToString((double)SettingsManager.WINDOW_SCALE.Get()); + var selectedItemText = ScaleToString((double)SettingsService.WINDOW_SCALE.Get()); if (!WindowScaleDropdown.Items.Contains(selectedItemText)) WindowScaleDropdown.Items.Add(selectedItemText); WindowScaleDropdown.SelectedItem = selectedItemText; - EnableAnimations.IsChecked = (bool)SettingsManager.ENABLE_ANIMATIONS.Get(); + EnableAnimations.IsChecked = (bool)SettingsService.ENABLE_ANIMATIONS.Get(); } private static string ScaleToString(double scale) @@ -138,11 +147,15 @@ private async void DolphinExeBrowse_OnClick(object sender, RoutedEventArgs e) TogglePathSettings(true); progressWindow.Show(); var progress = new Progress(progressWindow.UpdateProgress); - var success = await LinuxDolphinInstaller.InstallFlatpakDolphin(progress); + var installResult = await LinuxDolphinInstallerService.InstallFlatpakDolphin(progress); progressWindow.Close(); - if (!success) + if (installResult.IsFailure) { - await MessageTranslationHelper.AwaitMessageAsync(MessageTranslation.Error_FailedInstallDolphin); + await new MessageBoxWindow() + .SetMessageType(MessageBoxWindow.MessageType.Error) + .SetTitleText("Failed to install Dolphin") + .SetInfoText(installResult.Error.Message) + .ShowDialog(); return; } @@ -211,7 +224,7 @@ private async void DolphinExeBrowse_OnClick(object sender, RoutedEventArgs e) private bool IsFlatpakDolphinInstalled() { - return LinuxDolphinInstaller.IsDolphinInstalledInFlatpak(); + return LinuxDolphinInstallerService.IsDolphinInstalledInFlatpak(); } private async void GameLocationBrowse_OnClick(object sender, RoutedEventArgs e) @@ -248,7 +261,7 @@ private async void DolphinUserPathBrowse_OnClick(object sender, RoutedEventArgs await MessageTranslationHelper.AwaitMessageAsync(MessageTranslation.Warning_DolphinNotFound); } - var currentFolder = (string)SettingsManager.USER_FOLDER_PATH.Get(); + var currentFolder = (string)SettingsService.USER_FOLDER_PATH.Get(); var topLevel = TopLevel.GetTopLevel(this); // If a current folder exists and is valid, suggest it as the starting location if (!string.IsNullOrEmpty(currentFolder) && Directory.Exists(currentFolder)) @@ -280,16 +293,16 @@ private async void DolphinUserPathBrowse_OnClick(object sender, RoutedEventArgs private async void SaveButton_OnClick(object sender, RoutedEventArgs e) { - var oldPath1 = (string)SettingsManager.DOLPHIN_LOCATION.Get(); - var oldPath2 = (string)SettingsManager.GAME_LOCATION.Get(); - var oldPath3 = (string)SettingsManager.USER_FOLDER_PATH.Get(); + var oldPath1 = (string)SettingsService.DOLPHIN_LOCATION.Get(); + var oldPath2 = (string)SettingsService.GAME_LOCATION.Get(); + var oldPath3 = (string)SettingsService.USER_FOLDER_PATH.Get(); - var path1 = SettingsManager.DOLPHIN_LOCATION.Set(DolphinExeInput.Text); - var path2 = SettingsManager.GAME_LOCATION.Set(MarioKartInput.Text); - var path3 = SettingsManager.USER_FOLDER_PATH.Set(DolphinUserPathInput.Text.TrimEnd(Path.DirectorySeparatorChar)); + var path1 = SettingsService.DOLPHIN_LOCATION.Set(DolphinExeInput.Text); + var path2 = SettingsService.GAME_LOCATION.Set(MarioKartInput.Text); + var path3 = SettingsService.USER_FOLDER_PATH.Set(DolphinUserPathInput.Text.TrimEnd(Path.DirectorySeparatorChar)); // These 3 lines is only saving the settings TogglePathSettings(false); - if (!(SettingsHelper.PathsSetupCorrectly() && path1 && path2 && path3)) + if (!(SettingsService.PathsSetupCorrectly() && path1 && path2 && path3)) await MessageTranslationHelper.AwaitMessageAsync(MessageTranslation.Warning_InvalidPathSettings); else { @@ -297,7 +310,7 @@ private async void SaveButton_OnClick(object sender, RoutedEventArgs e) // This is not really the best approach, but it works for now if (oldPath1 + oldPath2 + oldPath3 != DolphinExeInput.Text + MarioKartInput.Text + DolphinUserPathInput.Text) - DolphinSettingManager.Instance.ReloadSettings(); + DolphinSettingsService.ReloadSettings(); } } @@ -326,7 +339,7 @@ private void TogglePathSettings(bool enable) { LocationBorder.BorderBrush = new SolidColorBrush(ViewUtils.Colors.Neutral900); } - else if (!SettingsHelper.PathsSetupCorrectly()) + else if (!SettingsService.PathsSetupCorrectly()) { LocationBorder.BorderBrush = new SolidColorBrush(ViewUtils.Colors.Warning400); LocationEditButton.Variant = Button.ButtonsVariantType.Warning; @@ -630,7 +643,7 @@ private async void WindowScaleDropdown_OnSelectionChanged(object sender, Selecti var selectedScale = WindowScaleDropdown.SelectedItem?.ToString() ?? "1"; var scale = double.Parse(selectedScale.Split(" ").Last().Replace("%", "")) / 100; - SettingsManager.WINDOW_SCALE.Set(scale); + SettingsService.WINDOW_SCALE.Set(scale); var seconds = 10; string ExtraScaleText() => @@ -657,11 +670,11 @@ string ExtraScaleText() => var yesNoAnswer = await yesNoWindow.AwaitAnswer(); if (yesNoAnswer) - SettingsManager.SAVED_WINDOW_SCALE.Set(SettingsManager.WINDOW_SCALE.Get()); + SettingsService.SAVED_WINDOW_SCALE.Set(SettingsService.WINDOW_SCALE.Get()); else { - SettingsManager.WINDOW_SCALE.Set(SettingsManager.SAVED_WINDOW_SCALE.Get()); - WindowScaleDropdown.SelectedItem = ScaleToString((double)SettingsManager.WINDOW_SCALE.Get()); + SettingsService.WINDOW_SCALE.Set(SettingsService.SAVED_WINDOW_SCALE.Get()); + WindowScaleDropdown.SelectedItem = ScaleToString((double)SettingsService.WINDOW_SCALE.Get()); } _editingScale = false; @@ -697,7 +710,7 @@ private async void WhWzLanguageDropdown_OnSelectionChanged(object? sender, Selec var selectedLanguage = WhWzLanguageDropdown.SelectedItem.ToString(); var key = SettingValues.WhWzLanguages.FirstOrDefault(x => x.Value() == selectedLanguage).Key; - var currentLanguage = (string)SettingsManager.WW_LANGUAGE.Get(); + var currentLanguage = (string)SettingsService.WW_LANGUAGE.Get(); if (key == null || key == currentLanguage) return; @@ -719,16 +732,16 @@ private async void WhWzLanguageDropdown_OnSelectionChanged(object? sender, Selec if (!yesNoWindow) { - var currentWhWzLanguage = (string)SettingsManager.WW_LANGUAGE.Get(); + var currentWhWzLanguage = (string)SettingsService.WW_LANGUAGE.Get(); var whWzLanguageDisplayName = SettingValues.WhWzLanguages[currentWhWzLanguage](); // gets the name of the current language back if the change was aborted WhWzLanguageDropdown.SelectedItem = whWzLanguageDisplayName; return; // We only want to change the setting if we really apply this change } - SettingsManager.WW_LANGUAGE.Set(key); + SettingsService.WW_LANGUAGE.Set(key); ViewUtils.RefreshWindow(); } private void EnableAnimations_OnClick(object sender, RoutedEventArgs e) => - SettingsManager.ENABLE_ANIMATIONS.Set(EnableAnimations.IsChecked == true); + SettingsService.ENABLE_ANIMATIONS.Set(EnableAnimations.IsChecked == true); } diff --git a/WheelWizard/Views/Pages/Settings/SettingsPage.axaml b/WheelWizard/Views/Pages/SettingsPage.axaml similarity index 97% rename from WheelWizard/Views/Pages/Settings/SettingsPage.axaml rename to WheelWizard/Views/Pages/SettingsPage.axaml index 769b470b..f80f4fd7 100644 --- a/WheelWizard/Views/Pages/Settings/SettingsPage.axaml +++ b/WheelWizard/Views/Pages/SettingsPage.axaml @@ -5,7 +5,7 @@ xmlns:lang="clr-namespace:WheelWizard.Resources.Languages" xmlns:components="clr-namespace:WheelWizard.Views.Components" mc:Ignorable="d" d:DesignWidth="656" d:DesignHeight="876" - x:Class="WheelWizard.Views.Pages.Settings.SettingsPage"> + x:Class="WheelWizard.Views.Pages.SettingsPage"> diff --git a/WheelWizard/Views/Pages/Settings/SettingsPage.axaml.cs b/WheelWizard/Views/Pages/SettingsPage.axaml.cs similarity index 71% rename from WheelWizard/Views/Pages/Settings/SettingsPage.axaml.cs rename to WheelWizard/Views/Pages/SettingsPage.axaml.cs index b0093d93..0e9ba539 100644 --- a/WheelWizard/Views/Pages/Settings/SettingsPage.axaml.cs +++ b/WheelWizard/Views/Pages/SettingsPage.axaml.cs @@ -1,12 +1,9 @@ using Avalonia.Controls; using Avalonia.Interactivity; -using WheelWizard.Branding; -using WheelWizard.CustomDistributions; -using WheelWizard.Services.Installation; -using WheelWizard.Shared.DependencyInjection; +using WheelWizard.Views.Pages.Settings; using WheelWizard.Views.Popups; -namespace WheelWizard.Views.Pages.Settings; +namespace WheelWizard.Views.Pages; public partial class SettingsPage : UserControlBase { @@ -29,9 +26,9 @@ private void TopBarRadio_OnClick(object? sender, RoutedEventArgs e) if (sender is not RadioButton radioButton) return; - // As long as the Ks... files are next to this file, it works. - var namespaceName = GetType().Namespace; - var typeName = $"{namespaceName}.{radioButton.Tag}"; + // Settings sub-pages stay in the nested Settings namespace. + var settingsSubPagesNamespace = typeof(WhWzSettings).Namespace; + var typeName = $"{settingsSubPagesNamespace}.{radioButton.Tag}"; var type = Type.GetType(typeName); if (type == null || !typeof(UserControl).IsAssignableFrom(type)) return; diff --git a/WheelWizard/Views/Pages/TestingPage.axaml.cs b/WheelWizard/Views/Pages/TestingPage.axaml.cs index 63bbb300..aada7ffc 100644 --- a/WheelWizard/Views/Pages/TestingPage.axaml.cs +++ b/WheelWizard/Views/Pages/TestingPage.axaml.cs @@ -2,8 +2,7 @@ using WheelWizard.CustomDistributions; using WheelWizard.Models.Enums; using WheelWizard.Services.Launcher; -using WheelWizard.Services.Launcher.Helpers; -using WheelWizard.Services.Settings; +using WheelWizard.Settings; using WheelWizard.Shared.DependencyInjection; using WheelWizard.Views.Popups.Generic; @@ -11,17 +10,21 @@ namespace WheelWizard.Views.Pages; public partial class TestingPage : UserControlBase { - private readonly ILauncher _launcher; private WheelWizardStatus _status = WheelWizardStatus.Loading; private bool _isBusy; [Inject] private ICustomDistributionSingletonService CustomDistributionSingletonService { get; set; } = null!; + [Inject] + private ISettingsManager SettingsService { get; set; } = null!; + + [Inject] + private RrBetaLauncher LauncherService { get; set; } = null!; + public TestingPage() { InitializeComponent(); - _launcher = new RrBetaLauncher(); UpdateStatusAsync(); } @@ -30,13 +33,13 @@ private async void UpdateStatusAsync() _status = WheelWizardStatus.Loading; UpdateUi(); - _status = await _launcher.GetCurrentStatus(); + _status = await LauncherService.GetCurrentStatus(); UpdateUi(); } private void UpdateUi() { - var pathsReady = SettingsHelper.PathsSetupCorrectly(); + var pathsReady = SettingsService.PathsSetupCorrectly(); var isInstalled = _status == WheelWizardStatus.Ready; InstallButton.IsEnabled = pathsReady && !_isBusy; @@ -67,7 +70,7 @@ private async void InstallButton_OnClick(object? sender, RoutedEventArgs e) _isBusy = true; UpdateUi(); - await _launcher.Install(); + await LauncherService.Install(); _isBusy = false; UpdateStatusAsync(); @@ -106,7 +109,7 @@ private async void LaunchButton_OnClick(object? sender, RoutedEventArgs e) _isBusy = true; UpdateUi(); - await _launcher.Launch(); + await LauncherService.Launch(); _isBusy = false; UpdateUi(); } diff --git a/WheelWizard/Views/Pages/UserProfilePage.axaml b/WheelWizard/Views/Pages/UserProfilePage.axaml index f4f8fcfc..9ef72528 100644 --- a/WheelWizard/Views/Pages/UserProfilePage.axaml +++ b/WheelWizard/Views/Pages/UserProfilePage.axaml @@ -4,7 +4,7 @@ xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" mc:Ignorable="d" d:DesignWidth="656" d:DesignHeight="876" xmlns:lang="clr-namespace:WheelWizard.Resources.Languages" - xmlns:behavior="clr-namespace:WheelWizard.Views.BehaviorComponent" + xmlns:behavior="clr-namespace:WheelWizard.Views.Patterns" xmlns:components="clr-namespace:WheelWizard.Views.Components" xmlns:animation="clr-namespace:Avalonia.Animation;assembly=Avalonia.Base" xmlns:miiVars="using:WheelWizard.MiiImages.Domain" @@ -78,7 +78,7 @@ - - + diff --git a/WheelWizard/Views/Pages/UserProfilePage.axaml.cs b/WheelWizard/Views/Pages/UserProfilePage.axaml.cs index fc94c937..ac6e1525 100644 --- a/WheelWizard/Views/Pages/UserProfilePage.axaml.cs +++ b/WheelWizard/Views/Pages/UserProfilePage.axaml.cs @@ -4,11 +4,11 @@ using Avalonia.Media; using WheelWizard.Helpers; using WheelWizard.Models.Enums; -using WheelWizard.Models.Settings; using WheelWizard.Resources.Languages; using WheelWizard.Services.LiveData; using WheelWizard.Services.Other; -using WheelWizard.Services.Settings; +using WheelWizard.Settings; +using WheelWizard.Settings.Types; using WheelWizard.Shared.DependencyInjection; using WheelWizard.Shared.MessageTranslations; using WheelWizard.Views.Components; @@ -44,6 +44,9 @@ public partial class UserProfilePage : UserControlBase, INotifyPropertyChanged [Inject] private IMiiDbService MiiDbService { get; set; } = null!; + [Inject] + private ISettingsManager SettingsService { get; set; } = null!; + public Mii? CurrentMii { get => _currentMii; @@ -110,13 +113,13 @@ public int ActiveInfoSlideIndex } private int _currentUserIndex; - private static int FocussedUser => (int)SettingsManager.FOCUSSED_USER.Get(); + private int FocusedUser => SettingsService.Get(SettingsService.FOCUSED_USER); public UserProfilePage() { InitializeComponent(); ResetMiiTopBar(); - ViewMii(FocussedUser); + ViewMii(FocusedUser); PopulateRegions(); UpdatePage(); DataContext = this; @@ -128,7 +131,7 @@ public UserProfilePage() private void PopulateRegions() { var validRegions = RRRegionManager.GetValidRegions(); - var currentRegion = (MarioKartWiiEnums.Regions)SettingsManager.RR_REGION.Get(); + var currentRegion = SettingsService.Get(SettingsService.RR_REGION); foreach (var region in Enum.GetValues()) { if (region == MarioKartWiiEnums.Regions.None) @@ -198,7 +201,7 @@ private void ResetMiiTopBar() private void UpdatePage() { - PrimaryCheckBox.IsChecked = FocussedUser == _currentUserIndex; + PrimaryCheckBox.IsChecked = FocusedUser == _currentUserIndex; currentPlayer = GameLicenseService.GetUserData(_currentUserIndex); CurrentFriendCode = currentPlayer.FriendCode; @@ -238,10 +241,10 @@ private void ViewMii(int? mii = null) private void SetUserAsPrimary() { - if (FocussedUser == _currentUserIndex) + if (FocusedUser == _currentUserIndex) return; - SettingsManager.FOCUSSED_USER.Set(_currentUserIndex); + SettingsService.Set(SettingsService.FOCUSED_USER, _currentUserIndex); PrimaryCheckBox.IsChecked = true; // Even though it's true when this method is called, we still set it to true, @@ -257,7 +260,7 @@ private void RegionDropdown_SelectionChanged(object sender, SelectionChangedEven if (RegionDropdown.SelectedItem is not ComboBoxItem { Tag: MarioKartWiiEnums.Regions region }) return; - SettingsManager.RR_REGION.Set(region); + SettingsService.Set(SettingsService.RR_REGION, region); ResetMiiTopBar(); var loadResult = GameLicenseService.LoadLicense(); if (loadResult.IsFailure) diff --git a/WheelWizard/Views/Pages/kitchenSink/KitchenSinkButtonsPage.axaml b/WheelWizard/Views/Pages/kitchenSink/KitchenSinkButtonsPage.axaml new file mode 100644 index 00000000..717aa57e --- /dev/null +++ b/WheelWizard/Views/Pages/kitchenSink/KitchenSinkButtonsPage.axaml @@ -0,0 +1,106 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/WheelWizard/Views/Pages/kitchenSink/KitchenSinkButtonsPage.axaml.cs b/WheelWizard/Views/Pages/kitchenSink/KitchenSinkButtonsPage.axaml.cs new file mode 100644 index 00000000..addf7ef7 --- /dev/null +++ b/WheelWizard/Views/Pages/kitchenSink/KitchenSinkButtonsPage.axaml.cs @@ -0,0 +1,12 @@ +namespace WheelWizard.Views.Pages.KitchenSink; + +public partial class KitchenSinkButtonsPage : KitchenSinkSectionPageBase +{ + public override string SectionName => "Buttons"; + public override string? SectionTooltip => null; + + public KitchenSinkButtonsPage() + { + InitializeComponent(); + } +} diff --git a/WheelWizard/Views/Pages/kitchenSink/KitchenSinkDropdownsPage.axaml b/WheelWizard/Views/Pages/kitchenSink/KitchenSinkDropdownsPage.axaml new file mode 100644 index 00000000..ea524fab --- /dev/null +++ b/WheelWizard/Views/Pages/kitchenSink/KitchenSinkDropdownsPage.axaml @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/WheelWizard/Views/Pages/kitchenSink/KitchenSinkDropdownsPage.axaml.cs b/WheelWizard/Views/Pages/kitchenSink/KitchenSinkDropdownsPage.axaml.cs new file mode 100644 index 00000000..ed2c866d --- /dev/null +++ b/WheelWizard/Views/Pages/kitchenSink/KitchenSinkDropdownsPage.axaml.cs @@ -0,0 +1,12 @@ +namespace WheelWizard.Views.Pages.KitchenSink; + +public partial class KitchenSinkDropdownsPage : KitchenSinkSectionPageBase +{ + public override string SectionName => "Dropdowns"; + public override string? SectionTooltip => null; + + public KitchenSinkDropdownsPage() + { + InitializeComponent(); + } +} diff --git a/WheelWizard/Views/Pages/kitchenSink/KitchenSinkIconLabelsPage.axaml b/WheelWizard/Views/Pages/kitchenSink/KitchenSinkIconLabelsPage.axaml new file mode 100644 index 00000000..cb1747af --- /dev/null +++ b/WheelWizard/Views/Pages/kitchenSink/KitchenSinkIconLabelsPage.axaml @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + + + diff --git a/WheelWizard/Views/Pages/kitchenSink/KitchenSinkIconLabelsPage.axaml.cs b/WheelWizard/Views/Pages/kitchenSink/KitchenSinkIconLabelsPage.axaml.cs new file mode 100644 index 00000000..ca8b3cf1 --- /dev/null +++ b/WheelWizard/Views/Pages/kitchenSink/KitchenSinkIconLabelsPage.axaml.cs @@ -0,0 +1,12 @@ +namespace WheelWizard.Views.Pages.KitchenSink; + +public partial class KitchenSinkIconLabelsPage : KitchenSinkSectionPageBase +{ + public override string SectionName => "Icon Labels"; + public override string? SectionTooltip => null; + + public KitchenSinkIconLabelsPage() + { + InitializeComponent(); + } +} diff --git a/WheelWizard/Views/Pages/kitchenSink/KitchenSinkIconsPage.axaml b/WheelWizard/Views/Pages/kitchenSink/KitchenSinkIconsPage.axaml new file mode 100644 index 00000000..e2295067 --- /dev/null +++ b/WheelWizard/Views/Pages/kitchenSink/KitchenSinkIconsPage.axaml @@ -0,0 +1,68 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/WheelWizard/Views/Pages/kitchenSink/KitchenSinkIconsPage.axaml.cs b/WheelWizard/Views/Pages/kitchenSink/KitchenSinkIconsPage.axaml.cs new file mode 100644 index 00000000..dc6562b3 --- /dev/null +++ b/WheelWizard/Views/Pages/kitchenSink/KitchenSinkIconsPage.axaml.cs @@ -0,0 +1,12 @@ +namespace WheelWizard.Views.Pages.KitchenSink; + +public partial class KitchenSinkIconsPage : KitchenSinkSectionPageBase +{ + public override string SectionName => "Icons"; + public override string? SectionTooltip => null; + + public KitchenSinkIconsPage() + { + InitializeComponent(); + } +} diff --git a/WheelWizard/Views/Pages/kitchenSink/KitchenSinkInputFieldsPage.axaml b/WheelWizard/Views/Pages/kitchenSink/KitchenSinkInputFieldsPage.axaml new file mode 100644 index 00000000..dfa75cd3 --- /dev/null +++ b/WheelWizard/Views/Pages/kitchenSink/KitchenSinkInputFieldsPage.axaml @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/WheelWizard/Views/Pages/kitchenSink/KitchenSinkInputFieldsPage.axaml.cs b/WheelWizard/Views/Pages/kitchenSink/KitchenSinkInputFieldsPage.axaml.cs new file mode 100644 index 00000000..0204f3d2 --- /dev/null +++ b/WheelWizard/Views/Pages/kitchenSink/KitchenSinkInputFieldsPage.axaml.cs @@ -0,0 +1,12 @@ +namespace WheelWizard.Views.Pages.KitchenSink; + +public partial class KitchenSinkInputFieldsPage : KitchenSinkSectionPageBase +{ + public override string SectionName => "Input Fields"; + public override string? SectionTooltip => null; + + public KitchenSinkInputFieldsPage() + { + InitializeComponent(); + } +} diff --git a/WheelWizard/Views/Pages/kitchenSink/KitchenSinkSectionPageBase.cs b/WheelWizard/Views/Pages/kitchenSink/KitchenSinkSectionPageBase.cs new file mode 100644 index 00000000..64863b7e --- /dev/null +++ b/WheelWizard/Views/Pages/kitchenSink/KitchenSinkSectionPageBase.cs @@ -0,0 +1,13 @@ +namespace WheelWizard.Views.Pages.KitchenSink; + +public interface IKitchenSinkSection +{ + string SectionName { get; } + string? SectionTooltip { get; } +} + +public abstract class KitchenSinkSectionPageBase : UserControlBase, IKitchenSinkSection +{ + public abstract string SectionName { get; } + public virtual string? SectionTooltip => null; +} diff --git a/WheelWizard/Views/Pages/kitchenSink/KitchenSinkStateBoxesPage.axaml b/WheelWizard/Views/Pages/kitchenSink/KitchenSinkStateBoxesPage.axaml new file mode 100644 index 00000000..7a2f5839 --- /dev/null +++ b/WheelWizard/Views/Pages/kitchenSink/KitchenSinkStateBoxesPage.axaml @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + + + + diff --git a/WheelWizard/Views/Pages/kitchenSink/KitchenSinkStateBoxesPage.axaml.cs b/WheelWizard/Views/Pages/kitchenSink/KitchenSinkStateBoxesPage.axaml.cs new file mode 100644 index 00000000..7b75436f --- /dev/null +++ b/WheelWizard/Views/Pages/kitchenSink/KitchenSinkStateBoxesPage.axaml.cs @@ -0,0 +1,12 @@ +namespace WheelWizard.Views.Pages.KitchenSink; + +public partial class KitchenSinkStateBoxesPage : KitchenSinkSectionPageBase +{ + public override string SectionName => "State Boxes"; + public override string? SectionTooltip => null; + + public KitchenSinkStateBoxesPage() + { + InitializeComponent(); + } +} diff --git a/WheelWizard/Views/Pages/kitchenSink/KitchenSinkTextStylesPage.axaml b/WheelWizard/Views/Pages/kitchenSink/KitchenSinkTextStylesPage.axaml new file mode 100644 index 00000000..3be42018 --- /dev/null +++ b/WheelWizard/Views/Pages/kitchenSink/KitchenSinkTextStylesPage.axaml @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/WheelWizard/Views/Pages/kitchenSink/KitchenSinkTextStylesPage.axaml.cs b/WheelWizard/Views/Pages/kitchenSink/KitchenSinkTextStylesPage.axaml.cs new file mode 100644 index 00000000..3f0b8f34 --- /dev/null +++ b/WheelWizard/Views/Pages/kitchenSink/KitchenSinkTextStylesPage.axaml.cs @@ -0,0 +1,12 @@ +namespace WheelWizard.Views.Pages.KitchenSink; + +public partial class KitchenSinkTextStylesPage : KitchenSinkSectionPageBase +{ + public override string SectionName => "Text Styles + Tooltips"; + public override string? SectionTooltip => null; + + public KitchenSinkTextStylesPage() + { + InitializeComponent(); + } +} diff --git a/WheelWizard/Views/Pages/kitchenSink/KitchenSinkToggleButtonsPage.axaml b/WheelWizard/Views/Pages/kitchenSink/KitchenSinkToggleButtonsPage.axaml new file mode 100644 index 00000000..ded94299 --- /dev/null +++ b/WheelWizard/Views/Pages/kitchenSink/KitchenSinkToggleButtonsPage.axaml @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/WheelWizard/Views/Pages/kitchenSink/KitchenSinkToggleButtonsPage.axaml.cs b/WheelWizard/Views/Pages/kitchenSink/KitchenSinkToggleButtonsPage.axaml.cs new file mode 100644 index 00000000..4cf64052 --- /dev/null +++ b/WheelWizard/Views/Pages/kitchenSink/KitchenSinkToggleButtonsPage.axaml.cs @@ -0,0 +1,12 @@ +namespace WheelWizard.Views.Pages.KitchenSink; + +public partial class KitchenSinkToggleButtonsPage : KitchenSinkSectionPageBase +{ + public override string SectionName => "Toggle Buttons"; + public override string? SectionTooltip => null; + + public KitchenSinkToggleButtonsPage() + { + InitializeComponent(); + } +} diff --git a/WheelWizard/Views/BehaviorComponent/CurrentUserProfile.axaml b/WheelWizard/Views/Patterns/CurrentUserProfile.axaml similarity index 96% rename from WheelWizard/Views/BehaviorComponent/CurrentUserProfile.axaml rename to WheelWizard/Views/Patterns/CurrentUserProfile.axaml index 69369396..ac759fb2 100644 --- a/WheelWizard/Views/BehaviorComponent/CurrentUserProfile.axaml +++ b/WheelWizard/Views/Patterns/CurrentUserProfile.axaml @@ -1,8 +1,8 @@  diff --git a/WheelWizard/Views/BehaviorComponent/CurrentUserProfile.axaml.cs b/WheelWizard/Views/Patterns/CurrentUserProfile.axaml.cs similarity index 96% rename from WheelWizard/Views/BehaviorComponent/CurrentUserProfile.axaml.cs rename to WheelWizard/Views/Patterns/CurrentUserProfile.axaml.cs index 5682e6b1..fdc8782c 100644 --- a/WheelWizard/Views/BehaviorComponent/CurrentUserProfile.axaml.cs +++ b/WheelWizard/Views/Patterns/CurrentUserProfile.axaml.cs @@ -1,14 +1,14 @@ using Avalonia; using Avalonia.Input; -using WheelWizard.Models.Settings; using WheelWizard.Resources.Languages; +using WheelWizard.Settings.Types; using WheelWizard.Shared.DependencyInjection; using WheelWizard.Views.Pages; using WheelWizard.WiiManagement; using WheelWizard.WiiManagement.GameLicense; using WheelWizard.WiiManagement.MiiManagement.Domain.Mii; -namespace WheelWizard.Views.BehaviorComponent; +namespace WheelWizard.Views.Patterns; public partial class CurrentUserProfile : UserControlBase { diff --git a/WheelWizard/Views/BehaviorComponent/FeedbackTextBox.axaml b/WheelWizard/Views/Patterns/FeedbackTextBox.axaml similarity index 72% rename from WheelWizard/Views/BehaviorComponent/FeedbackTextBox.axaml rename to WheelWizard/Views/Patterns/FeedbackTextBox.axaml index c67bb8af..45b26ae5 100644 --- a/WheelWizard/Views/BehaviorComponent/FeedbackTextBox.axaml +++ b/WheelWizard/Views/Patterns/FeedbackTextBox.axaml @@ -3,8 +3,8 @@ xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" mc:Ignorable="d" d:DesignWidth="400" d:DesignHeight="100" - x:Class="WheelWizard.Views.BehaviorComponent.FeedbackTextBox" - xmlns:behaviorComp="clr-namespace:WheelWizard.Views.BehaviorComponent" + x:Class="WheelWizard.Views.Patterns.FeedbackTextBox" + xmlns:behaviorComp="clr-namespace:WheelWizard.Views.Patterns" xmlns:components="clr-namespace:WheelWizard.Views.Components" x:DataType="behaviorComp:FeedbackTextBox" > @@ -19,6 +19,12 @@ IconData="{StaticResource WarningTriangle}" IconSize="15" FontSize="13" Margin="4,4,0,0" /> + diff --git a/WheelWizard/Views/BehaviorComponent/FeedbackTextBox.axaml.cs b/WheelWizard/Views/Patterns/FeedbackTextBox.axaml.cs similarity index 71% rename from WheelWizard/Views/BehaviorComponent/FeedbackTextBox.axaml.cs rename to WheelWizard/Views/Patterns/FeedbackTextBox.axaml.cs index 8e82392a..a002d004 100644 --- a/WheelWizard/Views/BehaviorComponent/FeedbackTextBox.axaml.cs +++ b/WheelWizard/Views/Patterns/FeedbackTextBox.axaml.cs @@ -2,7 +2,7 @@ using Avalonia.Controls; using Avalonia.Interactivity; -namespace WheelWizard.Views.BehaviorComponent; +namespace WheelWizard.Views.Patterns; public partial class FeedbackTextBox : UserControlBase { @@ -17,6 +17,10 @@ public partial class FeedbackTextBox : UserControlBase nameof(ErrorMessage) ); + public static readonly StyledProperty WarningMessageProperty = AvaloniaProperty.Register( + nameof(WarningMessage) + ); + public static readonly StyledProperty TextProperty = AvaloniaProperty.Register(nameof(Text)); public static readonly StyledProperty WatermarkProperty = AvaloniaProperty.Register( @@ -51,6 +55,12 @@ public string ErrorMessage set => SetValue(ErrorMessageProperty, value); } + public string WarningMessage + { + get => GetValue(WarningMessageProperty); + set => SetValue(WarningMessageProperty, value); + } + public string Text { get => GetValue(TextProperty); @@ -89,6 +99,7 @@ public FeedbackTextBox() DataContext = this; InputField.TextChanged += (_, _) => RaiseEvent(new TextChangedEventArgs(TextChangedEvent, this)); + UpdateValidationState(hasError: !string.IsNullOrWhiteSpace(ErrorMessage), hasWarning: !string.IsNullOrWhiteSpace(WarningMessage)); // If there is uses for more other events, then we can always add them } @@ -100,16 +111,26 @@ private void UpdateStyleClasses(TextBoxVariantType variant) InputField.Classes.Remove("dark"); } - private void UpdateErrorState(bool hasError) + private void UpdateValidationState(bool hasError, bool hasWarning) { - if (!hasError) + if (hasError) + { + if (!InputField.Classes.Contains("error")) + InputField.Classes.Add("error"); + InputField.Classes.Remove("warning"); + return; + } + + InputField.Classes.Remove("error"); + + if (!hasWarning) { - InputField.Classes.Remove("error"); + InputField.Classes.Remove("warning"); return; } - if (!InputField.Classes.Contains("error")) - InputField.Classes.Add("error"); + if (!InputField.Classes.Contains("warning")) + InputField.Classes.Add("warning"); } protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) @@ -119,7 +140,10 @@ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs chang if (change.Property == VariantProperty) UpdateStyleClasses(change.GetNewValue()); - if (change.Property == ErrorMessageProperty) - UpdateErrorState(hasError: !string.IsNullOrWhiteSpace(change.GetNewValue())); + if (change.Property == ErrorMessageProperty || change.Property == WarningMessageProperty) + UpdateValidationState( + hasError: !string.IsNullOrWhiteSpace(ErrorMessage), + hasWarning: !string.IsNullOrWhiteSpace(WarningMessage) + ); } } diff --git a/WheelWizard/Views/Components/WhWzLibrary/FriendsListItem.axaml b/WheelWizard/Views/Patterns/FriendsListItem.axaml similarity index 91% rename from WheelWizard/Views/Components/WhWzLibrary/FriendsListItem.axaml rename to WheelWizard/Views/Patterns/FriendsListItem.axaml index 184682ea..6b363241 100644 --- a/WheelWizard/Views/Components/WhWzLibrary/FriendsListItem.axaml +++ b/WheelWizard/Views/Patterns/FriendsListItem.axaml @@ -3,38 +3,39 @@ xmlns:lang="clr-namespace:WheelWizard.Resources.Languages" xmlns:components="clr-namespace:WheelWizard.Views.Components" xmlns:miiVars="using:WheelWizard.MiiImages.Domain" - xmlns:behaviorComponent="clr-namespace:WheelWizard.Views.BehaviorComponent"> + xmlns:behaviorComponent="clr-namespace:WheelWizard.Views.Patterns" + xmlns:patterns="clr-namespace:WheelWizard.Views.Patterns"> - - - - - - - - - diff --git a/WheelWizard/Views/Components/WhWzLibrary/FriendsListItem.axaml.cs b/WheelWizard/Views/Patterns/FriendsListItem.axaml.cs similarity index 97% rename from WheelWizard/Views/Components/WhWzLibrary/FriendsListItem.axaml.cs rename to WheelWizard/Views/Patterns/FriendsListItem.axaml.cs index 3a14bd0a..0926fc73 100644 --- a/WheelWizard/Views/Components/WhWzLibrary/FriendsListItem.axaml.cs +++ b/WheelWizard/Views/Patterns/FriendsListItem.axaml.cs @@ -1,11 +1,12 @@ -using Avalonia; +using Avalonia; using Avalonia.Controls; using Avalonia.Controls.Primitives; using Avalonia.Interactivity; using WheelWizard.WheelWizardData; using WheelWizard.WiiManagement.MiiManagement.Domain.Mii; +using Badge = WheelWizard.Views.Components.Badge; -namespace WheelWizard.Views.Components; +namespace WheelWizard.Views.Patterns; public class FriendsListItem : TemplatedControl { diff --git a/WheelWizard/Views/Patterns/GridModPanel.axaml b/WheelWizard/Views/Patterns/GridModPanel.axaml new file mode 100644 index 00000000..217a4d6a --- /dev/null +++ b/WheelWizard/Views/Patterns/GridModPanel.axaml @@ -0,0 +1,92 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/WheelWizard/Views/Patterns/GridModPanel.axaml.cs b/WheelWizard/Views/Patterns/GridModPanel.axaml.cs new file mode 100644 index 00000000..effc33cc --- /dev/null +++ b/WheelWizard/Views/Patterns/GridModPanel.axaml.cs @@ -0,0 +1,129 @@ +using System.Collections.Concurrent; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Media.Imaging; +using Microsoft.Extensions.DependencyInjection; +using WheelWizard.GameBanana; +using WheelWizard.Views.Pages; + +namespace WheelWizard.Views.Patterns; + +public partial class GridModPanel : UserControl +{ + private static readonly HttpClient s_httpClient = new(); + private static readonly ConcurrentDictionary s_imageCache = new(); + private int? _currentModId; + + public GridModPanel() + { + InitializeComponent(); + DataContextChanged += OnDataContextChanged; + } + + private void OnDataContextChanged(object? sender, EventArgs e) + { + if (DataContext is not ModListItem item) + { + _currentModId = null; + ModImage.Source = null; + PlaceholderIcon.IsVisible = true; + return; + } + + var modId = item.Mod.ModID; + _currentModId = modId; + + if (modId <= 0) + { + ModImage.Source = null; + PlaceholderIcon.IsVisible = true; + return; + } + + if (s_imageCache.TryGetValue(modId, out var cachedImage)) + { + ModImage.Source = cachedImage; + PlaceholderIcon.IsVisible = false; + return; + } + + ModImage.Source = null; + PlaceholderIcon.IsVisible = true; + LoadModImageAsync(modId); + } + + private async void LoadModImageAsync(int modId) + { + if (modId <= 0) + return; + + try + { + var gameBananaService = App.Services.GetService(); + if (gameBananaService == null) + return; + + var result = await gameBananaService.GetModDetails(modId); + if (!result.IsSuccess || result.Value.PreviewMedia?.Images == null || result.Value.PreviewMedia.Images.Count == 0) + return; + + var image = result.Value.PreviewMedia.Images[0]; + // Prefer smaller 220px thumbnail for grid cards, fall back to full size + var imageUrl = image.File220 != null ? $"{image.BaseUrl}/{image.File220}" : $"{image.BaseUrl}/{image.File}"; + + var response = await s_httpClient.GetAsync(imageUrl); + response.EnsureSuccessStatusCode(); + + await using var stream = await response.Content.ReadAsStreamAsync(); + var memoryStream = new MemoryStream(); + await stream.CopyToAsync(memoryStream); + memoryStream.Position = 0; + + var bitmap = new Bitmap(memoryStream); + if (!s_imageCache.TryAdd(modId, bitmap)) + bitmap.Dispose(); + + if (_currentModId != modId) + return; + + ModImage.Source = s_imageCache[modId]; + PlaceholderIcon.IsVisible = false; + } + catch + { + // Ignore - just show placeholder icon + } + } + + private void PriorityText_OnLostFocus(object? sender, RoutedEventArgs e) + { + if (DataContext is not ModListItem item || e.Source is not TextBox textBox) + return; + + textBox.Classes.Remove("error"); + if (int.TryParse(textBox.Text, out var newPriority)) + item.Mod.Priority = newPriority; + else + textBox.Text = item.Mod.Priority.ToString(); + } + + private void PriorityText_OnTextChanged(object? sender, TextChangedEventArgs e) + { + if (e.Source is not TextBox textBox) + return; + + if (int.TryParse(textBox.Text, out _)) + textBox.Classes.Remove("error"); + else if (!textBox.Classes.Contains("error")) + textBox.Classes.Add("error"); + } + + private void PriorityText_OnKeyDown(object? sender, KeyEventArgs e) + { + if (e.Key != Key.Enter || sender is not TextBox) + return; + + this.Focus(); + } +} diff --git a/WheelWizard/Views/Patterns/LeaderboardPodiumCard.axaml b/WheelWizard/Views/Patterns/LeaderboardPodiumCard.axaml new file mode 100644 index 00000000..7bc72aa2 --- /dev/null +++ b/WheelWizard/Views/Patterns/LeaderboardPodiumCard.axaml @@ -0,0 +1,469 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/WheelWizard/Views/Patterns/LeaderboardPodiumCard.axaml.cs b/WheelWizard/Views/Patterns/LeaderboardPodiumCard.axaml.cs new file mode 100644 index 00000000..9bc7d7da --- /dev/null +++ b/WheelWizard/Views/Patterns/LeaderboardPodiumCard.axaml.cs @@ -0,0 +1,100 @@ +using Avalonia; +using Avalonia.Controls.Primitives; +using WheelWizard.WheelWizardData.Domain; +using WheelWizard.WiiManagement.MiiManagement.Domain.Mii; + +namespace WheelWizard.Views.Patterns; + +public class LeaderboardPodiumCard : TemplatedControl +{ + public static readonly StyledProperty RankProperty = AvaloniaProperty.Register(nameof(Rank)); + + public int Rank + { + get => GetValue(RankProperty); + set => SetValue(RankProperty, value); + } + + public static readonly StyledProperty PlacementLabelProperty = AvaloniaProperty.Register( + nameof(PlacementLabel), + string.Empty + ); + + public string PlacementLabel + { + get => GetValue(PlacementLabelProperty); + set => SetValue(PlacementLabelProperty, value); + } + + public static readonly StyledProperty PlayerNameProperty = AvaloniaProperty.Register( + nameof(PlayerName), + string.Empty + ); + + public string PlayerName + { + get => GetValue(PlayerNameProperty); + set => SetValue(PlayerNameProperty, value); + } + + public static readonly StyledProperty VrTextProperty = AvaloniaProperty.Register( + nameof(VrText), + "--" + ); + + public string VrText + { + get => GetValue(VrTextProperty); + set => SetValue(VrTextProperty, value); + } + + public static readonly StyledProperty MiiProperty = AvaloniaProperty.Register(nameof(Mii)); + + public Mii? Mii + { + get => GetValue(MiiProperty); + set => SetValue(MiiProperty, value); + } + + public static readonly StyledProperty AvatarSizeProperty = AvaloniaProperty.Register( + nameof(AvatarSize), + 104 + ); + + public double AvatarSize + { + get => GetValue(AvatarSizeProperty); + set => SetValue(AvatarSizeProperty, value); + } + + public static readonly StyledProperty BadgeVariantProperty = AvaloniaProperty.Register< + LeaderboardPodiumCard, + BadgeVariant + >(nameof(BadgeVariant), BadgeVariant.None); + + public BadgeVariant BadgeVariant + { + get => GetValue(BadgeVariantProperty); + set => SetValue(BadgeVariantProperty, value); + } + + public static readonly StyledProperty ShowBadgeProperty = AvaloniaProperty.Register( + nameof(ShowBadge) + ); + + public bool ShowBadge + { + get => GetValue(ShowBadgeProperty); + set => SetValue(ShowBadgeProperty, value); + } + + public static readonly StyledProperty IsSuspiciousProperty = AvaloniaProperty.Register( + nameof(IsSuspicious) + ); + + public bool IsSuspicious + { + get => GetValue(IsSuspiciousProperty); + set => SetValue(IsSuspiciousProperty, value); + } +} diff --git a/WheelWizard/Views/Components/WhWzLibrary/MiiBlock.axaml b/WheelWizard/Views/Patterns/MiiBlock.axaml similarity index 95% rename from WheelWizard/Views/Components/WhWzLibrary/MiiBlock.axaml rename to WheelWizard/Views/Patterns/MiiBlock.axaml index 14b06bcd..22d64ad2 100644 --- a/WheelWizard/Views/Components/WhWzLibrary/MiiBlock.axaml +++ b/WheelWizard/Views/Patterns/MiiBlock.axaml @@ -1,18 +1,19 @@ + xmlns:behaviorComponent="clr-namespace:WheelWizard.Views.Patterns"> - - - - + + + + - - \ No newline at end of file + diff --git a/WheelWizard/Views/Components/WhWzLibrary/MiiBlock.axaml.cs b/WheelWizard/Views/Patterns/MiiBlock.axaml.cs similarity index 98% rename from WheelWizard/Views/Components/WhWzLibrary/MiiBlock.axaml.cs rename to WheelWizard/Views/Patterns/MiiBlock.axaml.cs index 129cf05c..c55f1c76 100644 --- a/WheelWizard/Views/Components/WhWzLibrary/MiiBlock.axaml.cs +++ b/WheelWizard/Views/Patterns/MiiBlock.axaml.cs @@ -5,12 +5,11 @@ using Avalonia.Interactivity; using WheelWizard.MiiImages; using WheelWizard.MiiImages.Domain; -using WheelWizard.Views.BehaviorComponent; using WheelWizard.WiiManagement; using WheelWizard.WiiManagement.MiiManagement; using WheelWizard.WiiManagement.MiiManagement.Domain.Mii; -namespace WheelWizard.Views.Components; +namespace WheelWizard.Views.Patterns; public class MiiBlock : RadioButton { diff --git a/WheelWizard/Views/BehaviorComponent/MiiImages/BaseMiiImage.cs b/WheelWizard/Views/Patterns/MiiImages/BaseMiiImage.cs similarity index 99% rename from WheelWizard/Views/BehaviorComponent/MiiImages/BaseMiiImage.cs rename to WheelWizard/Views/Patterns/MiiImages/BaseMiiImage.cs index e617028d..41dadd67 100644 --- a/WheelWizard/Views/BehaviorComponent/MiiImages/BaseMiiImage.cs +++ b/WheelWizard/Views/Patterns/MiiImages/BaseMiiImage.cs @@ -7,7 +7,7 @@ using WheelWizard.Shared.DependencyInjection; using WheelWizard.WiiManagement.MiiManagement.Domain.Mii; -namespace WheelWizard.Views.BehaviorComponent; +namespace WheelWizard.Views.Patterns; public abstract class BaseMiiImage : UserControlBase, INotifyPropertyChanged { diff --git a/WheelWizard/Views/BehaviorComponent/MiiImages/MiiCarousel.axaml b/WheelWizard/Views/Patterns/MiiImages/MiiCarousel.axaml similarity index 92% rename from WheelWizard/Views/BehaviorComponent/MiiImages/MiiCarousel.axaml rename to WheelWizard/Views/Patterns/MiiImages/MiiCarousel.axaml index 708d5a6a..39382036 100644 --- a/WheelWizard/Views/BehaviorComponent/MiiImages/MiiCarousel.axaml +++ b/WheelWizard/Views/Patterns/MiiImages/MiiCarousel.axaml @@ -3,16 +3,16 @@ 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" - xmlns:behaviorComp="clr-namespace:WheelWizard.Views.BehaviorComponent" + xmlns:behaviorComp="clr-namespace:WheelWizard.Views.Patterns" xmlns:components="clr-namespace:WheelWizard.Views.Components" xmlns:converters="clr-namespace:WheelWizard.Views.Converters" - x:Class="WheelWizard.Views.BehaviorComponent.MiiCarousel" x:Name="Self"> + x:Class="WheelWizard.Views.Patterns.MiiCarousel" x:Name="Self"> - - + - - - \ No newline at end of file + + diff --git a/WheelWizard/Views/BehaviorComponent/MiiImages/MiiImageLoader.axaml.cs b/WheelWizard/Views/Patterns/MiiImages/MiiImageLoader.axaml.cs similarity index 98% rename from WheelWizard/Views/BehaviorComponent/MiiImages/MiiImageLoader.axaml.cs rename to WheelWizard/Views/Patterns/MiiImages/MiiImageLoader.axaml.cs index eef04bec..fbbfa04a 100644 --- a/WheelWizard/Views/BehaviorComponent/MiiImages/MiiImageLoader.axaml.cs +++ b/WheelWizard/Views/Patterns/MiiImages/MiiImageLoader.axaml.cs @@ -5,7 +5,7 @@ using WheelWizard.MiiImages.Domain; using WheelWizard.WiiManagement.MiiManagement.Domain.Mii; -namespace WheelWizard.Views.BehaviorComponent; +namespace WheelWizard.Views.Patterns; public partial class MiiImageLoader : BaseMiiImage { diff --git a/WheelWizard/Views/BehaviorComponent/MiiImages/MiiImageLoaderWithHover.axaml b/WheelWizard/Views/Patterns/MiiImages/MiiImageLoaderWithHover.axaml similarity index 93% rename from WheelWizard/Views/BehaviorComponent/MiiImages/MiiImageLoaderWithHover.axaml rename to WheelWizard/Views/Patterns/MiiImages/MiiImageLoaderWithHover.axaml index 808a2d62..02ee9b18 100644 --- a/WheelWizard/Views/BehaviorComponent/MiiImages/MiiImageLoaderWithHover.axaml +++ b/WheelWizard/Views/Patterns/MiiImages/MiiImageLoaderWithHover.axaml @@ -3,13 +3,13 @@ xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" mc:Ignorable="d" d:DesignWidth="400" d:DesignHeight="400" - x:Class="WheelWizard.Views.BehaviorComponent.MiiImageLoaderWithHover" - xmlns:behaviorComp="clr-namespace:WheelWizard.Views.BehaviorComponent" + x:Class="WheelWizard.Views.Patterns.MiiImageLoaderWithHover" + xmlns:behaviorComp="clr-namespace:WheelWizard.Views.Patterns" xmlns:components="clr-namespace:WheelWizard.Views.Components" xmlns:conv="clr-namespace:WheelWizard.Views.Converters" x:DataType="behaviorComp:MiiImageLoaderWithHover" x:Name="Self" VerticalAlignment="Stretch" HorizontalAlignment="Stretch"> - - + diff --git a/WheelWizard/Views/BehaviorComponent/MiiImages/MiiImageLoaderWithHover.axaml.cs b/WheelWizard/Views/Patterns/MiiImages/MiiImageLoaderWithHover.axaml.cs similarity index 99% rename from WheelWizard/Views/BehaviorComponent/MiiImages/MiiImageLoaderWithHover.axaml.cs rename to WheelWizard/Views/Patterns/MiiImages/MiiImageLoaderWithHover.axaml.cs index 16d2d4bd..c30ff6f9 100644 --- a/WheelWizard/Views/BehaviorComponent/MiiImages/MiiImageLoaderWithHover.axaml.cs +++ b/WheelWizard/Views/Patterns/MiiImages/MiiImageLoaderWithHover.axaml.cs @@ -5,7 +5,7 @@ using WheelWizard.MiiImages.Domain; using WheelWizard.WiiManagement.MiiManagement.Domain.Mii; -namespace WheelWizard.Views.BehaviorComponent; +namespace WheelWizard.Views.Patterns; public partial class MiiImageLoaderWithHover : BaseMiiImage { diff --git a/WheelWizard/Views/Components/WhWzLibrary/ModBrowserListItem.axaml b/WheelWizard/Views/Patterns/ModBrowserListItem.axaml similarity index 91% rename from WheelWizard/Views/Components/WhWzLibrary/ModBrowserListItem.axaml rename to WheelWizard/Views/Patterns/ModBrowserListItem.axaml index 1487b2e6..1d8014e6 100644 --- a/WheelWizard/Views/Components/WhWzLibrary/ModBrowserListItem.axaml +++ b/WheelWizard/Views/Patterns/ModBrowserListItem.axaml @@ -1,33 +1,34 @@ + xmlns:components="using:WheelWizard.Views.Components" + xmlns:patterns="clr-namespace:WheelWizard.Views.Patterns"> - - - - - - - - diff --git a/WheelWizard/Views/Components/WhWzLibrary/ModBrowserListItem.axaml.cs b/WheelWizard/Views/Patterns/ModBrowserListItem.axaml.cs similarity index 98% rename from WheelWizard/Views/Components/WhWzLibrary/ModBrowserListItem.axaml.cs rename to WheelWizard/Views/Patterns/ModBrowserListItem.axaml.cs index 199eae95..22cc887e 100644 --- a/WheelWizard/Views/Components/WhWzLibrary/ModBrowserListItem.axaml.cs +++ b/WheelWizard/Views/Patterns/ModBrowserListItem.axaml.cs @@ -3,7 +3,7 @@ using Avalonia.Controls.Primitives; using Avalonia.Media.Imaging; -namespace WheelWizard.Views.Components; +namespace WheelWizard.Views.Patterns; public class ModBrowserListItem : TemplatedControl { diff --git a/WheelWizard/Views/Components/WhWzLibrary/PlayerListItem.axaml b/WheelWizard/Views/Patterns/PlayerListItem.axaml similarity index 68% rename from WheelWizard/Views/Components/WhWzLibrary/PlayerListItem.axaml rename to WheelWizard/Views/Patterns/PlayerListItem.axaml index b0a3f24d..bc58f7e6 100644 --- a/WheelWizard/Views/Components/WhWzLibrary/PlayerListItem.axaml +++ b/WheelWizard/Views/Patterns/PlayerListItem.axaml @@ -2,11 +2,12 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:lang="clr-namespace:WheelWizard.Resources.Languages" xmlns:components="using:WheelWizard.Views.Components" + xmlns:patterns="clr-namespace:WheelWizard.Views.Patterns" xmlns:miiVars="using:WheelWizard.MiiImages.Domain" - xmlns:behaviorComponent="clr-namespace:WheelWizard.Views.BehaviorComponent"> + xmlns:behaviorComponent="clr-namespace:WheelWizard.Views.Patterns"> - - - - - - - \ No newline at end of file + + + + + + + diff --git a/WheelWizard/Views/Components/WhWzLibrary/PlayerListItem.axaml.cs b/WheelWizard/Views/Patterns/PlayerListItem.axaml.cs similarity index 58% rename from WheelWizard/Views/Components/WhWzLibrary/PlayerListItem.axaml.cs rename to WheelWizard/Views/Patterns/PlayerListItem.axaml.cs index 969ac2b6..9a8e2db5 100644 --- a/WheelWizard/Views/Components/WhWzLibrary/PlayerListItem.axaml.cs +++ b/WheelWizard/Views/Patterns/PlayerListItem.axaml.cs @@ -1,13 +1,22 @@ -using Avalonia; +using Avalonia; using Avalonia.Controls; using Avalonia.Controls.Primitives; +using Avalonia.Interactivity; using WheelWizard.WheelWizardData; using WheelWizard.WiiManagement.MiiManagement.Domain.Mii; +using Badge = WheelWizard.Views.Components.Badge; -namespace WheelWizard.Views.Components; +namespace WheelWizard.Views.Patterns; public class PlayerListItem : TemplatedControl { + private Avalonia.Controls.Button? _joinRoomButton; + + public static readonly StyledProperty IsOnlineProperty = AvaloniaProperty.Register(nameof(IsOnline)); + public static readonly StyledProperty ShowJoinRoomButtonProperty = AvaloniaProperty.Register( + nameof(ShowJoinRoomButton) + ); + public static readonly StyledProperty HasBadgesProperty = AvaloniaProperty.Register(nameof(HasBadges)); public static readonly StyledProperty IsOpenHostProperty = AvaloniaProperty.Register(nameof(IsOpenHost)); @@ -25,6 +34,18 @@ public bool HasBadges set => SetValue(HasBadgesProperty, value); } + public bool IsOnline + { + get => GetValue(IsOnlineProperty); + set => SetValue(IsOnlineProperty, value); + } + + public bool ShowJoinRoomButton + { + get => GetValue(ShowJoinRoomButtonProperty); + set => SetValue(ShowJoinRoomButtonProperty, value); + } + public bool IsOpenHost { get => GetValue(IsOpenHostProperty); @@ -85,23 +106,50 @@ public string UserName set => SetValue(UserNameProperty, value); } + public static readonly StyledProperty?> JoinRoomActionProperty = AvaloniaProperty.Register< + PlayerListItem, + Action? + >(nameof(JoinRoomAction)); + + public Action? JoinRoomAction + { + get => GetValue(JoinRoomActionProperty); + set => SetValue(JoinRoomActionProperty, value); + } + + private void JoinRoom_OnClick(object? sender, RoutedEventArgs e) + { + if (string.IsNullOrWhiteSpace(FriendCode)) + return; + + JoinRoomAction?.Invoke(FriendCode); + } + protected override void OnApplyTemplate(TemplateAppliedEventArgs e) { base.OnApplyTemplate(e); - var container = e.NameScope.Find("PART_BadgeContainer"); - if (container == null) - return; - container.Children.Clear(); - var badges = App - .Services.GetRequiredService() - .GetBadges(FriendCode) - .Select(variant => new Badge { Variant = variant }); - foreach (var badge in badges) + var container = e.NameScope.Find("PART_BadgeContainer"); + if (container != null) { - badge.Height = 30; - badge.Width = 30; - container.Children.Add(badge); + container.Children.Clear(); + var badges = App + .Services.GetRequiredService() + .GetBadges(FriendCode) + .Select(variant => new Badge { Variant = variant }); + foreach (var badge in badges) + { + badge.Height = 30; + badge.Width = 30; + container.Children.Add(badge); + } } + + if (_joinRoomButton != null) + _joinRoomButton.Click -= JoinRoom_OnClick; + + _joinRoomButton = e.NameScope.Find("PART_JoinRoomButton"); + if (_joinRoomButton != null) + _joinRoomButton.Click += JoinRoom_OnClick; } } diff --git a/WheelWizard/Views/Components/StandardLibrary/SidebarRadioButton.axaml b/WheelWizard/Views/Patterns/SidebarRadioButton.axaml similarity index 88% rename from WheelWizard/Views/Components/StandardLibrary/SidebarRadioButton.axaml rename to WheelWizard/Views/Patterns/SidebarRadioButton.axaml index 8a9f04a0..d96c53fd 100644 --- a/WheelWizard/Views/Components/StandardLibrary/SidebarRadioButton.axaml +++ b/WheelWizard/Views/Patterns/SidebarRadioButton.axaml @@ -2,27 +2,28 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:lang="clr-namespace:WheelWizard.Resources.Languages" xmlns:pages="clr-namespace:WheelWizard.Views.Pages" - xmlns:components="clr-namespace:WheelWizard.Views.Components"> + xmlns:components="clr-namespace:WheelWizard.Views.Components" + xmlns:patterns="clr-namespace:WheelWizard.Views.Patterns"> - - - - - @@ -30,7 +31,7 @@ - - - - \ No newline at end of file + diff --git a/WheelWizard/Views/Components/StandardLibrary/SidebarRadioButton.axaml.cs b/WheelWizard/Views/Patterns/SidebarRadioButton.axaml.cs similarity index 98% rename from WheelWizard/Views/Components/StandardLibrary/SidebarRadioButton.axaml.cs rename to WheelWizard/Views/Patterns/SidebarRadioButton.axaml.cs index 7bc00fea..3e232f80 100644 --- a/WheelWizard/Views/Components/StandardLibrary/SidebarRadioButton.axaml.cs +++ b/WheelWizard/Views/Patterns/SidebarRadioButton.axaml.cs @@ -5,7 +5,7 @@ using Avalonia.Media; using WheelWizard.Views.Pages; -namespace WheelWizard.Views.Components; +namespace WheelWizard.Views.Patterns; public partial class SidebarRadioButton : RadioButton { diff --git a/WheelWizard/Views/Components/WhWzLibrary/VrHistoryGraph.axaml b/WheelWizard/Views/Patterns/VrHistoryGraph.axaml similarity index 94% rename from WheelWizard/Views/Components/WhWzLibrary/VrHistoryGraph.axaml rename to WheelWizard/Views/Patterns/VrHistoryGraph.axaml index 140fcee2..d55b65b0 100644 --- a/WheelWizard/Views/Components/WhWzLibrary/VrHistoryGraph.axaml +++ b/WheelWizard/Views/Patterns/VrHistoryGraph.axaml @@ -2,12 +2,13 @@ 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:behaviorComp="clr-namespace:WheelWizard.Views.Patterns" xmlns:components="clr-namespace:WheelWizard.Views.Components" mc:Ignorable="d" d:DesignWidth="560" d:DesignHeight="390" - x:Class="WheelWizard.Views.Components.VrHistoryGraph" - x:DataType="components:VrHistoryGraph"> + x:Class="WheelWizard.Views.Patterns.VrHistoryGraph" + x:DataType="behaviorComp:VrHistoryGraph"> - + + + + + + + + + + + + + + + + + - \ No newline at end of file + + diff --git a/WheelWizard/WheelWizard.csproj b/WheelWizard/WheelWizard.csproj index 24683410..35575db0 100644 --- a/WheelWizard/WheelWizard.csproj +++ b/WheelWizard/WheelWizard.csproj @@ -11,7 +11,7 @@ true - 2.4.0 + 2.4.1 This program will manage RetroRewind and mods :) GNU v3.0 https://github.com/patchzyy/WheelWizard diff --git a/global.json b/global.json index dad2db5e..a2d56b98 100644 --- a/global.json +++ b/global.json @@ -1,7 +1,7 @@ { "sdk": { - "version": "8.0.0", - "rollForward": "latestMajor", - "allowPrerelease": true + "version": "8.0.124", + "rollForward": "latestFeature", + "allowPrerelease": false } -} \ No newline at end of file +}