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.
This commit is contained in:
@@ -34,6 +34,10 @@ builder.Services.AddScoped<SignatureCacheService>();
|
||||
builder.Services.AddSingleton<AppVersionService>();
|
||||
builder.Services.AddScoped<EnvelopeService>();
|
||||
|
||||
// UI Preferences & Theme
|
||||
builder.Services.AddScoped<UserPreferencesService>();
|
||||
builder.Services.AddScoped<ThemeService>();
|
||||
|
||||
// DevExpress WASM
|
||||
builder.Services.AddDevExpressWebAssemblyBlazorPdfViewer();
|
||||
builder.Services.AddDevExpressWebAssemblyBlazorReportViewer();
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
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);
|
||||
@@ -0,0 +1,130 @@
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using EnvelopeGenerator.Application.Common.Dto;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace EnvelopeGenerator.Server.Client.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves and persists user UI preferences via the server API.
|
||||
/// The server stores preferences in the distributed cache (dbo.TBDD_CACHE).
|
||||
///
|
||||
/// Client-side memory cache:
|
||||
/// Preferences are cached in-memory for <see cref="CacheDuration"/> after the first fetch.
|
||||
/// All reads (GetAsync, GetCachedAsync) are served from cache after the first load.
|
||||
/// Writes (SaveAsync) update both the cache and the server atomically.
|
||||
/// Cache is invalidated explicitly via InvalidateCache().
|
||||
/// </summary>
|
||||
public class UserPreferencesService(
|
||||
IHttpClientFactory clientFactory,
|
||||
ILogger<UserPreferencesService> logger)
|
||||
{
|
||||
private static readonly JsonSerializerOptions _jsonOptions = new(JsonSerializerDefaults.Web);
|
||||
private const string Endpoint = "/api/UserPreferences";
|
||||
|
||||
// ── Client-side memory cache ──────────────────────────────────────────────
|
||||
private static readonly TimeSpan CacheDuration = TimeSpan.FromHours(1);
|
||||
private UserPreferencesDto? _cached;
|
||||
private DateTimeOffset _cachedAt = DateTimeOffset.MinValue;
|
||||
|
||||
private bool IsCacheValid => _cached is not null
|
||||
&& DateTimeOffset.UtcNow - _cachedAt < CacheDuration;
|
||||
|
||||
/// <summary>
|
||||
/// Returns preferences from in-memory cache if valid; otherwise fetches from the server.
|
||||
/// On first call after login this issues one HTTP GET — all subsequent calls within
|
||||
/// <see cref="CacheDuration"/> are served from memory with zero network overhead.
|
||||
/// </summary>
|
||||
public async Task<UserPreferencesDto> GetAsync(CancellationToken ct = default)
|
||||
{
|
||||
if (IsCacheValid)
|
||||
return _cached!;
|
||||
|
||||
try
|
||||
{
|
||||
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)
|
||||
{
|
||||
SetCache(new UserPreferencesDto());
|
||||
return _cached!;
|
||||
}
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var dto = await response.Content.ReadFromJsonAsync<UserPreferencesDto>(_jsonOptions, ct)
|
||||
?? new UserPreferencesDto();
|
||||
|
||||
SetCache(dto);
|
||||
return _cached!;
|
||||
}
|
||||
catch (Exception ex) when (ex is not TaskCanceledException)
|
||||
{
|
||||
logger.LogError(ex, "Failed to load user preferences from server.");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the cached preferences without hitting the network.
|
||||
/// Returns null if the cache has not been populated yet (before first GetAsync call).
|
||||
/// </summary>
|
||||
public UserPreferencesDto? GetCached() => IsCacheValid ? _cached : null;
|
||||
|
||||
/// <summary>
|
||||
/// Persists the given preferences on the server and updates the in-memory cache.
|
||||
/// </summary>
|
||||
public async Task<UserPreferencesDto> SaveAsync(UserPreferencesDto preferences, CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var client = clientFactory.CreateClient("EnvelopeGenerator.Server");
|
||||
var response = await client.PutAsJsonAsync(Endpoint, preferences, _jsonOptions, ct);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var saved = await response.Content.ReadFromJsonAsync<UserPreferencesDto>(_jsonOptions, ct)
|
||||
?? preferences;
|
||||
|
||||
SetCache(saved);
|
||||
return saved;
|
||||
}
|
||||
catch (Exception ex) when (ex is not TaskCanceledException)
|
||||
{
|
||||
logger.LogError(ex, "Failed to save user preferences to server.");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates a single grid layout key without a network read.
|
||||
/// Uses the in-memory cache for the current state, merges the new layout, then saves.
|
||||
/// If the cache is empty (cold start), fetches from server first.
|
||||
/// </summary>
|
||||
/// <param name="gridKey">Stable grid identifier, e.g. "sender.active-envelopes".</param>
|
||||
/// <param name="layoutJson">Opaque layout JSON produced by DxGrid.SaveLayoutAsync().</param>
|
||||
public async Task SaveGridLayoutAsync(string gridKey, string layoutJson, CancellationToken ct = default)
|
||||
{
|
||||
// Use cached copy — avoids a GET before every grid layout save.
|
||||
var prefs = GetCached() ?? await GetAsync(ct);
|
||||
prefs.GridLayouts[gridKey] = layoutJson;
|
||||
await SaveAsync(prefs, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Invalidates the in-memory cache, forcing the next GetAsync to fetch from the server.
|
||||
/// Call this after logout or when a forced refresh is needed.
|
||||
/// </summary>
|
||||
public void InvalidateCache()
|
||||
{
|
||||
_cached = null;
|
||||
_cachedAt = DateTimeOffset.MinValue;
|
||||
}
|
||||
|
||||
private void SetCache(UserPreferencesDto dto)
|
||||
{
|
||||
_cached = dto;
|
||||
_cachedAt = DateTimeOffset.UtcNow;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user