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.
237 lines
10 KiB
C#
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);
|