using Microsoft.JSInterop; using Microsoft.Extensions.Logging; using EnvelopeGenerator.Application.Common.Dto; namespace EnvelopeGenerator.Server.Client.Services; /// /// 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). /// 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 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 _logger; // ── Debounce ────────────────────────────────────────────────────────────── private CancellationTokenSource? _saveCts; // ── Change & error notification ─────────────────────────────────────────── /// Fired whenever theme changes. Subscribers call StateHasChanged. public event Action? OnChange; /// /// Set when a background save fails. UI layer can surface this as a warning. /// Cleared on the next successful save or theme change. /// public Exception? SaveError { get; private set; } public ThemeService(IJSRuntime js, UserPreferencesService preferencesService, ILogger logger) { _js = js; _preferencesService = preferencesService; _logger = logger; } // ── Public state ────────────────────────────────────────────────────────── public string ThemeName => _themeName; /// True when blazing-dark is active — used for Bootstrap data-bs-theme. public bool IsDarkMode => _themeName == "blazing-dark"; public string? DisplayName { get; private set; } /// /// CSS href for the active DevExpress theme. /// Example: "_content/DevExpress.Blazor.Themes/blazing-dark.bs5.min.css" /// public string ThemeCssHref => $"_content/DevExpress.Blazor.Themes/{_themeName}.bs5.min.css"; /// Bootstrap 5 data-bs-theme value. Only "dark" for blazing-dark. public string BootstrapTheme => _themeName == "blazing-dark" ? "dark" : "light"; // ── Initialization ──────────────────────────────────────────────────────── /// /// 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. /// public async Task InitializeAsync() { if (_initialized) return; _initialized = true; var stored = await _js.InvokeAsync("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( "eval", "(function(){ return window.matchMedia('(prefers-color-scheme: dark)').matches; })()"); _themeName = prefersDark ? "blazing-dark" : "blazing-berry"; } await ApplyThemeToDocumentAsync(); OnChange?.Invoke(); } /// /// Called after successful sender login. /// Syncs theme from the server cache; on first login propagates local theme to server. /// 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 ────────────────────────────────────────────────────────────── /// /// Sets the active theme, applies it immediately, and schedules a server save. /// State change is applied regardless of whether the save succeeds. /// 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, _themeName); } catch (Exception ex) { // JS unavailable during server prerender — 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; } } /// Describes a selectable DevExpress theme. /// User-facing display name. /// CSS theme identifier. public sealed record ThemeOption(string Label, string Value);