using System;
using System.Linq;
using System.Windows;
using Microsoft.Win32;
namespace TeamsISO.App.Services;
///
/// Owns the active theme for the WPF host. Three preferences:
///
/// - System — follows the Windows app-mode setting (default for new
/// users; re-reads on ).
/// - Dark — pin dark regardless of OS.
/// - Light — pin light regardless of OS.
///
/// The two color files (Theme.Dark.xaml + Theme.Light.xaml) are
/// kept in lockstep on the same set of brush keys; this manager swaps the
/// MergedDictionaries entry at runtime. Styles + control templates in
/// WildDragonTheme.xaml reach the brushes via ,
/// so the visual tree re-resolves without an app restart.
///
/// Preference is persisted via 's Theme field,
/// reserved on disk during the v1 → v2 rollout so the rebuild doesn't lose the
/// operator's choice.
///
public sealed class ThemeManager
{
public static ThemeManager Current { get; } = new(
isSystemDark: ReadSystemDarkFromRegistry,
loadPreference: TryLoadPreferenceFromDisk,
savePreference: TrySavePreferenceToDisk,
subscribeToSystemPreference: true);
private const string DarkUri = "/Themes/Theme.Dark.xaml";
private const string LightUri = "/Themes/Theme.Light.xaml";
private const string PreferenceKeySystem = "System";
private const string PreferenceKeyDark = "Dark";
private const string PreferenceKeyLight = "Light";
// Test seams. The production singleton wires these to the real
// registry / UIPreferences. Tests construct via the internal ctor
// with their own stubs so they don't touch HKCU or %LOCALAPPDATA%.
private readonly Func _isSystemDark;
private readonly Action _savePreference;
internal ThemeManager(
Func isSystemDark,
Func loadPreference,
Action savePreference,
bool subscribeToSystemPreference)
{
_isSystemDark = isSystemDark;
_savePreference = savePreference;
// Hydrate preference from the seam on first access. Disk / load
// failures fall back to defaults so the app always boots into a
// deterministic theme.
try
{
var loaded = loadPreference();
if (IsValidPreference(loaded))
{
_preference = loaded!;
}
}
catch
{
// Defensive — ctor must not throw or the app loses theming.
}
// Re-evaluate when Windows app-mode flips, but only when the
// operator hasn't pinned a preference. The explicit choice wins.
// Tests opt out so they don't latch into a process-wide event.
if (subscribeToSystemPreference)
{
SystemEvents.UserPreferenceChanged += OnSystemPreferenceChanged;
}
}
private string _preference = PreferenceKeySystem;
/// Current preference. One of "System", "Dark", "Light".
public string Preference => _preference;
/// Fires after a theme swap with the resolved (absolute) theme.
public event EventHandler? Themed;
///
/// Resolve the preference to an absolute theme name ("Dark" or "Light")
/// suitable for the dictionary lookup. "System" resolves to the OS
/// app-mode at the time of the call.
///
public string ResolveTheme() => _preference switch
{
PreferenceKeyDark => PreferenceKeyDark,
PreferenceKeyLight => PreferenceKeyLight,
_ => _isSystemDark() ? PreferenceKeyDark : PreferenceKeyLight,
};
///
/// Set the operator's preference, persist, and apply the resolved theme.
///
public void Set(string preference)
{
if (!IsValidPreference(preference))
{
throw new ArgumentException(
"Preference must be 'System', 'Dark', or 'Light'.",
nameof(preference));
}
_preference = preference;
try { _savePreference(preference); }
catch { /* persistence is best-effort */ }
Apply();
}
///
/// Cycle the theme between Dark and Light (one-click toggle from the header
/// theme icon). If the current preference is "System", the cycle pins to
/// the OPPOSITE of the currently-resolved theme so the click has a
/// visible effect.
///
public void Toggle()
{
var current = ResolveTheme();
Set(current == PreferenceKeyDark ? PreferenceKeyLight : PreferenceKeyDark);
}
///
/// Apply the current resolved theme. Should be called once during app
/// startup (after Application.Current.Resources is initialized) and
/// whenever changes — already
/// does the latter for you.
///
public void Apply()
{
var theme = ResolveTheme();
var uri = theme == PreferenceKeyDark ? DarkUri : LightUri;
SwapColorDictionary(uri);
Themed?.Invoke(this, theme);
}
private static void SwapColorDictionary(string newUri)
{
var app = Application.Current;
if (app is null) return;
var dicts = app.Resources.MergedDictionaries;
// Find the existing theme color dictionary by source URI. We
// distinguish "color" dictionaries from "WildDragonTheme" by name —
// the color files are at Theme.Dark.xaml / Theme.Light.xaml; the
// styles file is at WildDragonTheme.xaml. Replace in place to
// preserve merge order so DynamicResource refs resolve to the new
// brushes.
ResourceDictionary? old = null;
for (var i = 0; i < dicts.Count; i++)
{
var src = dicts[i].Source?.OriginalString ?? string.Empty;
if (src.EndsWith("Theme.Dark.xaml", StringComparison.OrdinalIgnoreCase) ||
src.EndsWith("Theme.Light.xaml", StringComparison.OrdinalIgnoreCase))
{
old = dicts[i];
break;
}
}
var fresh = new ResourceDictionary { Source = new Uri(newUri, UriKind.Relative) };
if (old is null)
{
dicts.Insert(0, fresh);
}
else
{
var idx = dicts.IndexOf(old);
dicts.RemoveAt(idx);
dicts.Insert(idx, fresh);
}
}
///
/// Read Windows' AppsUseLightTheme registry value. 1 = light, 0 = dark.
/// Returns true (dark) on any read failure — the dark scene is the
/// default per DESIGN.md so a missing value still lands somewhere
/// sensible. Backs the singleton's _isSystemDark seam.
///
private static bool ReadSystemDarkFromRegistry()
{
try
{
using var key = Registry.CurrentUser.OpenSubKey(
@"SOFTWARE\Microsoft\Windows\CurrentVersion\Themes\Personalize");
if (key?.GetValue("AppsUseLightTheme") is int value)
{
return value == 0;
}
}
catch
{
// Registry access can fail under unusual security contexts.
}
return true;
}
///
/// Load the operator's persisted theme preference from
/// %LOCALAPPDATA%\TeamsISO\ui-prefs.json. Returns null on any read
/// failure (missing file, corrupt JSON, schema mismatch) so the
/// caller falls back to the in-memory default of "System". Backs
/// the singleton's loadPreference seam.
///
private static string? TryLoadPreferenceFromDisk()
{
try { return UIPreferences.Load().Theme; }
catch { return null; }
}
///
/// Persist the operator's theme preference to ui-prefs.json. Errors
/// are swallowed — persistence is best-effort and a single failed
/// save shouldn't break the in-session UI experience. Backs the
/// singleton's savePreference seam.
///
private static void TrySavePreferenceToDisk(string preference)
{
try { UIPreferences.SetTheme(preference); }
catch { /* best-effort */ }
}
private void OnSystemPreferenceChanged(object? sender, UserPreferenceChangedEventArgs e)
{
if (e.Category != UserPreferenceCategory.General) return;
if (_preference != PreferenceKeySystem) return;
// Marshal to the UI thread — registry events fire on a system pool
// thread and resource dictionary mutations require dispatcher access.
Application.Current?.Dispatcher.BeginInvoke(new Action(Apply));
}
private static bool IsValidPreference(string? value) =>
value is PreferenceKeySystem or PreferenceKeyDark or PreferenceKeyLight;
}