Skip to content

Commit 6b34529

Browse files
CopilotKeboo
andauthored
Add light/dark theme toggle with system preference support (#458)
* Initial plan * Add light/dark theme toggle feature Co-authored-by: Keboo <[email protected]> * Add comprehensive tests for ThemeService Co-authored-by: Keboo <[email protected]> * Simplifies theme cycle management Refines the theme preference cycling logic to only allow switching between Light and Dark modes directly. The "System" preference is no longer part of the user-facing cycle, although system preference detection continues to determine the active theme when "System" is the selected preference. Updates associated UI elements (icons, tooltips) and removes outdated tests to reflect this change. Also updates the `launchSettings.json` for a more streamlined development experience. --------- Co-authored-by: copilot-swe-agent[bot] <[email protected]> Co-authored-by: Keboo <[email protected]> Co-authored-by: Kevin Bost <[email protected]>
1 parent 544b0ea commit 6b34529

File tree

6 files changed

+356
-36
lines changed

6 files changed

+356
-36
lines changed

PointerStar/Client/Cookies/WellKnownCookieValues.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ public static class WellKnownCookieValues
55
private const string NameKey = "Name";
66
private const string RoleKey = "RoleId";
77
private const string RoomKey = "RoomId";
8+
private const string ThemePreferenceKey = "ThemePreference";
89

910
public static ValueTask<string> GetNameAsync(this ICookie cookie)
1011
=> cookie.GetValueAsync(NameKey);
@@ -26,5 +27,10 @@ public static ValueTask SetRoleAsync(this ICookie cookie, Guid? value)
2627
=> cookie.SetValueAsync(RoleKey, value?.ToString("D") ?? "");
2728
public static ValueTask SetRoomAsync(this ICookie cookie, string value)
2829
=> cookie.SetValueAsync(RoomKey, value);
30+
31+
public static ValueTask<string> GetThemePreferenceAsync(this ICookie cookie)
32+
=> cookie.GetValueAsync(ThemePreferenceKey);
33+
public static ValueTask SetThemePreferenceAsync(this ICookie cookie, string value)
34+
=> cookie.SetValueAsync(ThemePreferenceKey, value);
2935
}
3036

PointerStar/Client/Program.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
using MudBlazor.Services;
66
using PointerStar.Client;
77
using PointerStar.Client.Cookies;
8+
using PointerStar.Client.Services;
89
using PointerStar.Client.ViewModels;
910
using PointerStar.Shared;
1011
using Toolbelt.Blazor.Extensions.DependencyInjection;
@@ -34,5 +35,6 @@
3435
builder.Services.AddScoped<UserDialogViewModel>();
3536
builder.Services.AddScoped<ICookie, Cookie>();
3637
builder.Services.AddScoped<IClipboardService, ClipboardService>();
38+
builder.Services.AddScoped<IThemeService, ThemeService>();
3739

3840
await builder.Build().RunAsync();
Lines changed: 4 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,40 +1,12 @@
11
{
2-
"iisSettings": {
3-
"windowsAuthentication": false,
4-
"anonymousAuthentication": true,
5-
"iisExpress": {
6-
"applicationUrl": "http://localhost:13580",
7-
"sslPort": 44352
8-
}
9-
},
102
"profiles": {
11-
"http": {
12-
"commandName": "Project",
13-
"dotnetRunMessages": true,
14-
"launchBrowser": true,
15-
"inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}",
16-
"applicationUrl": "http://localhost:5225",
17-
"environmentVariables": {
18-
"ASPNETCORE_ENVIRONMENT": "Development"
19-
}
20-
},
21-
"https": {
3+
"PointerStar.Client": {
224
"commandName": "Project",
23-
"dotnetRunMessages": true,
24-
"launchBrowser": true,
25-
"inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}",
26-
"applicationUrl": "https://localhost:7234;http://localhost:5225",
27-
"environmentVariables": {
28-
"ASPNETCORE_ENVIRONMENT": "Development"
29-
}
30-
},
31-
"IIS Express": {
32-
"commandName": "IISExpress",
335
"launchBrowser": true,
34-
"inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}",
356
"environmentVariables": {
367
"ASPNETCORE_ENVIRONMENT": "Development"
37-
}
8+
},
9+
"applicationUrl": "https://localhost:58850;http://localhost:58851"
3810
}
3911
}
40-
}
12+
}
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
namespace PointerStar.Client.Services;
2+
3+
using PointerStar.Client.Cookies;
4+
5+
public enum ThemePreference
6+
{
7+
System,
8+
Light,
9+
Dark
10+
}
11+
12+
public interface IThemeService
13+
{
14+
ThemePreference CurrentPreference { get; }
15+
bool IsDarkMode { get; }
16+
event EventHandler? ThemeChanged;
17+
Task InitializeAsync(Func<Task<bool>> getSystemPreference);
18+
Task SetPreferenceAsync(ThemePreference preference);
19+
Task CycleThemeAsync();
20+
}
21+
22+
public class ThemeService(ICookie cookie) : IThemeService
23+
{
24+
private readonly ICookie _cookie = cookie ?? throw new ArgumentNullException(nameof(cookie));
25+
private bool _systemIsDark;
26+
27+
public ThemePreference CurrentPreference { get; private set; }
28+
29+
public bool IsDarkMode => CurrentPreference switch
30+
{
31+
ThemePreference.Dark => true,
32+
ThemePreference.Light => false,
33+
_ => _systemIsDark
34+
};
35+
36+
public event EventHandler? ThemeChanged;
37+
38+
public async Task InitializeAsync(Func<Task<bool>> getSystemPreference)
39+
{
40+
// Get system preference
41+
_systemIsDark = await getSystemPreference();
42+
43+
// Load saved preference from cookie
44+
string preferenceValue = await _cookie.GetThemePreferenceAsync();
45+
if (!string.IsNullOrEmpty(preferenceValue) && Enum.TryParse<ThemePreference>(preferenceValue, out var preference))
46+
{
47+
CurrentPreference = preference;
48+
}
49+
else
50+
{
51+
CurrentPreference = ThemePreference.System;
52+
}
53+
}
54+
55+
public async Task SetPreferenceAsync(ThemePreference preference)
56+
{
57+
if (CurrentPreference != preference)
58+
{
59+
CurrentPreference = preference;
60+
await _cookie.SetThemePreferenceAsync(preference.ToString());
61+
ThemeChanged?.Invoke(this, EventArgs.Empty);
62+
}
63+
}
64+
65+
public async Task CycleThemeAsync()
66+
{
67+
var nextPreference = IsDarkMode switch
68+
{
69+
true => ThemePreference.Light,
70+
false => ThemePreference.Dark,
71+
};
72+
await SetPreferenceAsync(nextPreference);
73+
}
74+
75+
public async Task UpdateSystemPreferenceAsync(bool isDark)
76+
{
77+
if (_systemIsDark != isDark)
78+
{
79+
_systemIsDark = isDark;
80+
if (CurrentPreference == ThemePreference.System)
81+
{
82+
ThemeChanged?.Invoke(this, EventArgs.Empty);
83+
}
84+
}
85+
await Task.CompletedTask;
86+
}
87+
}

PointerStar/Client/Shared/MainLayout.razor

Lines changed: 49 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
@using System.Reflection;
2+
@using PointerStar.Client.Services
23
@inherits LayoutComponentBase
4+
@inject IThemeService ThemeService
5+
@implements IDisposable
36

47
<Toolbelt.Blazor.PWA.Updater.PWAUpdater />
58
<MudThemeProvider @ref="@_mudThemeProvider" @bind-IsDarkMode="@_isDarkMode" />
@@ -11,6 +14,9 @@
1114
<MudAppBar Elevation="1">
1215
<MudText Typo="Typo.h5" Class="ml-3">Pointer*</MudText>
1316
<MudSpacer />
17+
<MudTooltip Text="@GetThemeTooltip()">
18+
<MudIconButton Icon="@GetThemeIcon()" Color="Color.Inherit" OnClick="@CycleThemeAsync" />
19+
</MudTooltip>
1420
<MudText Typo="Typo.subtitle1" Align="Align.End">@Assembly.GetExecutingAssembly().GetName().Version?.ToString(3)</MudText>
1521
</MudAppBar>
1622
<MudMainContent>
@@ -26,16 +32,55 @@
2632
{
2733
if (firstRender && _mudThemeProvider is { } themeProvider)
2834
{
29-
_isDarkMode = await themeProvider.GetSystemDarkModeAsync();
35+
await ThemeService.InitializeAsync(() => themeProvider.GetSystemDarkModeAsync());
36+
_isDarkMode = ThemeService.IsDarkMode;
3037
await themeProvider.WatchSystemDarkModeAsync(OnSystemPreferenceChanged);
38+
ThemeService.ThemeChanged += OnThemeChanged;
3139
StateHasChanged();
3240
}
3341
}
3442

35-
private Task OnSystemPreferenceChanged(bool newValue)
43+
private async Task OnSystemPreferenceChanged(bool newValue)
3644
{
37-
_isDarkMode = newValue;
45+
if (ThemeService is ThemeService themeService)
46+
{
47+
await themeService.UpdateSystemPreferenceAsync(newValue);
48+
}
49+
}
50+
51+
private void OnThemeChanged(object? sender, EventArgs e)
52+
{
53+
_isDarkMode = ThemeService.IsDarkMode;
3854
StateHasChanged();
39-
return Task.CompletedTask;
55+
}
56+
57+
private async Task CycleThemeAsync()
58+
{
59+
await ThemeService.CycleThemeAsync();
60+
}
61+
62+
private string GetThemeIcon()
63+
{
64+
return ThemeService.CurrentPreference switch
65+
{
66+
ThemePreference.Light => Icons.Material.Filled.LightMode,
67+
ThemePreference.Dark => Icons.Material.Filled.DarkMode,
68+
_ => Icons.Material.Filled.Brightness4
69+
};
70+
}
71+
72+
private string GetThemeTooltip()
73+
{
74+
return ThemeService.CurrentPreference switch
75+
{
76+
ThemePreference.Light => "Theme: Light (click to switch to Dark)",
77+
ThemePreference.Dark => "Theme: Dark (click to switch to Light)",
78+
_ => "Toggle theme"
79+
};
80+
}
81+
82+
public void Dispose()
83+
{
84+
ThemeService.ThemeChanged -= OnThemeChanged;
4085
}
4186
}

0 commit comments

Comments
 (0)