using System; using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Linq; using System.Runtime.InteropServices.WindowsRuntime; using System.Threading.Tasks; using CommunityToolkit.Mvvm.Messaging; using Windows.Storage; using Windows.UI.ViewManagement; using Wino.Core.Domain; using Wino.Core.Domain.Enums; using Wino.Core.Domain.Exceptions; using Wino.Core.Domain.Interfaces; using Wino.Core.Domain.Models.Personalization; using Wino.Core.Messages.Shell; using Wino.Core.UWP.Extensions; using Wino.Core.UWP.Models.Personalization; using Wino.Core.UWP.Services; using System.Text.Json; using Wino.Core.WinUI.Services; #if NET8_0 using Microsoft.UI.Xaml.Controls; using CommunityToolkit.WinUI.Helpers; using Microsoft.UI.Xaml.Media; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Markup; using Microsoft.UI; #else using Windows.UI; using Windows.UI.Xaml.Controls; using Microsoft.Toolkit.Uwp.Helpers; using Windows.UI.Xaml.Media; using Windows.UI.Xaml; using Windows.UI.Xaml.Markup; #endif namespace Wino.Services; /// /// Class providing functionality around switching and restoring theme settings /// public class ThemeService(IConfigurationService configurationService, IUnderlyingThemeService underlyingThemeService, IApplicationResourceManager applicationResourceManager, IAppShellService appShellService) : IThemeService { public const string CustomThemeFolderName = "CustomThemes"; private const string MicaThemeId = "a160b1b0-2ab8-4e97-a803-f4050f036e25"; private const string AcrylicThemeId = "fc08e58c-36fd-46e2-a562-26cf277f1467"; private const string CloudsThemeId = "3b621cc2-e270-4a76-8477-737917cccda0"; private const string ForestThemeId = "8bc89b37-a7c5-4049-86e2-de1ae8858dbd"; private const string NightyThemeId = "5b65e04e-fd7e-4c2d-8221-068d3e02d23a"; private const string SnowflakeThemeId = "e143ddde-2e28-4846-9d98-dad63d6505f1"; private const string GardenThemeId = "698e4466-f88c-4799-9c61-f0ea1308ed49"; private Frame _mainApplicationFrame; public event EventHandler ElementThemeChanged; public event EventHandler AccentColorChanged; public event EventHandler AccentColorChangedBySystem; private const string AccentColorKey = nameof(AccentColorKey); private const string CurrentApplicationThemeKey = nameof(CurrentApplicationThemeKey); // Custom theme public const string CustomThemeAccentColorKey = nameof(CustomThemeAccentColorKey); // Keep reference so it does not get optimized/garbage collected private readonly UISettings _uiSettings = new(); private readonly IConfigurationService _configurationService = configurationService; private readonly IUnderlyingThemeService _underlyingThemeService = underlyingThemeService; private readonly IApplicationResourceManager _applicationResourceManager = applicationResourceManager; private readonly IAppShellService _appShellService = appShellService; private List _preDefinedThemes{ get; } = [ new SystemAppTheme("Mica", Guid.Parse(MicaThemeId)), new SystemAppTheme("Acrylic", Guid.Parse(AcrylicThemeId)), new PreDefinedAppTheme("Nighty", Guid.Parse(NightyThemeId), "#e1b12c", ApplicationElementTheme.Dark), new PreDefinedAppTheme("Forest", Guid.Parse(ForestThemeId), "#16a085", ApplicationElementTheme.Dark), new PreDefinedAppTheme("Clouds", Guid.Parse(CloudsThemeId), "#0984e3", ApplicationElementTheme.Light), new PreDefinedAppTheme("Snowflake", Guid.Parse(SnowflakeThemeId), "#4a69bd", ApplicationElementTheme.Light), new PreDefinedAppTheme("Garden", Guid.Parse(GardenThemeId), "#05c46b", ApplicationElementTheme.Light), ]; /// /// Gets or sets (with LocalSettings persistence) the RequestedTheme of the root element. /// public ApplicationElementTheme RootTheme { get => (_mainApplicationFrame?.RequestedTheme.ToWinoElementTheme()) ?? ApplicationElementTheme.Default; set { if (_mainApplicationFrame == null) return; _mainApplicationFrame.RequestedTheme = value.ToWindowsElementTheme(); _configurationService.Set(UnderlyingThemeService.SelectedAppThemeKey, value); UpdateSystemCaptionButtonColors(); // PopupRoot usually needs to react to changes. NotifyThemeUpdate(); } } private Guid currentApplicationThemeId; public Guid CurrentApplicationThemeId { get => currentApplicationThemeId; set { currentApplicationThemeId = value; _configurationService.Set(CurrentApplicationThemeKey, value); _ = _mainApplicationFrame.DispatcherQueue.TryEnqueue(Microsoft.UI.Dispatching.DispatcherQueuePriority.High, async () => await ApplyCustomThemeAsync(false)); } } private string accentColor; public string AccentColor { get => accentColor; set { accentColor = value; UpdateAccentColor(value); _configurationService.Set(AccentColorKey, value); AccentColorChanged?.Invoke(this, value); } } public async Task InitializeAsync() { // Already initialized. There is no need. if (_mainApplicationFrame != null) return; // Save reference as this might be null when the user is in another app #if NET8_0 _mainApplicationFrame = _appShellService.AppWindow.Content as Frame; #else mainApplicationFrame = Window.Current.Content as Frame; #endif if (_mainApplicationFrame == null) return; RootTheme = _configurationService.Get(UnderlyingThemeService.SelectedAppThemeKey, ApplicationElementTheme.Default); AccentColor = _configurationService.Get(AccentColorKey, string.Empty); // Set the current theme id. Default to Mica. var applicationThemeGuid = _configurationService.Get(CurrentApplicationThemeKey, MicaThemeId); currentApplicationThemeId = Guid.Parse(applicationThemeGuid); await ApplyCustomThemeAsync(true); // Registering to color changes, thus we notice when user changes theme system wide _uiSettings.ColorValuesChanged += UISettingsColorChanged; } private void NotifyThemeUpdate() { if (_mainApplicationFrame == null) return; _ = _mainApplicationFrame.DispatcherQueue.TryEnqueue(Microsoft.UI.Dispatching.DispatcherQueuePriority.High, () => { ElementThemeChanged?.Invoke(this, RootTheme); WeakReferenceMessenger.Default.Send(new ApplicationThemeChanged(_underlyingThemeService.IsUnderlyingThemeDark())); }); } private void UISettingsColorChanged(UISettings sender, object args) { // Make sure we have a reference to our window so we dispatch a UI change if (_mainApplicationFrame != null) { // Dispatch on UI thread so that we have a current appbar to access and change _ = _mainApplicationFrame.DispatcherQueue.TryEnqueue(Microsoft.UI.Dispatching.DispatcherQueuePriority.High, () => { UpdateSystemCaptionButtonColors(); var accentColor = sender.GetColorValue(UIColorType.Accent); //AccentColorChangedBySystem?.Invoke(this, accentColor.ToHex()); }); } NotifyThemeUpdate(); } public void UpdateSystemCaptionButtonColors() { if (_mainApplicationFrame == null) return; _ = _mainApplicationFrame.DispatcherQueue.TryEnqueue(Microsoft.UI.Dispatching.DispatcherQueuePriority.Normal, () => { ApplicationViewTitleBar titleBar = null; try { // TODO: Should be removed after migration to native titlebar. titleBar = ApplicationView.GetForCurrentView().TitleBar; } catch { } if (titleBar == null) return; if (_underlyingThemeService.IsUnderlyingThemeDark()) { titleBar.ButtonForegroundColor = Colors.White; } else { titleBar.ButtonForegroundColor = Colors.Black; } }); } public void UpdateAccentColor(string hex) { // Change accent color if specified. if (!string.IsNullOrEmpty(hex)) { #if NET8_0 var brush = new SolidColorBrush(hex.ToColor()); #else var brush = new SolidColorBrush(Microsoft.Toolkit.Uwp.Helpers.ColorHelper.ToColor(hex)); #endif if (_applicationResourceManager.ContainsResourceKey("SystemAccentColor")) _applicationResourceManager.ReplaceResource("SystemAccentColor", brush); if (_applicationResourceManager.ContainsResourceKey("NavigationViewSelectionIndicatorForeground")) _applicationResourceManager.ReplaceResource("NavigationViewSelectionIndicatorForeground", brush); RefreshThemeResource(); } } private void RefreshThemeResource() { if (_mainApplicationFrame == null) return; if (_mainApplicationFrame.RequestedTheme == ElementTheme.Dark) { _mainApplicationFrame.RequestedTheme = ElementTheme.Light; _mainApplicationFrame.RequestedTheme = ElementTheme.Dark; } else if (_mainApplicationFrame.RequestedTheme == ElementTheme.Light) { _mainApplicationFrame.RequestedTheme = ElementTheme.Dark; _mainApplicationFrame.RequestedTheme = ElementTheme.Light; } else { var isUnderlyingDark = _underlyingThemeService.IsUnderlyingThemeDark(); _mainApplicationFrame.RequestedTheme = isUnderlyingDark ? ElementTheme.Light : ElementTheme.Dark; _mainApplicationFrame.RequestedTheme = ElementTheme.Default; } } public async Task ApplyCustomThemeAsync(bool isInitializing) { AppThemeBase applyingTheme = null; List controlThemeList = [.. _preDefinedThemes]; // Don't search for custom themes if applying theme is already in pre-defined templates. // This is important for startup performance because we won't be loading the custom themes on launch. bool isApplyingPreDefinedTheme = _preDefinedThemes.Exists(a => a.Id == currentApplicationThemeId); if (isApplyingPreDefinedTheme) { applyingTheme = _preDefinedThemes.Find(a => a.Id == currentApplicationThemeId); } else { // User applied custom theme. Load custom themes and find it there. // Fallback to Mica if nothing found. var customThemes = await GetCurrentCustomThemesAsync(); controlThemeList.AddRange(customThemes.Select(a => new CustomAppTheme(a))); applyingTheme = controlThemeList.Find(a => a.Id == currentApplicationThemeId) ?? _preDefinedThemes.First(a => a.Id == Guid.Parse(MicaThemeId)); } try { var existingThemeDictionary = _applicationResourceManager.GetLastResource(); if (existingThemeDictionary == null || !existingThemeDictionary.TryGetValue("ThemeName", out object themeNameString)) return; var themeName = themeNameString.ToString(); // Applying different theme. if (themeName != applyingTheme.ThemeName) { var resourceDictionaryContent = await applyingTheme.GetThemeResourceDictionaryContentAsync(); var resourceDictionary = XamlReader.Load(resourceDictionaryContent) as ResourceDictionary; // Custom themes require special attention for background image because // they share the same base theme resource dictionary. if (applyingTheme is CustomAppTheme) { resourceDictionary["ThemeBackgroundImage"] = $"ms-appdata:///local/{CustomThemeFolderName}/{applyingTheme.Id}.jpg"; } _applicationResourceManager.RemoveResource(existingThemeDictionary); _applicationResourceManager.AddResource(resourceDictionary); bool isSystemTheme = applyingTheme is SystemAppTheme || applyingTheme is CustomAppTheme; if (isSystemTheme) { // For system themes, set the RootElement theme from saved values. // Potential bug: When we set it to system default, theme is not applied when system and // app element theme is different :) RootTheme = _configurationService.Get(UnderlyingThemeService.SelectedAppThemeKey, ApplicationElementTheme.Default); // Quickly switch theme to apply theme resource changes. RefreshThemeResource(); } else { RootTheme = applyingTheme.ForceElementTheme; } // Theme has accent color. Override. if (!isInitializing) { AccentColor = applyingTheme.AccentColor; } } else { UpdateSystemCaptionButtonColors(); } } catch (Exception ex) { Debug.WriteLine($"Apply theme failed -> {ex.Message}"); } } public async Task> GetAvailableThemesAsync() { List availableThemes = [.. _preDefinedThemes]; var customThemes = await GetCurrentCustomThemesAsync(); availableThemes.AddRange(customThemes.Select(a => new CustomAppTheme(a))); return availableThemes; } public async Task CreateNewCustomThemeAsync(string themeName, string accentColor, byte[] wallpaperData) { if (wallpaperData == null || wallpaperData.Length == 0) throw new CustomThemeCreationFailedException(Translator.Exception_CustomThemeMissingWallpaper); if (string.IsNullOrEmpty(themeName)) throw new CustomThemeCreationFailedException(Translator.Exception_CustomThemeMissingName); var themes = await GetCurrentCustomThemesAsync(); if (themes.Exists(a => a.Name == themeName)) throw new CustomThemeCreationFailedException(Translator.Exception_CustomThemeExists); var newTheme = new CustomThemeMetadata() { Id = Guid.NewGuid(), Name = themeName, AccentColorHex = accentColor }; // Save wallpaper. // Filename would be the same as metadata id, in jpg format. var themeFolder = await ApplicationData.Current.LocalFolder.CreateFolderAsync(CustomThemeFolderName, CreationCollisionOption.OpenIfExists); var wallpaperFile = await themeFolder.CreateFileAsync($"{newTheme.Id}.jpg", CreationCollisionOption.ReplaceExisting); await FileIO.WriteBytesAsync(wallpaperFile, wallpaperData); // Generate thumbnail for settings page. var thumbnail = await wallpaperFile.GetThumbnailAsync(Windows.Storage.FileProperties.ThumbnailMode.PicturesView); var thumbnailFile = await themeFolder.CreateFileAsync($"{newTheme.Id}_preview.jpg", CreationCollisionOption.ReplaceExisting); await using (var readerStream = thumbnail.AsStreamForRead()) { byte[] bytes = new byte[readerStream.Length]; await readerStream.ReadAsync(bytes); var buffer = bytes.AsBuffer(); await FileIO.WriteBufferAsync(thumbnailFile, buffer); } // Save metadata. var metadataFile = await themeFolder.CreateFileAsync($"{newTheme.Id}.json", CreationCollisionOption.ReplaceExisting); var serialized = JsonSerializer.Serialize(newTheme); await FileIO.WriteTextAsync(metadataFile, serialized); return newTheme; } public async Task> GetCurrentCustomThemesAsync() { var results = new List(); var themeFolder = await ApplicationData.Current.LocalFolder.CreateFolderAsync(CustomThemeFolderName, CreationCollisionOption.OpenIfExists); var allFiles = await themeFolder.GetFilesAsync(); var themeMetadatas = allFiles.Where(a => a.FileType == ".json"); foreach (var theme in themeMetadatas) { var metadata = await GetCustomMetadataAsync(theme).ConfigureAwait(false); if (metadata == null) continue; results.Add(metadata); } return results; } private static async Task GetCustomMetadataAsync(IStorageFile file) { var fileContent = await FileIO.ReadTextAsync(file); return JsonSerializer.Deserialize(fileContent); } public string GetSystemAccentColorHex() => _uiSettings.GetColorValue(UIColorType.Accent).ToHex(); }