diff --git a/EnvelopeGenerator.Server/EnvelopeGenerator.Server.Client/Program.cs b/EnvelopeGenerator.Server/EnvelopeGenerator.Server.Client/Program.cs index d21cf620..863f0b58 100644 --- a/EnvelopeGenerator.Server/EnvelopeGenerator.Server.Client/Program.cs +++ b/EnvelopeGenerator.Server/EnvelopeGenerator.Server.Client/Program.cs @@ -34,6 +34,10 @@ builder.Services.AddScoped(); builder.Services.AddSingleton(); builder.Services.AddScoped(); +// UI Preferences & Theme +builder.Services.AddScoped(); +builder.Services.AddScoped(); + // DevExpress WASM builder.Services.AddDevExpressWebAssemblyBlazorPdfViewer(); builder.Services.AddDevExpressWebAssemblyBlazorReportViewer(); diff --git a/EnvelopeGenerator.Server/EnvelopeGenerator.Server.Client/Services/ThemeService.cs b/EnvelopeGenerator.Server/EnvelopeGenerator.Server.Client/Services/ThemeService.cs new file mode 100644 index 00000000..7e32c9d6 --- /dev/null +++ b/EnvelopeGenerator.Server/EnvelopeGenerator.Server.Client/Services/ThemeService.cs @@ -0,0 +1,236 @@ +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); diff --git a/EnvelopeGenerator.Server/EnvelopeGenerator.Server.Client/Services/UserPreferencesService.cs b/EnvelopeGenerator.Server/EnvelopeGenerator.Server.Client/Services/UserPreferencesService.cs new file mode 100644 index 00000000..bad20009 --- /dev/null +++ b/EnvelopeGenerator.Server/EnvelopeGenerator.Server.Client/Services/UserPreferencesService.cs @@ -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; + +/// +/// 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 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(). +/// +public class UserPreferencesService( + IHttpClientFactory clientFactory, + ILogger 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; + + /// + /// 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 + /// are served from memory with zero network overhead. + /// + public async Task 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(_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; + } + } + + /// + /// Returns the cached preferences without hitting the network. + /// Returns null if the cache has not been populated yet (before first GetAsync call). + /// + public UserPreferencesDto? GetCached() => IsCacheValid ? _cached : null; + + /// + /// Persists the given preferences on the server and updates the in-memory cache. + /// + public async Task 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(_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; + } + } + + /// + /// 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. + /// + /// Stable grid identifier, e.g. "sender.active-envelopes". + /// Opaque layout JSON produced by DxGrid.SaveLayoutAsync(). + 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); + } + + /// + /// 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. + /// + public void InvalidateCache() + { + _cached = null; + _cachedAt = DateTimeOffset.MinValue; + } + + private void SetCache(UserPreferencesDto dto) + { + _cached = dto; + _cachedAt = DateTimeOffset.UtcNow; + } +}