diff --git a/EnvelopeGenerator.Server/EnvelopeGenerator.Server.Client/Services/ThemeService.cs b/EnvelopeGenerator.Server/EnvelopeGenerator.Server.Client/Services/ThemeService.cs index 7e32c9d6..517dd19b 100644 --- a/EnvelopeGenerator.Server/EnvelopeGenerator.Server.Client/Services/ThemeService.cs +++ b/EnvelopeGenerator.Server/EnvelopeGenerator.Server.Client/Services/ThemeService.cs @@ -5,45 +5,37 @@ using EnvelopeGenerator.Application.Common.Dto; namespace EnvelopeGenerator.Server.Client.Services; /// -/// Manages the active DevExpress theme and dark mode state for the WASM client. +/// Manages the active DevExpress theme 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. +/// 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: -/// - 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. +/// - 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 ──────────────────────────────────────────────────────── + // ── 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 Berry", "blazing-berry"), - new("Fluent", "fluent"), - new("Purple", "purple"), + new("Blazing Dark", "blazing-dark"), // index 0 — 🌙 + new("Blazing Berry", "blazing-berry"), // index 1 — middle + new("Purple", "purple"), // index 2 — ☀️ ]; - private const string LocalStorageDarkModeKey = "signflow.darkMode"; + private const string LocalStorageThemeKey = "signflow.theme"; // ── State ───────────────────────────────────────────────────────────────── private string _themeName = "blazing-berry"; - private bool _isDarkMode = false; private bool _initialized = false; // ── Dependencies ────────────────────────────────────────────────────────── @@ -55,12 +47,12 @@ public class ThemeService : IAsyncDisposable private CancellationTokenSource? _saveCts; // ── Change & error notification ─────────────────────────────────────────── - /// Fired whenever theme or dark mode changes. Subscribers call StateHasChanged. + /// Fired whenever theme 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. + /// 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; } @@ -74,98 +66,89 @@ public class ThemeService : IAsyncDisposable // ── Public state ────────────────────────────────────────────────────────── public string ThemeName => _themeName; - public bool IsDarkMode => _isDarkMode; + + /// True when blazing-dark is active — used for Bootstrap data-bs-theme. + public bool IsDarkMode => _themeName == "blazing-dark"; + + public string? DisplayName { get; private set; } /// - /// The CSS href value for the active DevExpress theme. - /// Example: "_content/DevExpress.Blazor.Themes/blazing-berry.dark.bs5.min.css" + /// 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}{(_isDarkMode ? ".dark" : "")}.bs5.min.css"; + $"_content/DevExpress.Blazor.Themes/{_themeName}.bs5.min.css"; - /// Bootstrap 5 data-bs-theme attribute value ("dark" or "light"). - public string BootstrapTheme => _isDarkMode ? "dark" : "light"; + /// Bootstrap 5 data-bs-theme value. Only "dark" for blazing-dark. + public string BootstrapTheme => _themeName == "blazing-dark" ? "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. + /// 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", LocalStorageDarkModeKey); + var stored = await _js.InvokeAsync("localStorage.getItem", LocalStorageThemeKey); - if (stored is not null) + if (stored is not null && AvailableThemes.Any(t => t.Value == stored)) { - _isDarkMode = stored == "true"; + _themeName = stored; } else { - // No stored preference — detect system setting - _isDarkMode = await _js.InvokeAsync( + // 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 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. + /// 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 && _isDarkMode) + if (isFirstLogin && _themeName != "blazing-berry") { - // First login: propagate local system preference to server - prefs.IsDarkMode = _isDarkMode; + // Propagate locally-detected theme to server on first login + prefs.ThemeName = _themeName; + prefs.IsDarkMode = IsDarkMode; prefs = await _preferencesService.SaveAsync(prefs, ct); } ApplyPreferences(prefs); - await _js.InvokeVoidAsync("localStorage.setItem", - LocalStorageDarkModeKey, _isDarkMode.ToString().ToLower()); + // Persist resolved theme to localStorage + await _js.InvokeVoidAsync("localStorage.setItem", LocalStorageThemeKey, _themeName); + DisplayName = prefs.DisplayName; + await ApplyThemeToDocumentAsync(); OnChange?.Invoke(); } - // ── Mutations ───────────────────────────────────────────────────────────── + // ── Mutation ────────────────────────────────────────────────────────────── /// - /// 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. + /// 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) { @@ -175,6 +158,9 @@ public class ThemeService : IAsyncDisposable _themeName = themeName; SaveError = null; + + await _js.InvokeVoidAsync("localStorage.setItem", LocalStorageThemeKey, _themeName); + await ApplyThemeToDocumentAsync(); OnChange?.Invoke(); await ScheduleSaveAsync(); } @@ -187,13 +173,24 @@ public class ThemeService : IAsyncDisposable ? prefs.ThemeName : "blazing-berry"; - _isDarkMode = prefs.IsDarkMode; + // 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 — link is the SSR fallback. + _logger.LogDebug(ex, "[ThemeService] ApplyThemeToDocumentAsync skipped (JS unavailable)."); + } } - /// - /// 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(); @@ -203,22 +200,21 @@ public class ThemeService : IAsyncDisposable { 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; + prefs.IsDarkMode = IsDarkMode; await _preferencesService.SaveAsync(prefs, _saveCts.Token); } catch (TaskCanceledException) { - // A newer save was scheduled — this one is intentionally cancelled. + // Newer save scheduled — 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 + OnChange?.Invoke(); } } @@ -232,5 +228,5 @@ public class ThemeService : IAsyncDisposable /// Describes a selectable DevExpress theme. /// User-facing display name. -/// CSS theme identifier (used in href and API). +/// CSS theme identifier. public sealed record ThemeOption(string Label, string Value); diff --git a/EnvelopeGenerator.Server/EnvelopeGenerator.Server.Client/Services/UserPreferencesService.cs b/EnvelopeGenerator.Server/EnvelopeGenerator.Server.Client/Services/UserPreferencesService.cs index bad20009..daaa34ed 100644 --- a/EnvelopeGenerator.Server/EnvelopeGenerator.Server.Client/Services/UserPreferencesService.cs +++ b/EnvelopeGenerator.Server/EnvelopeGenerator.Server.Client/Services/UserPreferencesService.cs @@ -45,8 +45,9 @@ public class UserPreferencesService( 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) + // Not authenticated yet (login page) or no preferences saved — return defaults. + if (response.StatusCode == System.Net.HttpStatusCode.NotFound || + response.StatusCode == System.Net.HttpStatusCode.Unauthorized) { SetCache(new UserPreferencesDto()); return _cached!; @@ -82,6 +83,11 @@ public class UserPreferencesService( { var client = clientFactory.CreateClient("EnvelopeGenerator.Server"); var response = await client.PutAsJsonAsync(Endpoint, preferences, _jsonOptions, ct); + + // Not authenticated (login page) — skip save, keep local state only. + if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized) + return preferences; + response.EnsureSuccessStatusCode(); var saved = await response.Content.ReadFromJsonAsync(_jsonOptions, ct)