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);