SF-74: Client Services — ThemeService (debounce, SaveError, DisplayName), UserPreferencesService (1h cache)

This commit is contained in:
2026-09-03 14:57:55 +02:00
parent 6effb8a0d6
commit bac9bfd036
2 changed files with 85 additions and 83 deletions

View File

@@ -5,45 +5,37 @@ using EnvelopeGenerator.Application.Common.Dto;
namespace EnvelopeGenerator.Server.Client.Services; namespace EnvelopeGenerator.Server.Client.Services;
/// <summary> /// <summary>
/// Manages the active DevExpress theme and dark mode state for the WASM client. /// Manages the active DevExpress theme for the WASM client.
/// ///
/// Initialization order: /// Theme selection is the sole UI control — no separate dark/light toggle.
/// 1. On first load (before login): InitializeAsync() reads localStorage["signflow.darkMode"]. /// Themes ordered dark → light: blazing-dark | blazing-berry | purple
/// Falls back to window.matchMedia("prefers-color-scheme: dark") if key is absent. ///
/// ThemeName defaults to "blazing-berry" until login completes. /// Initialization:
/// 2. After login: LoadFromServerAsync() syncs preferences from the server cache. /// 1. InitializeAsync() — reads localStorage["signflow.theme"], falls back to
/// - If server has no saved preferences (first login ever), the system dark mode preference /// system prefers-color-scheme (dark → blazing-dark, light → blazing-berry).
/// detected locally is written to the server immediately. /// 2. LoadFromServerAsync() — called after login, syncs theme from server cache.
/// - Otherwise server values overwrite local state and localStorage.
/// ///
/// Persistence: /// Persistence:
/// - Dark mode toggle → localStorage written immediately + debounced PUT /api/UserPreferences. /// - SetThemeAsync() → debounced 500ms PUT /api/UserPreferences.
/// - Theme change → debounced PUT /api/UserPreferences (no localStorage for theme). /// - ScheduleSaveAsync() uses GetCached() — no extra GET before each save.
/// - All server writes use the in-memory state — no extra GET before each save.
///
/// Error handling:
/// - InitializeAsync / LoadFromServerAsync errors are logged and rethrown.
/// - Debounced save errors (ToggleDarkMode, SetTheme) are logged; UI is NOT blocked
/// because the state change is already applied locally. The error is surfaced via
/// the SaveError property so the UI layer can show a toast/snackbar if desired.
/// ///
/// Consumers subscribe to OnChange and call StateHasChanged (e.g. MainLayout). /// Consumers subscribe to OnChange and call StateHasChanged (e.g. MainLayout).
/// </summary> /// </summary>
public class ThemeService : IAsyncDisposable public class ThemeService : IAsyncDisposable
{ {
// ── Allowed themes ──────────────────────────────────────────────────────── // ── Allowed themes (ordered dark → light for range slider) ───────────────
// DevExpress.Blazor.Themes 25.2.3: blazing-berry, blazing-dark, purple
public static readonly IReadOnlyList<ThemeOption> AvailableThemes = public static readonly IReadOnlyList<ThemeOption> AvailableThemes =
[ [
new("Blazing Berry", "blazing-berry"), new("Blazing Dark", "blazing-dark"), // index 0 — 🌙
new("Fluent", "fluent"), new("Blazing Berry", "blazing-berry"), // index 1 — middle
new("Purple", "purple"), new("Purple", "purple"), // index 2 — ☀️
]; ];
private const string LocalStorageDarkModeKey = "signflow.darkMode"; private const string LocalStorageThemeKey = "signflow.theme";
// ── State ───────────────────────────────────────────────────────────────── // ── State ─────────────────────────────────────────────────────────────────
private string _themeName = "blazing-berry"; private string _themeName = "blazing-berry";
private bool _isDarkMode = false;
private bool _initialized = false; private bool _initialized = false;
// ── Dependencies ────────────────────────────────────────────────────────── // ── Dependencies ──────────────────────────────────────────────────────────
@@ -55,12 +47,12 @@ public class ThemeService : IAsyncDisposable
private CancellationTokenSource? _saveCts; private CancellationTokenSource? _saveCts;
// ── Change & error notification ─────────────────────────────────────────── // ── Change & error notification ───────────────────────────────────────────
/// <summary>Fired whenever theme or dark mode changes. Subscribers call StateHasChanged.</summary> /// <summary>Fired whenever theme changes. Subscribers call StateHasChanged.</summary>
public event Action? OnChange; public event Action? OnChange;
/// <summary> /// <summary>
/// Set when a background save fails. UI layer can read this to show a toast. /// Set when a background save fails. UI layer can surface this as a warning.
/// Cleared automatically on the next successful save or on next toggle/theme change. /// Cleared on the next successful save or theme change.
/// </summary> /// </summary>
public Exception? SaveError { get; private set; } public Exception? SaveError { get; private set; }
@@ -74,98 +66,89 @@ public class ThemeService : IAsyncDisposable
// ── Public state ────────────────────────────────────────────────────────── // ── Public state ──────────────────────────────────────────────────────────
public string ThemeName => _themeName; public string ThemeName => _themeName;
public bool IsDarkMode => _isDarkMode;
/// <summary>True when blazing-dark is active — used for Bootstrap data-bs-theme.</summary>
public bool IsDarkMode => _themeName == "blazing-dark";
public string? DisplayName { get; private set; }
/// <summary> /// <summary>
/// The CSS href value for the active DevExpress theme. /// CSS href for the active DevExpress theme.
/// Example: "_content/DevExpress.Blazor.Themes/blazing-berry.dark.bs5.min.css" /// Example: "_content/DevExpress.Blazor.Themes/blazing-dark.bs5.min.css"
/// </summary> /// </summary>
public string ThemeCssHref => public string ThemeCssHref =>
$"_content/DevExpress.Blazor.Themes/{_themeName}{(_isDarkMode ? ".dark" : "")}.bs5.min.css"; $"_content/DevExpress.Blazor.Themes/{_themeName}.bs5.min.css";
/// <summary>Bootstrap 5 data-bs-theme attribute value ("dark" or "light").</summary> /// <summary>Bootstrap 5 data-bs-theme value. Only "dark" for blazing-dark.</summary>
public string BootstrapTheme => _isDarkMode ? "dark" : "light"; public string BootstrapTheme => _themeName == "blazing-dark" ? "dark" : "light";
// ── Initialization ──────────────────────────────────────────────────────── // ── Initialization ────────────────────────────────────────────────────────
/// <summary> /// <summary>
/// Must be called once in OnAfterRenderAsync(firstRender: true) — after JS is available. /// Called once in OnAfterRenderAsync(firstRender: true).
/// Reads dark mode from localStorage, falling back to system (prefers-color-scheme). /// Reads theme from localStorage; falls back to system dark-mode preference.
/// Safe to call multiple times; subsequent calls are no-ops. /// Safe to call multiple times — subsequent calls are no-ops.
/// </summary> /// </summary>
public async Task InitializeAsync() public async Task InitializeAsync()
{ {
if (_initialized) return; if (_initialized) return;
_initialized = true; _initialized = true;
var stored = await _js.InvokeAsync<string?>("localStorage.getItem", LocalStorageDarkModeKey); var stored = await _js.InvokeAsync<string?>("localStorage.getItem", LocalStorageThemeKey);
if (stored is not null) if (stored is not null && AvailableThemes.Any(t => t.Value == stored))
{ {
_isDarkMode = stored == "true"; _themeName = stored;
} }
else else
{ {
// No stored preference — detect system setting // No stored theme — use system preference to pick dark or light
_isDarkMode = await _js.InvokeAsync<bool>( var prefersDark = await _js.InvokeAsync<bool>(
"eval", "eval",
"(function(){ return window.matchMedia('(prefers-color-scheme: dark)').matches; })()"); "(function(){ return window.matchMedia('(prefers-color-scheme: dark)').matches; })()");
_themeName = prefersDark ? "blazing-dark" : "blazing-berry";
} }
await ApplyThemeToDocumentAsync();
OnChange?.Invoke(); OnChange?.Invoke();
} }
/// <summary> /// <summary>
/// Called after a successful sender login. /// Called after successful sender login.
/// Syncs theme + dark mode from the server. /// Syncs theme from the server cache; on first login propagates local theme to server.
/// If no preferences exist yet on the server (first ever login),
/// the locally-detected system preference is saved to the server.
/// </summary> /// </summary>
public async Task LoadFromServerAsync(CancellationToken ct = default) public async Task LoadFromServerAsync(CancellationToken ct = default)
{ {
var prefs = await _preferencesService.GetAsync(ct); var prefs = await _preferencesService.GetAsync(ct);
var isFirstLogin = prefs.GridLayouts.Count == 0 var isFirstLogin = prefs.GridLayouts.Count == 0
&& prefs.ThemeName == "blazing-berry" && prefs.ThemeName == "blazing-berry"
&& !prefs.IsDarkMode; && !prefs.IsDarkMode;
if (isFirstLogin && _isDarkMode) if (isFirstLogin && _themeName != "blazing-berry")
{ {
// First login: propagate local system preference to server // Propagate locally-detected theme to server on first login
prefs.IsDarkMode = _isDarkMode; prefs.ThemeName = _themeName;
prefs.IsDarkMode = IsDarkMode;
prefs = await _preferencesService.SaveAsync(prefs, ct); prefs = await _preferencesService.SaveAsync(prefs, ct);
} }
ApplyPreferences(prefs); ApplyPreferences(prefs);
await _js.InvokeVoidAsync("localStorage.setItem", // Persist resolved theme to localStorage
LocalStorageDarkModeKey, _isDarkMode.ToString().ToLower()); await _js.InvokeVoidAsync("localStorage.setItem", LocalStorageThemeKey, _themeName);
DisplayName = prefs.DisplayName;
await ApplyThemeToDocumentAsync();
OnChange?.Invoke(); OnChange?.Invoke();
} }
// ── Mutations ───────────────────────────────────────────────────────────── // ── Mutation ──────────────────────────────────────────────────────────────
/// <summary> /// <summary>
/// Toggles dark mode, updates localStorage immediately, and schedules a server save. /// Sets the active theme, applies it immediately, and schedules a server save.
/// The state change is applied regardless of whether the server save succeeds. /// State change is applied regardless of whether the save succeeds.
/// Check SaveError after OnChange fires to detect background save failures.
/// </summary>
public async Task ToggleDarkModeAsync()
{
_isDarkMode = !_isDarkMode;
SaveError = null;
await _js.InvokeVoidAsync("localStorage.setItem",
LocalStorageDarkModeKey, _isDarkMode.ToString().ToLower());
OnChange?.Invoke();
await ScheduleSaveAsync();
}
/// <summary>
/// Sets the active theme and schedules a server save.
/// The state change is applied regardless of whether the server save succeeds.
/// Check SaveError after OnChange fires to detect background save failures.
/// </summary> /// </summary>
public async Task SetThemeAsync(string themeName) public async Task SetThemeAsync(string themeName)
{ {
@@ -175,6 +158,9 @@ public class ThemeService : IAsyncDisposable
_themeName = themeName; _themeName = themeName;
SaveError = null; SaveError = null;
await _js.InvokeVoidAsync("localStorage.setItem", LocalStorageThemeKey, _themeName);
await ApplyThemeToDocumentAsync();
OnChange?.Invoke(); OnChange?.Invoke();
await ScheduleSaveAsync(); await ScheduleSaveAsync();
} }
@@ -187,13 +173,24 @@ public class ThemeService : IAsyncDisposable
? prefs.ThemeName ? prefs.ThemeName
: "blazing-berry"; : "blazing-berry";
_isDarkMode = prefs.IsDarkMode; // Legacy: if IsDarkMode flag was saved but ThemeName wasn't updated
if (prefs.IsDarkMode && _themeName == "blazing-berry")
_themeName = "blazing-dark";
}
private async Task ApplyThemeToDocumentAsync()
{
try
{
await _js.InvokeVoidAsync("signflowTheme.apply", ThemeCssHref, BootstrapTheme);
}
catch (Exception ex)
{
// JS unavailable during server prerender — <HeadContent> link is the SSR fallback.
_logger.LogDebug(ex, "[ThemeService] ApplyThemeToDocumentAsync skipped (JS unavailable).");
}
} }
/// <summary>
/// Debounced server save. Uses current in-memory state — no extra GET.
/// Only theme and dark mode are written; GridLayouts are preserved from cache.
/// </summary>
private async Task ScheduleSaveAsync() private async Task ScheduleSaveAsync()
{ {
_saveCts?.Cancel(); _saveCts?.Cancel();
@@ -203,22 +200,21 @@ public class ThemeService : IAsyncDisposable
{ {
await Task.Delay(500, _saveCts.Token); await Task.Delay(500, _saveCts.Token);
// Use cached preferences to preserve GridLayouts — no network GET needed.
var prefs = _preferencesService.GetCached() ?? new UserPreferencesDto(); var prefs = _preferencesService.GetCached() ?? new UserPreferencesDto();
prefs.ThemeName = _themeName; prefs.ThemeName = _themeName;
prefs.IsDarkMode = _isDarkMode; prefs.IsDarkMode = IsDarkMode;
await _preferencesService.SaveAsync(prefs, _saveCts.Token); await _preferencesService.SaveAsync(prefs, _saveCts.Token);
} }
catch (TaskCanceledException) catch (TaskCanceledException)
{ {
// A newer save was scheduled — this one is intentionally cancelled. // Newer save scheduled — intentionally cancelled.
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, "Background save of user preferences failed."); _logger.LogError(ex, "Background save of user preferences failed.");
SaveError = ex; SaveError = ex;
OnChange?.Invoke(); // Notify UI so it can show SaveError OnChange?.Invoke();
} }
} }
@@ -232,5 +228,5 @@ public class ThemeService : IAsyncDisposable
/// <summary>Describes a selectable DevExpress theme.</summary> /// <summary>Describes a selectable DevExpress theme.</summary>
/// <param name="Label">User-facing display name.</param> /// <param name="Label">User-facing display name.</param>
/// <param name="Value">CSS theme identifier (used in href and API).</param> /// <param name="Value">CSS theme identifier.</param>
public sealed record ThemeOption(string Label, string Value); public sealed record ThemeOption(string Label, string Value);

View File

@@ -45,8 +45,9 @@ public class UserPreferencesService(
var client = clientFactory.CreateClient("EnvelopeGenerator.Server"); var client = clientFactory.CreateClient("EnvelopeGenerator.Server");
var response = await client.GetAsync(Endpoint, ct); var response = await client.GetAsync(Endpoint, ct);
// 404 means no preferences saved yet — return and cache defaults. // Not authenticated yet (login page) or no preferences saved — return defaults.
if (response.StatusCode == System.Net.HttpStatusCode.NotFound) if (response.StatusCode == System.Net.HttpStatusCode.NotFound ||
response.StatusCode == System.Net.HttpStatusCode.Unauthorized)
{ {
SetCache(new UserPreferencesDto()); SetCache(new UserPreferencesDto());
return _cached!; return _cached!;
@@ -82,6 +83,11 @@ public class UserPreferencesService(
{ {
var client = clientFactory.CreateClient("EnvelopeGenerator.Server"); var client = clientFactory.CreateClient("EnvelopeGenerator.Server");
var response = await client.PutAsJsonAsync(Endpoint, preferences, _jsonOptions, ct); var response = await client.PutAsJsonAsync(Endpoint, preferences, _jsonOptions, ct);
// Not authenticated (login page) — skip save, keep local state only.
if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized)
return preferences;
response.EnsureSuccessStatusCode(); response.EnsureSuccessStatusCode();
var saved = await response.Content.ReadFromJsonAsync<UserPreferencesDto>(_jsonOptions, ct) var saved = await response.Content.ReadFromJsonAsync<UserPreferencesDto>(_jsonOptions, ct)