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