Files
EnvelopeGenerator/EnvelopeGenerator.Server/EnvelopeGenerator.Server.Client/Services/ThemeService.cs
TekH 5098deedd1 Add ThemeService and UserPreferencesService
Introduce ThemeService to manage themes and dark mode state,
including initialization, persistence, and server sync.

Add UserPreferencesService to handle API communication for
retrieving and saving user preferences with in-memory caching
to reduce network overhead.

Register both services in the DI container in Program.cs.

Enhance ThemeService with detailed documentation and introduce
ThemeOption record for selectable themes. Improve server-side
caching in UserPreferencesService for consistency and efficiency.
2026-09-03 01:18:01 +02:00

237 lines
10 KiB
C#

using Microsoft.JSInterop;
using Microsoft.Extensions.Logging;
using EnvelopeGenerator.Application.Common.Dto;
namespace EnvelopeGenerator.Server.Client.Services;
/// <summary>
/// Manages the active DevExpress theme and dark mode state 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.
///
/// 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.
///
/// Consumers subscribe to OnChange and call StateHasChanged (e.g. MainLayout).
/// </summary>
public class ThemeService : IAsyncDisposable
{
// ── Allowed themes ────────────────────────────────────────────────────────
public static readonly IReadOnlyList<ThemeOption> AvailableThemes =
[
new("Blazing Berry", "blazing-berry"),
new("Fluent", "fluent"),
new("Purple", "purple"),
];
private const string LocalStorageDarkModeKey = "signflow.darkMode";
// ── State ─────────────────────────────────────────────────────────────────
private string _themeName = "blazing-berry";
private bool _isDarkMode = false;
private bool _initialized = false;
// ── Dependencies ──────────────────────────────────────────────────────────
private readonly IJSRuntime _js;
private readonly UserPreferencesService _preferencesService;
private readonly ILogger<ThemeService> _logger;
// ── Debounce ──────────────────────────────────────────────────────────────
private CancellationTokenSource? _saveCts;
// ── Change & error notification ───────────────────────────────────────────
/// <summary>Fired whenever theme or dark mode 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.
/// </summary>
public Exception? SaveError { get; private set; }
public ThemeService(IJSRuntime js, UserPreferencesService preferencesService, ILogger<ThemeService> logger)
{
_js = js;
_preferencesService = preferencesService;
_logger = logger;
}
// ── Public state ──────────────────────────────────────────────────────────
public string ThemeName => _themeName;
public bool IsDarkMode => _isDarkMode;
/// <summary>
/// The CSS href value for the active DevExpress theme.
/// Example: "_content/DevExpress.Blazor.Themes/blazing-berry.dark.bs5.min.css"
/// </summary>
public string ThemeCssHref =>
$"_content/DevExpress.Blazor.Themes/{_themeName}{(_isDarkMode ? ".dark" : "")}.bs5.min.css";
/// <summary>Bootstrap 5 data-bs-theme attribute value ("dark" or "light").</summary>
public string BootstrapTheme => _isDarkMode ? "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.
/// </summary>
public async Task InitializeAsync()
{
if (_initialized) return;
_initialized = true;
var stored = await _js.InvokeAsync<string?>("localStorage.getItem", LocalStorageDarkModeKey);
if (stored is not null)
{
_isDarkMode = stored == "true";
}
else
{
// No stored preference — detect system setting
_isDarkMode = await _js.InvokeAsync<bool>(
"eval",
"(function(){ return window.matchMedia('(prefers-color-scheme: dark)').matches; })()");
}
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.
/// </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)
{
// First login: propagate local system preference to server
prefs.IsDarkMode = _isDarkMode;
prefs = await _preferencesService.SaveAsync(prefs, ct);
}
ApplyPreferences(prefs);
await _js.InvokeVoidAsync("localStorage.setItem",
LocalStorageDarkModeKey, _isDarkMode.ToString().ToLower());
OnChange?.Invoke();
}
// ── Mutations ─────────────────────────────────────────────────────────────
/// <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.
/// </summary>
public async Task SetThemeAsync(string themeName)
{
if (themeName == _themeName) return;
if (!AvailableThemes.Any(t => t.Value == themeName))
throw new ArgumentException($"Theme '{themeName}' is not allowed.", nameof(themeName));
_themeName = themeName;
SaveError = null;
OnChange?.Invoke();
await ScheduleSaveAsync();
}
// ── Private helpers ───────────────────────────────────────────────────────
private void ApplyPreferences(UserPreferencesDto prefs)
{
_themeName = AvailableThemes.Any(t => t.Value == prefs.ThemeName)
? prefs.ThemeName
: "blazing-berry";
_isDarkMode = prefs.IsDarkMode;
}
/// <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();
_saveCts = new CancellationTokenSource();
try
{
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;
await _preferencesService.SaveAsync(prefs, _saveCts.Token);
}
catch (TaskCanceledException)
{
// A newer save was scheduled — this one is 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
}
}
public ValueTask DisposeAsync()
{
_saveCts?.Cancel();
_saveCts?.Dispose();
return ValueTask.CompletedTask;
}
}
/// <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>
public sealed record ThemeOption(string Label, string Value);