From 478ed115a1d7c2e062bdab804dbf61a67e2d0519 Mon Sep 17 00:00:00 2001 From: lincube Date: Tue, 3 Mar 2026 15:09:49 +0800 Subject: [PATCH] 0.2.5 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 课表组件、天气组件全面升级。 --- .../.github/workflows/windows-ci.yml | 223 +++ LanMontainDesktop/App.axaml | 1 + .../Assets/Weather/HyperOS3/ATTRIBUTION.md | 20 + .../Weather/HyperOS3/hyper_cross_sky_day.png | Bin 0 -> 20699 bytes .../HyperOS3/hyper_cross_sky_night.png | Bin 0 -> 17123 bytes .../Assets/Weather/HyperOS3/hyper_fog.png | Bin 0 -> 79020 bytes .../Weather/HyperOS3/hyper_rain_drop.png | Bin 0 -> 477 bytes .../Weather/HyperOS3/hyper_sky_back.png | Bin 0 -> 15030 bytes .../Weather/HyperOS3/hyper_sky_front.png | Bin 0 -> 12293 bytes .../Assets/Weather/HyperOS3/hyper_sky_top.png | Bin 0 -> 10073 bytes .../Weather/HyperOS3/hyper_snow_flake.png | Bin 0 -> 683 bytes .../Weather/HyperOS3/hyper_sun_core.png | Bin 0 -> 1116 bytes .../Weather/HyperOS3/hyper_sun_ring.png | Bin 0 -> 1078 bytes .../Behaviors/PanelIntroAnimationBehavior.cs | 127 ++ .../Behaviors/PopupIntroAnimationBehavior.cs | 136 ++ .../ComponentSystem/BuiltInComponentIds.cs | 2 + .../ComponentSystem/ComponentRegistry.cs | 19 + LanMontainDesktop/LanMontainDesktop.csproj | 9 +- LanMontainDesktop/Localization/en-US.json | 27 + LanMontainDesktop/Localization/zh-CN.json | 27 + .../Models/AppSettingsSnapshot.cs | 10 + .../Models/ClassIslandScheduleModels.cs | 104 ++ .../Models/ImportedClassScheduleSnapshot.cs | 10 + LanMontainDesktop/Models/WeatherDataModels.cs | 1 + LanMontainDesktop/PACKAGING.md | 73 + .../ClassIslandScheduleDataService.cs | 1248 +++++++++++++++++ .../Services/XiaomiWeatherService.cs | 46 + .../Styles/SettingsAnimations.axaml | 99 ++ .../ClassScheduleSettingsWindow.axaml | 61 + .../ClassScheduleSettingsWindow.axaml.cs | 345 +++++ .../Components/ClassScheduleWidget.axaml | 59 + .../Components/ClassScheduleWidget.axaml.cs | 543 +++++++ .../DesktopComponentRuntimeRegistry.cs | 10 + .../Components/ExtendedWeatherWidget.axaml | 19 + .../Components/ExtendedWeatherWidget.axaml.cs | 48 + .../Components/HourlyWeatherWidget.axaml.cs | 340 ++--- .../Views/Components/HyperOS3WeatherTheme.cs | 434 ++++++ .../Components/MultiDayWeatherWidget.axaml.cs | 340 ++--- .../Components/WeatherClockWidget.axaml.cs | 148 +- .../Views/Components/WeatherWidget.axaml.cs | 318 ++--- .../Views/MainWindow.ComponentSystem.cs | 40 + .../Views/MainWindow.Localization.cs | 28 +- .../Views/MainWindow.Settings.cs | 258 +++- LanMontainDesktop/Views/MainWindow.axaml | 173 ++- LanMontainDesktop/Views/MainWindow.axaml.cs | 3 + .../installer/LanMontainDesktop.iss | 57 + LanMontainDesktop/scripts/package.ps1 | 241 ++++ 47 files changed, 4876 insertions(+), 771 deletions(-) create mode 100644 LanMontainDesktop/.github/workflows/windows-ci.yml create mode 100644 LanMontainDesktop/Assets/Weather/HyperOS3/ATTRIBUTION.md create mode 100644 LanMontainDesktop/Assets/Weather/HyperOS3/hyper_cross_sky_day.png create mode 100644 LanMontainDesktop/Assets/Weather/HyperOS3/hyper_cross_sky_night.png create mode 100644 LanMontainDesktop/Assets/Weather/HyperOS3/hyper_fog.png create mode 100644 LanMontainDesktop/Assets/Weather/HyperOS3/hyper_rain_drop.png create mode 100644 LanMontainDesktop/Assets/Weather/HyperOS3/hyper_sky_back.png create mode 100644 LanMontainDesktop/Assets/Weather/HyperOS3/hyper_sky_front.png create mode 100644 LanMontainDesktop/Assets/Weather/HyperOS3/hyper_sky_top.png create mode 100644 LanMontainDesktop/Assets/Weather/HyperOS3/hyper_snow_flake.png create mode 100644 LanMontainDesktop/Assets/Weather/HyperOS3/hyper_sun_core.png create mode 100644 LanMontainDesktop/Assets/Weather/HyperOS3/hyper_sun_ring.png create mode 100644 LanMontainDesktop/Behaviors/PanelIntroAnimationBehavior.cs create mode 100644 LanMontainDesktop/Behaviors/PopupIntroAnimationBehavior.cs create mode 100644 LanMontainDesktop/Models/ClassIslandScheduleModels.cs create mode 100644 LanMontainDesktop/Models/ImportedClassScheduleSnapshot.cs create mode 100644 LanMontainDesktop/PACKAGING.md create mode 100644 LanMontainDesktop/Services/ClassIslandScheduleDataService.cs create mode 100644 LanMontainDesktop/Styles/SettingsAnimations.axaml create mode 100644 LanMontainDesktop/Views/Components/ClassScheduleSettingsWindow.axaml create mode 100644 LanMontainDesktop/Views/Components/ClassScheduleSettingsWindow.axaml.cs create mode 100644 LanMontainDesktop/Views/Components/ClassScheduleWidget.axaml create mode 100644 LanMontainDesktop/Views/Components/ClassScheduleWidget.axaml.cs create mode 100644 LanMontainDesktop/Views/Components/ExtendedWeatherWidget.axaml create mode 100644 LanMontainDesktop/Views/Components/ExtendedWeatherWidget.axaml.cs create mode 100644 LanMontainDesktop/Views/Components/HyperOS3WeatherTheme.cs create mode 100644 LanMontainDesktop/installer/LanMontainDesktop.iss create mode 100644 LanMontainDesktop/scripts/package.ps1 diff --git a/LanMontainDesktop/.github/workflows/windows-ci.yml b/LanMontainDesktop/.github/workflows/windows-ci.yml new file mode 100644 index 0000000..be8490b --- /dev/null +++ b/LanMontainDesktop/.github/workflows/windows-ci.yml @@ -0,0 +1,223 @@ +name: Desktop CI + +on: + push: + branches: + - "**" + tags: + - "v*" + pull_request: + workflow_dispatch: + inputs: + version: + description: "Package version override (for example: 1.2.3)" + required: false + type: string + +jobs: + validate: + name: Validate Build (Windows) + runs-on: windows-latest + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup .NET SDK + uses: actions/setup-dotnet@v4 + with: + dotnet-version: "10.0.x" + cache: true + + - name: Restore + run: dotnet restore .\LanMontainDesktop.csproj + + - name: Build + run: dotnet build .\LanMontainDesktop.csproj -c Release --no-restore + + package_windows: + name: Package Windows + runs-on: windows-latest + needs: validate + if: github.event_name == 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/v') + permissions: + contents: write + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup .NET SDK + uses: actions/setup-dotnet@v4 + with: + dotnet-version: "10.0.x" + cache: true + + - name: Install Inno Setup + shell: pwsh + run: | + if (Get-Command choco -ErrorAction SilentlyContinue) { + choco install innosetup --yes --no-progress + } elseif (Get-Command winget -ErrorAction SilentlyContinue) { + winget install --id JRSoftware.InnoSetup -e --source winget --accept-package-agreements --accept-source-agreements + } else { + throw "Neither choco nor winget is available to install Inno Setup." + } + + - name: Resolve Package Version + id: version + shell: pwsh + run: | + $manualVersion = '${{ github.event.inputs.version }}' + if ($manualVersion) { + $version = $manualVersion.Trim() + } elseif ($env:GITHUB_REF -like "refs/tags/v*") { + $version = $env:GITHUB_REF_NAME.Substring(1) + } elseif ($env:GITHUB_REF -like "refs/tags/*") { + $version = $env:GITHUB_REF_NAME + } else { + $version = "0.0.$env:GITHUB_RUN_NUMBER" + } + + if (-not $version) { + throw "Failed to resolve package version." + } + + "value=$version" >> $env:GITHUB_OUTPUT + Write-Host "Using package version: $version" + + - name: Build Windows Installer + shell: pwsh + run: | + .\scripts\package.ps1 ` + -Configuration Release ` + -RuntimeIdentifier win-x64 ` + -Version "${{ steps.version.outputs.value }}" + + - name: Upload Windows Installer Artifact + uses: actions/upload-artifact@v4 + with: + name: LanMontainDesktop-Setup-${{ steps.version.outputs.value }} + path: artifacts/installer/*.exe + if-no-files-found: error + + - name: Upload Windows Publish Artifact + uses: actions/upload-artifact@v4 + with: + name: LanMontainDesktop-Publish-win-x64-${{ steps.version.outputs.value }} + path: artifacts/publish/win-x64/** + if-no-files-found: error + + - name: Attach Windows Installer to GitHub Release + if: startsWith(github.ref, 'refs/tags/v') + uses: softprops/action-gh-release@v2 + with: + files: artifacts/installer/*.exe + + package_linux: + name: Package Linux + runs-on: ubuntu-latest + needs: validate + if: github.event_name == 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/v') + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup .NET SDK + uses: actions/setup-dotnet@v4 + with: + dotnet-version: "10.0.x" + cache: true + + - name: Resolve Package Version + id: version + shell: pwsh + run: | + $manualVersion = '${{ github.event.inputs.version }}' + if ($manualVersion) { + $version = $manualVersion.Trim() + } elseif ($env:GITHUB_REF -like "refs/tags/v*") { + $version = $env:GITHUB_REF_NAME.Substring(1) + } elseif ($env:GITHUB_REF -like "refs/tags/*") { + $version = $env:GITHUB_REF_NAME + } else { + $version = "0.0.$env:GITHUB_RUN_NUMBER" + } + + if (-not $version) { + throw "Failed to resolve package version." + } + + "value=$version" >> $env:GITHUB_OUTPUT + Write-Host "Using package version: $version" + + - name: Build Linux Package + shell: pwsh + run: | + ./scripts/package.ps1 ` + -Configuration Release ` + -RuntimeIdentifier linux-x64 ` + -Version "${{ steps.version.outputs.value }}" + + - name: Upload Linux Package Artifact + uses: actions/upload-artifact@v4 + with: + name: LanMontainDesktop-linux-x64-${{ steps.version.outputs.value }} + path: artifacts/packages/*linux-x64*.zip + if-no-files-found: error + + package_macos: + name: Package macOS + runs-on: macos-latest + needs: validate + if: github.event_name == 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/v') + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup .NET SDK + uses: actions/setup-dotnet@v4 + with: + dotnet-version: "10.0.x" + cache: true + + - name: Resolve Package Version + id: version + shell: pwsh + run: | + $manualVersion = '${{ github.event.inputs.version }}' + if ($manualVersion) { + $version = $manualVersion.Trim() + } elseif ($env:GITHUB_REF -like "refs/tags/v*") { + $version = $env:GITHUB_REF_NAME.Substring(1) + } elseif ($env:GITHUB_REF -like "refs/tags/*") { + $version = $env:GITHUB_REF_NAME + } else { + $version = "0.0.$env:GITHUB_RUN_NUMBER" + } + + if (-not $version) { + throw "Failed to resolve package version." + } + + "value=$version" >> $env:GITHUB_OUTPUT + Write-Host "Using package version: $version" + + - name: Build macOS Package + shell: pwsh + run: | + ./scripts/package.ps1 ` + -Configuration Release ` + -RuntimeIdentifier osx-x64 ` + -Version "${{ steps.version.outputs.value }}" + + - name: Upload macOS Package Artifact + uses: actions/upload-artifact@v4 + with: + name: LanMontainDesktop-osx-x64-${{ steps.version.outputs.value }} + path: artifacts/packages/*osx-x64*.zip + if-no-files-found: error diff --git a/LanMontainDesktop/App.axaml b/LanMontainDesktop/App.axaml index 8680e54..4bfdaa9 100644 --- a/LanMontainDesktop/App.axaml +++ b/LanMontainDesktop/App.axaml @@ -18,6 +18,7 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/LanMontainDesktop/Views/Components/ClassScheduleSettingsWindow.axaml b/LanMontainDesktop/Views/Components/ClassScheduleSettingsWindow.axaml new file mode 100644 index 0000000..ddddbde --- /dev/null +++ b/LanMontainDesktop/Views/Components/ClassScheduleSettingsWindow.axaml @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + + + + + + diff --git a/LanMontainDesktop/Views/Components/ClassScheduleSettingsWindow.axaml.cs b/LanMontainDesktop/Views/Components/ClassScheduleSettingsWindow.axaml.cs new file mode 100644 index 0000000..8295de5 --- /dev/null +++ b/LanMontainDesktop/Views/Components/ClassScheduleSettingsWindow.axaml.cs @@ -0,0 +1,345 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Interactivity; +using Avalonia.Layout; +using Avalonia.Media; +using Avalonia.Platform.Storage; +using LanMontainDesktop.Models; +using LanMontainDesktop.Services; + +namespace LanMontainDesktop.Views.Components; + +public partial class ClassScheduleSettingsWindow : UserControl +{ + private readonly AppSettingsService _appSettingsService = new(); + private readonly LocalizationService _localizationService = new(); + private readonly List _importedSchedules = []; + private string _activeScheduleId = string.Empty; + private string _languageCode = "zh-CN"; + + public event EventHandler? SettingsChanged; + + public ClassScheduleSettingsWindow() + { + InitializeComponent(); + LoadState(); + ApplyLocalization(); + RenderImportedSchedules(); + } + + private void LoadState() + { + var snapshot = _appSettingsService.Load(); + _languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode); + + _importedSchedules.Clear(); + foreach (var item in snapshot.ImportedClassSchedules) + { + if (string.IsNullOrWhiteSpace(item.Id) || + string.IsNullOrWhiteSpace(item.FilePath)) + { + continue; + } + + _importedSchedules.Add(new ImportedClassScheduleSnapshot + { + Id = item.Id.Trim(), + DisplayName = item.DisplayName?.Trim() ?? string.Empty, + FilePath = item.FilePath.Trim() + }); + } + + _activeScheduleId = snapshot.ActiveImportedClassScheduleId?.Trim() ?? string.Empty; + if (_importedSchedules.Count > 0 && + !_importedSchedules.Any(item => string.Equals(item.Id, _activeScheduleId, StringComparison.OrdinalIgnoreCase))) + { + _activeScheduleId = _importedSchedules[0].Id; + } + } + + private void ApplyLocalization() + { + TitleTextBlock.Text = L("schedule.settings.title", "课表导入"); + DescriptionTextBlock.Text = L( + "schedule.settings.desc", + "导入 ClassIsland 的 CSES 课表文件并选择启用项。"); + AddScheduleButtonTextBlock.Text = L("schedule.settings.add", "添加课表"); + EmptyStateTextBlock.Text = L("schedule.settings.empty", "暂无导入课表"); + } + + private async void OnAddScheduleClick(object? sender, RoutedEventArgs e) + { + var topLevel = TopLevel.GetTopLevel(this); + var storageProvider = topLevel?.StorageProvider; + if (storageProvider is null) + { + return; + } + + var files = await storageProvider.OpenFilePickerAsync(new FilePickerOpenOptions + { + Title = L("schedule.settings.picker_title", "选择 ClassIsland 课表文件"), + AllowMultiple = false, + FileTypeFilter = + [ + new FilePickerFileType(L("schedule.settings.picker_file_type", "ClassIsland CSES 课表")) + { + Patterns = ["*.cses", "*.yaml", "*.yml"] + } + ] + }); + + if (files.Count == 0) + { + return; + } + + var importedPath = await ImportScheduleFileAsync(files[0]); + if (string.IsNullOrWhiteSpace(importedPath)) + { + return; + } + + var existing = _importedSchedules.FirstOrDefault(item => + string.Equals(item.FilePath, importedPath, StringComparison.OrdinalIgnoreCase)); + if (existing is not null) + { + _activeScheduleId = existing.Id; + SaveState(); + RenderImportedSchedules(); + return; + } + + var displayName = Path.GetFileNameWithoutExtension(importedPath)?.Trim(); + if (string.IsNullOrWhiteSpace(displayName)) + { + displayName = L("schedule.settings.unnamed", "未命名课表"); + } + + var imported = new ImportedClassScheduleSnapshot + { + Id = Guid.NewGuid().ToString("N"), + DisplayName = displayName, + FilePath = importedPath + }; + + _importedSchedules.Add(imported); + _activeScheduleId = imported.Id; + SaveState(); + RenderImportedSchedules(); + } + + private async Task ImportScheduleFileAsync(IStorageFile file) + { + try + { + var extension = Path.GetExtension(file.Name); + if (string.IsNullOrWhiteSpace(extension)) + { + extension = ".cses"; + } + + var importedDirectory = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "LanMontainDesktop", + "Schedules"); + Directory.CreateDirectory(importedDirectory); + + var destinationPath = Path.Combine( + importedDirectory, + $"{DateTime.Now:yyyyMMdd_HHmmss}_{Guid.NewGuid():N}{extension}"); + + await using var sourceStream = await file.OpenReadAsync(); + await using var destinationStream = File.Create(destinationPath); + await sourceStream.CopyToAsync(destinationStream); + return destinationPath; + } + catch + { + return null; + } + } + + private void RenderImportedSchedules() + { + ScheduleItemsPanel.Children.Clear(); + + if (_importedSchedules.Count == 0) + { + EmptyStateTextBlock.IsVisible = true; + return; + } + + EmptyStateTextBlock.IsVisible = false; + foreach (var item in _importedSchedules) + { + var selector = new RadioButton + { + GroupName = "class_schedule_imports", + IsChecked = string.Equals(item.Id, _activeScheduleId, StringComparison.OrdinalIgnoreCase), + VerticalAlignment = VerticalAlignment.Center, + Tag = item.Id + }; + selector.IsCheckedChanged += OnScheduleSelectionChanged; + + var title = new TextBlock + { + Text = string.IsNullOrWhiteSpace(item.DisplayName) + ? L("schedule.settings.unnamed", "未命名课表") + : item.DisplayName, + FontSize = 14, + FontWeight = FontWeight.SemiBold, + Foreground = ResolveThemeBrush("AdaptiveTextPrimaryBrush", "#FFEFF3FF"), + TextTrimming = TextTrimming.CharacterEllipsis + }; + + var path = new TextBlock + { + Text = item.FilePath, + FontSize = 11, + Foreground = ResolveThemeBrush("AdaptiveTextSecondaryBrush", "#FF99A2B5"), + TextTrimming = TextTrimming.CharacterEllipsis, + TextWrapping = TextWrapping.NoWrap + }; + + var textStack = new StackPanel + { + Spacing = 4, + VerticalAlignment = VerticalAlignment.Center, + Children = { title, path } + }; + + var deleteButton = new Button + { + Content = L("schedule.settings.delete", "删除"), + Tag = item.Id, + Padding = new Thickness(10, 6), + MinWidth = 64, + HorizontalAlignment = HorizontalAlignment.Right, + VerticalAlignment = VerticalAlignment.Center + }; + deleteButton.Click += OnDeleteScheduleClick; + + var rowGrid = new Grid + { + ColumnDefinitions = new ColumnDefinitions("Auto,*,Auto"), + ColumnSpacing = 10 + }; + rowGrid.Children.Add(selector); + rowGrid.Children.Add(textStack); + rowGrid.Children.Add(deleteButton); + Grid.SetColumn(selector, 0); + Grid.SetColumn(textStack, 1); + Grid.SetColumn(deleteButton, 2); + + var rowBorder = new Border + { + Padding = new Thickness(10, 8), + CornerRadius = new CornerRadius(12), + Background = ResolveThemeBrush("AdaptiveSurfaceRaisedBrush", "#1AFFFFFF"), + BorderBrush = ResolveThemeBrush("AdaptiveButtonBorderBrush", "#22000000"), + BorderThickness = new Thickness(1), + Child = rowGrid + }; + + ScheduleItemsPanel.Children.Add(rowBorder); + } + } + + private void OnScheduleSelectionChanged(object? sender, RoutedEventArgs e) + { + if (sender is not RadioButton button || + button.IsChecked != true || + button.Tag is not string scheduleId) + { + return; + } + + if (string.Equals(_activeScheduleId, scheduleId, StringComparison.OrdinalIgnoreCase)) + { + return; + } + + _activeScheduleId = scheduleId; + SaveState(); + } + + private void OnDeleteScheduleClick(object? sender, RoutedEventArgs e) + { + if (sender is not Button button || button.Tag is not string scheduleId) + { + return; + } + + var target = _importedSchedules.FirstOrDefault(item => + string.Equals(item.Id, scheduleId, StringComparison.OrdinalIgnoreCase)); + if (target is null) + { + return; + } + + _importedSchedules.Remove(target); + TryDeleteImportedFile(target.FilePath); + if (string.Equals(_activeScheduleId, scheduleId, StringComparison.OrdinalIgnoreCase)) + { + _activeScheduleId = _importedSchedules.Count > 0 ? _importedSchedules[0].Id : string.Empty; + } + + SaveState(); + RenderImportedSchedules(); + } + + private void SaveState() + { + var snapshot = _appSettingsService.Load(); + snapshot.ImportedClassSchedules = _importedSchedules + .Select(item => new ImportedClassScheduleSnapshot + { + Id = item.Id, + DisplayName = item.DisplayName, + FilePath = item.FilePath + }) + .ToList(); + snapshot.ActiveImportedClassScheduleId = _activeScheduleId ?? string.Empty; + _appSettingsService.Save(snapshot); + SettingsChanged?.Invoke(this, EventArgs.Empty); + } + + private string L(string key, string fallback) + { + return _localizationService.GetString(_languageCode, key, fallback); + } + + private static void TryDeleteImportedFile(string? filePath) + { + if (string.IsNullOrWhiteSpace(filePath) || !File.Exists(filePath)) + { + return; + } + + try + { + File.Delete(filePath); + } + catch + { + // Keep settings operation resilient even when file deletion fails. + } + } + + private IBrush ResolveThemeBrush(string key, string fallbackHex) + { + if (this.TryFindResource(key, out var value) && value is IBrush brush) + { + return brush; + } + + return new SolidColorBrush(Color.Parse(fallbackHex)); + } +} diff --git a/LanMontainDesktop/Views/Components/ClassScheduleWidget.axaml b/LanMontainDesktop/Views/Components/ClassScheduleWidget.axaml new file mode 100644 index 0000000..dd40f95 --- /dev/null +++ b/LanMontainDesktop/Views/Components/ClassScheduleWidget.axaml @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/LanMontainDesktop/Views/Components/ClassScheduleWidget.axaml.cs b/LanMontainDesktop/Views/Components/ClassScheduleWidget.axaml.cs new file mode 100644 index 0000000..d7f8378 --- /dev/null +++ b/LanMontainDesktop/Views/Components/ClassScheduleWidget.axaml.cs @@ -0,0 +1,543 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Media; +using Avalonia.Styling; +using Avalonia.Threading; +using LanMontainDesktop.Models; +using LanMontainDesktop.Services; + +namespace LanMontainDesktop.Views.Components; + +public partial class ClassScheduleWidget : UserControl, IDesktopComponentWidget, ITimeZoneAwareComponentWidget +{ + private sealed record CourseItemViewModel( + string Name, + string TimeRange, + string Detail, + bool IsCurrent); + + private readonly DispatcherTimer _refreshTimer = new() + { + Interval = TimeSpan.FromMinutes(4) + }; + + private readonly AppSettingsService _appSettingsService = new(); + private readonly LocalizationService _localizationService = new(); + private readonly IClassIslandScheduleDataService _scheduleService = new ClassIslandScheduleDataService(); + + private TimeZoneService? _timeZoneService; + private double _currentCellSize = 48; + private IReadOnlyList _courseItems = Array.Empty(); + private bool _isNightVisual = true; + private string _languageCode = "zh-CN"; + + public ClassScheduleWidget() + { + InitializeComponent(); + + _refreshTimer.Tick += OnRefreshTimerTick; + AttachedToVisualTree += OnAttachedToVisualTree; + DetachedFromVisualTree += OnDetachedFromVisualTree; + SizeChanged += OnSizeChanged; + ActualThemeVariantChanged += OnActualThemeVariantChanged; + + ApplyCellSize(_currentCellSize); + RefreshSchedule(); + } + + public void ApplyCellSize(double cellSize) + { + _currentCellSize = Math.Max(1, cellSize); + ApplyAdaptiveLayout(); + RenderScheduleItems(); + } + + public void SetTimeZoneService(TimeZoneService timeZoneService) + { + ClearTimeZoneService(); + _timeZoneService = timeZoneService; + _timeZoneService.TimeZoneChanged += OnTimeZoneChanged; + RefreshSchedule(); + } + + public void ClearTimeZoneService() + { + if (_timeZoneService is null) + { + return; + } + + _timeZoneService.TimeZoneChanged -= OnTimeZoneChanged; + _timeZoneService = null; + } + + private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e) + { + _refreshTimer.Start(); + RefreshSchedule(); + } + + private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e) + { + _refreshTimer.Stop(); + } + + private void OnSizeChanged(object? sender, SizeChangedEventArgs e) + { + ApplyCellSize(_currentCellSize); + } + + private void OnActualThemeVariantChanged(object? sender, EventArgs e) + { + ApplyAdaptiveLayout(); + RenderScheduleItems(); + } + + private void OnTimeZoneChanged(object? sender, EventArgs e) + { + RefreshSchedule(); + } + + private void OnRefreshTimerTick(object? sender, EventArgs e) + { + RefreshSchedule(); + } + + public void RefreshFromSettings() + { + RefreshSchedule(); + } + + private void RefreshSchedule() + { + var appSettings = _appSettingsService.Load(); + _languageCode = _localizationService.NormalizeLanguageCode(appSettings.LanguageCode); + var now = _timeZoneService?.GetCurrentTime() ?? DateTime.Now; + UpdateHeader(now); + + var importedSchedulePath = ResolveImportedSchedulePath(appSettings); + var readResult = _scheduleService.Load(importedSchedulePath); + if (!readResult.Success || readResult.Snapshot is null) + { + _courseItems = Array.Empty(); + ShowStatus(L("schedule.widget.no_source", "未读取到 ClassIsland 课表")); + RenderScheduleItems(); + return; + } + + var snapshot = readResult.Snapshot; + var today = DateOnly.FromDateTime(now); + if (!_scheduleService.TryResolveClassPlanForDate(snapshot, today, out var resolvedClassPlan)) + { + _courseItems = Array.Empty(); + ShowStatus(L("schedule.widget.no_class_today", "今天没有课程")); + RenderScheduleItems(); + return; + } + + if (!snapshot.TimeLayouts.TryGetValue(resolvedClassPlan.ClassPlan.TimeLayoutId, out var layout)) + { + _courseItems = Array.Empty(); + ShowStatus(L("schedule.widget.layout_missing", "课表时间布局缺失")); + RenderScheduleItems(); + return; + } + + _courseItems = BuildCourseItemViewModels(snapshot, resolvedClassPlan.ClassPlan, layout, now); + if (_courseItems.Count == 0) + { + ShowStatus(L("schedule.widget.no_class_today", "今天没有课程")); + } + else + { + HideStatus(); + } + + RenderScheduleItems(); + } + + private IReadOnlyList BuildCourseItemViewModels( + ClassIslandScheduleSnapshot snapshot, + ClassIslandClassPlan classPlan, + ClassIslandTimeLayout layout, + DateTime now) + { + var teachingSlots = layout.Items + .Where(static item => item.TimeType == 0) + .ToList(); + if (teachingSlots.Count == 0 || classPlan.Classes.Count == 0) + { + return Array.Empty(); + } + + var result = new List(teachingSlots.Count); + var max = Math.Min(teachingSlots.Count, classPlan.Classes.Count); + for (var i = 0; i < max; i++) + { + var classInfo = classPlan.Classes[i]; + if (!classInfo.IsEnabled) + { + continue; + } + + var slot = teachingSlots[i]; + var subjectName = ResolveSubjectName(snapshot, classInfo.SubjectId); + var detail = ResolveSubjectDetail(snapshot, classInfo.SubjectId); + var isCurrent = now.TimeOfDay >= slot.StartTime && now.TimeOfDay <= slot.EndTime; + result.Add(new CourseItemViewModel( + Name: subjectName, + TimeRange: $"{FormatTime(slot.StartTime)}-{FormatTime(slot.EndTime)}", + Detail: detail, + IsCurrent: isCurrent)); + } + + return result; + } + + private string ResolveSubjectName(ClassIslandScheduleSnapshot snapshot, Guid? subjectId) + { + if (subjectId.HasValue && + snapshot.Subjects.TryGetValue(subjectId.Value, out var subject) && + !string.IsNullOrWhiteSpace(subject.Name)) + { + return subject.Name.Trim(); + } + + return L("schedule.widget.subject_fallback", "未命名课程"); + } + + private string ResolveSubjectDetail(ClassIslandScheduleSnapshot snapshot, Guid? subjectId) + { + if (subjectId.HasValue && + snapshot.Subjects.TryGetValue(subjectId.Value, out var subject)) + { + if (!string.IsNullOrWhiteSpace(subject.TeacherName)) + { + return subject.TeacherName.Trim(); + } + + if (!string.IsNullOrWhiteSpace(subject.Initial)) + { + return subject.Initial.Trim(); + } + } + + return L("schedule.widget.detail_fallback", "未设置详情"); + } + + private void UpdateHeader(DateTime now) + { + var month = now.Month.ToString(CultureInfo.InvariantCulture); + var day = now.Day.ToString(CultureInfo.InvariantCulture); + MonthTextBlock.Text = month; + DayTextBlock.Text = day; + WeekdayTextBlock.Text = FormatWeekday(now.DayOfWeek); + ClassCountTextBlock.Text = FormatClassCount(_courseItems.Count); + } + + private string FormatClassCount(int count) + { + if (string.Equals(_languageCode, "zh-CN", StringComparison.OrdinalIgnoreCase)) + { + return string.Create(CultureInfo.InvariantCulture, $"{Math.Max(0, count)}节课"); + } + + if (count == 1) + { + return "1 class"; + } + + return string.Create(CultureInfo.InvariantCulture, $"{Math.Max(0, count)} classes"); + } + + private string FormatWeekday(DayOfWeek dayOfWeek) + { + if (string.Equals(_languageCode, "zh-CN", StringComparison.OrdinalIgnoreCase)) + { + return dayOfWeek switch + { + DayOfWeek.Monday => "周一", + DayOfWeek.Tuesday => "周二", + DayOfWeek.Wednesday => "周三", + DayOfWeek.Thursday => "周四", + DayOfWeek.Friday => "周五", + DayOfWeek.Saturday => "周六", + _ => "周日" + }; + } + + return dayOfWeek.ToString()[..3]; + } + + private static string? ResolveImportedSchedulePath(AppSettingsSnapshot snapshot) + { + if (snapshot.ImportedClassSchedules.Count == 0) + { + return null; + } + + var activeId = snapshot.ActiveImportedClassScheduleId?.Trim() ?? string.Empty; + ImportedClassScheduleSnapshot? selected = null; + if (!string.IsNullOrWhiteSpace(activeId)) + { + selected = snapshot.ImportedClassSchedules + .FirstOrDefault(item => string.Equals(item.Id, activeId, StringComparison.OrdinalIgnoreCase)); + } + + selected ??= snapshot.ImportedClassSchedules[0]; + return selected.FilePath; + } + + private string L(string key, string fallback) + { + return _localizationService.GetString(_languageCode, key, fallback); + } + + private void ShowStatus(string text) + { + StatusTextBlock.Text = text; + StatusTextBlock.IsVisible = true; + } + + private void HideStatus() + { + StatusTextBlock.Text = string.Empty; + StatusTextBlock.IsVisible = false; + } + + private void RenderScheduleItems() + { + CourseListPanel.Children.Clear(); + ClassCountTextBlock.Text = FormatClassCount(_courseItems.Count); + if (_courseItems.Count == 0) + { + return; + } + + var scale = ResolveScale(); + var bulletSize = Math.Clamp(10 * scale, 5, 12); + var courseNameSize = Math.Clamp(42 * scale, 14, 42); + var secondarySize = Math.Clamp(29 * scale, 10, 28); + var lineSpacing = Math.Clamp(4 * scale, 1.5, 8); + var itemPadding = new Thickness( + Math.Clamp(6 * scale, 3, 10), + Math.Clamp(4 * scale, 2, 8), + Math.Clamp(4 * scale, 2, 8), + Math.Clamp(4 * scale, 2, 8)); + var maxVisibleItems = ResolveMaxVisibleItems(scale); + + var primaryBrush = CreateBrush(_isNightVisual ? "#F9FBFF" : "#151821"); + var secondaryBrush = CreateBrush(_isNightVisual ? "#848B99" : "#667084"); + var currentBrush = CreateBrush("#FF4D5A"); + var normalBulletBrush = CreateBrush(_isNightVisual ? "#B8BEC9" : "#9AA3B2"); + + var visibleItems = _courseItems.Take(maxVisibleItems).ToList(); + for (var i = 0; i < visibleItems.Count; i++) + { + var item = visibleItems[i]; + var bulletBrush = item.IsCurrent ? currentBrush : normalBulletBrush; + + var bullet = new Border + { + Width = bulletSize, + Height = bulletSize, + CornerRadius = new CornerRadius(bulletSize * 0.5), + Background = bulletBrush, + VerticalAlignment = Avalonia.Layout.VerticalAlignment.Top, + Margin = new Thickness(0, Math.Clamp(8 * scale, 2, 12), 0, 0) + }; + + var titleText = new TextBlock + { + Text = item.Name, + FontSize = courseNameSize, + FontWeight = ToVariableWeight(Lerp(620, 780, Math.Clamp((scale - 0.60) / 1.2, 0, 1))), + Foreground = primaryBrush, + TextTrimming = TextTrimming.CharacterEllipsis, + TextWrapping = TextWrapping.NoWrap + }; + + var timeText = new TextBlock + { + Text = item.TimeRange, + FontSize = secondarySize, + FontWeight = ToVariableWeight(Lerp(520, 680, Math.Clamp((scale - 0.60) / 1.2, 0, 1))), + Foreground = secondaryBrush, + TextTrimming = TextTrimming.CharacterEllipsis, + TextWrapping = TextWrapping.NoWrap + }; + + var detailText = new TextBlock + { + Text = item.Detail, + FontSize = secondarySize, + FontWeight = ToVariableWeight(Lerp(500, 640, Math.Clamp((scale - 0.60) / 1.2, 0, 1))), + Foreground = secondaryBrush, + TextTrimming = TextTrimming.CharacterEllipsis, + TextWrapping = TextWrapping.NoWrap + }; + + var textStack = new StackPanel + { + Spacing = lineSpacing, + Children = { titleText, timeText, detailText } + }; + + var itemGrid = new Grid + { + ColumnDefinitions = new ColumnDefinitions("Auto,*"), + ColumnSpacing = Math.Clamp(10 * scale, 4, 14) + }; + itemGrid.Children.Add(bullet); + itemGrid.Children.Add(textStack); + Grid.SetColumn(textStack, 1); + + var itemBorder = new Border + { + Padding = itemPadding, + Background = Brushes.Transparent, + Child = itemGrid + }; + + CourseListPanel.Children.Add(itemBorder); + } + } + + private int ResolveMaxVisibleItems(double scale) + { + var height = Bounds.Height > 1 ? Bounds.Height : _currentCellSize * 4; + var rootVerticalPadding = RootBorder.Padding.Top + RootBorder.Padding.Bottom; + var headerEstimatedHeight = Math.Clamp(100 * scale, 54, 140); + var itemEstimatedHeight = Math.Clamp(136 * scale, 72, 178); + var available = Math.Max(1, height - rootVerticalPadding - headerEstimatedHeight); + var count = (int)Math.Floor(available / Math.Max(1, itemEstimatedHeight)); + return Math.Clamp(count, 1, 6); + } + + private void ApplyAdaptiveLayout() + { + var scale = ResolveScale(); + _isNightVisual = ResolveNightMode(); + + var cornerRadius = Math.Clamp(_currentCellSize * 0.45, 24, 44); + RootBorder.CornerRadius = new CornerRadius(cornerRadius); + RootBorder.Background = _isNightVisual + ? CreateGradientBrush("#171A21", "#0C0E14") + : CreateGradientBrush("#F7F8FC", "#ECEFF6"); + RootBorder.BorderBrush = CreateBrush(_isNightVisual ? "#24FFFFFF" : "#15000000"); + + var rootPadding = new Thickness( + Math.Clamp(16 * scale, 10, 24), + Math.Clamp(14 * scale, 9, 20), + Math.Clamp(16 * scale, 10, 24), + Math.Clamp(14 * scale, 8, 20)); + RootBorder.Padding = rootPadding; + + LayoutGrid.RowSpacing = Math.Clamp(14 * scale, 6, 20); + HeaderGrid.ColumnSpacing = Math.Clamp(10 * scale, 4, 16); + DateGroup.Spacing = Math.Clamp(1.5 * scale, 0.5, 3); + MetaStack.Spacing = Math.Clamp(6 * scale, 3, 10); + CourseListPanel.Spacing = Math.Clamp(6 * scale, 3, 10); + + var dateFont = Math.Clamp(66 * scale, 26, 82); + MonthTextBlock.FontSize = dateFont; + DayTextBlock.FontSize = dateFont; + SlashTextBlock.FontSize = dateFont; + + MonthTextBlock.Foreground = CreateBrush(_isNightVisual ? "#F8FAFF" : "#131722"); + DayTextBlock.Foreground = CreateBrush(_isNightVisual ? "#F8FAFF" : "#131722"); + SlashTextBlock.Foreground = CreateBrush("#FF3250"); + WeekdayTextBlock.Foreground = CreateBrush(_isNightVisual ? "#C6CBD5" : "#4B5463"); + ClassCountTextBlock.Foreground = CreateBrush(_isNightVisual ? "#8D95A4" : "#738095"); + StatusTextBlock.Foreground = CreateBrush(_isNightVisual ? "#9AA2B1" : "#4B5565"); + + WeekdayTextBlock.FontSize = Math.Clamp(34 * scale, 13, 32); + ClassCountTextBlock.FontSize = Math.Clamp(40 * scale, 14, 36); + StatusTextBlock.FontSize = Math.Clamp(30 * scale, 12, 30); + + WeekdayTextBlock.FontWeight = ToVariableWeight(Lerp(560, 700, Math.Clamp((scale - 0.60) / 1.2, 0, 1))); + ClassCountTextBlock.FontWeight = ToVariableWeight(Lerp(560, 680, Math.Clamp((scale - 0.60) / 1.2, 0, 1))); + } + + private static string FormatTime(TimeSpan time) + { + return string.Create(CultureInfo.InvariantCulture, $"{time.Hours}:{time.Minutes:00}"); + } + + private double ResolveScale() + { + var cellScale = Math.Clamp(_currentCellSize / 44d, 0.58, 2.2); + var widthScale = Bounds.Width > 1 ? Math.Clamp(Bounds.Width / 230d, 0.52, 2.4) : 1; + var heightScale = Bounds.Height > 1 ? Math.Clamp(Bounds.Height / 440d, 0.52, 2.4) : 1; + return Math.Clamp(Math.Min(cellScale, Math.Min(widthScale, heightScale) * 1.04), 0.52, 2.2); + } + + private bool ResolveNightMode() + { + if (ActualThemeVariant == ThemeVariant.Dark) + { + return true; + } + + if (ActualThemeVariant == ThemeVariant.Light) + { + return false; + } + + if (this.TryFindResource("AdaptiveSurfaceBaseBrush", out var value) && + value is ISolidColorBrush brush) + { + return CalculateRelativeLuminance(brush.Color) < 0.45; + } + + return true; + } + + private static double CalculateRelativeLuminance(Color color) + { + static double ToLinear(double channel) + { + return channel <= 0.03928 + ? channel / 12.92 + : Math.Pow((channel + 0.055) / 1.055, 2.4); + } + + var r = ToLinear(color.R / 255d); + var g = ToLinear(color.G / 255d); + var b = ToLinear(color.B / 255d); + return 0.2126 * r + 0.7152 * g + 0.0722 * b; + } + + private static FontWeight ToVariableWeight(double value) + { + return (FontWeight)(int)Math.Clamp(Math.Round(value), 1, 1000); + } + + private static double Lerp(double from, double to, double t) + { + return from + ((to - from) * t); + } + + private static IBrush CreateBrush(string colorHex) + { + return new SolidColorBrush(Color.Parse(colorHex)); + } + + private static IBrush CreateGradientBrush(string fromHex, string toHex) + { + return new LinearGradientBrush + { + StartPoint = new RelativePoint(0, 0, RelativeUnit.Relative), + EndPoint = new RelativePoint(1, 1, RelativeUnit.Relative), + GradientStops = new GradientStops + { + new GradientStop(Color.Parse(fromHex), 0), + new GradientStop(Color.Parse(toHex), 1) + } + }; + } +} diff --git a/LanMontainDesktop/Views/Components/DesktopComponentRuntimeRegistry.cs b/LanMontainDesktop/Views/Components/DesktopComponentRuntimeRegistry.cs index 2d19fd9..6e7c2b3 100644 --- a/LanMontainDesktop/Views/Components/DesktopComponentRuntimeRegistry.cs +++ b/LanMontainDesktop/Views/Components/DesktopComponentRuntimeRegistry.cs @@ -145,6 +145,16 @@ public sealed class DesktopComponentRuntimeRegistry "component.multiday_weather", () => new MultiDayWeatherWidget(), cellSize => Math.Clamp(cellSize * 0.45, 24, 44)), + new DesktopComponentRuntimeRegistration( + BuiltInComponentIds.DesktopExtendedWeather, + "component.extended_weather", + () => new ExtendedWeatherWidget(), + cellSize => Math.Clamp(cellSize * 0.45, 24, 44)), + new DesktopComponentRuntimeRegistration( + BuiltInComponentIds.DesktopClassSchedule, + "component.class_schedule", + () => new ClassScheduleWidget(), + cellSize => Math.Clamp(cellSize * 0.45, 24, 44)), new DesktopComponentRuntimeRegistration( BuiltInComponentIds.DesktopWhiteboard, "component.whiteboard", diff --git a/LanMontainDesktop/Views/Components/ExtendedWeatherWidget.axaml b/LanMontainDesktop/Views/Components/ExtendedWeatherWidget.axaml new file mode 100644 index 0000000..2c9fc6b --- /dev/null +++ b/LanMontainDesktop/Views/Components/ExtendedWeatherWidget.axaml @@ -0,0 +1,19 @@ + + + + + + + diff --git a/LanMontainDesktop/Views/Components/ExtendedWeatherWidget.axaml.cs b/LanMontainDesktop/Views/Components/ExtendedWeatherWidget.axaml.cs new file mode 100644 index 0000000..e19f6e6 --- /dev/null +++ b/LanMontainDesktop/Views/Components/ExtendedWeatherWidget.axaml.cs @@ -0,0 +1,48 @@ +using System; +using Avalonia.Controls; +using LanMontainDesktop.Services; + +namespace LanMontainDesktop.Views.Components; + +public partial class ExtendedWeatherWidget : UserControl, IDesktopComponentWidget, ITimeZoneAwareComponentWidget, IWeatherInfoAwareComponentWidget +{ + private TimeZoneService? _timeZoneService; + private IWeatherInfoService? _weatherInfoService; + private double _currentCellSize = 48; + + public ExtendedWeatherWidget() + { + InitializeComponent(); + ApplyCellSize(_currentCellSize); + } + + public void ApplyCellSize(double cellSize) + { + _currentCellSize = Math.Max(1, cellSize); + var metrics = HyperOS3WeatherTheme.ResolveMetrics(HyperOS3WeatherWidgetKind.Extended4x4); + ContainerGrid.RowSpacing = Math.Clamp(_currentCellSize * metrics.SectionGap * 0.22, 6, 18); + HourlyHost.ApplyCellSize(_currentCellSize); + MultiDayHost.ApplyCellSize(_currentCellSize); + } + + public void SetTimeZoneService(TimeZoneService timeZoneService) + { + _timeZoneService = timeZoneService; + HourlyHost.SetTimeZoneService(timeZoneService); + MultiDayHost.SetTimeZoneService(timeZoneService); + } + + public void ClearTimeZoneService() + { + HourlyHost.ClearTimeZoneService(); + MultiDayHost.ClearTimeZoneService(); + _timeZoneService = null; + } + + public void SetWeatherInfoService(IWeatherInfoService weatherInfoService) + { + _weatherInfoService = weatherInfoService; + HourlyHost.SetWeatherInfoService(weatherInfoService); + MultiDayHost.SetWeatherInfoService(weatherInfoService); + } +} diff --git a/LanMontainDesktop/Views/Components/HourlyWeatherWidget.axaml.cs b/LanMontainDesktop/Views/Components/HourlyWeatherWidget.axaml.cs index 97107c1..53bd68c 100644 --- a/LanMontainDesktop/Views/Components/HourlyWeatherWidget.axaml.cs +++ b/LanMontainDesktop/Views/Components/HourlyWeatherWidget.axaml.cs @@ -39,6 +39,7 @@ public partial class HourlyWeatherWidget : UserControl, IDesktopComponentWidget, string Tint, string PrimaryText, string SecondaryText, + string TertiaryText, string ParticleColor); private readonly record struct WeatherMotionProfile( @@ -81,20 +82,6 @@ public partial class HourlyWeatherWidget : UserControl, IDesktopComponentWidget, Symbol Icon, string TemperatureText); - private static readonly IReadOnlyDictionary WeatherBackgroundAssets = - new Dictionary - { - [WeatherVisualKind.ClearDay] = "avares://LanMontainDesktop/Assets/Weather/clear_day.jpg", - [WeatherVisualKind.ClearNight] = "avares://LanMontainDesktop/Assets/Weather/clear_night.jpg", - [WeatherVisualKind.CloudyDay] = "avares://LanMontainDesktop/Assets/Weather/cloudy_day.jpg", - [WeatherVisualKind.CloudyNight] = "avares://LanMontainDesktop/Assets/Weather/cloudy_night.jpg", - [WeatherVisualKind.RainLight] = "avares://LanMontainDesktop/Assets/Weather/rain_light.jpg", - [WeatherVisualKind.RainHeavy] = "avares://LanMontainDesktop/Assets/Weather/rain_heavy.jpg", - [WeatherVisualKind.Storm] = "avares://LanMontainDesktop/Assets/Weather/storm_dark.jpg", - [WeatherVisualKind.Snow] = "avares://LanMontainDesktop/Assets/Weather/snow_soft.jpg", - [WeatherVisualKind.Fog] = "avares://LanMontainDesktop/Assets/Weather/fog_haze.jpg" - }; - private static readonly IWeatherInfoService DefaultWeatherInfoService = new XiaomiWeatherService(); private readonly DispatcherTimer _refreshTimer = new() @@ -110,6 +97,7 @@ public partial class HourlyWeatherWidget : UserControl, IDesktopComponentWidget, private readonly AppSettingsService _settingsService = new(); private readonly LocalizationService _localizationService = new(); private readonly Dictionary _backgroundBrushCache = new(); + private readonly Dictionary _particleBrushCache = new(); private readonly List _particleVisuals = new(); private readonly List _particleStates = new(); private readonly Random _particleRandom = new(); @@ -223,10 +211,11 @@ public partial class HourlyWeatherWidget : UserControl, IDesktopComponentWidget, public void ApplyCellSize(double cellSize) { _currentCellSize = Math.Max(1, cellSize); + var metrics = HyperOS3WeatherTheme.ResolveMetrics(HyperOS3WeatherWidgetKind.Hourly4x2); var scale = ResolveScale(); var hostWidth = Bounds.Width > 1 ? Bounds.Width : Math.Max(140, _currentCellSize * 4); var hostHeight = Bounds.Height > 1 ? Bounds.Height : Math.Max(78, _currentCellSize * 2); - var cornerRadius = Math.Clamp(_currentCellSize * 0.45, 24, 44); + var cornerRadius = Math.Clamp(_currentCellSize * metrics.CornerRadiusScale, 24, 44); RootBorder.CornerRadius = new CornerRadius(cornerRadius); BackgroundImageLayer.CornerRadius = new CornerRadius(cornerRadius); @@ -235,8 +224,8 @@ public partial class HourlyWeatherWidget : UserControl, IDesktopComponentWidget, BackgroundLightLayer.CornerRadius = new CornerRadius(cornerRadius); BackgroundShadeLayer.CornerRadius = new CornerRadius(cornerRadius); ContentPaddingBorder.Padding = new Thickness( - Math.Clamp(Math.Min(20 * scale, hostWidth * 0.028), 3, 18), - Math.Clamp(Math.Min(14 * scale, hostHeight * 0.060), 2, 14)); + Math.Clamp(Math.Min((_currentCellSize * metrics.HorizontalPaddingScale) * scale, hostWidth * 0.028), 3, 18), + Math.Clamp(Math.Min((_currentCellSize * metrics.VerticalPaddingScale) * scale, hostHeight * 0.060), 2, 14)); ApplyAdaptiveTypography(); ResetParticles(); } @@ -319,26 +308,10 @@ public partial class HourlyWeatherWidget : UserControl, IDesktopComponentWidget, private bool ResolveIsNight(WeatherSnapshot snapshot) { - if (snapshot.ObservationTime.HasValue) - { - var observed = snapshot.ObservationTime.Value; - try - { - if (_timeZoneService is not null) - { - var zoned = TimeZoneInfo.ConvertTime(observed, _timeZoneService.CurrentTimeZone); - return zoned.Hour < 6 || zoned.Hour >= 18; - } - } - catch - { - // fall through to local clock - } - - return observed.Hour < 6 || observed.Hour >= 18; - } - - return IsNightNow(); + return HyperOS3WeatherTheme.ResolveIsNightPreferred( + snapshot, + _timeZoneService?.CurrentTimeZone, + _timeZoneService?.GetCurrentTime() ?? DateTime.Now); } private bool IsNightNow() @@ -557,11 +530,11 @@ public partial class HourlyWeatherWidget : UserControl, IDesktopComponentWidget, BackgroundTintLayer.Background = CreateSolidBrush(palette.Tint); var primary = CreateSolidBrush(palette.PrimaryText); - var particleBrush = CreateSolidBrush(palette.ParticleColor); + var particleBrush = ResolveParticleBrush(ToThemeKind(kind), palette.ParticleColor); var isNightVisual = kind is WeatherVisualKind.ClearNight or WeatherVisualKind.CloudyNight; var conditionSecondary = CreateSolidBrush(palette.SecondaryText, isNightVisual ? (byte)0xF0 : (byte)0xE6); var rangeSecondary = CreateSolidBrush(palette.SecondaryText, isNightVisual ? (byte)0xE8 : (byte)0xD6); - var forecastTimeBrush = CreateSolidBrush(palette.SecondaryText, isNightVisual ? (byte)0xDA : (byte)0xC6); + var forecastTimeBrush = CreateSolidBrush(palette.TertiaryText, isNightVisual ? (byte)0xDA : (byte)0xC6); var forecastTempBrush = CreateSolidBrush(palette.PrimaryText, isNightVisual ? (byte)0xF4 : (byte)0xEA); HourlyPanelBorder.Background = CreateSolidBrush(isNightVisual ? "#1BFFFFFF" : "#1EFFFFFF"); LocationIcon.Foreground = primary; @@ -593,7 +566,8 @@ public partial class HourlyWeatherWidget : UserControl, IDesktopComponentWidget, return cached; } - if (WeatherBackgroundAssets.TryGetValue(kind, out var uriText)) + var uriText = HyperOS3WeatherTheme.ResolveBackgroundAsset(ToThemeKind(kind)); + if (!string.IsNullOrWhiteSpace(uriText)) { try { @@ -621,104 +595,89 @@ public partial class HourlyWeatherWidget : UserControl, IDesktopComponentWidget, return gradientBrush; } + private IBrush ResolveParticleBrush(HyperOS3WeatherVisualKind kind, string fallbackColor) + { + if (_particleBrushCache.TryGetValue(kind, out var cached)) + { + return cached; + } + + var uriText = HyperOS3WeatherTheme.ResolveParticleAsset(kind); + if (!string.IsNullOrWhiteSpace(uriText)) + { + try + { + var uri = new Uri(uriText, UriKind.Absolute); + using var stream = AssetLoader.Open(uri); + var bitmap = new Bitmap(stream); + var imageBrush = new ImageBrush + { + Source = bitmap, + Stretch = Stretch.UniformToFill, + AlignmentX = AlignmentX.Center, + AlignmentY = AlignmentY.Center + }; + _particleBrushCache[kind] = imageBrush; + return imageBrush; + } + catch + { + // Fall through to solid particle color when the image cannot be loaded. + } + } + + var solidBrush = CreateSolidBrush(fallbackColor); + _particleBrushCache[kind] = solidBrush; + return solidBrush; + } + private static WeatherVisualKind ResolveVisualKind(int? weatherCode, bool isNight) { - return weatherCode switch + return HyperOS3WeatherTheme.ResolveVisualKind(weatherCode, isNight) switch { - 0 => isNight ? WeatherVisualKind.ClearNight : WeatherVisualKind.ClearDay, - 1 or 2 => isNight ? WeatherVisualKind.CloudyNight : WeatherVisualKind.CloudyDay, - 3 or 7 => WeatherVisualKind.RainLight, - 8 or 9 => WeatherVisualKind.RainHeavy, - 4 => WeatherVisualKind.Storm, - 13 or 14 or 15 or 16 => WeatherVisualKind.Snow, - 18 or 32 => WeatherVisualKind.Fog, - _ => isNight ? WeatherVisualKind.CloudyNight : WeatherVisualKind.CloudyDay + HyperOS3WeatherVisualKind.ClearDay => WeatherVisualKind.ClearDay, + HyperOS3WeatherVisualKind.ClearNight => WeatherVisualKind.ClearNight, + HyperOS3WeatherVisualKind.CloudyDay => WeatherVisualKind.CloudyDay, + HyperOS3WeatherVisualKind.CloudyNight => WeatherVisualKind.CloudyNight, + HyperOS3WeatherVisualKind.RainLight => WeatherVisualKind.RainLight, + HyperOS3WeatherVisualKind.RainHeavy => WeatherVisualKind.RainHeavy, + HyperOS3WeatherVisualKind.Storm => WeatherVisualKind.Storm, + HyperOS3WeatherVisualKind.Snow => WeatherVisualKind.Snow, + _ => WeatherVisualKind.Fog }; } private static WeatherVisualPalette ResolvePalette(WeatherVisualKind kind) { - return kind switch - { - WeatherVisualKind.ClearDay => new WeatherVisualPalette( - GradientFrom: "#4F92E8", - GradientTo: "#83C5FF", - Tint: "#234D87", - PrimaryText: "#FFFFFFFF", - SecondaryText: "#EEF5FF", - ParticleColor: "#00FFFFFF"), - WeatherVisualKind.ClearNight => new WeatherVisualPalette( - GradientFrom: "#0E2B72", - GradientTo: "#193A85", - Tint: "#0A1E52", - PrimaryText: "#FFFFFFFF", - SecondaryText: "#CFE0FF", - ParticleColor: "#00FFFFFF"), - WeatherVisualKind.CloudyDay => new WeatherVisualPalette( - GradientFrom: "#4A72B3", - GradientTo: "#6A8EC2", - Tint: "#2A487C", - PrimaryText: "#FFFFFFFF", - SecondaryText: "#EAF2FF", - ParticleColor: "#16FFFFFF"), - WeatherVisualKind.CloudyNight => new WeatherVisualPalette( - GradientFrom: "#102A6B", - GradientTo: "#193A80", - Tint: "#0B1F51", - PrimaryText: "#FFFFFFFF", - SecondaryText: "#D5E4FF", - ParticleColor: "#24FFFFFF"), - WeatherVisualKind.RainLight => new WeatherVisualPalette( - GradientFrom: "#32588A", - GradientTo: "#4D74A8", - Tint: "#1F3454", - PrimaryText: "#FFFFFFFF", - SecondaryText: "#E6F0FF", - ParticleColor: "#88D7E8FF"), - WeatherVisualKind.RainHeavy => new WeatherVisualPalette( - GradientFrom: "#253F66", - GradientTo: "#36567F", - Tint: "#17263E", - PrimaryText: "#FFFFFFFF", - SecondaryText: "#DCE9FF", - ParticleColor: "#A2CDE1FF"), - WeatherVisualKind.Storm => new WeatherVisualPalette( - GradientFrom: "#293A67", - GradientTo: "#3A4F78", - Tint: "#161E35", - PrimaryText: "#FFFFFFFF", - SecondaryText: "#DCE4F8", - ParticleColor: "#A8C2D6F2"), - WeatherVisualKind.Snow => new WeatherVisualPalette( - GradientFrom: "#D1E8FF", - GradientTo: "#A7D0F4", - Tint: "#607C9D", - PrimaryText: "#FF10253D", - SecondaryText: "#FF2B435E", - ParticleColor: "#CCFFFFFF"), - _ => new WeatherVisualPalette( - GradientFrom: "#445B7A", - GradientTo: "#5B738F", - Tint: "#2A3E56", - PrimaryText: "#FFFFFFFF", - SecondaryText: "#E7EDF6", - ParticleColor: "#88E4EDF7") - }; + var palette = HyperOS3WeatherTheme.ResolvePalette(ToThemeKind(kind)); + return new WeatherVisualPalette( + palette.GradientFrom, + palette.GradientTo, + palette.Tint, + palette.PrimaryText, + palette.SecondaryText, + palette.TertiaryText, + palette.ParticleColor); } private static Symbol ResolveWeatherSymbol(WeatherVisualKind kind) + { + return HyperOS3WeatherTheme.ResolveWeatherSymbol(ToThemeKind(kind)); + } + + private static HyperOS3WeatherVisualKind ToThemeKind(WeatherVisualKind kind) { return kind switch { - WeatherVisualKind.ClearDay => Symbol.WeatherSunny, - WeatherVisualKind.ClearNight => Symbol.WeatherMoon, - WeatherVisualKind.CloudyDay => Symbol.WeatherPartlyCloudyDay, - WeatherVisualKind.CloudyNight => Symbol.WeatherPartlyCloudyNight, - WeatherVisualKind.RainLight => Symbol.WeatherRainShowersDay, - WeatherVisualKind.RainHeavy => Symbol.WeatherRain, - WeatherVisualKind.Storm => Symbol.WeatherThunderstorm, - WeatherVisualKind.Snow => Symbol.WeatherSnow, - _ => Symbol.WeatherFog + WeatherVisualKind.ClearDay => HyperOS3WeatherVisualKind.ClearDay, + WeatherVisualKind.ClearNight => HyperOS3WeatherVisualKind.ClearNight, + WeatherVisualKind.CloudyDay => HyperOS3WeatherVisualKind.CloudyDay, + WeatherVisualKind.CloudyNight => HyperOS3WeatherVisualKind.CloudyNight, + WeatherVisualKind.RainLight => HyperOS3WeatherVisualKind.RainLight, + WeatherVisualKind.RainHeavy => HyperOS3WeatherVisualKind.RainHeavy, + WeatherVisualKind.Storm => HyperOS3WeatherVisualKind.Storm, + WeatherVisualKind.Snow => HyperOS3WeatherVisualKind.Snow, + _ => HyperOS3WeatherVisualKind.Fog }; } @@ -994,18 +953,8 @@ public partial class HourlyWeatherWidget : UserControl, IDesktopComponentWidget, private static string ResolveWeatherIconAccent(Symbol symbol, bool isNightVisual) { - return symbol switch - { - Symbol.WeatherSunny => isNightVisual ? "#FFD978" : "#F7C40A", - Symbol.WeatherMoon => "#F3D38C", - Symbol.WeatherPartlyCloudyDay => "#75B0FF", - Symbol.WeatherPartlyCloudyNight => "#8AB6FF", - Symbol.WeatherRainShowersDay => "#9ECBFF", - Symbol.WeatherRain => "#8DBDF5", - Symbol.WeatherThunderstorm => "#F4D16E", - Symbol.WeatherSnow => "#C7E6FF", - _ => isNightVisual ? "#D5E2F4" : "#E2ECFA" - }; + var kind = isNightVisual ? HyperOS3WeatherVisualKind.ClearNight : HyperOS3WeatherVisualKind.ClearDay; + return HyperOS3WeatherTheme.ResolveIconAccent(kind, symbol); } private static string ResolvePreciseDisplayLocation(string? rawName, string languageCode, string fallback) @@ -1154,6 +1103,7 @@ public partial class HourlyWeatherWidget : UserControl, IDesktopComponentWidget, private void ApplyAdaptiveTypography() { var (layoutWidth, layoutHeight) = ResolveLayoutViewport(); + var metrics = HyperOS3WeatherTheme.ResolveMetrics(HyperOS3WeatherWidgetKind.Hourly4x2); var scale = ResolveScale(layoutWidth, layoutHeight); var densityBoost = scale <= 0.55 ? 0.80 : scale <= 0.72 ? 0.88 : scale <= 0.92 ? 0.95 : scale >= 1.45 ? 1.06 : 1.0; var compactness = Math.Clamp((0.88 - scale) / 0.50, 0, 1); @@ -1162,9 +1112,9 @@ public partial class HourlyWeatherWidget : UserControl, IDesktopComponentWidget, var conditionLength = Math.Max(1, ConditionTextBlock.Text?.Length ?? 2); var conditionCompression = conditionLength >= 12 ? 0.72 : conditionLength >= 8 ? 0.85 : conditionLength >= 6 ? 0.92 : 1.0; - ContentGrid.RowSpacing = Math.Clamp(layoutHeight * Lerp(0.030, 0.018, compactness), 2, 14); - TopRowGrid.ColumnSpacing = Math.Clamp(layoutWidth * 0.014, 3, 14); - BottomInfoStack.Spacing = Math.Clamp(layoutHeight * 0.016, 2, 10); + ContentGrid.RowSpacing = Math.Clamp(Math.Max(metrics.MainGap * scale, layoutHeight * Lerp(0.030, 0.018, compactness)), 2, 14); + TopRowGrid.ColumnSpacing = Math.Clamp(Math.Max(metrics.MainGap * scale, layoutWidth * 0.014), 3, 14); + BottomInfoStack.Spacing = Math.Clamp(Math.Max(metrics.SectionGap * scale, layoutHeight * 0.016), 2, 10); BottomInfoStack.Margin = new Thickness(0, 0, 0, Math.Clamp(layoutHeight * 0.018, 0, 10)); ConditionRangeStack.Spacing = Math.Clamp(layoutWidth * 0.010, 3, 12); ConditionRangeStack.Margin = new Thickness(0, 0, 0, Math.Clamp(layoutHeight * 0.018, 0, 10)); @@ -1179,12 +1129,12 @@ public partial class HourlyWeatherWidget : UserControl, IDesktopComponentWidget, var middleBandHeight = Math.Max(24, layoutHeight * 0.30); var bottomBandHeight = Math.Max(22, layoutHeight - topBandHeight - middleBandHeight - (ContentGrid.RowSpacing * 2)); - LocationIcon.FontSize = Math.Min(Math.Clamp(24 * scale * densityBoost, 9, 30), topBandHeight * 0.58); - CityTextBlock.FontSize = Math.Min(Math.Clamp(40 * scale * cityCompression * densityBoost, 12, 46), topBandHeight * 0.76); - WeatherIconSymbol.FontSize = Math.Min(Math.Clamp(52 * scale * densityBoost, 12, 56), topBandHeight * 0.95); - TemperatureTextBlock.FontSize = Math.Min(Math.Clamp(134 * scale * densityBoost, 26, 138), middleBandHeight * 0.92); - ConditionTextBlock.FontSize = Math.Min(Math.Clamp(32 * scale * conditionCompression * densityBoost, 9, 40), middleBandHeight * 0.42); - RangeTextBlock.FontSize = Math.Min(Math.Clamp(37 * scale * densityBoost, 10, 46), middleBandHeight * 0.50); + LocationIcon.FontSize = Math.Min(Math.Clamp((metrics.IconFont * 0.6) * scale * densityBoost, 9, 30), topBandHeight * 0.58); + CityTextBlock.FontSize = Math.Min(Math.Clamp((metrics.PrimaryTextFont * 1.42) * scale * cityCompression * densityBoost, 12, 46), topBandHeight * 0.76); + WeatherIconSymbol.FontSize = Math.Min(Math.Clamp((metrics.IconFont * 1.02) * scale * densityBoost, 12, 56), topBandHeight * 0.95); + TemperatureTextBlock.FontSize = Math.Min(Math.Clamp((metrics.PrimaryTemperatureFont * 1.40) * scale * densityBoost, 26, 138), middleBandHeight * 0.92); + ConditionTextBlock.FontSize = Math.Min(Math.Clamp((metrics.PrimaryTextFont * 1.14) * scale * conditionCompression * densityBoost, 9, 40), middleBandHeight * 0.42); + RangeTextBlock.FontSize = Math.Min(Math.Clamp((metrics.SecondaryTextFont * 1.54) * scale * densityBoost, 10, 46), middleBandHeight * 0.50); TemperatureTextBlock.Margin = new Thickness(0, Math.Clamp(layoutHeight * 0.008, 0, 6), 0, Math.Clamp(layoutHeight * 0.012, 0, 8)); var weightProgress = Math.Clamp((scale - 0.34) / 1.18, 0, 1); @@ -1219,13 +1169,13 @@ public partial class HourlyWeatherWidget : UserControl, IDesktopComponentWidget, var hourlyIconMaxByHeight = Math.Clamp(hourlyLineHeight * 1.05, 8, 30); var hourlyTimeSize = Math.Min( - Math.Clamp(24 * scale * densityBoost, 8, 30), + Math.Clamp((metrics.CaptionFont * 1.20) * scale * densityBoost, 8, 30), Math.Min(hourlyTimeMaxByWidth, hourlyTimeMaxByHeight)); var hourlyIconSize = Math.Min( - Math.Clamp(30 * scale * densityBoost, 8, 34), + Math.Clamp((metrics.IconFont * 0.64) * scale * densityBoost, 8, 34), Math.Min(hourlyIconMaxByWidth, hourlyIconMaxByHeight)); var hourlyTempSize = Math.Min( - Math.Clamp(32 * scale * densityBoost, 8, 34), + Math.Clamp((metrics.SecondaryTextFont * 1.34) * scale * densityBoost, 8, 34), Math.Min(hourlyTempMaxByWidth, hourlyTempMaxByHeight)); for (var i = 0; i < _hourlyTimeBlocks.Length; i++) @@ -1256,81 +1206,25 @@ public partial class HourlyWeatherWidget : UserControl, IDesktopComponentWidget, private WeatherMotionProfile ResolveMotionProfile(WeatherVisualKind kind) { - return kind switch - { - WeatherVisualKind.ClearDay => new WeatherMotionProfile( - DriftX: 8.0, DriftY: 4.0, ZoomBase: 1.055, ZoomAmplitude: 0.012, - MotionOpacityBase: 0.22, MotionOpacityPulse: 0.05, - LightOpacityBase: 0.68, LightOpacityPulse: 0.08, - ShadeOpacityBase: 0.72, ShadeOpacityPulse: 0.03, - PhaseStep: 0.015, ParticleCount: 0, - ParticleSpeedMin: 0, ParticleSpeedMax: 0, - ParticleLengthMin: 0, ParticleLengthMax: 0, ParticleDriftPerTick: 0), - WeatherVisualKind.ClearNight => new WeatherMotionProfile( - DriftX: 10.0, DriftY: 6.0, ZoomBase: 1.060, ZoomAmplitude: 0.014, - MotionOpacityBase: 0.28, MotionOpacityPulse: 0.06, - LightOpacityBase: 0.58, LightOpacityPulse: 0.07, - ShadeOpacityBase: 0.82, ShadeOpacityPulse: 0.04, - PhaseStep: 0.018, ParticleCount: 0, - ParticleSpeedMin: 0, ParticleSpeedMax: 0, - ParticleLengthMin: 0, ParticleLengthMax: 0, ParticleDriftPerTick: 0), - WeatherVisualKind.CloudyDay => new WeatherMotionProfile( - DriftX: 12.0, DriftY: 7.0, ZoomBase: 1.060, ZoomAmplitude: 0.013, - MotionOpacityBase: 0.32, MotionOpacityPulse: 0.06, - LightOpacityBase: 0.62, LightOpacityPulse: 0.07, - ShadeOpacityBase: 0.80, ShadeOpacityPulse: 0.03, - PhaseStep: 0.020, ParticleCount: 6, - ParticleSpeedMin: 0.30, ParticleSpeedMax: 0.70, - ParticleLengthMin: 14, ParticleLengthMax: 28, ParticleDriftPerTick: 0.10), - WeatherVisualKind.CloudyNight => new WeatherMotionProfile( - DriftX: 14.0, DriftY: 8.0, ZoomBase: 1.065, ZoomAmplitude: 0.013, - MotionOpacityBase: 0.34, MotionOpacityPulse: 0.07, - LightOpacityBase: 0.54, LightOpacityPulse: 0.06, - ShadeOpacityBase: 0.85, ShadeOpacityPulse: 0.03, - PhaseStep: 0.021, ParticleCount: 8, - ParticleSpeedMin: 0.35, ParticleSpeedMax: 0.80, - ParticleLengthMin: 16, ParticleLengthMax: 30, ParticleDriftPerTick: 0.12), - WeatherVisualKind.RainLight => new WeatherMotionProfile( - DriftX: 6.0, DriftY: 10.0, ZoomBase: 1.050, ZoomAmplitude: 0.010, - MotionOpacityBase: 0.30, MotionOpacityPulse: 0.08, - LightOpacityBase: 0.50, LightOpacityPulse: 0.04, - ShadeOpacityBase: 0.84, ShadeOpacityPulse: 0.04, - PhaseStep: 0.030, ParticleCount: 18, - ParticleSpeedMin: 1.80, ParticleSpeedMax: 3.20, - ParticleLengthMin: 14, ParticleLengthMax: 26, ParticleDriftPerTick: 0.70), - WeatherVisualKind.RainHeavy => new WeatherMotionProfile( - DriftX: 5.0, DriftY: 11.0, ZoomBase: 1.045, ZoomAmplitude: 0.010, - MotionOpacityBase: 0.34, MotionOpacityPulse: 0.10, - LightOpacityBase: 0.42, LightOpacityPulse: 0.03, - ShadeOpacityBase: 0.88, ShadeOpacityPulse: 0.05, - PhaseStep: 0.036, ParticleCount: 30, - ParticleSpeedMin: 2.80, ParticleSpeedMax: 4.80, - ParticleLengthMin: 18, ParticleLengthMax: 34, ParticleDriftPerTick: 0.92), - WeatherVisualKind.Storm => new WeatherMotionProfile( - DriftX: 4.0, DriftY: 12.0, ZoomBase: 1.042, ZoomAmplitude: 0.012, - MotionOpacityBase: 0.38, MotionOpacityPulse: 0.12, - LightOpacityBase: 0.36, LightOpacityPulse: 0.02, - ShadeOpacityBase: 0.91, ShadeOpacityPulse: 0.04, - PhaseStep: 0.042, ParticleCount: 34, - ParticleSpeedMin: 3.60, ParticleSpeedMax: 5.80, - ParticleLengthMin: 20, ParticleLengthMax: 36, ParticleDriftPerTick: 1.08), - WeatherVisualKind.Snow => new WeatherMotionProfile( - DriftX: 9.0, DriftY: 7.0, ZoomBase: 1.055, ZoomAmplitude: 0.012, - MotionOpacityBase: 0.28, MotionOpacityPulse: 0.06, - LightOpacityBase: 0.74, LightOpacityPulse: 0.08, - ShadeOpacityBase: 0.68, ShadeOpacityPulse: 0.03, - PhaseStep: 0.020, ParticleCount: 24, - ParticleSpeedMin: 0.60, ParticleSpeedMax: 1.60, - ParticleLengthMin: 3.0, ParticleLengthMax: 8.5, ParticleDriftPerTick: 0.24), - _ => new WeatherMotionProfile( - DriftX: 7.0, DriftY: 5.0, ZoomBase: 1.050, ZoomAmplitude: 0.011, - MotionOpacityBase: 0.30, MotionOpacityPulse: 0.05, - LightOpacityBase: 0.58, LightOpacityPulse: 0.05, - ShadeOpacityBase: 0.86, ShadeOpacityPulse: 0.03, - PhaseStep: 0.018, ParticleCount: 10, - ParticleSpeedMin: 0.25, ParticleSpeedMax: 0.70, - ParticleLengthMin: 16, ParticleLengthMax: 34, ParticleDriftPerTick: 0.12) - }; + var motion = HyperOS3WeatherTheme.ResolveMotion(ToThemeKind(kind)); + return new WeatherMotionProfile( + motion.DriftX, + motion.DriftY, + motion.ZoomBase, + motion.ZoomAmplitude, + motion.MotionOpacityBase, + motion.MotionOpacityPulse, + motion.LightOpacityBase, + motion.LightOpacityPulse, + motion.ShadeOpacityBase, + motion.ShadeOpacityPulse, + motion.PhaseStep, + motion.ParticleCount, + motion.ParticleSpeedMin, + motion.ParticleSpeedMax, + motion.ParticleLengthMin, + motion.ParticleLengthMax, + motion.ParticleDriftPerTick); } private void ResetAnimationState() diff --git a/LanMontainDesktop/Views/Components/HyperOS3WeatherTheme.cs b/LanMontainDesktop/Views/Components/HyperOS3WeatherTheme.cs new file mode 100644 index 0000000..a002cb9 --- /dev/null +++ b/LanMontainDesktop/Views/Components/HyperOS3WeatherTheme.cs @@ -0,0 +1,434 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using FluentIcons.Common; +using LanMontainDesktop.Models; + +namespace LanMontainDesktop.Views.Components; + +public enum HyperOS3WeatherVisualKind +{ + ClearDay, + ClearNight, + CloudyDay, + CloudyNight, + RainLight, + RainHeavy, + Storm, + Snow, + Fog +} + +public enum HyperOS3WeatherWidgetKind +{ + Realtime2x2, + Hourly4x2, + MultiDay4x2, + WeatherClock2x1, + Extended4x4 +} + +public readonly record struct HyperOS3WeatherPalette( + string GradientFrom, + string GradientTo, + string Tint, + string PrimaryText, + string SecondaryText, + string TertiaryText, + string ParticleColor); + +public readonly record struct HyperOS3WeatherMotion( + double DriftX, + double DriftY, + double ZoomBase, + double ZoomAmplitude, + double MotionOpacityBase, + double MotionOpacityPulse, + double LightOpacityBase, + double LightOpacityPulse, + double ShadeOpacityBase, + double ShadeOpacityPulse, + double PhaseStep, + int ParticleCount, + double ParticleSpeedMin, + double ParticleSpeedMax, + double ParticleLengthMin, + double ParticleLengthMax, + double ParticleDriftPerTick); + +public readonly record struct HyperOS3WeatherMetrics( + double CornerRadiusScale, + double HorizontalPaddingScale, + double VerticalPaddingScale, + double PrimaryTemperatureFont, + double PrimaryTextFont, + double SecondaryTextFont, + double CaptionFont, + double IconFont, + double MainGap, + double SectionGap); + +public static class HyperOS3WeatherTheme +{ + private static readonly HyperOS3WeatherPalette FallbackPalette = new( + GradientFrom: "#7187A8", + GradientTo: "#92A5C2", + Tint: "#3C4E66", + PrimaryText: "#FFFFFFFF", + SecondaryText: "#E4ECF7", + TertiaryText: "#C9D4E4", + ParticleColor: "#66EAF2FF"); + + private static readonly HyperOS3WeatherMotion FallbackMotion = new( + DriftX: 8.0, DriftY: 6.0, ZoomBase: 1.050, ZoomAmplitude: 0.010, + MotionOpacityBase: 0.28, MotionOpacityPulse: 0.05, + LightOpacityBase: 0.62, LightOpacityPulse: 0.05, + ShadeOpacityBase: 0.83, ShadeOpacityPulse: 0.03, + PhaseStep: 0.018, ParticleCount: 10, + ParticleSpeedMin: 0.25, ParticleSpeedMax: 0.70, + ParticleLengthMin: 16, ParticleLengthMax: 34, ParticleDriftPerTick: 0.12); + + private static readonly IReadOnlyDictionary BackgroundAssets = + new Dictionary + { + [HyperOS3WeatherVisualKind.ClearDay] = "avares://LanMontainDesktop/Assets/Weather/HyperOS3/hyper_cross_sky_day.png", + [HyperOS3WeatherVisualKind.ClearNight] = "avares://LanMontainDesktop/Assets/Weather/HyperOS3/hyper_cross_sky_night.png", + [HyperOS3WeatherVisualKind.CloudyDay] = "avares://LanMontainDesktop/Assets/Weather/HyperOS3/hyper_sky_front.png", + [HyperOS3WeatherVisualKind.CloudyNight] = "avares://LanMontainDesktop/Assets/Weather/HyperOS3/hyper_sky_back.png", + [HyperOS3WeatherVisualKind.RainLight] = "avares://LanMontainDesktop/Assets/Weather/HyperOS3/hyper_sky_front.png", + [HyperOS3WeatherVisualKind.RainHeavy] = "avares://LanMontainDesktop/Assets/Weather/HyperOS3/hyper_sky_back.png", + [HyperOS3WeatherVisualKind.Storm] = "avares://LanMontainDesktop/Assets/Weather/HyperOS3/hyper_cross_sky_night.png", + [HyperOS3WeatherVisualKind.Snow] = "avares://LanMontainDesktop/Assets/Weather/HyperOS3/hyper_sky_top.png", + [HyperOS3WeatherVisualKind.Fog] = "avares://LanMontainDesktop/Assets/Weather/HyperOS3/hyper_sky_back.png" + }; + + private static readonly IReadOnlyDictionary Palettes = + new Dictionary + { + [HyperOS3WeatherVisualKind.ClearDay] = new( + GradientFrom: "#2D87DA", + GradientTo: "#79BAF2", + Tint: "#2E6CB5", + PrimaryText: "#F7FCFF", + SecondaryText: "#E8F1FD", + TertiaryText: "#D6E5F8", + ParticleColor: "#00FFFFFF"), + [HyperOS3WeatherVisualKind.ClearNight] = new( + GradientFrom: "#5A6B85", + GradientTo: "#9DADC2", + Tint: "#495B78", + PrimaryText: "#F9FBFF", + SecondaryText: "#E2EAF6", + TertiaryText: "#C6D2E3", + ParticleColor: "#00FFFFFF"), + [HyperOS3WeatherVisualKind.CloudyDay] = new( + GradientFrom: "#5F88B6", + GradientTo: "#8FB0D1", + Tint: "#496F98", + PrimaryText: "#F8FCFF", + SecondaryText: "#E4EDF8", + TertiaryText: "#CBD9EA", + ParticleColor: "#26FFFFFF"), + [HyperOS3WeatherVisualKind.CloudyNight] = new( + GradientFrom: "#556A85", + GradientTo: "#95A5BC", + Tint: "#43566E", + PrimaryText: "#F6FAFF", + SecondaryText: "#DEE7F4", + TertiaryText: "#C1CDDE", + ParticleColor: "#30F0F5FF"), + [HyperOS3WeatherVisualKind.RainLight] = new( + GradientFrom: "#5A7DA7", + GradientTo: "#8FAAC8", + Tint: "#3F5F84", + PrimaryText: "#F8FBFF", + SecondaryText: "#E3EAF5", + TertiaryText: "#C4D0E0", + ParticleColor: "#88D7E8FF"), + [HyperOS3WeatherVisualKind.RainHeavy] = new( + GradientFrom: "#4C678A", + GradientTo: "#7D95AF", + Tint: "#354C69", + PrimaryText: "#F9FCFF", + SecondaryText: "#E0E8F4", + TertiaryText: "#C0CBDA", + ParticleColor: "#A2CDE1FF"), + [HyperOS3WeatherVisualKind.Storm] = new( + GradientFrom: "#435D7B", + GradientTo: "#6F869F", + Tint: "#2B3D53", + PrimaryText: "#F9FCFF", + SecondaryText: "#DBE5F2", + TertiaryText: "#B9C5D7", + ParticleColor: "#A8C2D6F2"), + [HyperOS3WeatherVisualKind.Snow] = new( + GradientFrom: "#9FB7D0", + GradientTo: "#B7CAE0", + Tint: "#6D839D", + PrimaryText: "#F8FBFF", + SecondaryText: "#E5EDF7", + TertiaryText: "#CDD9E7", + ParticleColor: "#CCFFFFFF"), + [HyperOS3WeatherVisualKind.Fog] = new( + GradientFrom: "#687E9A", + GradientTo: "#9AACBE", + Tint: "#4B6078", + PrimaryText: "#F8FBFF", + SecondaryText: "#E3EAF4", + TertiaryText: "#C4D0DF", + ParticleColor: "#88E4EDF7") + }; + + private static readonly IReadOnlyDictionary Motions = + new Dictionary + { + [HyperOS3WeatherVisualKind.ClearDay] = new( + DriftX: 8.0, DriftY: 4.0, ZoomBase: 1.055, ZoomAmplitude: 0.012, + MotionOpacityBase: 0.22, MotionOpacityPulse: 0.05, + LightOpacityBase: 0.68, LightOpacityPulse: 0.08, + ShadeOpacityBase: 0.72, ShadeOpacityPulse: 0.03, + PhaseStep: 0.015, ParticleCount: 0, + ParticleSpeedMin: 0, ParticleSpeedMax: 0, + ParticleLengthMin: 0, ParticleLengthMax: 0, ParticleDriftPerTick: 0), + [HyperOS3WeatherVisualKind.ClearNight] = new( + DriftX: 10.0, DriftY: 6.0, ZoomBase: 1.060, ZoomAmplitude: 0.014, + MotionOpacityBase: 0.28, MotionOpacityPulse: 0.06, + LightOpacityBase: 0.58, LightOpacityPulse: 0.07, + ShadeOpacityBase: 0.82, ShadeOpacityPulse: 0.04, + PhaseStep: 0.018, ParticleCount: 0, + ParticleSpeedMin: 0, ParticleSpeedMax: 0, + ParticleLengthMin: 0, ParticleLengthMax: 0, ParticleDriftPerTick: 0), + [HyperOS3WeatherVisualKind.CloudyDay] = new( + DriftX: 12.0, DriftY: 7.0, ZoomBase: 1.060, ZoomAmplitude: 0.013, + MotionOpacityBase: 0.32, MotionOpacityPulse: 0.06, + LightOpacityBase: 0.62, LightOpacityPulse: 0.07, + ShadeOpacityBase: 0.80, ShadeOpacityPulse: 0.03, + PhaseStep: 0.020, ParticleCount: 6, + ParticleSpeedMin: 0.30, ParticleSpeedMax: 0.70, + ParticleLengthMin: 14, ParticleLengthMax: 28, ParticleDriftPerTick: 0.10), + [HyperOS3WeatherVisualKind.CloudyNight] = new( + DriftX: 14.0, DriftY: 8.0, ZoomBase: 1.065, ZoomAmplitude: 0.013, + MotionOpacityBase: 0.34, MotionOpacityPulse: 0.07, + LightOpacityBase: 0.54, LightOpacityPulse: 0.06, + ShadeOpacityBase: 0.85, ShadeOpacityPulse: 0.03, + PhaseStep: 0.021, ParticleCount: 8, + ParticleSpeedMin: 0.35, ParticleSpeedMax: 0.80, + ParticleLengthMin: 16, ParticleLengthMax: 30, ParticleDriftPerTick: 0.12), + [HyperOS3WeatherVisualKind.RainLight] = new( + DriftX: 6.0, DriftY: 10.0, ZoomBase: 1.050, ZoomAmplitude: 0.010, + MotionOpacityBase: 0.30, MotionOpacityPulse: 0.08, + LightOpacityBase: 0.50, LightOpacityPulse: 0.04, + ShadeOpacityBase: 0.84, ShadeOpacityPulse: 0.04, + PhaseStep: 0.030, ParticleCount: 18, + ParticleSpeedMin: 1.80, ParticleSpeedMax: 3.20, + ParticleLengthMin: 14, ParticleLengthMax: 26, ParticleDriftPerTick: 0.70), + [HyperOS3WeatherVisualKind.RainHeavy] = new( + DriftX: 5.0, DriftY: 11.0, ZoomBase: 1.045, ZoomAmplitude: 0.010, + MotionOpacityBase: 0.34, MotionOpacityPulse: 0.10, + LightOpacityBase: 0.42, LightOpacityPulse: 0.03, + ShadeOpacityBase: 0.88, ShadeOpacityPulse: 0.05, + PhaseStep: 0.036, ParticleCount: 30, + ParticleSpeedMin: 2.80, ParticleSpeedMax: 4.80, + ParticleLengthMin: 18, ParticleLengthMax: 34, ParticleDriftPerTick: 0.92), + [HyperOS3WeatherVisualKind.Storm] = new( + DriftX: 4.0, DriftY: 12.0, ZoomBase: 1.042, ZoomAmplitude: 0.012, + MotionOpacityBase: 0.38, MotionOpacityPulse: 0.12, + LightOpacityBase: 0.36, LightOpacityPulse: 0.02, + ShadeOpacityBase: 0.91, ShadeOpacityPulse: 0.04, + PhaseStep: 0.042, ParticleCount: 34, + ParticleSpeedMin: 3.60, ParticleSpeedMax: 5.80, + ParticleLengthMin: 20, ParticleLengthMax: 36, ParticleDriftPerTick: 1.08), + [HyperOS3WeatherVisualKind.Snow] = new( + DriftX: 9.0, DriftY: 7.0, ZoomBase: 1.055, ZoomAmplitude: 0.012, + MotionOpacityBase: 0.28, MotionOpacityPulse: 0.06, + LightOpacityBase: 0.74, LightOpacityPulse: 0.08, + ShadeOpacityBase: 0.68, ShadeOpacityPulse: 0.03, + PhaseStep: 0.020, ParticleCount: 24, + ParticleSpeedMin: 0.60, ParticleSpeedMax: 1.60, + ParticleLengthMin: 3.0, ParticleLengthMax: 8.5, ParticleDriftPerTick: 0.24), + [HyperOS3WeatherVisualKind.Fog] = new( + DriftX: 7.0, DriftY: 5.0, ZoomBase: 1.050, ZoomAmplitude: 0.011, + MotionOpacityBase: 0.30, MotionOpacityPulse: 0.05, + LightOpacityBase: 0.58, LightOpacityPulse: 0.05, + ShadeOpacityBase: 0.86, ShadeOpacityPulse: 0.03, + PhaseStep: 0.018, ParticleCount: 10, + ParticleSpeedMin: 0.25, ParticleSpeedMax: 0.70, + ParticleLengthMin: 16, ParticleLengthMax: 34, ParticleDriftPerTick: 0.12) + }; + + private static readonly IReadOnlyDictionary Metrics = + new Dictionary + { + [HyperOS3WeatherWidgetKind.Realtime2x2] = new(0.45, 0.38, 0.38, 108, 30, 30, 24, 40, 8, 4), + [HyperOS3WeatherWidgetKind.Hourly4x2] = new(0.45, 0.32, 0.30, 96, 28, 24, 20, 30, 8, 4), + [HyperOS3WeatherWidgetKind.MultiDay4x2] = new(0.45, 0.32, 0.30, 96, 28, 24, 20, 30, 8, 4), + [HyperOS3WeatherWidgetKind.WeatherClock2x1] = new(0.40, 0.18, 0.14, 42, 18, 15, 12, 18, 4, 3), + [HyperOS3WeatherWidgetKind.Extended4x4] = new(0.45, 0.28, 0.28, 88, 24, 20, 18, 24, 8, 6) + }; + + public static HyperOS3WeatherVisualKind ResolveVisualKind(int? weatherCode, bool isNight) + { + return weatherCode switch + { + 0 => isNight ? HyperOS3WeatherVisualKind.ClearNight : HyperOS3WeatherVisualKind.ClearDay, + 1 or 2 => isNight ? HyperOS3WeatherVisualKind.CloudyNight : HyperOS3WeatherVisualKind.CloudyDay, + 3 or 7 => HyperOS3WeatherVisualKind.RainLight, + 8 or 9 => HyperOS3WeatherVisualKind.RainHeavy, + 4 => HyperOS3WeatherVisualKind.Storm, + 13 or 14 or 15 or 16 => HyperOS3WeatherVisualKind.Snow, + 18 or 32 => HyperOS3WeatherVisualKind.Fog, + _ => isNight ? HyperOS3WeatherVisualKind.CloudyNight : HyperOS3WeatherVisualKind.CloudyDay + }; + } + + public static HyperOS3WeatherPalette ResolvePalette(HyperOS3WeatherVisualKind kind) + { + return Palettes.TryGetValue(kind, out var palette) ? palette : FallbackPalette; + } + + public static HyperOS3WeatherMotion ResolveMotion(HyperOS3WeatherVisualKind kind) + { + return Motions.TryGetValue(kind, out var motion) ? motion : FallbackMotion; + } + + public static HyperOS3WeatherMetrics ResolveMetrics(HyperOS3WeatherWidgetKind kind) + { + return Metrics.TryGetValue(kind, out var metrics) + ? metrics + : Metrics[HyperOS3WeatherWidgetKind.Realtime2x2]; + } + + public static string? ResolveBackgroundAsset(HyperOS3WeatherVisualKind kind) + { + return BackgroundAssets.TryGetValue(kind, out var asset) ? asset : null; + } + + public static string ResolveSunCoreAsset() + { + return "avares://LanMontainDesktop/Assets/Weather/HyperOS3/hyper_sun_core.png"; + } + + public static string ResolveSunRingAsset() + { + return "avares://LanMontainDesktop/Assets/Weather/HyperOS3/hyper_sun_ring.png"; + } + + public static string? ResolveParticleAsset(HyperOS3WeatherVisualKind kind) + { + return kind switch + { + HyperOS3WeatherVisualKind.RainLight or HyperOS3WeatherVisualKind.RainHeavy or HyperOS3WeatherVisualKind.Storm + => "avares://LanMontainDesktop/Assets/Weather/HyperOS3/hyper_rain_drop.png", + HyperOS3WeatherVisualKind.Snow + => "avares://LanMontainDesktop/Assets/Weather/HyperOS3/hyper_snow_flake.png", + HyperOS3WeatherVisualKind.Fog + => "avares://LanMontainDesktop/Assets/Weather/HyperOS3/hyper_fog.png", + _ => null + }; + } + + public static Symbol ResolveWeatherSymbol(HyperOS3WeatherVisualKind kind) + { + return kind switch + { + HyperOS3WeatherVisualKind.ClearDay => Symbol.WeatherSunny, + HyperOS3WeatherVisualKind.ClearNight => Symbol.WeatherMoon, + HyperOS3WeatherVisualKind.CloudyDay => Symbol.WeatherPartlyCloudyDay, + HyperOS3WeatherVisualKind.CloudyNight => Symbol.WeatherPartlyCloudyNight, + HyperOS3WeatherVisualKind.RainLight => Symbol.WeatherRainShowersDay, + HyperOS3WeatherVisualKind.RainHeavy => Symbol.WeatherRain, + HyperOS3WeatherVisualKind.Storm => Symbol.WeatherThunderstorm, + HyperOS3WeatherVisualKind.Snow => Symbol.WeatherSnow, + _ => Symbol.WeatherFog + }; + } + + public static string ResolveIconAccent(HyperOS3WeatherVisualKind kind, Symbol symbol) + { + var isNight = kind is HyperOS3WeatherVisualKind.ClearNight or HyperOS3WeatherVisualKind.CloudyNight; + return symbol switch + { + Symbol.WeatherSunny => isNight ? "#F0D18A" : "#F5C65C", + Symbol.WeatherMoon => "#EED49A", + Symbol.WeatherPartlyCloudyDay => "#F3D68E", + Symbol.WeatherPartlyCloudyNight => "#CFDCFF", + Symbol.WeatherRainShowersDay => "#C7DCF9", + Symbol.WeatherRain => "#BCD4F4", + Symbol.WeatherThunderstorm => "#F0D38B", + Symbol.WeatherSnow => "#EBF5FF", + Symbol.WeatherFog => "#E3EBF6", + _ => isNight ? "#D2DDEE" : "#E5EEF9" + }; + } + + public static bool ResolveIsNightPreferred( + WeatherSnapshot snapshot, + TimeZoneInfo? timeZone, + DateTime fallbackLocalTime) + { + if (snapshot.Current.IsDaylight.HasValue) + { + return !snapshot.Current.IsDaylight.Value; + } + + var referenceTime = snapshot.ObservationTime?.DateTime ?? fallbackLocalTime; + if (snapshot.ObservationTime.HasValue && timeZone is not null) + { + referenceTime = TimeZoneInfo.ConvertTime(snapshot.ObservationTime.Value, timeZone).DateTime; + } + + var date = DateOnly.FromDateTime(referenceTime); + var todayForecast = snapshot.DailyForecasts.FirstOrDefault(item => item.Date == date); + if (todayForecast is not null && + TryParseClockTime(todayForecast.SunriseTime, out var sunrise) && + TryParseClockTime(todayForecast.SunsetTime, out var sunset) && + sunrise < sunset) + { + var time = referenceTime.TimeOfDay; + return time < sunrise || time >= sunset; + } + + if (snapshot.ObservationTime.HasValue) + { + var observed = snapshot.ObservationTime.Value; + if (timeZone is not null) + { + observed = TimeZoneInfo.ConvertTime(observed, timeZone); + } + + return observed.Hour < 6 || observed.Hour >= 18; + } + + return fallbackLocalTime.Hour < 6 || fallbackLocalTime.Hour >= 18; + } + + private static bool TryParseClockTime(string? text, out TimeSpan value) + { + if (string.IsNullOrWhiteSpace(text)) + { + value = default; + return false; + } + + var candidate = text.Trim(); + if (TimeSpan.TryParse(candidate, CultureInfo.InvariantCulture, out value)) + { + return true; + } + + if (DateTimeOffset.TryParse(candidate, CultureInfo.InvariantCulture, DateTimeStyles.AssumeLocal, out var dto)) + { + value = dto.TimeOfDay; + return true; + } + + if (DateTime.TryParse(candidate, CultureInfo.InvariantCulture, DateTimeStyles.AssumeLocal, out var dt)) + { + value = dt.TimeOfDay; + return true; + } + + return false; + } +} diff --git a/LanMontainDesktop/Views/Components/MultiDayWeatherWidget.axaml.cs b/LanMontainDesktop/Views/Components/MultiDayWeatherWidget.axaml.cs index 796ff96..5d37a98 100644 --- a/LanMontainDesktop/Views/Components/MultiDayWeatherWidget.axaml.cs +++ b/LanMontainDesktop/Views/Components/MultiDayWeatherWidget.axaml.cs @@ -39,6 +39,7 @@ public partial class MultiDayWeatherWidget : UserControl, IDesktopComponentWidge string Tint, string PrimaryText, string SecondaryText, + string TertiaryText, string ParticleColor); private readonly record struct WeatherMotionProfile( @@ -81,20 +82,6 @@ public partial class MultiDayWeatherWidget : UserControl, IDesktopComponentWidge Symbol Icon, string TemperatureText); - private static readonly IReadOnlyDictionary WeatherBackgroundAssets = - new Dictionary - { - [WeatherVisualKind.ClearDay] = "avares://LanMontainDesktop/Assets/Weather/clear_day.jpg", - [WeatherVisualKind.ClearNight] = "avares://LanMontainDesktop/Assets/Weather/clear_night.jpg", - [WeatherVisualKind.CloudyDay] = "avares://LanMontainDesktop/Assets/Weather/cloudy_day.jpg", - [WeatherVisualKind.CloudyNight] = "avares://LanMontainDesktop/Assets/Weather/cloudy_night.jpg", - [WeatherVisualKind.RainLight] = "avares://LanMontainDesktop/Assets/Weather/rain_light.jpg", - [WeatherVisualKind.RainHeavy] = "avares://LanMontainDesktop/Assets/Weather/rain_heavy.jpg", - [WeatherVisualKind.Storm] = "avares://LanMontainDesktop/Assets/Weather/storm_dark.jpg", - [WeatherVisualKind.Snow] = "avares://LanMontainDesktop/Assets/Weather/snow_soft.jpg", - [WeatherVisualKind.Fog] = "avares://LanMontainDesktop/Assets/Weather/fog_haze.jpg" - }; - private static readonly IWeatherInfoService DefaultWeatherInfoService = new XiaomiWeatherService(); private readonly DispatcherTimer _refreshTimer = new() @@ -110,6 +97,7 @@ public partial class MultiDayWeatherWidget : UserControl, IDesktopComponentWidge private readonly AppSettingsService _settingsService = new(); private readonly LocalizationService _localizationService = new(); private readonly Dictionary _backgroundBrushCache = new(); + private readonly Dictionary _particleBrushCache = new(); private readonly List _particleVisuals = new(); private readonly List _particleStates = new(); private readonly Random _particleRandom = new(); @@ -223,10 +211,11 @@ public partial class MultiDayWeatherWidget : UserControl, IDesktopComponentWidge public void ApplyCellSize(double cellSize) { _currentCellSize = Math.Max(1, cellSize); + var metrics = HyperOS3WeatherTheme.ResolveMetrics(HyperOS3WeatherWidgetKind.MultiDay4x2); var scale = ResolveScale(); var hostWidth = Bounds.Width > 1 ? Bounds.Width : Math.Max(140, _currentCellSize * 4); var hostHeight = Bounds.Height > 1 ? Bounds.Height : Math.Max(78, _currentCellSize * 2); - var cornerRadius = Math.Clamp(_currentCellSize * 0.45, 24, 44); + var cornerRadius = Math.Clamp(_currentCellSize * metrics.CornerRadiusScale, 24, 44); RootBorder.CornerRadius = new CornerRadius(cornerRadius); BackgroundImageLayer.CornerRadius = new CornerRadius(cornerRadius); @@ -235,8 +224,8 @@ public partial class MultiDayWeatherWidget : UserControl, IDesktopComponentWidge BackgroundLightLayer.CornerRadius = new CornerRadius(cornerRadius); BackgroundShadeLayer.CornerRadius = new CornerRadius(cornerRadius); ContentPaddingBorder.Padding = new Thickness( - Math.Clamp(Math.Min(20 * scale, hostWidth * 0.028), 3, 18), - Math.Clamp(Math.Min(14 * scale, hostHeight * 0.060), 2, 14)); + Math.Clamp(Math.Min((_currentCellSize * metrics.HorizontalPaddingScale) * scale, hostWidth * 0.028), 3, 18), + Math.Clamp(Math.Min((_currentCellSize * metrics.VerticalPaddingScale) * scale, hostHeight * 0.060), 2, 14)); ApplyAdaptiveTypography(); ResetParticles(); } @@ -319,26 +308,10 @@ public partial class MultiDayWeatherWidget : UserControl, IDesktopComponentWidge private bool ResolveIsNight(WeatherSnapshot snapshot) { - if (snapshot.ObservationTime.HasValue) - { - var observed = snapshot.ObservationTime.Value; - try - { - if (_timeZoneService is not null) - { - var zoned = TimeZoneInfo.ConvertTime(observed, _timeZoneService.CurrentTimeZone); - return zoned.Hour < 6 || zoned.Hour >= 18; - } - } - catch - { - // fall through to local clock - } - - return observed.Hour < 6 || observed.Hour >= 18; - } - - return IsNightNow(); + return HyperOS3WeatherTheme.ResolveIsNightPreferred( + snapshot, + _timeZoneService?.CurrentTimeZone, + _timeZoneService?.GetCurrentTime() ?? DateTime.Now); } private bool IsNightNow() @@ -556,11 +529,11 @@ public partial class MultiDayWeatherWidget : UserControl, IDesktopComponentWidge BackgroundTintLayer.Background = CreateSolidBrush(palette.Tint); var primary = CreateSolidBrush(palette.PrimaryText); - var particleBrush = CreateSolidBrush(palette.ParticleColor); + var particleBrush = ResolveParticleBrush(ToThemeKind(kind), palette.ParticleColor); var isNightVisual = kind is WeatherVisualKind.ClearNight or WeatherVisualKind.CloudyNight; var conditionSecondary = CreateSolidBrush(palette.SecondaryText, isNightVisual ? (byte)0xF0 : (byte)0xE6); var airQualitySecondary = CreateSolidBrush(palette.SecondaryText, isNightVisual ? (byte)0xDE : (byte)0xCC); - var forecastTimeBrush = CreateSolidBrush(palette.SecondaryText, isNightVisual ? (byte)0xEA : (byte)0xB6); + var forecastTimeBrush = CreateSolidBrush(palette.TertiaryText, isNightVisual ? (byte)0xEA : (byte)0xB6); var forecastTempBrush = CreateSolidBrush(palette.PrimaryText, isNightVisual ? (byte)0xF8 : (byte)0xE4); HourlyPanelBorder.Background = CreateSolidBrush(isNightVisual ? "#24FFFFFF" : "#1EFFFFFF"); LocationIcon.Foreground = primary; @@ -592,7 +565,8 @@ public partial class MultiDayWeatherWidget : UserControl, IDesktopComponentWidge return cached; } - if (WeatherBackgroundAssets.TryGetValue(kind, out var uriText)) + var uriText = HyperOS3WeatherTheme.ResolveBackgroundAsset(ToThemeKind(kind)); + if (!string.IsNullOrWhiteSpace(uriText)) { try { @@ -620,104 +594,89 @@ public partial class MultiDayWeatherWidget : UserControl, IDesktopComponentWidge return gradientBrush; } + private IBrush ResolveParticleBrush(HyperOS3WeatherVisualKind kind, string fallbackColor) + { + if (_particleBrushCache.TryGetValue(kind, out var cached)) + { + return cached; + } + + var uriText = HyperOS3WeatherTheme.ResolveParticleAsset(kind); + if (!string.IsNullOrWhiteSpace(uriText)) + { + try + { + var uri = new Uri(uriText, UriKind.Absolute); + using var stream = AssetLoader.Open(uri); + var bitmap = new Bitmap(stream); + var imageBrush = new ImageBrush + { + Source = bitmap, + Stretch = Stretch.UniformToFill, + AlignmentX = AlignmentX.Center, + AlignmentY = AlignmentY.Center + }; + _particleBrushCache[kind] = imageBrush; + return imageBrush; + } + catch + { + // Fall through to solid particle color when the image cannot be loaded. + } + } + + var solidBrush = CreateSolidBrush(fallbackColor); + _particleBrushCache[kind] = solidBrush; + return solidBrush; + } + private static WeatherVisualKind ResolveVisualKind(int? weatherCode, bool isNight) { - return weatherCode switch + return HyperOS3WeatherTheme.ResolveVisualKind(weatherCode, isNight) switch { - 0 => isNight ? WeatherVisualKind.ClearNight : WeatherVisualKind.ClearDay, - 1 or 2 => isNight ? WeatherVisualKind.CloudyNight : WeatherVisualKind.CloudyDay, - 3 or 7 => WeatherVisualKind.RainLight, - 8 or 9 => WeatherVisualKind.RainHeavy, - 4 => WeatherVisualKind.Storm, - 13 or 14 or 15 or 16 => WeatherVisualKind.Snow, - 18 or 32 => WeatherVisualKind.Fog, - _ => isNight ? WeatherVisualKind.CloudyNight : WeatherVisualKind.CloudyDay + HyperOS3WeatherVisualKind.ClearDay => WeatherVisualKind.ClearDay, + HyperOS3WeatherVisualKind.ClearNight => WeatherVisualKind.ClearNight, + HyperOS3WeatherVisualKind.CloudyDay => WeatherVisualKind.CloudyDay, + HyperOS3WeatherVisualKind.CloudyNight => WeatherVisualKind.CloudyNight, + HyperOS3WeatherVisualKind.RainLight => WeatherVisualKind.RainLight, + HyperOS3WeatherVisualKind.RainHeavy => WeatherVisualKind.RainHeavy, + HyperOS3WeatherVisualKind.Storm => WeatherVisualKind.Storm, + HyperOS3WeatherVisualKind.Snow => WeatherVisualKind.Snow, + _ => WeatherVisualKind.Fog }; } private static WeatherVisualPalette ResolvePalette(WeatherVisualKind kind) { - return kind switch - { - WeatherVisualKind.ClearDay => new WeatherVisualPalette( - GradientFrom: "#4F92E8", - GradientTo: "#83C5FF", - Tint: "#234D87", - PrimaryText: "#FFFFFFFF", - SecondaryText: "#EEF5FF", - ParticleColor: "#00FFFFFF"), - WeatherVisualKind.ClearNight => new WeatherVisualPalette( - GradientFrom: "#0E2B72", - GradientTo: "#193A85", - Tint: "#0A1E52", - PrimaryText: "#FFFFFFFF", - SecondaryText: "#CFE0FF", - ParticleColor: "#00FFFFFF"), - WeatherVisualKind.CloudyDay => new WeatherVisualPalette( - GradientFrom: "#4A72B3", - GradientTo: "#6A8EC2", - Tint: "#2A487C", - PrimaryText: "#FFFFFFFF", - SecondaryText: "#EAF2FF", - ParticleColor: "#16FFFFFF"), - WeatherVisualKind.CloudyNight => new WeatherVisualPalette( - GradientFrom: "#102A6B", - GradientTo: "#193A80", - Tint: "#0B1F51", - PrimaryText: "#FFFFFFFF", - SecondaryText: "#D5E4FF", - ParticleColor: "#24FFFFFF"), - WeatherVisualKind.RainLight => new WeatherVisualPalette( - GradientFrom: "#32588A", - GradientTo: "#4D74A8", - Tint: "#1F3454", - PrimaryText: "#FFFFFFFF", - SecondaryText: "#E6F0FF", - ParticleColor: "#88D7E8FF"), - WeatherVisualKind.RainHeavy => new WeatherVisualPalette( - GradientFrom: "#253F66", - GradientTo: "#36567F", - Tint: "#17263E", - PrimaryText: "#FFFFFFFF", - SecondaryText: "#DCE9FF", - ParticleColor: "#A2CDE1FF"), - WeatherVisualKind.Storm => new WeatherVisualPalette( - GradientFrom: "#293A67", - GradientTo: "#3A4F78", - Tint: "#161E35", - PrimaryText: "#FFFFFFFF", - SecondaryText: "#DCE4F8", - ParticleColor: "#A8C2D6F2"), - WeatherVisualKind.Snow => new WeatherVisualPalette( - GradientFrom: "#D1E8FF", - GradientTo: "#A7D0F4", - Tint: "#607C9D", - PrimaryText: "#FF10253D", - SecondaryText: "#FF2B435E", - ParticleColor: "#CCFFFFFF"), - _ => new WeatherVisualPalette( - GradientFrom: "#445B7A", - GradientTo: "#5B738F", - Tint: "#2A3E56", - PrimaryText: "#FFFFFFFF", - SecondaryText: "#E7EDF6", - ParticleColor: "#88E4EDF7") - }; + var palette = HyperOS3WeatherTheme.ResolvePalette(ToThemeKind(kind)); + return new WeatherVisualPalette( + palette.GradientFrom, + palette.GradientTo, + palette.Tint, + palette.PrimaryText, + palette.SecondaryText, + palette.TertiaryText, + palette.ParticleColor); } private static Symbol ResolveWeatherSymbol(WeatherVisualKind kind) + { + return HyperOS3WeatherTheme.ResolveWeatherSymbol(ToThemeKind(kind)); + } + + private static HyperOS3WeatherVisualKind ToThemeKind(WeatherVisualKind kind) { return kind switch { - WeatherVisualKind.ClearDay => Symbol.WeatherSunny, - WeatherVisualKind.ClearNight => Symbol.WeatherMoon, - WeatherVisualKind.CloudyDay => Symbol.WeatherPartlyCloudyDay, - WeatherVisualKind.CloudyNight => Symbol.WeatherPartlyCloudyNight, - WeatherVisualKind.RainLight => Symbol.WeatherRainShowersDay, - WeatherVisualKind.RainHeavy => Symbol.WeatherRain, - WeatherVisualKind.Storm => Symbol.WeatherThunderstorm, - WeatherVisualKind.Snow => Symbol.WeatherSnow, - _ => Symbol.WeatherFog + WeatherVisualKind.ClearDay => HyperOS3WeatherVisualKind.ClearDay, + WeatherVisualKind.ClearNight => HyperOS3WeatherVisualKind.ClearNight, + WeatherVisualKind.CloudyDay => HyperOS3WeatherVisualKind.CloudyDay, + WeatherVisualKind.CloudyNight => HyperOS3WeatherVisualKind.CloudyNight, + WeatherVisualKind.RainLight => HyperOS3WeatherVisualKind.RainLight, + WeatherVisualKind.RainHeavy => HyperOS3WeatherVisualKind.RainHeavy, + WeatherVisualKind.Storm => HyperOS3WeatherVisualKind.Storm, + WeatherVisualKind.Snow => HyperOS3WeatherVisualKind.Snow, + _ => HyperOS3WeatherVisualKind.Fog }; } @@ -932,18 +891,8 @@ public partial class MultiDayWeatherWidget : UserControl, IDesktopComponentWidge private static string ResolveWeatherIconAccent(Symbol symbol, bool isNightVisual) { - return symbol switch - { - Symbol.WeatherSunny => isNightVisual ? "#FFD978" : "#F7C40A", - Symbol.WeatherMoon => "#F3D38C", - Symbol.WeatherPartlyCloudyDay => "#75B0FF", - Symbol.WeatherPartlyCloudyNight => "#8AB6FF", - Symbol.WeatherRainShowersDay => "#9ECBFF", - Symbol.WeatherRain => "#8DBDF5", - Symbol.WeatherThunderstorm => "#F4D16E", - Symbol.WeatherSnow => "#C7E6FF", - _ => isNightVisual ? "#D5E2F4" : "#E2ECFA" - }; + var kind = isNightVisual ? HyperOS3WeatherVisualKind.ClearNight : HyperOS3WeatherVisualKind.ClearDay; + return HyperOS3WeatherTheme.ResolveIconAccent(kind, symbol); } private static string ResolvePreciseDisplayLocation(string? rawName, string languageCode, string fallback) @@ -1092,6 +1041,7 @@ public partial class MultiDayWeatherWidget : UserControl, IDesktopComponentWidge private void ApplyAdaptiveTypography() { var (layoutWidth, layoutHeight) = ResolveLayoutViewport(); + var metrics = HyperOS3WeatherTheme.ResolveMetrics(HyperOS3WeatherWidgetKind.MultiDay4x2); var scale = ResolveScale(layoutWidth, layoutHeight); var densityBoost = scale <= 0.55 ? 0.80 : scale <= 0.72 ? 0.88 : scale <= 0.92 ? 0.95 : scale >= 1.45 ? 1.06 : 1.0; var compactness = Math.Clamp((0.88 - scale) / 0.50, 0, 1); @@ -1100,9 +1050,9 @@ public partial class MultiDayWeatherWidget : UserControl, IDesktopComponentWidge var conditionLength = Math.Max(1, ConditionTextBlock.Text?.Length ?? 2); var conditionCompression = conditionLength >= 12 ? 0.72 : conditionLength >= 8 ? 0.85 : conditionLength >= 6 ? 0.92 : 1.0; - ContentGrid.RowSpacing = Math.Clamp(layoutHeight * Lerp(0.030, 0.018, compactness), 2, 14); - TopRowGrid.ColumnSpacing = Math.Clamp(layoutWidth * 0.014, 3, 14); - BottomInfoStack.Spacing = Math.Clamp(layoutHeight * 0.016, 2, 10); + ContentGrid.RowSpacing = Math.Clamp(Math.Max(metrics.MainGap * scale, layoutHeight * Lerp(0.030, 0.018, compactness)), 2, 14); + TopRowGrid.ColumnSpacing = Math.Clamp(Math.Max(metrics.MainGap * scale, layoutWidth * 0.014), 3, 14); + BottomInfoStack.Spacing = Math.Clamp(Math.Max(metrics.SectionGap * scale, layoutHeight * 0.016), 2, 10); BottomInfoStack.Margin = new Thickness(0, 0, 0, Math.Clamp(layoutHeight * 0.018, 0, 10)); ConditionIconStack.Spacing = Math.Clamp(layoutWidth * 0.009, 3, 12); RangeTextBlock.Margin = new Thickness(0, 0, 0, Math.Clamp(layoutHeight * 0.020, 0, 12)); @@ -1117,12 +1067,12 @@ public partial class MultiDayWeatherWidget : UserControl, IDesktopComponentWidge var middleBandHeight = Math.Max(24, layoutHeight * 0.30); var bottomBandHeight = Math.Max(22, layoutHeight - topBandHeight - middleBandHeight - (ContentGrid.RowSpacing * 2)); - LocationIcon.FontSize = Math.Min(Math.Clamp(24 * scale * densityBoost, 9, 30), topBandHeight * 0.58); - CityTextBlock.FontSize = Math.Min(Math.Clamp(40 * scale * cityCompression * densityBoost, 12, 46), topBandHeight * 0.76); - WeatherIconSymbol.FontSize = Math.Min(Math.Clamp(52 * scale * densityBoost, 12, 56), topBandHeight * 0.95); - TemperatureTextBlock.FontSize = Math.Min(Math.Clamp(134 * scale * densityBoost, 26, 138), middleBandHeight * 0.92); - ConditionTextBlock.FontSize = Math.Min(Math.Clamp(31 * scale * conditionCompression * densityBoost, 9, 40), topBandHeight * 0.70); - RangeTextBlock.FontSize = Math.Min(Math.Clamp(34 * scale * densityBoost, 9, 42), middleBandHeight * 0.50); + LocationIcon.FontSize = Math.Min(Math.Clamp((metrics.IconFont * 0.6) * scale * densityBoost, 9, 30), topBandHeight * 0.58); + CityTextBlock.FontSize = Math.Min(Math.Clamp((metrics.PrimaryTextFont * 1.42) * scale * cityCompression * densityBoost, 12, 46), topBandHeight * 0.76); + WeatherIconSymbol.FontSize = Math.Min(Math.Clamp((metrics.IconFont * 1.02) * scale * densityBoost, 12, 56), topBandHeight * 0.95); + TemperatureTextBlock.FontSize = Math.Min(Math.Clamp((metrics.PrimaryTemperatureFont * 1.40) * scale * densityBoost, 26, 138), middleBandHeight * 0.92); + ConditionTextBlock.FontSize = Math.Min(Math.Clamp((metrics.PrimaryTextFont * 1.10) * scale * conditionCompression * densityBoost, 9, 40), topBandHeight * 0.70); + RangeTextBlock.FontSize = Math.Min(Math.Clamp((metrics.SecondaryTextFont * 1.42) * scale * densityBoost, 9, 42), middleBandHeight * 0.50); TemperatureTextBlock.Margin = new Thickness(0, Math.Clamp(layoutHeight * 0.008, 0, 6), 0, Math.Clamp(layoutHeight * 0.012, 0, 8)); var weightProgress = Math.Clamp((scale - 0.34) / 1.18, 0, 1); @@ -1161,13 +1111,13 @@ public partial class MultiDayWeatherWidget : UserControl, IDesktopComponentWidge var hourlyIconMaxByHeight = Math.Clamp(forecastLineHeight * 1.05, 8, 28); var hourlyTimeSize = Math.Min( - Math.Clamp(23 * scale * densityBoost, 8, 30), + Math.Clamp((metrics.CaptionFont * 1.15) * scale * densityBoost, 8, 30), Math.Min(hourlyTimeMaxByWidth, hourlyTimeMaxByHeight)); var hourlyIconSize = Math.Min( - Math.Clamp(30 * scale * densityBoost, 8, 34), + Math.Clamp((metrics.IconFont * 0.64) * scale * densityBoost, 8, 34), Math.Min(hourlyIconMaxByWidth, hourlyIconMaxByHeight)); var hourlyTempSize = Math.Min( - Math.Clamp(30 * scale * densityBoost, 8, 32), + Math.Clamp((metrics.SecondaryTextFont * 1.24) * scale * densityBoost, 8, 32), Math.Min(hourlyTempMaxByWidth, hourlyTempMaxByHeight)); for (var i = 0; i < _hourlyTimeBlocks.Length; i++) { @@ -1197,81 +1147,25 @@ public partial class MultiDayWeatherWidget : UserControl, IDesktopComponentWidge private WeatherMotionProfile ResolveMotionProfile(WeatherVisualKind kind) { - return kind switch - { - WeatherVisualKind.ClearDay => new WeatherMotionProfile( - DriftX: 8.0, DriftY: 4.0, ZoomBase: 1.055, ZoomAmplitude: 0.012, - MotionOpacityBase: 0.22, MotionOpacityPulse: 0.05, - LightOpacityBase: 0.68, LightOpacityPulse: 0.08, - ShadeOpacityBase: 0.72, ShadeOpacityPulse: 0.03, - PhaseStep: 0.015, ParticleCount: 0, - ParticleSpeedMin: 0, ParticleSpeedMax: 0, - ParticleLengthMin: 0, ParticleLengthMax: 0, ParticleDriftPerTick: 0), - WeatherVisualKind.ClearNight => new WeatherMotionProfile( - DriftX: 10.0, DriftY: 6.0, ZoomBase: 1.060, ZoomAmplitude: 0.014, - MotionOpacityBase: 0.28, MotionOpacityPulse: 0.06, - LightOpacityBase: 0.58, LightOpacityPulse: 0.07, - ShadeOpacityBase: 0.82, ShadeOpacityPulse: 0.04, - PhaseStep: 0.018, ParticleCount: 0, - ParticleSpeedMin: 0, ParticleSpeedMax: 0, - ParticleLengthMin: 0, ParticleLengthMax: 0, ParticleDriftPerTick: 0), - WeatherVisualKind.CloudyDay => new WeatherMotionProfile( - DriftX: 12.0, DriftY: 7.0, ZoomBase: 1.060, ZoomAmplitude: 0.013, - MotionOpacityBase: 0.32, MotionOpacityPulse: 0.06, - LightOpacityBase: 0.62, LightOpacityPulse: 0.07, - ShadeOpacityBase: 0.80, ShadeOpacityPulse: 0.03, - PhaseStep: 0.020, ParticleCount: 6, - ParticleSpeedMin: 0.30, ParticleSpeedMax: 0.70, - ParticleLengthMin: 14, ParticleLengthMax: 28, ParticleDriftPerTick: 0.10), - WeatherVisualKind.CloudyNight => new WeatherMotionProfile( - DriftX: 14.0, DriftY: 8.0, ZoomBase: 1.065, ZoomAmplitude: 0.013, - MotionOpacityBase: 0.34, MotionOpacityPulse: 0.07, - LightOpacityBase: 0.54, LightOpacityPulse: 0.06, - ShadeOpacityBase: 0.85, ShadeOpacityPulse: 0.03, - PhaseStep: 0.021, ParticleCount: 8, - ParticleSpeedMin: 0.35, ParticleSpeedMax: 0.80, - ParticleLengthMin: 16, ParticleLengthMax: 30, ParticleDriftPerTick: 0.12), - WeatherVisualKind.RainLight => new WeatherMotionProfile( - DriftX: 6.0, DriftY: 10.0, ZoomBase: 1.050, ZoomAmplitude: 0.010, - MotionOpacityBase: 0.30, MotionOpacityPulse: 0.08, - LightOpacityBase: 0.50, LightOpacityPulse: 0.04, - ShadeOpacityBase: 0.84, ShadeOpacityPulse: 0.04, - PhaseStep: 0.030, ParticleCount: 18, - ParticleSpeedMin: 1.80, ParticleSpeedMax: 3.20, - ParticleLengthMin: 14, ParticleLengthMax: 26, ParticleDriftPerTick: 0.70), - WeatherVisualKind.RainHeavy => new WeatherMotionProfile( - DriftX: 5.0, DriftY: 11.0, ZoomBase: 1.045, ZoomAmplitude: 0.010, - MotionOpacityBase: 0.34, MotionOpacityPulse: 0.10, - LightOpacityBase: 0.42, LightOpacityPulse: 0.03, - ShadeOpacityBase: 0.88, ShadeOpacityPulse: 0.05, - PhaseStep: 0.036, ParticleCount: 30, - ParticleSpeedMin: 2.80, ParticleSpeedMax: 4.80, - ParticleLengthMin: 18, ParticleLengthMax: 34, ParticleDriftPerTick: 0.92), - WeatherVisualKind.Storm => new WeatherMotionProfile( - DriftX: 4.0, DriftY: 12.0, ZoomBase: 1.042, ZoomAmplitude: 0.012, - MotionOpacityBase: 0.38, MotionOpacityPulse: 0.12, - LightOpacityBase: 0.36, LightOpacityPulse: 0.02, - ShadeOpacityBase: 0.91, ShadeOpacityPulse: 0.04, - PhaseStep: 0.042, ParticleCount: 34, - ParticleSpeedMin: 3.60, ParticleSpeedMax: 5.80, - ParticleLengthMin: 20, ParticleLengthMax: 36, ParticleDriftPerTick: 1.08), - WeatherVisualKind.Snow => new WeatherMotionProfile( - DriftX: 9.0, DriftY: 7.0, ZoomBase: 1.055, ZoomAmplitude: 0.012, - MotionOpacityBase: 0.28, MotionOpacityPulse: 0.06, - LightOpacityBase: 0.74, LightOpacityPulse: 0.08, - ShadeOpacityBase: 0.68, ShadeOpacityPulse: 0.03, - PhaseStep: 0.020, ParticleCount: 24, - ParticleSpeedMin: 0.60, ParticleSpeedMax: 1.60, - ParticleLengthMin: 3.0, ParticleLengthMax: 8.5, ParticleDriftPerTick: 0.24), - _ => new WeatherMotionProfile( - DriftX: 7.0, DriftY: 5.0, ZoomBase: 1.050, ZoomAmplitude: 0.011, - MotionOpacityBase: 0.30, MotionOpacityPulse: 0.05, - LightOpacityBase: 0.58, LightOpacityPulse: 0.05, - ShadeOpacityBase: 0.86, ShadeOpacityPulse: 0.03, - PhaseStep: 0.018, ParticleCount: 10, - ParticleSpeedMin: 0.25, ParticleSpeedMax: 0.70, - ParticleLengthMin: 16, ParticleLengthMax: 34, ParticleDriftPerTick: 0.12) - }; + var motion = HyperOS3WeatherTheme.ResolveMotion(ToThemeKind(kind)); + return new WeatherMotionProfile( + motion.DriftX, + motion.DriftY, + motion.ZoomBase, + motion.ZoomAmplitude, + motion.MotionOpacityBase, + motion.MotionOpacityPulse, + motion.LightOpacityBase, + motion.LightOpacityPulse, + motion.ShadeOpacityBase, + motion.ShadeOpacityPulse, + motion.PhaseStep, + motion.ParticleCount, + motion.ParticleSpeedMin, + motion.ParticleSpeedMax, + motion.ParticleLengthMin, + motion.ParticleLengthMax, + motion.ParticleDriftPerTick); } private void ResetAnimationState() diff --git a/LanMontainDesktop/Views/Components/WeatherClockWidget.axaml.cs b/LanMontainDesktop/Views/Components/WeatherClockWidget.axaml.cs index 3dbe71d..55d5d8e 100644 --- a/LanMontainDesktop/Views/Components/WeatherClockWidget.axaml.cs +++ b/LanMontainDesktop/Views/Components/WeatherClockWidget.axaml.cs @@ -55,6 +55,7 @@ public partial class WeatherClockWidget : UserControl, IDesktopComponentWidget, private bool? _isNightModeApplied; private string _languageCode = "zh-CN"; private Symbol _activeWeatherSymbol = Symbol.WeatherPartlyCloudyDay; + private HyperOS3WeatherVisualKind _activeVisualKind = HyperOS3WeatherVisualKind.CloudyDay; public WeatherClockWidget() { @@ -104,6 +105,7 @@ public partial class WeatherClockWidget : UserControl, IDesktopComponentWidget, public void ApplyCellSize(double cellSize) { _currentCellSize = Math.Max(1, cellSize); + var metrics = HyperOS3WeatherTheme.ResolveMetrics(HyperOS3WeatherWidgetKind.WeatherClock2x1); var scale = ResolveScale(); var targetHeight = Bounds.Height > 1 ? Math.Clamp(Bounds.Height, 38, 160) @@ -111,9 +113,10 @@ public partial class WeatherClockWidget : UserControl, IDesktopComponentWidget, var targetWidth = Bounds.Width > 1 ? Math.Clamp(Bounds.Width, 48, 520) : Math.Clamp(_currentCellSize * 2.15, 88, 260); - var compactness = Math.Clamp((170 - targetWidth) / 78d, 0, 1); - var compactFactor = Lerp(1, 0.72, compactness); - var cornerRadius = Math.Clamp(targetHeight * 0.40, 15, 36); + var compactness = Math.Clamp((176 - targetWidth) / 86d, 0, 1); + var ultraCompact = targetWidth < 126 || targetHeight < 46; + var compactFactor = Lerp(1, ultraCompact ? 0.64 : 0.72, compactness); + var cornerRadius = Math.Clamp(targetHeight * metrics.CornerRadiusScale, 15, 36); var horizontalPadding = Math.Clamp(targetHeight * Lerp(0.18, 0.12, compactness), 5, 30); var verticalPadding = Math.Clamp(targetHeight * Lerp(0.14, 0.10, compactness), 3, 20); @@ -121,31 +124,75 @@ public partial class WeatherClockWidget : UserControl, IDesktopComponentWidget, RootBorder.CornerRadius = new CornerRadius(cornerRadius); RootBorder.Padding = new Thickness(horizontalPadding, verticalPadding, horizontalPadding, verticalPadding); - var columnSpacing = Math.Clamp(targetHeight * Lerp(0.16, 0.08, compactness), 3, 22); - ContentGrid.ColumnSpacing = columnSpacing; + var columnSpacing = Math.Clamp(targetHeight * Lerp(0.16, 0.08, compactness), 2, 22); LeftStack.Spacing = Math.Clamp(targetHeight * Lerp(0.06, 0.04, compactness), 1.5, 10); DateWeatherStack.Spacing = Math.Clamp(targetHeight * Lerp(0.10, 0.06, compactness), 3, 14); - TimeTextBlock.FontSize = Math.Clamp(31 * scale * compactFactor, 14, 62); - DateTextBlock.FontSize = Math.Clamp(15.5 * scale * compactFactor, 9, 30); - WeatherIconSymbol.FontSize = Math.Clamp(17 * scale * compactFactor, 10, 32); + var contentHeight = Math.Max(24, targetHeight - (verticalPadding * 2)); + var contentWidth = Math.Max(48, targetWidth - (horizontalPadding * 2)); + var minimumLeftWidth = Math.Clamp(contentWidth * Lerp(0.56, 0.64, compactness), ultraCompact ? 34 : 52, 360); + var maxDialByWidth = Math.Max(0, contentWidth - minimumLeftWidth - columnSpacing); + var dialByHeight = contentHeight * Lerp(0.94, 0.82, compactness); + var dialMinSize = ultraCompact ? 14 : 20; + var dialSize = Math.Min(dialByHeight, maxDialByWidth); + if (dialSize < dialMinSize && maxDialByWidth >= dialMinSize * 0.8) + { + dialSize = dialMinSize; + } + + dialSize = Math.Clamp(dialSize, 0, 140); + var showDial = dialSize >= 12; + if (!showDial) + { + dialSize = 0; + columnSpacing = 0; + } + + var leftContentWidth = Math.Max(0, contentWidth - (showDial ? dialSize + columnSpacing : 0)); + if (showDial && leftContentWidth < 26) + { + var fittedDial = Math.Max(12, Math.Min(dialSize, Math.Max(0, contentWidth - columnSpacing - 26))); + dialSize = fittedDial; + leftContentWidth = Math.Max(0, contentWidth - dialSize - columnSpacing); + if (leftContentWidth < 20) + { + showDial = false; + dialSize = 0; + columnSpacing = 0; + leftContentWidth = contentWidth; + } + } + + ContentGrid.ColumnSpacing = showDial ? columnSpacing : 0; + if (ContentGrid.ColumnDefinitions.Count >= 2) + { + ContentGrid.ColumnDefinitions[0].Width = new GridLength(leftContentWidth, GridUnitType.Pixel); + ContentGrid.ColumnDefinitions[1].Width = new GridLength(showDial ? dialSize : 0, GridUnitType.Pixel); + } + + var leftWidthFactor = Math.Clamp(leftContentWidth / 122d, 0.48, 1.35); + TimeTextBlock.FontSize = Math.Clamp((metrics.PrimaryTemperatureFont * 0.74) * scale * compactFactor * leftWidthFactor, 10, 62); + DateTextBlock.FontSize = Math.Clamp(metrics.SecondaryTextFont * scale * compactFactor * leftWidthFactor, 8, 30); + WeatherIconSymbol.FontSize = Math.Clamp(metrics.IconFont * scale * compactFactor * leftWidthFactor, 9, 32); TimeTextBlock.FontWeight = ToVariableWeight(Lerp(620, 760, Math.Clamp((scale - 0.68) / 1.35, 0, 1))); DateTextBlock.FontWeight = ToVariableWeight(Lerp(540, 680, Math.Clamp((scale - 0.68) / 1.35, 0, 1))); - var contentHeight = Math.Max(24, targetHeight - (verticalPadding * 2)); - var contentWidth = Math.Max(48, targetWidth - (horizontalPadding * 2)); - var minimumLeftWidth = Math.Clamp(contentWidth * Lerp(0.56, 0.64, compactness), 52, 360); - var maxDialByWidth = Math.Max(18, contentWidth - minimumLeftWidth - columnSpacing); - var dialByHeight = contentHeight * Lerp(0.94, 0.84, compactness); - var dialSize = Math.Clamp(Math.Min(dialByHeight, maxDialByWidth), 20, 140); - var leftContentWidth = Math.Max(26, contentWidth - dialSize - columnSpacing); - + LeftStack.Width = leftContentWidth; LeftStack.MaxWidth = leftContentWidth; DateWeatherStack.MaxWidth = leftContentWidth; TimeTextBlock.MaxWidth = leftContentWidth; - DateTextBlock.MaxWidth = Math.Max(18, leftContentWidth - WeatherIconSymbol.FontSize - DateWeatherStack.Spacing); + var showDateLine = leftContentWidth >= Math.Max(40, TimeTextBlock.FontSize * 1.72); + DateWeatherStack.IsVisible = showDateLine; + WeatherIconSymbol.IsVisible = showDateLine && leftContentWidth >= Math.Max(56, DateTextBlock.FontSize * 2.4); + + var dateReservedWidth = WeatherIconSymbol.IsVisible + ? WeatherIconSymbol.FontSize + DateWeatherStack.Spacing + : 0; + DateTextBlock.MaxWidth = Math.Max(12, leftContentWidth - dateReservedWidth); + + AnalogDialBorder.IsVisible = showDial; AnalogDialBorder.Width = dialSize; AnalogDialBorder.Height = dialSize; AnalogDialBorder.CornerRadius = new CornerRadius(dialSize / 2d); @@ -264,17 +311,19 @@ public partial class WeatherClockWidget : UserControl, IDesktopComponentWidget, private void ApplyWeatherSnapshot(WeatherSnapshot snapshot) { var isNight = ResolveIsNight(snapshot); - _activeWeatherSymbol = ResolveWeatherSymbol(snapshot.Current.WeatherCode, isNight); + _activeVisualKind = HyperOS3WeatherTheme.ResolveVisualKind(snapshot.Current.WeatherCode, isNight); + _activeWeatherSymbol = HyperOS3WeatherTheme.ResolveWeatherSymbol(_activeVisualKind); WeatherIconSymbol.Symbol = _activeWeatherSymbol; - WeatherIconSymbol.Foreground = CreateBrush(ResolveWeatherIconColor(_activeWeatherSymbol, isNight)); + WeatherIconSymbol.Foreground = CreateBrush(HyperOS3WeatherTheme.ResolveIconAccent(_activeVisualKind, _activeWeatherSymbol)); } private void ApplyDefaultWeatherIcon() { var isNight = IsNightNow(); - _activeWeatherSymbol = isNight ? Symbol.WeatherMoon : Symbol.WeatherPartlyCloudyDay; + _activeVisualKind = isNight ? HyperOS3WeatherVisualKind.ClearNight : HyperOS3WeatherVisualKind.CloudyDay; + _activeWeatherSymbol = HyperOS3WeatherTheme.ResolveWeatherSymbol(_activeVisualKind); WeatherIconSymbol.Symbol = _activeWeatherSymbol; - WeatherIconSymbol.Foreground = CreateBrush(ResolveWeatherIconColor(_activeWeatherSymbol, isNight)); + WeatherIconSymbol.Foreground = CreateBrush(HyperOS3WeatherTheme.ResolveIconAccent(_activeVisualKind, _activeWeatherSymbol)); } private void UpdateClockVisual() @@ -381,7 +430,7 @@ public partial class WeatherClockWidget : UserControl, IDesktopComponentWidget, CenterDotInner.Fill = CreateBrush("#1A74F2"); BuildTicks(isNightMode); - WeatherIconSymbol.Foreground = CreateBrush(ResolveWeatherIconColor(_activeWeatherSymbol, isNightMode)); + WeatherIconSymbol.Foreground = CreateBrush(HyperOS3WeatherTheme.ResolveIconAccent(_activeVisualKind, _activeWeatherSymbol)); } private WeatherClockConfig LoadConfig() @@ -442,26 +491,10 @@ public partial class WeatherClockWidget : UserControl, IDesktopComponentWidget, private bool ResolveIsNight(WeatherSnapshot snapshot) { - if (snapshot.ObservationTime.HasValue) - { - var observed = snapshot.ObservationTime.Value; - try - { - if (_timeZoneService is not null) - { - var zoned = TimeZoneInfo.ConvertTime(observed, _timeZoneService.CurrentTimeZone); - return zoned.Hour < 6 || zoned.Hour >= 18; - } - } - catch - { - // Fall through to local observation. - } - - return observed.Hour < 6 || observed.Hour >= 18; - } - - return IsNightNow(); + return HyperOS3WeatherTheme.ResolveIsNightPreferred( + snapshot, + _timeZoneService?.CurrentTimeZone, + _timeZoneService?.GetCurrentTime() ?? DateTime.Now); } private bool IsNightNow() @@ -491,37 +524,6 @@ public partial class WeatherClockWidget : UserControl, IDesktopComponentWidget, return false; } - private static Symbol ResolveWeatherSymbol(int? weatherCode, bool isNight) - { - return weatherCode switch - { - 0 => isNight ? Symbol.WeatherMoon : Symbol.WeatherSunny, - 1 or 2 => isNight ? Symbol.WeatherPartlyCloudyNight : Symbol.WeatherPartlyCloudyDay, - 3 or 7 => Symbol.WeatherRainShowersDay, - 8 or 9 => Symbol.WeatherRain, - 4 => Symbol.WeatherThunderstorm, - 13 or 14 or 15 or 16 => Symbol.WeatherSnow, - 18 or 32 => Symbol.WeatherFog, - _ => isNight ? Symbol.WeatherPartlyCloudyNight : Symbol.WeatherPartlyCloudyDay - }; - } - - private static string ResolveWeatherIconColor(Symbol symbol, bool isNightMode) - { - return symbol switch - { - Symbol.WeatherSunny => isNightMode ? "#FFD978" : "#F7B500", - Symbol.WeatherMoon => "#F6D98F", - Symbol.WeatherPartlyCloudyDay => "#5A9CFF", - Symbol.WeatherPartlyCloudyNight => "#8AB6FF", - Symbol.WeatherRainShowersDay => "#5F96E8", - Symbol.WeatherRain => "#4B84DA", - Symbol.WeatherThunderstorm => "#F1C24D", - Symbol.WeatherSnow => "#8EBFE5", - _ => isNightMode ? "#A9BDD7" : "#93A2B8" - }; - } - private static void SetHandGeometry(Line hand, double angleDeg, double forwardLength, double backwardLength) { var radians = (angleDeg - 90) * Math.PI / 180d; diff --git a/LanMontainDesktop/Views/Components/WeatherWidget.axaml.cs b/LanMontainDesktop/Views/Components/WeatherWidget.axaml.cs index d47cc53..55de526 100644 --- a/LanMontainDesktop/Views/Components/WeatherWidget.axaml.cs +++ b/LanMontainDesktop/Views/Components/WeatherWidget.axaml.cs @@ -74,20 +74,6 @@ public partial class WeatherWidget : UserControl, IDesktopComponentWidget, ITime double Latitude, double Longitude); - private static readonly IReadOnlyDictionary WeatherBackgroundAssets = - new Dictionary - { - [WeatherVisualKind.ClearDay] = "avares://LanMontainDesktop/Assets/Weather/clear_day.jpg", - [WeatherVisualKind.ClearNight] = "avares://LanMontainDesktop/Assets/Weather/clear_night.jpg", - [WeatherVisualKind.CloudyDay] = "avares://LanMontainDesktop/Assets/Weather/cloudy_day.jpg", - [WeatherVisualKind.CloudyNight] = "avares://LanMontainDesktop/Assets/Weather/cloudy_night.jpg", - [WeatherVisualKind.RainLight] = "avares://LanMontainDesktop/Assets/Weather/rain_light.jpg", - [WeatherVisualKind.RainHeavy] = "avares://LanMontainDesktop/Assets/Weather/rain_heavy.jpg", - [WeatherVisualKind.Storm] = "avares://LanMontainDesktop/Assets/Weather/storm_dark.jpg", - [WeatherVisualKind.Snow] = "avares://LanMontainDesktop/Assets/Weather/snow_soft.jpg", - [WeatherVisualKind.Fog] = "avares://LanMontainDesktop/Assets/Weather/fog_haze.jpg" - }; - private static readonly IWeatherInfoService DefaultWeatherInfoService = new XiaomiWeatherService(); private readonly DispatcherTimer _refreshTimer = new() @@ -103,6 +89,7 @@ public partial class WeatherWidget : UserControl, IDesktopComponentWidget, ITime private readonly AppSettingsService _settingsService = new(); private readonly LocalizationService _localizationService = new(); private readonly Dictionary _backgroundBrushCache = new(); + private readonly Dictionary _particleBrushCache = new(); private readonly List _particleVisuals = new(); private readonly List _particleStates = new(); private readonly Random _particleRandom = new(); @@ -166,7 +153,10 @@ public partial class WeatherWidget : UserControl, IDesktopComponentWidget, ITime { _currentCellSize = Math.Max(1, cellSize); var scale = ResolveScale(); - var cornerRadius = Math.Clamp(_currentCellSize * 0.45, 24, 44); + var metrics = HyperOS3WeatherTheme.ResolveMetrics(HyperOS3WeatherWidgetKind.Realtime2x2); + var cornerRadius = Math.Clamp(_currentCellSize * metrics.CornerRadiusScale, 24, 44); + var horizontalPadding = Math.Clamp(_currentCellSize * metrics.HorizontalPaddingScale, 12, 24); + var verticalPadding = Math.Clamp(_currentCellSize * metrics.VerticalPaddingScale, 12, 24); RootBorder.CornerRadius = new CornerRadius(cornerRadius); BackgroundImageLayer.CornerRadius = new CornerRadius(cornerRadius); @@ -174,7 +164,9 @@ public partial class WeatherWidget : UserControl, IDesktopComponentWidget, ITime BackgroundTintLayer.CornerRadius = new CornerRadius(cornerRadius); BackgroundLightLayer.CornerRadius = new CornerRadius(cornerRadius); BackgroundShadeLayer.CornerRadius = new CornerRadius(cornerRadius); - ContentPaddingBorder.Padding = new Thickness(Math.Clamp(18 * scale, 12, 24)); + ContentPaddingBorder.Padding = new Thickness( + Math.Clamp(horizontalPadding * scale, 12, 24), + Math.Clamp(verticalPadding * scale, 12, 24)); ApplyAdaptiveTypography(); ResetParticles(); } @@ -257,26 +249,10 @@ public partial class WeatherWidget : UserControl, IDesktopComponentWidget, ITime private bool ResolveIsNight(WeatherSnapshot snapshot) { - if (snapshot.ObservationTime.HasValue) - { - var observed = snapshot.ObservationTime.Value; - try - { - if (_timeZoneService is not null) - { - var zoned = TimeZoneInfo.ConvertTime(observed, _timeZoneService.CurrentTimeZone); - return zoned.Hour < 6 || zoned.Hour >= 18; - } - } - catch - { - // fall through to local clock - } - - return observed.Hour < 6 || observed.Hour >= 18; - } - - return IsNightNow(); + return HyperOS3WeatherTheme.ResolveIsNightPreferred( + snapshot, + _timeZoneService?.CurrentTimeZone, + _timeZoneService?.GetCurrentTime() ?? DateTime.Now); } private bool IsNightNow() @@ -479,7 +455,7 @@ public partial class WeatherWidget : UserControl, IDesktopComponentWidget, ITime var primary = CreateSolidBrush(palette.PrimaryText); var secondary = CreateSolidBrush(palette.SecondaryText); - var particleBrush = CreateSolidBrush(palette.ParticleColor); + var particleBrush = ResolveParticleBrush(ToThemeKind(kind), palette.ParticleColor); LocationIcon.Foreground = primary; CityTextBlock.Foreground = primary; TemperatureTextBlock.Foreground = primary; @@ -503,7 +479,8 @@ public partial class WeatherWidget : UserControl, IDesktopComponentWidget, ITime return cached; } - if (WeatherBackgroundAssets.TryGetValue(kind, out var uriText)) + var uriText = HyperOS3WeatherTheme.ResolveBackgroundAsset(ToThemeKind(kind)); + if (!string.IsNullOrWhiteSpace(uriText)) { try { @@ -531,104 +508,88 @@ public partial class WeatherWidget : UserControl, IDesktopComponentWidget, ITime return gradientBrush; } + private IBrush ResolveParticleBrush(HyperOS3WeatherVisualKind kind, string fallbackColor) + { + if (_particleBrushCache.TryGetValue(kind, out var cached)) + { + return cached; + } + + var uriText = HyperOS3WeatherTheme.ResolveParticleAsset(kind); + if (!string.IsNullOrWhiteSpace(uriText)) + { + try + { + var uri = new Uri(uriText, UriKind.Absolute); + using var stream = AssetLoader.Open(uri); + var bitmap = new Bitmap(stream); + var imageBrush = new ImageBrush + { + Source = bitmap, + Stretch = Stretch.UniformToFill, + AlignmentX = AlignmentX.Center, + AlignmentY = AlignmentY.Center + }; + _particleBrushCache[kind] = imageBrush; + return imageBrush; + } + catch + { + // Fall through to solid particle color when the image cannot be loaded. + } + } + + var solidBrush = CreateSolidBrush(fallbackColor); + _particleBrushCache[kind] = solidBrush; + return solidBrush; + } + private static WeatherVisualKind ResolveVisualKind(int? weatherCode, bool isNight) { - return weatherCode switch + return HyperOS3WeatherTheme.ResolveVisualKind(weatherCode, isNight) switch { - 0 => isNight ? WeatherVisualKind.ClearNight : WeatherVisualKind.ClearDay, - 1 or 2 => isNight ? WeatherVisualKind.CloudyNight : WeatherVisualKind.CloudyDay, - 3 or 7 => WeatherVisualKind.RainLight, - 8 or 9 => WeatherVisualKind.RainHeavy, - 4 => WeatherVisualKind.Storm, - 13 or 14 or 15 or 16 => WeatherVisualKind.Snow, - 18 or 32 => WeatherVisualKind.Fog, - _ => isNight ? WeatherVisualKind.CloudyNight : WeatherVisualKind.CloudyDay + HyperOS3WeatherVisualKind.ClearDay => WeatherVisualKind.ClearDay, + HyperOS3WeatherVisualKind.ClearNight => WeatherVisualKind.ClearNight, + HyperOS3WeatherVisualKind.CloudyDay => WeatherVisualKind.CloudyDay, + HyperOS3WeatherVisualKind.CloudyNight => WeatherVisualKind.CloudyNight, + HyperOS3WeatherVisualKind.RainLight => WeatherVisualKind.RainLight, + HyperOS3WeatherVisualKind.RainHeavy => WeatherVisualKind.RainHeavy, + HyperOS3WeatherVisualKind.Storm => WeatherVisualKind.Storm, + HyperOS3WeatherVisualKind.Snow => WeatherVisualKind.Snow, + _ => WeatherVisualKind.Fog }; } private static WeatherVisualPalette ResolvePalette(WeatherVisualKind kind) { - return kind switch - { - WeatherVisualKind.ClearDay => new WeatherVisualPalette( - GradientFrom: "#4F92E8", - GradientTo: "#83C5FF", - Tint: "#234D87", - PrimaryText: "#FFFFFFFF", - SecondaryText: "#EEF5FF", - ParticleColor: "#00FFFFFF"), - WeatherVisualKind.ClearNight => new WeatherVisualPalette( - GradientFrom: "#0E2B72", - GradientTo: "#193A85", - Tint: "#0A1E52", - PrimaryText: "#FFFFFFFF", - SecondaryText: "#CFE0FF", - ParticleColor: "#00FFFFFF"), - WeatherVisualKind.CloudyDay => new WeatherVisualPalette( - GradientFrom: "#4A72B3", - GradientTo: "#6A8EC2", - Tint: "#2A487C", - PrimaryText: "#FFFFFFFF", - SecondaryText: "#EAF2FF", - ParticleColor: "#16FFFFFF"), - WeatherVisualKind.CloudyNight => new WeatherVisualPalette( - GradientFrom: "#102A6B", - GradientTo: "#193A80", - Tint: "#0B1F51", - PrimaryText: "#FFFFFFFF", - SecondaryText: "#D5E4FF", - ParticleColor: "#24FFFFFF"), - WeatherVisualKind.RainLight => new WeatherVisualPalette( - GradientFrom: "#32588A", - GradientTo: "#4D74A8", - Tint: "#1F3454", - PrimaryText: "#FFFFFFFF", - SecondaryText: "#E6F0FF", - ParticleColor: "#88D7E8FF"), - WeatherVisualKind.RainHeavy => new WeatherVisualPalette( - GradientFrom: "#253F66", - GradientTo: "#36567F", - Tint: "#17263E", - PrimaryText: "#FFFFFFFF", - SecondaryText: "#DCE9FF", - ParticleColor: "#A2CDE1FF"), - WeatherVisualKind.Storm => new WeatherVisualPalette( - GradientFrom: "#293A67", - GradientTo: "#3A4F78", - Tint: "#161E35", - PrimaryText: "#FFFFFFFF", - SecondaryText: "#DCE4F8", - ParticleColor: "#A8C2D6F2"), - WeatherVisualKind.Snow => new WeatherVisualPalette( - GradientFrom: "#D1E8FF", - GradientTo: "#A7D0F4", - Tint: "#607C9D", - PrimaryText: "#FF10253D", - SecondaryText: "#FF2B435E", - ParticleColor: "#CCFFFFFF"), - _ => new WeatherVisualPalette( - GradientFrom: "#445B7A", - GradientTo: "#5B738F", - Tint: "#2A3E56", - PrimaryText: "#FFFFFFFF", - SecondaryText: "#E7EDF6", - ParticleColor: "#88E4EDF7") - }; + var palette = HyperOS3WeatherTheme.ResolvePalette(ToThemeKind(kind)); + return new WeatherVisualPalette( + palette.GradientFrom, + palette.GradientTo, + palette.Tint, + palette.PrimaryText, + palette.SecondaryText, + palette.ParticleColor); } private static Symbol ResolveWeatherSymbol(WeatherVisualKind kind) + { + return HyperOS3WeatherTheme.ResolveWeatherSymbol(ToThemeKind(kind)); + } + + private static HyperOS3WeatherVisualKind ToThemeKind(WeatherVisualKind kind) { return kind switch { - WeatherVisualKind.ClearDay => Symbol.WeatherSunny, - WeatherVisualKind.ClearNight => Symbol.WeatherMoon, - WeatherVisualKind.CloudyDay => Symbol.WeatherPartlyCloudyDay, - WeatherVisualKind.CloudyNight => Symbol.WeatherPartlyCloudyNight, - WeatherVisualKind.RainLight => Symbol.WeatherRainShowersDay, - WeatherVisualKind.RainHeavy => Symbol.WeatherRain, - WeatherVisualKind.Storm => Symbol.WeatherThunderstorm, - WeatherVisualKind.Snow => Symbol.WeatherSnow, - _ => Symbol.WeatherFog + WeatherVisualKind.ClearDay => HyperOS3WeatherVisualKind.ClearDay, + WeatherVisualKind.ClearNight => HyperOS3WeatherVisualKind.ClearNight, + WeatherVisualKind.CloudyDay => HyperOS3WeatherVisualKind.CloudyDay, + WeatherVisualKind.CloudyNight => HyperOS3WeatherVisualKind.CloudyNight, + WeatherVisualKind.RainLight => HyperOS3WeatherVisualKind.RainLight, + WeatherVisualKind.RainHeavy => HyperOS3WeatherVisualKind.RainHeavy, + WeatherVisualKind.Storm => HyperOS3WeatherVisualKind.Storm, + WeatherVisualKind.Snow => HyperOS3WeatherVisualKind.Snow, + _ => HyperOS3WeatherVisualKind.Fog }; } @@ -840,23 +801,24 @@ public partial class WeatherWidget : UserControl, IDesktopComponentWidget, ITime private void ApplyAdaptiveTypography() { var scale = ResolveScale(); + var metrics = HyperOS3WeatherTheme.ResolveMetrics(HyperOS3WeatherWidgetKind.Realtime2x2); var densityBoost = scale <= 0.70 ? 0.88 : scale <= 0.88 ? 0.94 : scale >= 1.45 ? 1.06 : 1.0; var cityLength = Math.Max(1, CityTextBlock.Text?.Length ?? 2); var cityCompression = cityLength >= 10 ? 0.72 : cityLength >= 7 ? 0.83 : cityLength >= 5 ? 0.92 : 1.0; var conditionLength = Math.Max(1, ConditionTextBlock.Text?.Length ?? 2); var conditionCompression = conditionLength >= 9 ? 0.84 : conditionLength >= 6 ? 0.92 : 1.0; - ContentGrid.RowSpacing = Math.Clamp(8 * scale, 4, 14); - TopRowGrid.ColumnSpacing = Math.Clamp(8 * scale, 4, 12); - BottomInfoStack.Spacing = Math.Clamp(4 * scale, 2, 8); + ContentGrid.RowSpacing = Math.Clamp(metrics.MainGap * scale, 4, 14); + TopRowGrid.ColumnSpacing = Math.Clamp(metrics.MainGap * scale, 4, 12); + BottomInfoStack.Spacing = Math.Clamp(metrics.SectionGap * scale, 2, 8); BottomInfoStack.Margin = new Thickness(0, 0, 0, Math.Clamp(10 * scale, 4, 16)); - LocationIcon.FontSize = Math.Clamp(20 * scale * densityBoost, 10, 30); - CityTextBlock.FontSize = Math.Clamp(30 * scale * cityCompression * densityBoost, 12, 42); - WeatherIconSymbol.FontSize = Math.Clamp(40 * scale * densityBoost, 14, 56); - TemperatureTextBlock.FontSize = Math.Clamp(108 * scale * densityBoost, 36, 144); - ConditionTextBlock.FontSize = Math.Clamp(30 * scale * conditionCompression * densityBoost, 11, 44); - RangeTextBlock.FontSize = Math.Clamp(36 * scale * densityBoost, 12, 50); + LocationIcon.FontSize = Math.Clamp((metrics.IconFont * 0.50) * scale * densityBoost, 10, 30); + CityTextBlock.FontSize = Math.Clamp(metrics.PrimaryTextFont * scale * cityCompression * densityBoost, 12, 42); + WeatherIconSymbol.FontSize = Math.Clamp(metrics.IconFont * scale * densityBoost, 14, 56); + TemperatureTextBlock.FontSize = Math.Clamp(metrics.PrimaryTemperatureFont * scale * densityBoost, 36, 144); + ConditionTextBlock.FontSize = Math.Clamp(metrics.PrimaryTextFont * scale * conditionCompression * densityBoost, 11, 44); + RangeTextBlock.FontSize = Math.Clamp(metrics.SecondaryTextFont * scale * densityBoost, 12, 50); TemperatureTextBlock.Margin = new Thickness(0, Math.Clamp(4 * scale, 1, 8), 0, Math.Clamp(10 * scale, 4, 16)); CityTextBlock.FontWeight = ToVariableWeight(Lerp(560, 700, Math.Clamp((scale - 0.58) / 1.3, 0, 1))); @@ -877,81 +839,25 @@ public partial class WeatherWidget : UserControl, IDesktopComponentWidget, ITime private WeatherMotionProfile ResolveMotionProfile(WeatherVisualKind kind) { - return kind switch - { - WeatherVisualKind.ClearDay => new WeatherMotionProfile( - DriftX: 8.0, DriftY: 4.0, ZoomBase: 1.055, ZoomAmplitude: 0.012, - MotionOpacityBase: 0.22, MotionOpacityPulse: 0.05, - LightOpacityBase: 0.68, LightOpacityPulse: 0.08, - ShadeOpacityBase: 0.72, ShadeOpacityPulse: 0.03, - PhaseStep: 0.015, ParticleCount: 0, - ParticleSpeedMin: 0, ParticleSpeedMax: 0, - ParticleLengthMin: 0, ParticleLengthMax: 0, ParticleDriftPerTick: 0), - WeatherVisualKind.ClearNight => new WeatherMotionProfile( - DriftX: 10.0, DriftY: 6.0, ZoomBase: 1.060, ZoomAmplitude: 0.014, - MotionOpacityBase: 0.28, MotionOpacityPulse: 0.06, - LightOpacityBase: 0.58, LightOpacityPulse: 0.07, - ShadeOpacityBase: 0.82, ShadeOpacityPulse: 0.04, - PhaseStep: 0.018, ParticleCount: 0, - ParticleSpeedMin: 0, ParticleSpeedMax: 0, - ParticleLengthMin: 0, ParticleLengthMax: 0, ParticleDriftPerTick: 0), - WeatherVisualKind.CloudyDay => new WeatherMotionProfile( - DriftX: 12.0, DriftY: 7.0, ZoomBase: 1.060, ZoomAmplitude: 0.013, - MotionOpacityBase: 0.32, MotionOpacityPulse: 0.06, - LightOpacityBase: 0.62, LightOpacityPulse: 0.07, - ShadeOpacityBase: 0.80, ShadeOpacityPulse: 0.03, - PhaseStep: 0.020, ParticleCount: 6, - ParticleSpeedMin: 0.30, ParticleSpeedMax: 0.70, - ParticleLengthMin: 14, ParticleLengthMax: 28, ParticleDriftPerTick: 0.10), - WeatherVisualKind.CloudyNight => new WeatherMotionProfile( - DriftX: 14.0, DriftY: 8.0, ZoomBase: 1.065, ZoomAmplitude: 0.013, - MotionOpacityBase: 0.34, MotionOpacityPulse: 0.07, - LightOpacityBase: 0.54, LightOpacityPulse: 0.06, - ShadeOpacityBase: 0.85, ShadeOpacityPulse: 0.03, - PhaseStep: 0.021, ParticleCount: 8, - ParticleSpeedMin: 0.35, ParticleSpeedMax: 0.80, - ParticleLengthMin: 16, ParticleLengthMax: 30, ParticleDriftPerTick: 0.12), - WeatherVisualKind.RainLight => new WeatherMotionProfile( - DriftX: 6.0, DriftY: 10.0, ZoomBase: 1.050, ZoomAmplitude: 0.010, - MotionOpacityBase: 0.30, MotionOpacityPulse: 0.08, - LightOpacityBase: 0.50, LightOpacityPulse: 0.04, - ShadeOpacityBase: 0.84, ShadeOpacityPulse: 0.04, - PhaseStep: 0.030, ParticleCount: 18, - ParticleSpeedMin: 1.80, ParticleSpeedMax: 3.20, - ParticleLengthMin: 14, ParticleLengthMax: 26, ParticleDriftPerTick: 0.70), - WeatherVisualKind.RainHeavy => new WeatherMotionProfile( - DriftX: 5.0, DriftY: 11.0, ZoomBase: 1.045, ZoomAmplitude: 0.010, - MotionOpacityBase: 0.34, MotionOpacityPulse: 0.10, - LightOpacityBase: 0.42, LightOpacityPulse: 0.03, - ShadeOpacityBase: 0.88, ShadeOpacityPulse: 0.05, - PhaseStep: 0.036, ParticleCount: 30, - ParticleSpeedMin: 2.80, ParticleSpeedMax: 4.80, - ParticleLengthMin: 18, ParticleLengthMax: 34, ParticleDriftPerTick: 0.92), - WeatherVisualKind.Storm => new WeatherMotionProfile( - DriftX: 4.0, DriftY: 12.0, ZoomBase: 1.042, ZoomAmplitude: 0.012, - MotionOpacityBase: 0.38, MotionOpacityPulse: 0.12, - LightOpacityBase: 0.36, LightOpacityPulse: 0.02, - ShadeOpacityBase: 0.91, ShadeOpacityPulse: 0.04, - PhaseStep: 0.042, ParticleCount: 34, - ParticleSpeedMin: 3.60, ParticleSpeedMax: 5.80, - ParticleLengthMin: 20, ParticleLengthMax: 36, ParticleDriftPerTick: 1.08), - WeatherVisualKind.Snow => new WeatherMotionProfile( - DriftX: 9.0, DriftY: 7.0, ZoomBase: 1.055, ZoomAmplitude: 0.012, - MotionOpacityBase: 0.28, MotionOpacityPulse: 0.06, - LightOpacityBase: 0.74, LightOpacityPulse: 0.08, - ShadeOpacityBase: 0.68, ShadeOpacityPulse: 0.03, - PhaseStep: 0.020, ParticleCount: 24, - ParticleSpeedMin: 0.60, ParticleSpeedMax: 1.60, - ParticleLengthMin: 3.0, ParticleLengthMax: 8.5, ParticleDriftPerTick: 0.24), - _ => new WeatherMotionProfile( - DriftX: 7.0, DriftY: 5.0, ZoomBase: 1.050, ZoomAmplitude: 0.011, - MotionOpacityBase: 0.30, MotionOpacityPulse: 0.05, - LightOpacityBase: 0.58, LightOpacityPulse: 0.05, - ShadeOpacityBase: 0.86, ShadeOpacityPulse: 0.03, - PhaseStep: 0.018, ParticleCount: 10, - ParticleSpeedMin: 0.25, ParticleSpeedMax: 0.70, - ParticleLengthMin: 16, ParticleLengthMax: 34, ParticleDriftPerTick: 0.12) - }; + var motion = HyperOS3WeatherTheme.ResolveMotion(ToThemeKind(kind)); + return new WeatherMotionProfile( + motion.DriftX, + motion.DriftY, + motion.ZoomBase, + motion.ZoomAmplitude, + motion.MotionOpacityBase, + motion.MotionOpacityPulse, + motion.LightOpacityBase, + motion.LightOpacityPulse, + motion.ShadeOpacityBase, + motion.ShadeOpacityPulse, + motion.PhaseStep, + motion.ParticleCount, + motion.ParticleSpeedMin, + motion.ParticleSpeedMax, + motion.ParticleLengthMin, + motion.ParticleLengthMax, + motion.ParticleDriftPerTick); } private void ResetAnimationState() diff --git a/LanMontainDesktop/Views/MainWindow.ComponentSystem.cs b/LanMontainDesktop/Views/MainWindow.ComponentSystem.cs index 6e8c61a..83ad87f 100644 --- a/LanMontainDesktop/Views/MainWindow.ComponentSystem.cs +++ b/LanMontainDesktop/Views/MainWindow.ComponentSystem.cs @@ -697,6 +697,12 @@ public partial class MainWindow if (placement.ComponentId == BuiltInComponentIds.Date) { OpenDateComponentSettings(); + return; + } + + if (placement.ComponentId == BuiltInComponentIds.DesktopClassSchedule) + { + OpenClassScheduleComponentSettings(); } } @@ -716,6 +722,35 @@ public partial class MainWindow ComponentSettingsWindow.Opacity = 1; } + private void OpenClassScheduleComponentSettings() + { + if (ComponentSettingsWindow is null || ComponentSettingsContentHost is null) + { + return; + } + + var settingsContent = new ClassScheduleSettingsWindow(); + settingsContent.SettingsChanged += OnClassScheduleSettingsChanged; + ComponentSettingsContentHost.Content = settingsContent; + + ComponentSettingsWindow.IsVisible = true; + ComponentSettingsWindow.Opacity = 0; + ComponentSettingsWindow.Opacity = 1; + } + + private void OnClassScheduleSettingsChanged(object? sender, EventArgs e) + { + if (_selectedDesktopComponentHost is null) + { + return; + } + + if (TryGetContentHost(_selectedDesktopComponentHost)?.Child is ClassScheduleWidget widget) + { + widget.RefreshFromSettings(); + } + } + private void CloseComponentSettingsWindow() { if (ComponentSettingsWindow is null) @@ -723,6 +758,11 @@ public partial class MainWindow return; } + if (ComponentSettingsContentHost?.Content is ClassScheduleSettingsWindow classScheduleSettingsWindow) + { + classScheduleSettingsWindow.SettingsChanged -= OnClassScheduleSettingsChanged; + } + ComponentSettingsWindow.Opacity = 0; DispatcherTimer.RunOnce(() => diff --git a/LanMontainDesktop/Views/MainWindow.Localization.cs b/LanMontainDesktop/Views/MainWindow.Localization.cs index 9c6a4a3..896d7c4 100644 --- a/LanMontainDesktop/Views/MainWindow.Localization.cs +++ b/LanMontainDesktop/Views/MainWindow.Localization.cs @@ -177,6 +177,8 @@ public partial class MainWindow "Choose how weather widgets resolve location."); WeatherLocationModeCityItem.Content = L("settings.weather.mode_city_search", "City Search"); WeatherLocationModeCoordinatesItem.Content = L("settings.weather.mode_coordinates", "Coordinates"); + WeatherLocationModeCityChipItem.Content = L("settings.weather.mode_city_search", "City Search"); + WeatherLocationModeCoordinatesChipItem.Content = L("settings.weather.mode_coordinates", "Coordinates"); WeatherAutoRefreshToggleSwitch.Content = L("settings.weather.auto_refresh", "Auto refresh location on startup"); WeatherCitySearchSettingsExpander.Header = L("settings.weather.city_search_header", "City Search"); @@ -197,11 +199,29 @@ public partial class MainWindow WeatherLocationNameTextBox.Watermark = L("settings.weather.location_name_placeholder", "Display name (optional)"); WeatherApplyCoordinatesButton.Content = L("settings.weather.apply_coordinates_button", "Apply Coordinates"); - WeatherPreviewSettingsExpander.Header = L("settings.weather.preview_header", "Connection Test"); + WeatherPreviewSettingsExpander.Header = L("settings.weather.preview_panel_header", "Weather Preview"); WeatherPreviewSettingsExpander.Description = L( - "settings.weather.preview_desc", - "Send one test request to verify current settings."); - WeatherPreviewButton.Content = L("settings.weather.preview_button", "Test Fetch"); + "settings.weather.preview_panel_desc", + "Refresh and verify current weather service status."); + WeatherPreviewButton.Content = L("settings.weather.refresh_button", "Refresh"); + + WeatherAlertFilterSettingsExpander.Header = L("settings.weather.alert_filter_header", "Excluded Alerts"); + WeatherAlertFilterSettingsExpander.Description = L( + "settings.weather.alert_filter_desc", + "Alerts containing these words will not be shown. One rule per line."); + WeatherExcludedAlertsTextBox.Watermark = L("settings.weather.alert_filter_placeholder", "One keyword per line"); + + WeatherIconPackSettingsExpander.Header = L("settings.weather.icon_style_header", "Weather Icon Style"); + WeatherIconPackSettingsExpander.Description = L( + "settings.weather.icon_style_desc", + "Choose Fluent Icon style for weather symbols."); + WeatherIconPackFluentRegularItem.Content = L("settings.weather.icon_style_fluent_regular", "Fluent Regular"); + WeatherIconPackFluentFilledItem.Content = L("settings.weather.icon_style_fluent_filled", "Fluent Filled"); + + WeatherNoTlsSettingsExpander.Header = L("settings.weather.no_tls_header", "No TLS Weather Request"); + WeatherNoTlsSettingsExpander.Description = L( + "settings.weather.no_tls_desc", + "Not recommended. Enable only for incompatible network environments."); if (string.IsNullOrWhiteSpace(_weatherSearchKeyword)) { diff --git a/LanMontainDesktop/Views/MainWindow.Settings.cs b/LanMontainDesktop/Views/MainWindow.Settings.cs index 9259705..7a70a35 100644 --- a/LanMontainDesktop/Views/MainWindow.Settings.cs +++ b/LanMontainDesktop/Views/MainWindow.Settings.cs @@ -1,4 +1,4 @@ -using System; +using System; using FluentIcons.Avalonia; using FluentIcons.Common; using LanMontainDesktop.Views.Components; @@ -655,6 +655,9 @@ public partial class MainWindow WeatherLongitude = _weatherLongitude, WeatherAutoRefreshLocation = _weatherAutoRefreshLocation, WeatherLocationQuery = BuildLegacyWeatherLocationQuery(), + WeatherExcludedAlerts = _weatherExcludedAlertsRaw, + WeatherIconPackId = _weatherIconPackId, + WeatherNoTlsRequests = _weatherNoTlsRequests, TopStatusComponentIds = _topStatusComponentIds.ToList(), PinnedTaskbarActions = _pinnedTaskbarActions.Select(action => action.ToString()).ToList(), EnableDynamicTaskbarActions = _enableDynamicTaskbarActions, @@ -698,6 +701,11 @@ public partial class MainWindow _weatherLatitude = NormalizeLatitude(snapshot.WeatherLatitude); _weatherLongitude = NormalizeLongitude(snapshot.WeatherLongitude); _weatherAutoRefreshLocation = snapshot.WeatherAutoRefreshLocation; + _weatherExcludedAlertsRaw = snapshot.WeatherExcludedAlerts?.Trim() ?? string.Empty; + _weatherIconPackId = string.IsNullOrWhiteSpace(snapshot.WeatherIconPackId) + ? "FluentRegular" + : snapshot.WeatherIconPackId.Trim(); + _weatherNoTlsRequests = snapshot.WeatherNoTlsRequests; _weatherSearchKeyword = string.Empty; var legacyQuery = snapshot.WeatherLocationQuery?.Trim() ?? string.Empty; @@ -717,6 +725,11 @@ public partial class MainWindow WeatherAutoRefreshToggleSwitch.IsChecked = _weatherAutoRefreshLocation; } + if (WeatherNoTlsToggleSwitch is not null) + { + WeatherNoTlsToggleSwitch.IsChecked = _weatherNoTlsRequests; + } + if (WeatherCitySearchTextBox is not null) { WeatherCitySearchTextBox.Text = string.Empty; @@ -747,6 +760,13 @@ public partial class MainWindow WeatherLongitudeNumberBox.Value = _weatherLongitude; } + if (WeatherExcludedAlertsTextBox is not null) + { + WeatherExcludedAlertsTextBox.Text = _weatherExcludedAlertsRaw; + } + + SelectWeatherIconPackInUi(_weatherIconPackId); + if (WeatherSearchStatusTextBlock is not null) { WeatherSearchStatusTextBlock.Text = L( @@ -766,6 +786,11 @@ public partial class MainWindow "Use test fetch to verify your weather configuration."); } + UpdateWeatherPreviewSummary( + weatherCode: null, + temperatureText: "--", + updatedAt: null); + UpdateWeatherLocationModePanels(); UpdateWeatherLocationStatusText(); } @@ -826,21 +851,61 @@ public partial class MainWindow private void SelectWeatherLocationModeInUi(WeatherLocationMode mode) { - if (WeatherLocationModeComboBox is null) + var targetTag = ToWeatherLocationModeTag(mode); + var selected = false; + if (WeatherLocationModeComboBox is not null) + { + foreach (var item in WeatherLocationModeComboBox.Items.OfType()) + { + if (string.Equals(item.Tag?.ToString(), targetTag, StringComparison.OrdinalIgnoreCase)) + { + WeatherLocationModeComboBox.SelectedItem = item; + selected = true; + break; + } + } + + if (!selected) + { + WeatherLocationModeComboBox.SelectedIndex = mode == WeatherLocationMode.Coordinates ? 1 : 0; + } + } + + if (WeatherLocationModeChipListBox is null) { return; } - foreach (var item in WeatherLocationModeComboBox.Items.OfType()) + foreach (var item in WeatherLocationModeChipListBox.Items.OfType()) { - if (string.Equals(item.Tag?.ToString(), ToWeatherLocationModeTag(mode), StringComparison.OrdinalIgnoreCase)) + if (string.Equals(item.Tag?.ToString(), targetTag, StringComparison.OrdinalIgnoreCase)) { - WeatherLocationModeComboBox.SelectedItem = item; + WeatherLocationModeChipListBox.SelectedItem = item; return; } } - WeatherLocationModeComboBox.SelectedIndex = mode == WeatherLocationMode.Coordinates ? 1 : 0; + WeatherLocationModeChipListBox.SelectedIndex = mode == WeatherLocationMode.Coordinates ? 1 : 0; + } + + private void SelectWeatherIconPackInUi(string iconPackId) + { + if (WeatherIconPackComboBox is null) + { + return; + } + + foreach (var item in WeatherIconPackComboBox.Items.OfType()) + { + if (string.Equals(item.Tag?.ToString(), iconPackId, StringComparison.OrdinalIgnoreCase)) + { + WeatherIconPackComboBox.SelectedItem = item; + return; + } + } + + WeatherIconPackComboBox.SelectedIndex = 0; + _weatherIconPackId = "FluentRegular"; } private void UpdateWeatherLocationModePanels() @@ -864,6 +929,38 @@ public partial class MainWindow } _weatherLocationMode = ParseWeatherLocationMode(item.Tag?.ToString()); + _suppressWeatherLocationEvents = true; + try + { + SelectWeatherLocationModeInUi(_weatherLocationMode); + } + finally + { + _suppressWeatherLocationEvents = false; + } + UpdateWeatherLocationModePanels(); + UpdateWeatherLocationStatusText(); + PersistSettings(); + } + + private void OnWeatherLocationModeChipSelectionChanged(object? sender, SelectionChangedEventArgs e) + { + if (_suppressWeatherLocationEvents || WeatherLocationModeChipListBox?.SelectedItem is not ListBoxItem item) + { + return; + } + + _weatherLocationMode = ParseWeatherLocationMode(item.Tag?.ToString()); + _suppressWeatherLocationEvents = true; + try + { + SelectWeatherLocationModeInUi(_weatherLocationMode); + } + finally + { + _suppressWeatherLocationEvents = false; + } + UpdateWeatherLocationModePanels(); UpdateWeatherLocationStatusText(); PersistSettings(); @@ -880,6 +977,51 @@ public partial class MainWindow PersistSettings(); } + private void OnWeatherExcludedAlertsLostFocus(object? sender, RoutedEventArgs e) + { + if (WeatherExcludedAlertsTextBox is null) + { + return; + } + + _weatherExcludedAlertsRaw = WeatherExcludedAlertsTextBox.Text?.Trim() ?? string.Empty; + PersistSettings(); + } + + private void OnWeatherIconPackSelectionChanged(object? sender, SelectionChangedEventArgs e) + { + if (_suppressWeatherLocationEvents || WeatherIconPackComboBox?.SelectedItem is not ComboBoxItem item) + { + return; + } + + _weatherIconPackId = item.Tag?.ToString() switch + { + "FluentFilled" => "FluentFilled", + _ => "FluentRegular" + }; + + if (WeatherPreviewIconSymbol is not null) + { + WeatherPreviewIconSymbol.IconVariant = string.Equals(_weatherIconPackId, "FluentFilled", StringComparison.OrdinalIgnoreCase) + ? IconVariant.Filled + : IconVariant.Regular; + } + + PersistSettings(); + } + + private void OnWeatherNoTlsToggled(object? sender, RoutedEventArgs e) + { + if (_suppressWeatherLocationEvents || WeatherNoTlsToggleSwitch is null) + { + return; + } + + _weatherNoTlsRequests = WeatherNoTlsToggleSwitch.IsChecked == true; + PersistSettings(); + } + private async void OnSearchWeatherCityClick(object? sender, RoutedEventArgs e) { if (_isWeatherSearchInProgress || WeatherCitySearchTextBox is null || WeatherCityResultsComboBox is null) @@ -974,7 +1116,7 @@ public partial class MainWindow : $" ({location.Affiliation})"; return string.Create( CultureInfo.InvariantCulture, - $"{location.Name}{affiliation} · {location.LocationKey}"); + $"{location.Name}{affiliation} | {location.LocationKey}"); } private static string BuildWeatherLocationName(WeatherLocation location) @@ -1140,6 +1282,11 @@ public partial class MainWindow "Please apply one weather location before testing."); } + UpdateWeatherPreviewSummary( + weatherCode: null, + temperatureText: "--", + updatedAt: null); + return; } } @@ -1168,6 +1315,11 @@ public partial class MainWindow result.ErrorMessage ?? result.ErrorCode ?? "Unknown error"); } + UpdateWeatherPreviewSummary( + weatherCode: null, + temperatureText: "--", + updatedAt: DateTimeOffset.Now); + return; } @@ -1178,18 +1330,24 @@ public partial class MainWindow var weather = snapshot.Current.WeatherText ?? L("settings.weather.preview_unknown", "Unknown"); var temperature = snapshot.Current.TemperatureC.HasValue - ? string.Create(CultureInfo.InvariantCulture, $"{snapshot.Current.TemperatureC.Value:F1}°C") + ? string.Create(CultureInfo.InvariantCulture, $"{snapshot.Current.TemperatureC.Value:F1} C") : "--"; + var updatedAt = snapshot.ObservationTime ?? snapshot.FetchedAt; if (WeatherPreviewResultTextBlock is not null) { WeatherPreviewResultTextBlock.Text = Lf( "settings.weather.preview_success_format", - "Test success: {0} · {1} · {2}", + "Test success: {0} | {1} | {2}", location, weather, temperature); } + + UpdateWeatherPreviewSummary( + weatherCode: snapshot.Current.WeatherCode, + temperatureText: temperature, + updatedAt: updatedAt); } catch (Exception ex) { @@ -1200,6 +1358,11 @@ public partial class MainWindow "Test fetch failed: {0}", ex.Message); } + + UpdateWeatherPreviewSummary( + weatherCode: null, + temperatureText: "--", + updatedAt: DateTimeOffset.Now); } finally { @@ -1208,6 +1371,46 @@ public partial class MainWindow } } + private void UpdateWeatherPreviewSummary(int? weatherCode, string temperatureText, DateTimeOffset? updatedAt) + { + if (WeatherPreviewIconSymbol is not null) + { + WeatherPreviewIconSymbol.Symbol = ResolveWeatherPreviewSymbol(weatherCode, _isNightMode); + WeatherPreviewIconSymbol.IconVariant = string.Equals(_weatherIconPackId, "FluentFilled", StringComparison.OrdinalIgnoreCase) + ? IconVariant.Filled + : IconVariant.Regular; + } + + if (WeatherPreviewTemperatureTextBlock is not null) + { + WeatherPreviewTemperatureTextBlock.Text = string.IsNullOrWhiteSpace(temperatureText) ? "--" : temperatureText; + } + + if (WeatherPreviewUpdatedTextBlock is null) + { + return; + } + + WeatherPreviewUpdatedTextBlock.Text = updatedAt.HasValue + ? Lf("weather.widget.updated_format", "Updated {0:HH:mm}", updatedAt.Value.LocalDateTime) + : "-"; + } + + private static Symbol ResolveWeatherPreviewSymbol(int? weatherCode, bool isNight) + { + return weatherCode switch + { + 0 => isNight ? Symbol.WeatherMoon : Symbol.WeatherSunny, + 1 or 2 => isNight ? Symbol.WeatherPartlyCloudyNight : Symbol.WeatherPartlyCloudyDay, + 3 or 7 => Symbol.WeatherRainShowersDay, + 8 or 9 => Symbol.WeatherRain, + 4 => Symbol.WeatherThunderstorm, + 13 or 14 or 15 or 16 => Symbol.WeatherSnow, + 18 or 32 => Symbol.WeatherFog, + _ => isNight ? Symbol.WeatherPartlyCloudyNight : Symbol.WeatherPartlyCloudyDay + }; + } + private void SetWeatherSearchBusy(bool isBusy) { if (WeatherSearchButton is not null) @@ -1683,6 +1886,42 @@ public partial class MainWindow }; } + if (WeatherPreviewSettingsExpander is not null) + { + WeatherPreviewSettingsExpander.IconSource = new FluentIcons.Avalonia.Fluent.SymbolIconSource + { + Symbol = Symbol.WeatherSunny, + IconVariant = variant + }; + } + + if (WeatherAlertFilterSettingsExpander is not null) + { + WeatherAlertFilterSettingsExpander.IconSource = new FluentIcons.Avalonia.Fluent.SymbolIconSource + { + Symbol = Symbol.Info, + IconVariant = variant + }; + } + + if (WeatherIconPackSettingsExpander is not null) + { + WeatherIconPackSettingsExpander.IconSource = new FluentIcons.Avalonia.Fluent.SymbolIconSource + { + Symbol = Symbol.Color, + IconVariant = variant + }; + } + + if (WeatherNoTlsSettingsExpander is not null) + { + WeatherNoTlsSettingsExpander.IconSource = new FluentIcons.Avalonia.Fluent.SymbolIconSource + { + Symbol = Symbol.Globe, + IconVariant = variant + }; + } + if (LanguageSettingsExpander is not null) { LanguageSettingsExpander.IconSource = new FluentIcons.Avalonia.Fluent.SymbolIconSource @@ -1718,3 +1957,4 @@ public partial class MainWindow }; } } + diff --git a/LanMontainDesktop/Views/MainWindow.axaml b/LanMontainDesktop/Views/MainWindow.axaml index fc997a6..edaeeff 100644 --- a/LanMontainDesktop/Views/MainWindow.axaml +++ b/LanMontainDesktop/Views/MainWindow.axaml @@ -1076,23 +1076,89 @@ - + + + + + + + + + + + + + + + +