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;
/// <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:
/// 1. On first load (before login): InitializeAsync() reads localStorage["signflow.darkMode"].
/// Falls back to window.matchMedia("prefers-color-scheme: dark") if key is absent.
/// ThemeName defaults to "blazing-berry" until login completes.
/// 2. After login: LoadFromServerAsync() syncs preferences from the server cache.
/// - If server has no saved preferences (first login ever), the system dark mode preference
/// detected locally is written to the server immediately.
/// - Otherwise server values overwrite local state and localStorage.
/// Theme selection is the sole UI control — no separate dark/light toggle.
/// Themes ordered dark → light: blazing-dark | blazing-berry | purple
///
/// Initialization:
/// 1. InitializeAsync() — reads localStorage["signflow.theme"], falls back to
/// system prefers-color-scheme (dark → blazing-dark, light → blazing-berry).
/// 2. LoadFromServerAsync() — called after login, syncs theme from server cache.
///
/// Persistence:
/// - Dark mode toggle → localStorage written immediately + debounced PUT /api/UserPreferences.
/// - Theme change → debounced PUT /api/UserPreferences (no localStorage for theme).
/// - 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.
/// - SetThemeAsync() → debounced 500ms PUT /api/UserPreferences.
/// - ScheduleSaveAsync() uses GetCached() — no extra GET before each save.
///
/// Consumers subscribe to OnChange and call StateHasChanged (e.g. MainLayout).
/// </summary>
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 =
[
new("Blazing Berry", "blazing-berry"),
new("Fluent", "fluent"),
new("Purple", "purple"),
new("Blazing Dark", "blazing-dark"), // index 0 — 🌙
new("Blazing Berry", "blazing-berry"), // index 1 — middle
new("Purple", "purple"), // index 2 — ☀️
];
private const string LocalStorageDarkModeKey = "signflow.darkMode";
private const string LocalStorageThemeKey = "signflow.theme";
// ── State ─────────────────────────────────────────────────────────────────
private string _themeName = "blazing-berry";
private bool _isDarkMode = false;
private bool _initialized = false;
// ── Dependencies ──────────────────────────────────────────────────────────
@@ -55,12 +47,12 @@ public class ThemeService : IAsyncDisposable
private CancellationTokenSource? _saveCts;
// ── 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;
/// <summary>
/// Set when a background save fails. UI layer can read this to show a toast.
/// Cleared automatically on the next successful save or on next toggle/theme change.
/// Set when a background save fails. UI layer can surface this as a warning.
/// Cleared on the next successful save or theme change.
/// </summary>
public Exception? SaveError { get; private set; }
@@ -74,98 +66,89 @@ public class ThemeService : IAsyncDisposable
// ── Public state ──────────────────────────────────────────────────────────
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>
/// The CSS href value for the active DevExpress theme.
/// Example: "_content/DevExpress.Blazor.Themes/blazing-berry.dark.bs5.min.css"
/// CSS href for the active DevExpress theme.
/// Example: "_content/DevExpress.Blazor.Themes/blazing-dark.bs5.min.css"
/// </summary>
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>
public string BootstrapTheme => _isDarkMode ? "dark" : "light";
/// <summary>Bootstrap 5 data-bs-theme value. Only "dark" for blazing-dark.</summary>
public string BootstrapTheme => _themeName == "blazing-dark" ? "dark" : "light";
// ── Initialization ────────────────────────────────────────────────────────
/// <summary>
/// Must be called once in OnAfterRenderAsync(firstRender: true) — after JS is available.
/// Reads dark mode from localStorage, falling back to system (prefers-color-scheme).
/// Safe to call multiple times; subsequent calls are no-ops.
/// Called once in OnAfterRenderAsync(firstRender: true).
/// Reads theme from localStorage; falls back to system dark-mode preference.
/// Safe to call multiple times — subsequent calls are no-ops.
/// </summary>
public async Task InitializeAsync()
{
if (_initialized) return;
_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
{
// No stored preference — detect system setting
_isDarkMode = await _js.InvokeAsync<bool>(
// No stored theme — use system preference to pick dark or light
var prefersDark = await _js.InvokeAsync<bool>(
"eval",
"(function(){ return window.matchMedia('(prefers-color-scheme: dark)').matches; })()");
_themeName = prefersDark ? "blazing-dark" : "blazing-berry";
}
await ApplyThemeToDocumentAsync();
OnChange?.Invoke();
}
/// <summary>
/// Called after a successful sender login.
/// Syncs theme + dark mode from the server.
/// If no preferences exist yet on the server (first ever login),
/// the locally-detected system preference is saved to the server.
/// Called after successful sender login.
/// Syncs theme from the server cache; on first login propagates local theme to server.
/// </summary>
public async Task LoadFromServerAsync(CancellationToken ct = default)
{
var prefs = await _preferencesService.GetAsync(ct);
var isFirstLogin = prefs.GridLayouts.Count == 0
&& prefs.ThemeName == "blazing-berry"
&& !prefs.IsDarkMode;
if (isFirstLogin && _isDarkMode)
if (isFirstLogin && _themeName != "blazing-berry")
{
// First login: propagate local system preference to server
prefs.IsDarkMode = _isDarkMode;
// Propagate locally-detected theme to server on first login
prefs.ThemeName = _themeName;
prefs.IsDarkMode = IsDarkMode;
prefs = await _preferencesService.SaveAsync(prefs, ct);
}
ApplyPreferences(prefs);
await _js.InvokeVoidAsync("localStorage.setItem",
LocalStorageDarkModeKey, _isDarkMode.ToString().ToLower());
// Persist resolved theme to localStorage
await _js.InvokeVoidAsync("localStorage.setItem", LocalStorageThemeKey, _themeName);
DisplayName = prefs.DisplayName;
await ApplyThemeToDocumentAsync();
OnChange?.Invoke();
}
// ── Mutations ─────────────────────────────────────────────────────────────
// ── Mutation ──────────────────────────────────────────────────────────────
/// <summary>
/// Toggles dark mode, updates localStorage immediately, 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>
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.
/// Sets the active theme, applies it immediately, and schedules a server save.
/// State change is applied regardless of whether the save succeeds.
/// </summary>
public async Task SetThemeAsync(string themeName)
{
@@ -175,6 +158,9 @@ public class ThemeService : IAsyncDisposable
_themeName = themeName;
SaveError = null;
await _js.InvokeVoidAsync("localStorage.setItem", LocalStorageThemeKey, _themeName);
await ApplyThemeToDocumentAsync();
OnChange?.Invoke();
await ScheduleSaveAsync();
}
@@ -187,13 +173,24 @@ public class ThemeService : IAsyncDisposable
? prefs.ThemeName
: "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()
{
_saveCts?.Cancel();
@@ -203,22 +200,21 @@ public class ThemeService : IAsyncDisposable
{
await Task.Delay(500, _saveCts.Token);
// Use cached preferences to preserve GridLayouts — no network GET needed.
var prefs = _preferencesService.GetCached() ?? new UserPreferencesDto();
prefs.ThemeName = _themeName;
prefs.IsDarkMode = _isDarkMode;
prefs.IsDarkMode = IsDarkMode;
await _preferencesService.SaveAsync(prefs, _saveCts.Token);
}
catch (TaskCanceledException)
{
// A newer save was scheduled — this one is intentionally cancelled.
// Newer save scheduled — intentionally cancelled.
}
catch (Exception ex)
{
_logger.LogError(ex, "Background save of user preferences failed.");
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>
/// <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);

View File

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