diff --git a/PointerStar/Client/Cookies/WellKnownCookieValues.cs b/PointerStar/Client/Cookies/WellKnownCookieValues.cs index 5af7b5e..d6705f4 100644 --- a/PointerStar/Client/Cookies/WellKnownCookieValues.cs +++ b/PointerStar/Client/Cookies/WellKnownCookieValues.cs @@ -5,6 +5,7 @@ public static class WellKnownCookieValues private const string NameKey = "Name"; private const string RoleKey = "RoleId"; private const string RoomKey = "RoomId"; + private const string ThemePreferenceKey = "ThemePreference"; public static ValueTask GetNameAsync(this ICookie cookie) => cookie.GetValueAsync(NameKey); @@ -26,5 +27,10 @@ public static ValueTask SetRoleAsync(this ICookie cookie, Guid? value) => cookie.SetValueAsync(RoleKey, value?.ToString("D") ?? ""); public static ValueTask SetRoomAsync(this ICookie cookie, string value) => cookie.SetValueAsync(RoomKey, value); + + public static ValueTask GetThemePreferenceAsync(this ICookie cookie) + => cookie.GetValueAsync(ThemePreferenceKey); + public static ValueTask SetThemePreferenceAsync(this ICookie cookie, string value) + => cookie.SetValueAsync(ThemePreferenceKey, value); } diff --git a/PointerStar/Client/Program.cs b/PointerStar/Client/Program.cs index c6e8331..da3f021 100644 --- a/PointerStar/Client/Program.cs +++ b/PointerStar/Client/Program.cs @@ -5,6 +5,7 @@ using MudBlazor.Services; using PointerStar.Client; using PointerStar.Client.Cookies; +using PointerStar.Client.Services; using PointerStar.Client.ViewModels; using PointerStar.Shared; using Toolbelt.Blazor.Extensions.DependencyInjection; @@ -34,5 +35,6 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); await builder.Build().RunAsync(); diff --git a/PointerStar/Client/Properties/launchSettings.json b/PointerStar/Client/Properties/launchSettings.json index ee4d9f0..ff1c49c 100644 --- a/PointerStar/Client/Properties/launchSettings.json +++ b/PointerStar/Client/Properties/launchSettings.json @@ -1,40 +1,12 @@ { - "iisSettings": { - "windowsAuthentication": false, - "anonymousAuthentication": true, - "iisExpress": { - "applicationUrl": "http://localhost:13580", - "sslPort": 44352 - } - }, "profiles": { - "http": { - "commandName": "Project", - "dotnetRunMessages": true, - "launchBrowser": true, - "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", - "applicationUrl": "http://localhost:5225", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } - }, - "https": { + "PointerStar.Client": { "commandName": "Project", - "dotnetRunMessages": true, - "launchBrowser": true, - "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", - "applicationUrl": "https://localhost:7234;http://localhost:5225", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } - }, - "IIS Express": { - "commandName": "IISExpress", "launchBrowser": true, - "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" - } + }, + "applicationUrl": "https://localhost:58850;http://localhost:58851" } } -} +} \ No newline at end of file diff --git a/PointerStar/Client/Services/ThemeService.cs b/PointerStar/Client/Services/ThemeService.cs new file mode 100644 index 0000000..7c1c700 --- /dev/null +++ b/PointerStar/Client/Services/ThemeService.cs @@ -0,0 +1,87 @@ +namespace PointerStar.Client.Services; + +using PointerStar.Client.Cookies; + +public enum ThemePreference +{ + System, + Light, + Dark +} + +public interface IThemeService +{ + ThemePreference CurrentPreference { get; } + bool IsDarkMode { get; } + event EventHandler? ThemeChanged; + Task InitializeAsync(Func> getSystemPreference); + Task SetPreferenceAsync(ThemePreference preference); + Task CycleThemeAsync(); +} + +public class ThemeService(ICookie cookie) : IThemeService +{ + private readonly ICookie _cookie = cookie ?? throw new ArgumentNullException(nameof(cookie)); + private bool _systemIsDark; + + public ThemePreference CurrentPreference { get; private set; } + + public bool IsDarkMode => CurrentPreference switch + { + ThemePreference.Dark => true, + ThemePreference.Light => false, + _ => _systemIsDark + }; + + public event EventHandler? ThemeChanged; + + public async Task InitializeAsync(Func> getSystemPreference) + { + // Get system preference + _systemIsDark = await getSystemPreference(); + + // Load saved preference from cookie + string preferenceValue = await _cookie.GetThemePreferenceAsync(); + if (!string.IsNullOrEmpty(preferenceValue) && Enum.TryParse(preferenceValue, out var preference)) + { + CurrentPreference = preference; + } + else + { + CurrentPreference = ThemePreference.System; + } + } + + public async Task SetPreferenceAsync(ThemePreference preference) + { + if (CurrentPreference != preference) + { + CurrentPreference = preference; + await _cookie.SetThemePreferenceAsync(preference.ToString()); + ThemeChanged?.Invoke(this, EventArgs.Empty); + } + } + + public async Task CycleThemeAsync() + { + var nextPreference = IsDarkMode switch + { + true => ThemePreference.Light, + false => ThemePreference.Dark, + }; + await SetPreferenceAsync(nextPreference); + } + + public async Task UpdateSystemPreferenceAsync(bool isDark) + { + if (_systemIsDark != isDark) + { + _systemIsDark = isDark; + if (CurrentPreference == ThemePreference.System) + { + ThemeChanged?.Invoke(this, EventArgs.Empty); + } + } + await Task.CompletedTask; + } +} diff --git a/PointerStar/Client/Shared/MainLayout.razor b/PointerStar/Client/Shared/MainLayout.razor index 5c1a608..e093342 100644 --- a/PointerStar/Client/Shared/MainLayout.razor +++ b/PointerStar/Client/Shared/MainLayout.razor @@ -1,5 +1,8 @@ @using System.Reflection; +@using PointerStar.Client.Services @inherits LayoutComponentBase +@inject IThemeService ThemeService +@implements IDisposable @@ -11,6 +14,9 @@ Pointer* + + + @Assembly.GetExecutingAssembly().GetName().Version?.ToString(3) @@ -26,16 +32,55 @@ { if (firstRender && _mudThemeProvider is { } themeProvider) { - _isDarkMode = await themeProvider.GetSystemDarkModeAsync(); + await ThemeService.InitializeAsync(() => themeProvider.GetSystemDarkModeAsync()); + _isDarkMode = ThemeService.IsDarkMode; await themeProvider.WatchSystemDarkModeAsync(OnSystemPreferenceChanged); + ThemeService.ThemeChanged += OnThemeChanged; StateHasChanged(); } } - private Task OnSystemPreferenceChanged(bool newValue) + private async Task OnSystemPreferenceChanged(bool newValue) { - _isDarkMode = newValue; + if (ThemeService is ThemeService themeService) + { + await themeService.UpdateSystemPreferenceAsync(newValue); + } + } + + private void OnThemeChanged(object? sender, EventArgs e) + { + _isDarkMode = ThemeService.IsDarkMode; StateHasChanged(); - return Task.CompletedTask; + } + + private async Task CycleThemeAsync() + { + await ThemeService.CycleThemeAsync(); + } + + private string GetThemeIcon() + { + return ThemeService.CurrentPreference switch + { + ThemePreference.Light => Icons.Material.Filled.LightMode, + ThemePreference.Dark => Icons.Material.Filled.DarkMode, + _ => Icons.Material.Filled.Brightness4 + }; + } + + private string GetThemeTooltip() + { + return ThemeService.CurrentPreference switch + { + ThemePreference.Light => "Theme: Light (click to switch to Dark)", + ThemePreference.Dark => "Theme: Dark (click to switch to Light)", + _ => "Toggle theme" + }; + } + + public void Dispose() + { + ThemeService.ThemeChanged -= OnThemeChanged; } } diff --git a/Tests/PointerStar.Client.Tests/Services/ThemeServiceTests.cs b/Tests/PointerStar.Client.Tests/Services/ThemeServiceTests.cs new file mode 100644 index 0000000..92fe712 --- /dev/null +++ b/Tests/PointerStar.Client.Tests/Services/ThemeServiceTests.cs @@ -0,0 +1,208 @@ +using Moq; +using Moq.AutoMock; +using PointerStar.Client.Cookies; +using PointerStar.Client.Services; + +namespace PointerStar.Client.Tests.Services; + +[ConstructorTests(typeof(ThemeService))] +public partial class ThemeServiceTests +{ + [Fact] + public async Task InitializeAsync_WithNoSavedPreference_DefaultsToSystem() + { + AutoMocker mocker = new(); + mocker.GetMock() + .Setup(x => x.GetValueAsync("ThemePreference", "")) + .ReturnsAsync(string.Empty); + + ThemeService service = mocker.CreateInstance(); + + await service.InitializeAsync(() => Task.FromResult(false)); + + Assert.Equal(ThemePreference.System, service.CurrentPreference); + } + + [Fact] + public async Task InitializeAsync_WithSavedLightPreference_LoadsPreference() + { + AutoMocker mocker = new(); + mocker.GetMock() + .Setup(x => x.GetValueAsync("ThemePreference", "")) + .ReturnsAsync("Light"); + + ThemeService service = mocker.CreateInstance(); + + await service.InitializeAsync(() => Task.FromResult(false)); + + Assert.Equal(ThemePreference.Light, service.CurrentPreference); + } + + [Fact] + public async Task InitializeAsync_WithSavedDarkPreference_LoadsPreference() + { + AutoMocker mocker = new(); + mocker.GetMock() + .Setup(x => x.GetValueAsync("ThemePreference", "")) + .ReturnsAsync("Dark"); + + ThemeService service = mocker.CreateInstance(); + + await service.InitializeAsync(() => Task.FromResult(true)); + + Assert.Equal(ThemePreference.Dark, service.CurrentPreference); + } + + [Fact] + public async Task IsDarkMode_WithSystemPreferenceAndDarkSystem_ReturnsTrue() + { + AutoMocker mocker = new(); + mocker.GetMock() + .Setup(x => x.GetValueAsync("ThemePreference", "")) + .ReturnsAsync("System"); + + ThemeService service = mocker.CreateInstance(); + await service.InitializeAsync(() => Task.FromResult(true)); + + Assert.True(service.IsDarkMode); + } + + [Fact] + public async Task IsDarkMode_WithSystemPreferenceAndLightSystem_ReturnsFalse() + { + AutoMocker mocker = new(); + mocker.GetMock() + .Setup(x => x.GetValueAsync("ThemePreference", "")) + .ReturnsAsync("System"); + + ThemeService service = mocker.CreateInstance(); + await service.InitializeAsync(() => Task.FromResult(false)); + + Assert.False(service.IsDarkMode); + } + + [Fact] + public async Task IsDarkMode_WithLightPreference_ReturnsFalse() + { + AutoMocker mocker = new(); + mocker.GetMock() + .Setup(x => x.GetValueAsync("ThemePreference", "")) + .ReturnsAsync("Light"); + + ThemeService service = mocker.CreateInstance(); + await service.InitializeAsync(() => Task.FromResult(true)); + + Assert.False(service.IsDarkMode); + } + + [Fact] + public async Task IsDarkMode_WithDarkPreference_ReturnsTrue() + { + AutoMocker mocker = new(); + mocker.GetMock() + .Setup(x => x.GetValueAsync("ThemePreference", "")) + .ReturnsAsync("Dark"); + + ThemeService service = mocker.CreateInstance(); + await service.InitializeAsync(() => Task.FromResult(false)); + + Assert.True(service.IsDarkMode); + } + + [Fact] + public async Task SetPreferenceAsync_WithNewPreference_UpdatesCookieAndRaisesEvent() + { + AutoMocker mocker = new(); + mocker.GetMock() + .Setup(x => x.GetValueAsync("ThemePreference", "")) + .ReturnsAsync("System"); + + ThemeService service = mocker.CreateInstance(); + await service.InitializeAsync(() => Task.FromResult(false)); + + bool eventRaised = false; + service.ThemeChanged += (sender, args) => eventRaised = true; + + await service.SetPreferenceAsync(ThemePreference.Dark); + + Assert.Equal(ThemePreference.Dark, service.CurrentPreference); + Assert.True(eventRaised); + mocker.GetMock().Verify(x => x.SetValueAsync("ThemePreference", "Dark", null), Times.Once); + } + + [Fact] + public async Task SetPreferenceAsync_WithSamePreference_DoesNotRaiseEvent() + { + AutoMocker mocker = new(); + mocker.GetMock() + .Setup(x => x.GetValueAsync("ThemePreference", "")) + .ReturnsAsync("Dark"); + + ThemeService service = mocker.CreateInstance(); + await service.InitializeAsync(() => Task.FromResult(false)); + + bool eventRaised = false; + service.ThemeChanged += (sender, args) => eventRaised = true; + + await service.SetPreferenceAsync(ThemePreference.Dark); + + Assert.False(eventRaised); + mocker.GetMock().Verify(x => x.SetValueAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task CycleThemeAsync_FromLight_SwitchesToDark() + { + AutoMocker mocker = new(); + mocker.GetMock() + .Setup(x => x.GetValueAsync("ThemePreference", "")) + .ReturnsAsync("Light"); + + ThemeService service = mocker.CreateInstance(); + await service.InitializeAsync(() => Task.FromResult(false)); + + await service.CycleThemeAsync(); + + Assert.Equal(ThemePreference.Dark, service.CurrentPreference); + } + + [Fact] + public async Task UpdateSystemPreferenceAsync_WhenSystemModeActive_RaisesThemeChangedEvent() + { + AutoMocker mocker = new(); + mocker.GetMock() + .Setup(x => x.GetValueAsync("ThemePreference", "")) + .ReturnsAsync("System"); + + ThemeService service = mocker.CreateInstance(); + await service.InitializeAsync(() => Task.FromResult(false)); + + bool eventRaised = false; + service.ThemeChanged += (sender, args) => eventRaised = true; + + await service.UpdateSystemPreferenceAsync(true); + + Assert.True(eventRaised); + Assert.True(service.IsDarkMode); + } + + [Fact] + public async Task UpdateSystemPreferenceAsync_WhenNotSystemMode_DoesNotRaiseEvent() + { + AutoMocker mocker = new(); + mocker.GetMock() + .Setup(x => x.GetValueAsync("ThemePreference", "")) + .ReturnsAsync("Light"); + + ThemeService service = mocker.CreateInstance(); + await service.InitializeAsync(() => Task.FromResult(false)); + + bool eventRaised = false; + service.ThemeChanged += (sender, args) => eventRaised = true; + + await service.UpdateSystemPreferenceAsync(true); + + Assert.False(eventRaised); + Assert.False(service.IsDarkMode); // Still light mode + } +}