using Microsoft.JSInterop; using Microsoft.Extensions.Logging; using EnvelopeGenerator.Application.Common.Dto; namespace EnvelopeGenerator.Server.Client.Services; /// /// 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). /// public class ThemeService : IAsyncDisposable { // ── Allowed themes ──────────────────────────────────────────────────────── public static readonly IReadOnlyList 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 _logger; // ── Debounce ────────────────────────────────────────────────────────────── private CancellationTokenSource? _saveCts; // ── Change & error notification ─────────────────────────────────────────── /// Fired whenever theme or dark mode changes. Subscribers call StateHasChanged. public event Action? OnChange; /// /// 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. /// 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; public bool IsDarkMode => _isDarkMode; /// /// The CSS href value for the active DevExpress theme. /// Example: "_content/DevExpress.Blazor.Themes/blazing-berry.dark.bs5.min.css" /// public string ThemeCssHref => $"_content/DevExpress.Blazor.Themes/{_themeName}{(_isDarkMode ? ".dark" : "")}.bs5.min.css"; /// Bootstrap 5 data-bs-theme attribute value ("dark" or "light"). public string BootstrapTheme => _isDarkMode ? "dark" : "light"; // ── Initialization ──────────────────────────────────────────────────────── /// /// 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. /// public async Task InitializeAsync() { if (_initialized) return; _initialized = true; var stored = await _js.InvokeAsync("localStorage.getItem", LocalStorageDarkModeKey); if (stored is not null) { _isDarkMode = stored == "true"; } else { // No stored preference — detect system setting _isDarkMode = await _js.InvokeAsync( "eval", "(function(){ return window.matchMedia('(prefers-color-scheme: dark)').matches; })()"); } OnChange?.Invoke(); } /// /// 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. /// 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 ───────────────────────────────────────────────────────────── /// /// 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. /// public async Task ToggleDarkModeAsync() { _isDarkMode = !_isDarkMode; SaveError = null; await _js.InvokeVoidAsync("localStorage.setItem", LocalStorageDarkModeKey, _isDarkMode.ToString().ToLower()); OnChange?.Invoke(); await ScheduleSaveAsync(); } /// /// 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. /// 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; } /// /// Debounced server save. Uses current in-memory state — no extra GET. /// Only theme and dark mode are written; GridLayouts are preserved from cache. /// 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; } } /// Describes a selectable DevExpress theme. /// User-facing display name. /// CSS theme identifier (used in href and API). public sealed record ThemeOption(string Label, string Value);