233 lines
9.3 KiB
C#
233 lines
9.3 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 for the WASM client.
|
|
///
|
|
/// 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:
|
|
/// - 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 (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 Dark", "blazing-dark"), // index 0 — 🌙
|
|
new("Blazing Berry", "blazing-berry"), // index 1 — middle
|
|
new("Purple", "purple"), // index 2 — ☀️
|
|
];
|
|
|
|
private const string LocalStorageThemeKey = "signflow.theme";
|
|
|
|
// ── State ─────────────────────────────────────────────────────────────────
|
|
private string _themeName = "blazing-berry";
|
|
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 changes. Subscribers call StateHasChanged.</summary>
|
|
public event Action? OnChange;
|
|
|
|
/// <summary>
|
|
/// 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; }
|
|
|
|
public ThemeService(IJSRuntime js, UserPreferencesService preferencesService, ILogger<ThemeService> logger)
|
|
{
|
|
_js = js;
|
|
_preferencesService = preferencesService;
|
|
_logger = logger;
|
|
}
|
|
|
|
// ── Public state ──────────────────────────────────────────────────────────
|
|
|
|
public string ThemeName => _themeName;
|
|
|
|
/// <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>
|
|
/// 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}.bs5.min.css";
|
|
|
|
/// <summary>Bootstrap 5 data-bs-theme value. Only "dark" for blazing-dark.</summary>
|
|
public string BootstrapTheme => _themeName == "blazing-dark" ? "dark" : "light";
|
|
|
|
// ── Initialization ────────────────────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// 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", LocalStorageThemeKey);
|
|
|
|
if (stored is not null && AvailableThemes.Any(t => t.Value == stored))
|
|
{
|
|
_themeName = stored;
|
|
}
|
|
else
|
|
{
|
|
// 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 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 && _themeName != "blazing-berry")
|
|
{
|
|
// Propagate locally-detected theme to server on first login
|
|
prefs.ThemeName = _themeName;
|
|
prefs.IsDarkMode = IsDarkMode;
|
|
prefs = await _preferencesService.SaveAsync(prefs, ct);
|
|
}
|
|
|
|
ApplyPreferences(prefs);
|
|
|
|
// Persist resolved theme to localStorage
|
|
await _js.InvokeVoidAsync("localStorage.setItem", LocalStorageThemeKey, _themeName);
|
|
|
|
DisplayName = prefs.DisplayName;
|
|
await ApplyThemeToDocumentAsync();
|
|
OnChange?.Invoke();
|
|
}
|
|
|
|
// ── Mutation ──────────────────────────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// 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)
|
|
{
|
|
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;
|
|
|
|
await _js.InvokeVoidAsync("localStorage.setItem", LocalStorageThemeKey, _themeName);
|
|
await ApplyThemeToDocumentAsync();
|
|
OnChange?.Invoke();
|
|
await ScheduleSaveAsync();
|
|
}
|
|
|
|
// ── Private helpers ───────────────────────────────────────────────────────
|
|
|
|
private void ApplyPreferences(UserPreferencesDto prefs)
|
|
{
|
|
_themeName = AvailableThemes.Any(t => t.Value == prefs.ThemeName)
|
|
? prefs.ThemeName
|
|
: "blazing-berry";
|
|
|
|
// 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).");
|
|
}
|
|
}
|
|
|
|
private async Task ScheduleSaveAsync()
|
|
{
|
|
_saveCts?.Cancel();
|
|
_saveCts = new CancellationTokenSource();
|
|
|
|
try
|
|
{
|
|
await Task.Delay(500, _saveCts.Token);
|
|
|
|
var prefs = _preferencesService.GetCached() ?? new UserPreferencesDto();
|
|
prefs.ThemeName = _themeName;
|
|
prefs.IsDarkMode = IsDarkMode;
|
|
|
|
await _preferencesService.SaveAsync(prefs, _saveCts.Token);
|
|
}
|
|
catch (TaskCanceledException)
|
|
{
|
|
// Newer save scheduled — intentionally cancelled.
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Background save of user preferences failed.");
|
|
SaveError = ex;
|
|
OnChange?.Invoke();
|
|
}
|
|
}
|
|
|
|
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.</param>
|
|
public sealed record ThemeOption(string Label, string Value);
|