0.2.5
课表组件、天气组件全面升级。
223
LanMontainDesktop/.github/workflows/windows-ci.yml
vendored
Normal file
@@ -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
|
||||
@@ -18,6 +18,7 @@
|
||||
<Application.Styles>
|
||||
<sty:FluentAvaloniaTheme />
|
||||
<StyleInclude Source="avares://LanMontainDesktop/Styles/GlassModule.axaml" />
|
||||
<StyleInclude Source="avares://LanMontainDesktop/Styles/SettingsAnimations.axaml" />
|
||||
|
||||
<Style Selector="Window">
|
||||
<Setter Property="FontFamily" Value="{DynamicResource AppFontFamily}" />
|
||||
|
||||
20
LanMontainDesktop/Assets/Weather/HyperOS3/ATTRIBUTION.md
Normal file
@@ -0,0 +1,20 @@
|
||||
# HyperOS3 Weather Assets (Official Xiaomi Package)
|
||||
|
||||
These assets were extracted from the official Xiaomi Weather APK provided by the user:
|
||||
- Source APK: `c:\Program Files\Netease\GameViewer\Download\MI SKY 12.apk`
|
||||
- Package: `com.miui.weather2` (Mi Weather)
|
||||
- Extraction date: 2026-03-03
|
||||
|
||||
Extracted source paths inside APK:
|
||||
- `assets/map_custom/particle/sun_0.png` -> `hyper_sun_core.png`
|
||||
- `assets/map_custom/particle/sun_1.png` -> `hyper_sun_ring.png`
|
||||
- `assets/map_custom/particle/fog.png` -> `hyper_fog.png`
|
||||
- `assets/map_custom/particle/rain.png` -> `hyper_rain_drop.png`
|
||||
- `assets/map_custom/particle/snow.png` -> `hyper_snow_flake.png`
|
||||
- `assets/map_custom/skybox/top.png` -> `hyper_sky_top.png`
|
||||
- `assets/map_custom/skybox/back.png` -> `hyper_sky_back.png`
|
||||
- `assets/map_custom/skybox/front.png` -> `hyper_sky_front.png`
|
||||
- `assets/map_assets/VM3DRes/cross_sky_day.png` -> `hyper_cross_sky_day.png`
|
||||
- `assets/map_assets/VM3DRes/cross_sky_night.png` -> `hyper_cross_sky_night.png`
|
||||
|
||||
Use only according to Xiaomi's applicable license and usage terms.
|
||||
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 17 KiB |
BIN
LanMontainDesktop/Assets/Weather/HyperOS3/hyper_fog.png
Normal file
|
After Width: | Height: | Size: 77 KiB |
BIN
LanMontainDesktop/Assets/Weather/HyperOS3/hyper_rain_drop.png
Normal file
|
After Width: | Height: | Size: 477 B |
BIN
LanMontainDesktop/Assets/Weather/HyperOS3/hyper_sky_back.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
LanMontainDesktop/Assets/Weather/HyperOS3/hyper_sky_front.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
LanMontainDesktop/Assets/Weather/HyperOS3/hyper_sky_top.png
Normal file
|
After Width: | Height: | Size: 9.8 KiB |
BIN
LanMontainDesktop/Assets/Weather/HyperOS3/hyper_snow_flake.png
Normal file
|
After Width: | Height: | Size: 683 B |
BIN
LanMontainDesktop/Assets/Weather/HyperOS3/hyper_sun_core.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
LanMontainDesktop/Assets/Weather/HyperOS3/hyper_sun_ring.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
127
LanMontainDesktop/Behaviors/PanelIntroAnimationBehavior.cs
Normal file
@@ -0,0 +1,127 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Threading;
|
||||
|
||||
namespace LanMontainDesktop.Behaviors;
|
||||
|
||||
public class PanelIntroAnimationBehavior
|
||||
{
|
||||
public static readonly AttachedProperty<bool> IsEnabledProperty =
|
||||
AvaloniaProperty.RegisterAttached<PanelIntroAnimationBehavior, Panel, bool>("IsEnabled");
|
||||
|
||||
public static readonly AttachedProperty<bool> IsAnimationPlayedProperty =
|
||||
AvaloniaProperty.RegisterAttached<PanelIntroAnimationBehavior, Control, bool>("IsAnimationPlayed");
|
||||
|
||||
public static readonly AttachedProperty<bool> CanPlayAnimationProperty =
|
||||
AvaloniaProperty.RegisterAttached<PanelIntroAnimationBehavior, Control, bool>("CanPlayAnimation");
|
||||
|
||||
private static readonly AttachedProperty<bool> IsAnimationStartedProperty =
|
||||
AvaloniaProperty.RegisterAttached<PanelIntroAnimationBehavior, Panel, bool>("IsAnimationStarted");
|
||||
|
||||
static PanelIntroAnimationBehavior()
|
||||
{
|
||||
IsEnabledProperty.Changed.AddClassHandler<Panel>(OnIsEnabledChanged);
|
||||
}
|
||||
|
||||
public static void SetIsEnabled(Panel panel, bool value)
|
||||
{
|
||||
panel.SetValue(IsEnabledProperty, value);
|
||||
}
|
||||
|
||||
public static bool GetIsEnabled(Panel panel)
|
||||
{
|
||||
return panel.GetValue(IsEnabledProperty);
|
||||
}
|
||||
|
||||
public static void SetIsAnimationPlayed(Control control, bool value)
|
||||
{
|
||||
control.SetValue(IsAnimationPlayedProperty, value);
|
||||
}
|
||||
|
||||
public static bool GetIsAnimationPlayed(Control control)
|
||||
{
|
||||
return control.GetValue(IsAnimationPlayedProperty);
|
||||
}
|
||||
|
||||
public static void SetCanPlayAnimation(Control control, bool value)
|
||||
{
|
||||
control.SetValue(CanPlayAnimationProperty, value);
|
||||
}
|
||||
|
||||
public static bool GetCanPlayAnimation(Control control)
|
||||
{
|
||||
return control.GetValue(CanPlayAnimationProperty);
|
||||
}
|
||||
|
||||
private static bool GetIsAnimationStarted(Panel panel)
|
||||
{
|
||||
return panel.GetValue(IsAnimationStartedProperty);
|
||||
}
|
||||
|
||||
private static void SetIsAnimationStarted(Panel panel, bool value)
|
||||
{
|
||||
panel.SetValue(IsAnimationStartedProperty, value);
|
||||
}
|
||||
|
||||
private static void OnIsEnabledChanged(Panel panel, AvaloniaPropertyChangedEventArgs args)
|
||||
{
|
||||
if (args.NewValue is not bool isEnabled || !isEnabled || GetIsAnimationStarted(panel))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
panel.AttachedToVisualTree += OnPanelAttachedToVisualTree;
|
||||
StartStaggerAnimation(panel);
|
||||
}
|
||||
|
||||
private static void OnPanelAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
||||
{
|
||||
if (sender is not Panel panel)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
StartStaggerAnimation(panel);
|
||||
}
|
||||
|
||||
private static void StartStaggerAnimation(Panel panel)
|
||||
{
|
||||
if (!GetIsEnabled(panel) || GetIsAnimationStarted(panel))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var targets = panel.Children
|
||||
.OfType<Control>()
|
||||
.Where(control => control.IsVisible)
|
||||
.ToList();
|
||||
|
||||
foreach (var target in targets)
|
||||
{
|
||||
SetCanPlayAnimation(target, true);
|
||||
SetIsAnimationPlayed(target, false);
|
||||
}
|
||||
|
||||
SetIsAnimationStarted(panel, true);
|
||||
|
||||
var index = 0;
|
||||
var timer = new DispatcherTimer(DispatcherPriority.Background)
|
||||
{
|
||||
Interval = TimeSpan.FromMilliseconds(24)
|
||||
};
|
||||
timer.Tick += (_, _) =>
|
||||
{
|
||||
if (index >= targets.Count)
|
||||
{
|
||||
timer.Stop();
|
||||
return;
|
||||
}
|
||||
|
||||
SetIsAnimationPlayed(targets[index], true);
|
||||
index++;
|
||||
};
|
||||
timer.Start();
|
||||
}
|
||||
}
|
||||
136
LanMontainDesktop/Behaviors/PopupIntroAnimationBehavior.cs
Normal file
@@ -0,0 +1,136 @@
|
||||
using System;
|
||||
using Avalonia;
|
||||
using Avalonia.Animation.Easings;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.Primitives;
|
||||
using Avalonia.Rendering.Composition;
|
||||
|
||||
namespace LanMontainDesktop.Behaviors;
|
||||
|
||||
public class PopupIntroAnimationBehavior
|
||||
{
|
||||
public static readonly AttachedProperty<bool> IsEnabledProperty =
|
||||
AvaloniaProperty.RegisterAttached<PopupIntroAnimationBehavior, Control, bool>("IsEnabled");
|
||||
|
||||
private static readonly AttachedProperty<bool> IsHookedProperty =
|
||||
AvaloniaProperty.RegisterAttached<PopupIntroAnimationBehavior, Control, bool>("IsHooked");
|
||||
|
||||
static PopupIntroAnimationBehavior()
|
||||
{
|
||||
IsEnabledProperty.Changed.AddClassHandler<Control>(OnIsEnabledChanged);
|
||||
}
|
||||
|
||||
public static void SetIsEnabled(Control control, bool value)
|
||||
{
|
||||
control.SetValue(IsEnabledProperty, value);
|
||||
}
|
||||
|
||||
public static bool GetIsEnabled(Control control)
|
||||
{
|
||||
return control.GetValue(IsEnabledProperty);
|
||||
}
|
||||
|
||||
private static bool GetIsHooked(Control control)
|
||||
{
|
||||
return control.GetValue(IsHookedProperty);
|
||||
}
|
||||
|
||||
private static void SetIsHooked(Control control, bool value)
|
||||
{
|
||||
control.SetValue(IsHookedProperty, value);
|
||||
}
|
||||
|
||||
private static void OnIsEnabledChanged(Control control, AvaloniaPropertyChangedEventArgs args)
|
||||
{
|
||||
if (args.NewValue is not bool isEnabled || !isEnabled || GetIsHooked(control))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
switch (control)
|
||||
{
|
||||
case PopupRoot popupRoot:
|
||||
popupRoot.Opened += OnPopupOpened;
|
||||
SetIsHooked(popupRoot, true);
|
||||
break;
|
||||
case OverlayPopupHost overlayPopupHost:
|
||||
overlayPopupHost.AttachedToVisualTree += OnOverlayPopupHostAttached;
|
||||
SetIsHooked(overlayPopupHost, true);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private static void OnPopupOpened(object? sender, EventArgs e)
|
||||
{
|
||||
if (sender is Control control)
|
||||
{
|
||||
PlayIntroAnimation(control);
|
||||
}
|
||||
}
|
||||
|
||||
private static void OnOverlayPopupHostAttached(object? sender, VisualTreeAttachmentEventArgs e)
|
||||
{
|
||||
if (sender is Control control)
|
||||
{
|
||||
PlayIntroAnimation(control);
|
||||
}
|
||||
}
|
||||
|
||||
private static void PlayIntroAnimation(Control control)
|
||||
{
|
||||
var compositionVisual = ElementComposition.GetElementVisual(control);
|
||||
if (compositionVisual is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var popup = control.Parent as Popup;
|
||||
compositionVisual.CenterPoint = ResolveCenterPoint(
|
||||
popup?.Placement ?? PlacementMode.Pointer,
|
||||
control.Bounds.Size,
|
||||
compositionVisual.CenterPoint);
|
||||
|
||||
var compositor = compositionVisual.Compositor;
|
||||
|
||||
var opacityAnimation = compositor.CreateScalarKeyFrameAnimation();
|
||||
opacityAnimation.Target = nameof(compositionVisual.Opacity);
|
||||
opacityAnimation.Duration = TimeSpan.FromMilliseconds(160);
|
||||
opacityAnimation.InsertKeyFrame(0f, 0f);
|
||||
opacityAnimation.InsertKeyFrame(1f, 1f, Easing.Parse("0.22, 1, 0.36, 1"));
|
||||
compositionVisual.StartAnimation(nameof(compositionVisual.Opacity), opacityAnimation);
|
||||
|
||||
var scaleAnimation = compositor.CreateVector3DKeyFrameAnimation();
|
||||
scaleAnimation.Target = nameof(compositionVisual.Scale);
|
||||
scaleAnimation.Duration = TimeSpan.FromMilliseconds(160);
|
||||
scaleAnimation.InsertKeyFrame(0f, compositionVisual.Scale with { X = 0.94, Y = 0.94 });
|
||||
scaleAnimation.InsertKeyFrame(1f, compositionVisual.Scale with { X = 1, Y = 1 }, Easing.Parse("0.22, 1, 0.36, 1"));
|
||||
compositionVisual.StartAnimation(nameof(compositionVisual.Scale), scaleAnimation);
|
||||
}
|
||||
|
||||
private static Vector3D ResolveCenterPoint(PlacementMode placement, Size size, Vector3D fallback)
|
||||
{
|
||||
var relative = placement switch
|
||||
{
|
||||
PlacementMode.Bottom => new RelativePoint(0.5, 0.0, RelativeUnit.Relative),
|
||||
PlacementMode.Left => new RelativePoint(1.0, 0.5, RelativeUnit.Relative),
|
||||
PlacementMode.Right => new RelativePoint(0.0, 0.5, RelativeUnit.Relative),
|
||||
PlacementMode.Top => new RelativePoint(0.5, 1.0, RelativeUnit.Relative),
|
||||
PlacementMode.Pointer => new RelativePoint(0.0, 0.0, RelativeUnit.Relative),
|
||||
PlacementMode.BottomEdgeAlignedLeft => new RelativePoint(0.0, 0.0, RelativeUnit.Relative),
|
||||
PlacementMode.BottomEdgeAlignedRight => new RelativePoint(1.0, 0.0, RelativeUnit.Relative),
|
||||
PlacementMode.LeftEdgeAlignedTop => new RelativePoint(1.0, 1.0, RelativeUnit.Relative),
|
||||
PlacementMode.LeftEdgeAlignedBottom => new RelativePoint(1.0, 0.0, RelativeUnit.Relative),
|
||||
PlacementMode.RightEdgeAlignedTop => new RelativePoint(0.0, 1.0, RelativeUnit.Relative),
|
||||
PlacementMode.RightEdgeAlignedBottom => new RelativePoint(0.0, 0.0, RelativeUnit.Relative),
|
||||
PlacementMode.TopEdgeAlignedLeft => new RelativePoint(0.0, 1.0, RelativeUnit.Relative),
|
||||
PlacementMode.TopEdgeAlignedRight => new RelativePoint(1.0, 1.0, RelativeUnit.Relative),
|
||||
_ => new RelativePoint(0.5, 0.5, RelativeUnit.Relative)
|
||||
};
|
||||
|
||||
return fallback with
|
||||
{
|
||||
X = size.Width * relative.Point.X,
|
||||
Y = size.Height * relative.Point.Y
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,8 @@ public static class BuiltInComponentIds
|
||||
public const string DesktopWeather = "DesktopWeather";
|
||||
public const string DesktopHourlyWeather = "DesktopHourlyWeather";
|
||||
public const string DesktopMultiDayWeather = "DesktopMultiDayWeather";
|
||||
public const string DesktopExtendedWeather = "DesktopExtendedWeather";
|
||||
public const string DesktopClassSchedule = "DesktopClassSchedule";
|
||||
public const string Blank2x4 = "Blank2x4";
|
||||
public const string Date = "Date";
|
||||
public const string MonthCalendar = "MonthCalendar";
|
||||
|
||||
@@ -84,6 +84,25 @@ public sealed class ComponentRegistry
|
||||
MinHeightCells: 2,
|
||||
AllowStatusBarPlacement: false,
|
||||
AllowDesktopPlacement: true),
|
||||
new DesktopComponentDefinition(
|
||||
BuiltInComponentIds.DesktopExtendedWeather,
|
||||
"Extended Weather",
|
||||
"WeatherSunny",
|
||||
"Weather",
|
||||
MinWidthCells: 4,
|
||||
MinHeightCells: 4,
|
||||
AllowStatusBarPlacement: false,
|
||||
AllowDesktopPlacement: true),
|
||||
new DesktopComponentDefinition(
|
||||
BuiltInComponentIds.DesktopClassSchedule,
|
||||
"Class Schedule",
|
||||
"CalendarDate",
|
||||
"Date",
|
||||
MinWidthCells: 2,
|
||||
MinHeightCells: 4,
|
||||
AllowStatusBarPlacement: false,
|
||||
AllowDesktopPlacement: true,
|
||||
ResizeMode: DesktopComponentResizeMode.Free),
|
||||
new DesktopComponentDefinition(
|
||||
BuiltInComponentIds.DesktopWhiteboard,
|
||||
"Blackboard Portrait",
|
||||
|
||||
@@ -32,6 +32,13 @@
|
||||
<PackageReference Include="FluentIcons.Avalonia.Fluent" Version="2.0.319" />
|
||||
<PackageReference Include="LibVLCSharp.Avalonia" Version="3.9.5" />
|
||||
<PackageReference Include="System.Drawing.Common" Version="10.0.0" />
|
||||
<PackageReference Include="VideoLAN.LibVLC.Windows" Version="3.0.23" />
|
||||
<PackageReference Include="VideoLAN.LibVLC.Windows" Version="3.0.23"
|
||||
Condition="('$(RuntimeIdentifier)' == '' and $([MSBuild]::IsOSPlatform('Windows')))
|
||||
or '$(RuntimeIdentifier)' == 'win-x64'
|
||||
or '$(RuntimeIdentifier)' == 'win-x86'" />
|
||||
<PackageReference Include="VideoLAN.LibVLC.Mac" Version="3.1.3.1"
|
||||
Condition="('$(RuntimeIdentifier)' == '' and $([MSBuild]::IsOSPlatform('OSX')))
|
||||
or '$(RuntimeIdentifier)' == 'osx-x64'" />
|
||||
<PackageReference Include="YamlDotNet" Version="16.3.0" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -106,11 +106,23 @@
|
||||
"settings.weather.preview_header": "Connection Test",
|
||||
"settings.weather.preview_desc": "Send one test request to verify current settings.",
|
||||
"settings.weather.preview_button": "Test Fetch",
|
||||
"settings.weather.preview_panel_header": "Weather Preview",
|
||||
"settings.weather.preview_panel_desc": "Refresh and verify current weather service status.",
|
||||
"settings.weather.refresh_button": "Refresh",
|
||||
"settings.weather.preview_hint": "Use test fetch to verify your weather configuration.",
|
||||
"settings.weather.preview_missing_location": "Please apply one weather location before testing.",
|
||||
"settings.weather.preview_success_format": "Test success: {0} · {1} · {2}",
|
||||
"settings.weather.preview_failed_format": "Test fetch failed: {0}",
|
||||
"settings.weather.preview_unknown": "Unknown",
|
||||
"settings.weather.alert_filter_header": "Excluded Alerts",
|
||||
"settings.weather.alert_filter_desc": "Alerts containing these words will not be shown. One rule per line.",
|
||||
"settings.weather.alert_filter_placeholder": "One keyword per line",
|
||||
"settings.weather.icon_style_header": "Weather Icon Style",
|
||||
"settings.weather.icon_style_desc": "Choose Fluent Icon style for weather symbols.",
|
||||
"settings.weather.icon_style_fluent_regular": "Fluent Regular",
|
||||
"settings.weather.icon_style_fluent_filled": "Fluent Filled",
|
||||
"settings.weather.no_tls_header": "No TLS Weather Request",
|
||||
"settings.weather.no_tls_desc": "Not recommended. Enable only for incompatible network environments.",
|
||||
"settings.weather.status_city_empty": "No city location is configured.",
|
||||
"settings.weather.status_city_format": "Mode: {0} | {1} | Key: {2}",
|
||||
"settings.weather.status_coordinates_format": "Mode: {0} | Lat {1:F4}, Lon {2:F4} | Key: {3}",
|
||||
@@ -137,6 +149,19 @@
|
||||
"weather.widget.condition_unknown": "Unknown",
|
||||
"weather.widget.range_unknown": "-- / --",
|
||||
"weather.widget.range_format": "{0} / {1}",
|
||||
"schedule.widget.no_source": "ClassIsland schedule data not found",
|
||||
"schedule.widget.no_class_today": "No classes today",
|
||||
"schedule.widget.layout_missing": "Schedule time layout is missing",
|
||||
"schedule.widget.subject_fallback": "Untitled class",
|
||||
"schedule.widget.detail_fallback": "No details",
|
||||
"schedule.settings.title": "Schedule Import",
|
||||
"schedule.settings.desc": "Import ClassIsland CSES schedules and choose which one is enabled.",
|
||||
"schedule.settings.add": "Add Schedule",
|
||||
"schedule.settings.empty": "No imported schedules",
|
||||
"schedule.settings.unnamed": "Unnamed Schedule",
|
||||
"schedule.settings.delete": "Delete",
|
||||
"schedule.settings.picker_title": "Select ClassIsland schedule file",
|
||||
"schedule.settings.picker_file_type": "ClassIsland CSES schedule",
|
||||
"weather.widget.aqi_unknown": "AQI --",
|
||||
"weather.widget.aqi_format": "AQI {0}",
|
||||
"weather.widget.updated_format": "Updated {0:HH:mm}",
|
||||
@@ -194,6 +219,8 @@
|
||||
"component.desktop_weather": "Weather",
|
||||
"component.hourly_weather": "Hourly Weather",
|
||||
"component.multiday_weather": "Multi-day Weather",
|
||||
"component.extended_weather": "Extended Weather",
|
||||
"component.class_schedule": "Class Schedule",
|
||||
"component.whiteboard": "Blackboard (Portrait)",
|
||||
"component.blackboard_landscape": "Blackboard (Landscape)",
|
||||
"component.holiday_calendar": "Holiday Calendar",
|
||||
|
||||
@@ -106,11 +106,23 @@
|
||||
"settings.weather.preview_header": "连接测试",
|
||||
"settings.weather.preview_desc": "发送一次测试请求,验证当前配置是否可用。",
|
||||
"settings.weather.preview_button": "测试获取",
|
||||
"settings.weather.preview_panel_header": "天气预览",
|
||||
"settings.weather.preview_panel_desc": "刷新并验证当前天气服务状态。",
|
||||
"settings.weather.refresh_button": "刷新",
|
||||
"settings.weather.preview_hint": "可通过测试获取快速验证天气配置。",
|
||||
"settings.weather.preview_missing_location": "请先应用一个天气位置后再测试。",
|
||||
"settings.weather.preview_success_format": "测试成功:{0} · {1} · {2}",
|
||||
"settings.weather.preview_failed_format": "测试失败:{0}",
|
||||
"settings.weather.preview_unknown": "未知",
|
||||
"settings.weather.alert_filter_header": "排除的气象预警",
|
||||
"settings.weather.alert_filter_desc": "包含以下关键字的预警将不会显示,每行一条规则。",
|
||||
"settings.weather.alert_filter_placeholder": "每行输入一个关键字",
|
||||
"settings.weather.icon_style_header": "天气图标样式",
|
||||
"settings.weather.icon_style_desc": "选择天气符号使用的 Fluent Icon 风格。",
|
||||
"settings.weather.icon_style_fluent_regular": "Fluent 线框",
|
||||
"settings.weather.icon_style_fluent_filled": "Fluent 填充",
|
||||
"settings.weather.no_tls_header": "禁用 TLS 获取天气",
|
||||
"settings.weather.no_tls_desc": "不建议启用,仅在网络兼容性较差时尝试。",
|
||||
"settings.weather.status_city_empty": "尚未配置城市位置。",
|
||||
"settings.weather.status_city_format": "模式:{0}|{1}|Key:{2}",
|
||||
"settings.weather.status_coordinates_format": "模式:{0}|纬度 {1:F4},经度 {2:F4}|Key:{3}",
|
||||
@@ -137,6 +149,19 @@
|
||||
"weather.widget.condition_unknown": "未知天气",
|
||||
"weather.widget.range_unknown": "-- / --",
|
||||
"weather.widget.range_format": "{0} / {1}",
|
||||
"schedule.widget.no_source": "未读取到 ClassIsland 课表",
|
||||
"schedule.widget.no_class_today": "今天没有课程",
|
||||
"schedule.widget.layout_missing": "课表时间布局缺失",
|
||||
"schedule.widget.subject_fallback": "未命名课程",
|
||||
"schedule.widget.detail_fallback": "未设置详情",
|
||||
"schedule.settings.title": "课表导入",
|
||||
"schedule.settings.desc": "导入 ClassIsland 的 CSES 课表文件并选择启用项。",
|
||||
"schedule.settings.add": "添加课表",
|
||||
"schedule.settings.empty": "暂无导入课表",
|
||||
"schedule.settings.unnamed": "未命名课表",
|
||||
"schedule.settings.delete": "删除",
|
||||
"schedule.settings.picker_title": "选择 ClassIsland 课表文件",
|
||||
"schedule.settings.picker_file_type": "ClassIsland CSES 课表",
|
||||
"weather.widget.aqi_unknown": "AQI --",
|
||||
"weather.widget.aqi_format": "AQI {0}",
|
||||
"weather.widget.updated_format": "更新于 {0:HH:mm}",
|
||||
@@ -194,6 +219,8 @@
|
||||
"component.desktop_weather": "天气",
|
||||
"component.hourly_weather": "小时天气",
|
||||
"component.multiday_weather": "多日天气",
|
||||
"component.extended_weather": "扩展天气",
|
||||
"component.class_schedule": "课表",
|
||||
"component.whiteboard": "竖向小黑板",
|
||||
"component.blackboard_landscape": "横向小黑板",
|
||||
"component.holiday_calendar": "节假日日历",
|
||||
|
||||
@@ -38,6 +38,12 @@ public sealed class AppSettingsSnapshot
|
||||
|
||||
public string WeatherLocationQuery { get; set; } = string.Empty;
|
||||
|
||||
public string WeatherExcludedAlerts { get; set; } = string.Empty;
|
||||
|
||||
public string WeatherIconPackId { get; set; } = "FluentRegular";
|
||||
|
||||
public bool WeatherNoTlsRequests { get; set; }
|
||||
|
||||
public List<string> TopStatusComponentIds { get; set; } = [];
|
||||
|
||||
public List<string> PinnedTaskbarActions { get; set; } =
|
||||
@@ -61,4 +67,8 @@ public sealed class AppSettingsSnapshot
|
||||
public int CurrentDesktopSurfaceIndex { get; set; } = 0;
|
||||
|
||||
public List<DesktopComponentPlacementSnapshot> DesktopComponentPlacements { get; set; } = [];
|
||||
|
||||
public List<ImportedClassScheduleSnapshot> ImportedClassSchedules { get; set; } = [];
|
||||
|
||||
public string ActiveImportedClassScheduleId { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
104
LanMontainDesktop/Models/ClassIslandScheduleModels.cs
Normal file
@@ -0,0 +1,104 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace LanMontainDesktop.Models;
|
||||
|
||||
public sealed record ClassIslandScheduleReadResult(
|
||||
bool Success,
|
||||
ClassIslandScheduleSnapshot? Snapshot,
|
||||
string? ErrorCode = null,
|
||||
string? ErrorMessage = null,
|
||||
IReadOnlyList<string>? Warnings = null)
|
||||
{
|
||||
public static ClassIslandScheduleReadResult Ok(
|
||||
ClassIslandScheduleSnapshot snapshot,
|
||||
IReadOnlyList<string>? warnings = null)
|
||||
{
|
||||
return new ClassIslandScheduleReadResult(true, snapshot, Warnings: warnings);
|
||||
}
|
||||
|
||||
public static ClassIslandScheduleReadResult Fail(
|
||||
string errorCode,
|
||||
string errorMessage,
|
||||
IReadOnlyList<string>? warnings = null)
|
||||
{
|
||||
return new ClassIslandScheduleReadResult(false, null, errorCode, errorMessage, warnings);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record ClassIslandScheduleSnapshot(
|
||||
string SourceRootPath,
|
||||
string ProfilePath,
|
||||
string ProfileFileName,
|
||||
DateTimeOffset LoadedAt,
|
||||
ClassIslandScheduleCycleRule CycleRule,
|
||||
Guid? SelectedClassPlanGroupId,
|
||||
Guid? TempClassPlanGroupId,
|
||||
bool IsTempClassPlanGroupEnabled,
|
||||
DateOnly? TempClassPlanGroupExpireDate,
|
||||
int TempClassPlanGroupType,
|
||||
Guid? TempClassPlanId,
|
||||
DateOnly? TempClassPlanSetupDate,
|
||||
bool IsOverlayClassPlanEnabled,
|
||||
Guid? OverlayClassPlanId,
|
||||
IReadOnlyDictionary<Guid, ClassIslandSubject> Subjects,
|
||||
IReadOnlyDictionary<Guid, ClassIslandTimeLayout> TimeLayouts,
|
||||
IReadOnlyDictionary<Guid, ClassIslandClassPlan> ClassPlans,
|
||||
IReadOnlyDictionary<DateOnly, Guid> OrderedSchedules,
|
||||
IReadOnlyDictionary<Guid, ClassIslandClassPlanGroup> ClassPlanGroups);
|
||||
|
||||
public sealed record ClassIslandScheduleCycleRule(
|
||||
DateOnly? SingleWeekStartDate,
|
||||
int MultiWeekRotationMaxCycle,
|
||||
IReadOnlyList<int> MultiWeekRotationOffset);
|
||||
|
||||
public sealed record ClassIslandSubject(
|
||||
Guid Id,
|
||||
string Name,
|
||||
string? Initial,
|
||||
string? TeacherName,
|
||||
bool? IsOutDoor);
|
||||
|
||||
public sealed record ClassIslandTimeLayout(
|
||||
Guid Id,
|
||||
string Name,
|
||||
IReadOnlyList<ClassIslandTimeLayoutItem> Items);
|
||||
|
||||
public sealed record ClassIslandTimeLayoutItem(
|
||||
TimeSpan StartTime,
|
||||
TimeSpan EndTime,
|
||||
int TimeType,
|
||||
bool IsHiddenByDefault,
|
||||
Guid? DefaultSubjectId,
|
||||
string? BreakName);
|
||||
|
||||
public sealed record ClassIslandClassPlan(
|
||||
Guid Id,
|
||||
string Name,
|
||||
Guid TimeLayoutId,
|
||||
ClassIslandTimeRule Rule,
|
||||
IReadOnlyList<ClassIslandClassInfo> Classes,
|
||||
bool IsEnabled,
|
||||
bool IsOverlay,
|
||||
Guid? OverlaySourceId,
|
||||
DateOnly? OverlaySetupDate,
|
||||
Guid? AssociatedGroupId);
|
||||
|
||||
public sealed record ClassIslandClassInfo(
|
||||
Guid? SubjectId,
|
||||
bool IsEnabled);
|
||||
|
||||
public sealed record ClassIslandTimeRule(
|
||||
int WeekDay,
|
||||
int WeekCountDiv,
|
||||
int WeekCountDivTotal);
|
||||
|
||||
public sealed record ClassIslandClassPlanGroup(
|
||||
Guid Id,
|
||||
string Name,
|
||||
bool IsGlobal);
|
||||
|
||||
public sealed record ClassIslandResolvedClassPlan(
|
||||
Guid ClassPlanId,
|
||||
ClassIslandClassPlan ClassPlan,
|
||||
string Source);
|
||||
10
LanMontainDesktop/Models/ImportedClassScheduleSnapshot.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
namespace LanMontainDesktop.Models;
|
||||
|
||||
public sealed class ImportedClassScheduleSnapshot
|
||||
{
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
public string DisplayName { get; set; } = string.Empty;
|
||||
|
||||
public string FilePath { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -18,6 +18,7 @@ public sealed record WeatherCurrentCondition(
|
||||
double? WindSpeedKph,
|
||||
double? WindDirectionDegree,
|
||||
int? WeatherCode,
|
||||
bool? IsDaylight,
|
||||
string? WeatherText);
|
||||
|
||||
public sealed record WeatherDailyForecast(
|
||||
|
||||
73
LanMontainDesktop/PACKAGING.md
Normal file
@@ -0,0 +1,73 @@
|
||||
# Desktop Packaging Guide
|
||||
|
||||
## Prerequisites
|
||||
- Install `.NET SDK 10`
|
||||
- Windows installer build only:
|
||||
- Install `Inno Setup 6` (`ISCC.exe`)
|
||||
|
||||
## Local packaging commands
|
||||
|
||||
### Windows installer (`win-x64`)
|
||||
```powershell
|
||||
.\scripts\package.ps1 -RuntimeIdentifier win-x64 -Version 1.0.1
|
||||
```
|
||||
|
||||
Output:
|
||||
- Published files: `artifacts/publish/win-x64`
|
||||
- Installer: `artifacts/installer`
|
||||
|
||||
### Linux package (`linux-x64`)
|
||||
```powershell
|
||||
pwsh ./scripts/package.ps1 -RuntimeIdentifier linux-x64 -Version 1.0.1
|
||||
```
|
||||
|
||||
Output:
|
||||
- Published files: `artifacts/publish/linux-x64`
|
||||
- Zip package: `artifacts/packages/LanMontainDesktop-1.0.1-linux-x64.zip`
|
||||
|
||||
### macOS package (`osx-x64`)
|
||||
```powershell
|
||||
pwsh ./scripts/package.ps1 -RuntimeIdentifier osx-x64 -Version 1.0.1
|
||||
```
|
||||
|
||||
Output:
|
||||
- Published files: `artifacts/publish/osx-x64`
|
||||
- Zip package: `artifacts/packages/LanMontainDesktop-1.0.1-osx-x64.zip`
|
||||
|
||||
## Optional script flags
|
||||
```powershell
|
||||
# Publish only (skip Windows installer step)
|
||||
.\scripts\package.ps1 -RuntimeIdentifier win-x64 -SkipInstaller
|
||||
|
||||
# Publish only (skip Linux/macOS zip package step)
|
||||
pwsh ./scripts/package.ps1 -RuntimeIdentifier linux-x64 -SkipArchive
|
||||
```
|
||||
|
||||
## Runtime dependency notes
|
||||
- Linux build does not bundle a native `libvlc` package from NuGet.
|
||||
- Install VLC runtime on target machine, for example:
|
||||
- Ubuntu/Debian: `sudo apt install vlc libvlc-dev`
|
||||
- macOS packaging target in CI is currently `osx-x64`.
|
||||
|
||||
## CI workflow
|
||||
- Workflow file: `.github/workflows/windows-ci.yml`
|
||||
- Workflow name: `Desktop CI`
|
||||
|
||||
Jobs:
|
||||
- `Validate Build (Windows)` runs on every push and pull request.
|
||||
- Package jobs run on manual trigger or `v*` tag push:
|
||||
- `Package Windows` (`win-x64` installer)
|
||||
- `Package Linux` (`linux-x64` zip)
|
||||
- `Package macOS` (`osx-x64` zip)
|
||||
|
||||
### Trigger manual packaging
|
||||
1. Open GitHub Actions.
|
||||
2. Choose `Desktop CI`.
|
||||
3. Click `Run workflow`.
|
||||
4. Optional: set `version` input, for example `1.0.1`.
|
||||
|
||||
### Trigger by tag
|
||||
```powershell
|
||||
git tag v1.0.1
|
||||
git push origin v1.0.1
|
||||
```
|
||||
1248
LanMontainDesktop/Services/ClassIslandScheduleDataService.cs
Normal file
@@ -335,6 +335,12 @@ public sealed class XiaomiWeatherService : IWeatherDataService, IDisposable
|
||||
WindDirectionDegree: ReadDouble(currentNode, "wind", "angle", "value") ??
|
||||
ReadDouble(currentNode, "wind", "direction", "value"),
|
||||
WeatherCode: weatherCode,
|
||||
IsDaylight: ReadBool(currentNode, "daylight", "value") ??
|
||||
ReadBool(currentNode, "daylight") ??
|
||||
ReadBool(currentNode, "isDaylight") ??
|
||||
ReadBool(currentNode, "isDay") ??
|
||||
ReadBool(currentNode, "day") ??
|
||||
ReadBool(payload, "isDaylight"),
|
||||
WeatherText: weatherText);
|
||||
|
||||
var forecasts = ParseDailyForecasts(dailyNode, days, locale);
|
||||
@@ -827,6 +833,46 @@ public sealed class XiaomiWeatherService : IWeatherDataService, IDisposable
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool? ReadBool(JsonElement? node, params string[] path)
|
||||
{
|
||||
if (!node.HasValue)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var target = path.Length == 0 ? node : TryGetNode(node.Value, path);
|
||||
if (!target.HasValue)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (target.Value.ValueKind is JsonValueKind.True or JsonValueKind.False)
|
||||
{
|
||||
return target.Value.GetBoolean();
|
||||
}
|
||||
|
||||
if (target.Value.ValueKind == JsonValueKind.Number && target.Value.TryGetInt32(out var number))
|
||||
{
|
||||
return number != 0;
|
||||
}
|
||||
|
||||
if (target.Value.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var text = target.Value.GetString();
|
||||
if (bool.TryParse(text, out var parsed))
|
||||
{
|
||||
return parsed;
|
||||
}
|
||||
|
||||
if (int.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out number))
|
||||
{
|
||||
return number != 0;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static DateTimeOffset? ParseTime(string? raw)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(raw))
|
||||
|
||||
99
LanMontainDesktop/Styles/SettingsAnimations.axaml
Normal file
@@ -0,0 +1,99 @@
|
||||
<Styles xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:behaviors="using:LanMontainDesktop.Behaviors">
|
||||
|
||||
<Style Selector="PopupRoot">
|
||||
<Setter Property="behaviors:PopupIntroAnimationBehavior.IsEnabled" Value="True" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="OverlayPopupHost">
|
||||
<Setter Property="behaviors:PopupIntroAnimationBehavior.IsEnabled" Value="True" />
|
||||
</Style>
|
||||
|
||||
<Style Selector=":is(Panel).settings-animated-intro">
|
||||
<Setter Property="behaviors:PanelIntroAnimationBehavior.IsEnabled" Value="True" />
|
||||
<Style Selector="^ > :is(Control)[(behaviors|PanelIntroAnimationBehavior.CanPlayAnimation)=True]">
|
||||
<Setter Property="Opacity" Value="0" />
|
||||
<Setter Property="RenderTransform">
|
||||
<Setter.Value>
|
||||
<TranslateTransform Y="14" />
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
<Style Selector="^[(behaviors|PanelIntroAnimationBehavior.IsAnimationPlayed)=True]">
|
||||
<Style.Animations>
|
||||
<Animation Duration="0:0:0.32"
|
||||
FillMode="Both"
|
||||
Easing="0.22,1,0.36,1">
|
||||
<KeyFrame Cue="0%">
|
||||
<Setter Property="Opacity" Value="0" />
|
||||
<Setter Property="TranslateTransform.Y" Value="14" />
|
||||
</KeyFrame>
|
||||
<KeyFrame Cue="100%">
|
||||
<Setter Property="Opacity" Value="1" />
|
||||
<Setter Property="TranslateTransform.Y" Value="0" />
|
||||
</KeyFrame>
|
||||
</Animation>
|
||||
</Style.Animations>
|
||||
</Style>
|
||||
</Style>
|
||||
</Style>
|
||||
|
||||
<Style Selector="ListBox.settings-chip-list">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderThickness" Value="0" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="ListBox.settings-chip-list ListBoxItem">
|
||||
<Setter Property="Background" Value="{DynamicResource AdaptiveButtonBackgroundBrush}" />
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource AdaptiveButtonBorderBrush}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="CornerRadius" Value="{DynamicResource DesignCornerRadiusXs}" />
|
||||
<Setter Property="Padding" Value="12,6" />
|
||||
<Setter Property="Margin" Value="0,0,8,8" />
|
||||
<Setter Property="MinHeight" Value="34" />
|
||||
<Setter Property="Transitions">
|
||||
<Transitions>
|
||||
<BrushTransition Property="Background" Duration="0:0:0.16" Easing="0.22,1,0.36,1" />
|
||||
<BrushTransition Property="BorderBrush" Duration="0:0:0.16" Easing="0.22,1,0.36,1" />
|
||||
<TransformOperationsTransition Property="RenderTransform" Duration="0:0:0.16" Easing="0.22,1,0.36,1" />
|
||||
</Transitions>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style Selector="ListBox.settings-chip-list ListBoxItem:pointerover">
|
||||
<Setter Property="RenderTransform" Value="scale(1.015)" />
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource AdaptiveAccentBrush}" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="ListBox.settings-chip-list ListBoxItem:selected">
|
||||
<Setter Property="RenderTransform" Value="scale(1.02)" />
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource AdaptiveAccentBrush}" />
|
||||
<Setter Property="Background" Value="{DynamicResource AdaptiveNavItemSelectedBackgroundBrush}" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Grid.settings-scope ComboBox">
|
||||
<Setter Property="Transitions">
|
||||
<Transitions>
|
||||
<BrushTransition Property="Background" Duration="0:0:0.12" Easing="0.22,1,0.36,1" />
|
||||
<TransformOperationsTransition Property="RenderTransform" Duration="0:0:0.12" Easing="0.22,1,0.36,1" />
|
||||
</Transitions>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style Selector="Grid.settings-scope ComboBox:pointerover">
|
||||
<Setter Property="RenderTransform" Value="scale(1.01)" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Grid.settings-scope ToggleSwitch">
|
||||
<Setter Property="Transitions">
|
||||
<Transitions>
|
||||
<DoubleTransition Property="Opacity" Duration="0:0:0.16" Easing="0.22,1,0.36,1" />
|
||||
<TransformOperationsTransition Property="RenderTransform" Duration="0:0:0.16" Easing="0.22,1,0.36,1" />
|
||||
</Transitions>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style Selector="Grid.settings-scope ToggleSwitch:pointerover">
|
||||
<Setter Property="RenderTransform" Value="scale(1.01)" />
|
||||
</Style>
|
||||
</Styles>
|
||||
@@ -0,0 +1,61 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:fi="using:FluentIcons.Avalonia"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
mc:Ignorable="d"
|
||||
d:DesignWidth="520"
|
||||
d:DesignHeight="340"
|
||||
x:Class="LanMontainDesktop.Views.Components.ClassScheduleSettingsWindow">
|
||||
<Border Background="{DynamicResource AdaptiveBackgroundBrush}"
|
||||
Padding="16">
|
||||
<Grid RowDefinitions="Auto,Auto,Auto,*"
|
||||
RowSpacing="10">
|
||||
<TextBlock x:Name="TitleTextBlock"
|
||||
Text="课表导入"
|
||||
FontSize="18"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
|
||||
|
||||
<TextBlock x:Name="DescriptionTextBlock"
|
||||
Grid.Row="1"
|
||||
Text="导入 ClassIsland 的 CSES 课表文件并选择启用项。"
|
||||
FontSize="12"
|
||||
TextWrapping="Wrap"
|
||||
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}" />
|
||||
|
||||
<Button x:Name="AddScheduleButton"
|
||||
Grid.Row="2"
|
||||
HorizontalAlignment="Left"
|
||||
MinWidth="132"
|
||||
Padding="12,8"
|
||||
Click="OnAddScheduleClick">
|
||||
<StackPanel Orientation="Horizontal"
|
||||
Spacing="6"
|
||||
VerticalAlignment="Center">
|
||||
<fi:FluentIcon Icon="Add" />
|
||||
<TextBlock x:Name="AddScheduleButtonTextBlock"
|
||||
Text="添加课表"
|
||||
VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
|
||||
<Grid Grid.Row="3">
|
||||
<ScrollViewer HorizontalScrollBarVisibility="Disabled"
|
||||
VerticalScrollBarVisibility="Auto">
|
||||
<StackPanel x:Name="ScheduleItemsPanel"
|
||||
Spacing="8" />
|
||||
</ScrollViewer>
|
||||
|
||||
<TextBlock x:Name="EmptyStateTextBlock"
|
||||
IsVisible="False"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
TextAlignment="Center"
|
||||
FontSize="12"
|
||||
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
|
||||
Text="暂无导入课表" />
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Border>
|
||||
</UserControl>
|
||||
@@ -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<ImportedClassScheduleSnapshot> _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<string?> 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));
|
||||
}
|
||||
}
|
||||
59
LanMontainDesktop/Views/Components/ClassScheduleWidget.axaml
Normal file
@@ -0,0 +1,59 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
x:Class="LanMontainDesktop.Views.Components.ClassScheduleWidget">
|
||||
<Border x:Name="RootBorder"
|
||||
ClipToBounds="True"
|
||||
CornerRadius="28"
|
||||
BorderThickness="1">
|
||||
<Grid x:Name="LayoutGrid"
|
||||
RowDefinitions="Auto,*">
|
||||
<Grid x:Name="HeaderGrid"
|
||||
ColumnDefinitions="*,Auto">
|
||||
<StackPanel x:Name="DateGroup"
|
||||
Orientation="Horizontal"
|
||||
VerticalAlignment="Top">
|
||||
<TextBlock x:Name="MonthTextBlock"
|
||||
Text="7"
|
||||
FontWeight="Bold"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock x:Name="SlashTextBlock"
|
||||
Text="/"
|
||||
FontWeight="Bold" />
|
||||
<TextBlock x:Name="DayTextBlock"
|
||||
Text="24"
|
||||
FontWeight="Bold"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel x:Name="MetaStack"
|
||||
Grid.Column="1"
|
||||
HorizontalAlignment="Right"
|
||||
VerticalAlignment="Top">
|
||||
<TextBlock x:Name="WeekdayTextBlock"
|
||||
Text="周一"
|
||||
TextAlignment="Right"
|
||||
FontWeight="SemiBold" />
|
||||
<TextBlock x:Name="ClassCountTextBlock"
|
||||
Text="0节课"
|
||||
TextAlignment="Right"
|
||||
FontWeight="SemiBold" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<Grid Grid.Row="1">
|
||||
<ScrollViewer x:Name="ContentScrollViewer"
|
||||
HorizontalScrollBarVisibility="Disabled"
|
||||
VerticalScrollBarVisibility="Disabled">
|
||||
<StackPanel x:Name="CourseListPanel" />
|
||||
</ScrollViewer>
|
||||
|
||||
<TextBlock x:Name="StatusTextBlock"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
TextAlignment="Center"
|
||||
IsVisible="False"
|
||||
TextWrapping="Wrap" />
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Border>
|
||||
</UserControl>
|
||||
543
LanMontainDesktop/Views/Components/ClassScheduleWidget.axaml.cs
Normal file
@@ -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<CourseItemViewModel> _courseItems = Array.Empty<CourseItemViewModel>();
|
||||
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<CourseItemViewModel>();
|
||||
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<CourseItemViewModel>();
|
||||
ShowStatus(L("schedule.widget.no_class_today", "今天没有课程"));
|
||||
RenderScheduleItems();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!snapshot.TimeLayouts.TryGetValue(resolvedClassPlan.ClassPlan.TimeLayoutId, out var layout))
|
||||
{
|
||||
_courseItems = Array.Empty<CourseItemViewModel>();
|
||||
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<CourseItemViewModel> 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<CourseItemViewModel>();
|
||||
}
|
||||
|
||||
var result = new List<CourseItemViewModel>(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)
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:local="using:LanMontainDesktop.Views.Components"
|
||||
mc:Ignorable="d"
|
||||
d:DesignWidth="640"
|
||||
d:DesignHeight="640"
|
||||
x:Class="LanMontainDesktop.Views.Components.ExtendedWeatherWidget">
|
||||
|
||||
<Grid x:Name="ContainerGrid"
|
||||
RowDefinitions="*,*"
|
||||
RowSpacing="8">
|
||||
<local:HourlyWeatherWidget x:Name="HourlyHost"
|
||||
Grid.Row="0" />
|
||||
<local:MultiDayWeatherWidget x:Name="MultiDayHost"
|
||||
Grid.Row="1" />
|
||||
</Grid>
|
||||
</UserControl>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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<WeatherVisualKind, string> WeatherBackgroundAssets =
|
||||
new Dictionary<WeatherVisualKind, string>
|
||||
{
|
||||
[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<WeatherVisualKind, IBrush> _backgroundBrushCache = new();
|
||||
private readonly Dictionary<HyperOS3WeatherVisualKind, IBrush> _particleBrushCache = new();
|
||||
private readonly List<Border> _particleVisuals = new();
|
||||
private readonly List<ParticleState> _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()
|
||||
|
||||
434
LanMontainDesktop/Views/Components/HyperOS3WeatherTheme.cs
Normal file
@@ -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<HyperOS3WeatherVisualKind, string> BackgroundAssets =
|
||||
new Dictionary<HyperOS3WeatherVisualKind, string>
|
||||
{
|
||||
[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<HyperOS3WeatherVisualKind, HyperOS3WeatherPalette> Palettes =
|
||||
new Dictionary<HyperOS3WeatherVisualKind, HyperOS3WeatherPalette>
|
||||
{
|
||||
[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<HyperOS3WeatherVisualKind, HyperOS3WeatherMotion> Motions =
|
||||
new Dictionary<HyperOS3WeatherVisualKind, HyperOS3WeatherMotion>
|
||||
{
|
||||
[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<HyperOS3WeatherWidgetKind, HyperOS3WeatherMetrics> Metrics =
|
||||
new Dictionary<HyperOS3WeatherWidgetKind, HyperOS3WeatherMetrics>
|
||||
{
|
||||
[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;
|
||||
}
|
||||
}
|
||||
@@ -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<WeatherVisualKind, string> WeatherBackgroundAssets =
|
||||
new Dictionary<WeatherVisualKind, string>
|
||||
{
|
||||
[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<WeatherVisualKind, IBrush> _backgroundBrushCache = new();
|
||||
private readonly Dictionary<HyperOS3WeatherVisualKind, IBrush> _particleBrushCache = new();
|
||||
private readonly List<Border> _particleVisuals = new();
|
||||
private readonly List<ParticleState> _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()
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -74,20 +74,6 @@ public partial class WeatherWidget : UserControl, IDesktopComponentWidget, ITime
|
||||
double Latitude,
|
||||
double Longitude);
|
||||
|
||||
private static readonly IReadOnlyDictionary<WeatherVisualKind, string> WeatherBackgroundAssets =
|
||||
new Dictionary<WeatherVisualKind, string>
|
||||
{
|
||||
[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<WeatherVisualKind, IBrush> _backgroundBrushCache = new();
|
||||
private readonly Dictionary<HyperOS3WeatherVisualKind, IBrush> _particleBrushCache = new();
|
||||
private readonly List<Border> _particleVisuals = new();
|
||||
private readonly List<ParticleState> _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()
|
||||
|
||||
@@ -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(() =>
|
||||
|
||||
@@ -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))
|
||||
{
|
||||
|
||||
@@ -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<ComboBoxItem>())
|
||||
{
|
||||
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<ComboBoxItem>())
|
||||
foreach (var item in WeatherLocationModeChipListBox.Items.OfType<ListBoxItem>())
|
||||
{
|
||||
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<ComboBoxItem>())
|
||||
{
|
||||
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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1076,23 +1076,89 @@
|
||||
</Border>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel x:Name="WeatherSettingsPanel"
|
||||
IsVisible="False"
|
||||
Spacing="16">
|
||||
<ScrollViewer x:Name="WeatherSettingsPanel"
|
||||
IsVisible="False"
|
||||
HorizontalScrollBarVisibility="Disabled"
|
||||
VerticalScrollBarVisibility="Auto">
|
||||
<StackPanel x:Name="WeatherSettingsContentPanel"
|
||||
Margin="0,0,8,0"
|
||||
Spacing="16"
|
||||
Classes="settings-animated-intro">
|
||||
<TextBlock x:Name="WeatherPanelTitleTextBlock"
|
||||
FontSize="24"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}"
|
||||
Text="天气" />
|
||||
|
||||
<Border Classes="settings-expander-shell">
|
||||
<ui:SettingsExpander x:Name="WeatherPreviewSettingsExpander"
|
||||
Header="Weather Preview"
|
||||
Description="Refresh and verify current weather service status."
|
||||
IsExpanded="True">
|
||||
<ui:SettingsExpander.Footer>
|
||||
<StackPanel Spacing="10">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto"
|
||||
ColumnSpacing="12">
|
||||
<Border Width="44"
|
||||
Height="44"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusXs}"
|
||||
BorderThickness="1"
|
||||
BorderBrush="{DynamicResource AdaptiveButtonBorderBrush}"
|
||||
Background="{DynamicResource AdaptiveButtonBackgroundBrush}">
|
||||
<fi:SymbolIcon x:Name="WeatherPreviewIconSymbol"
|
||||
Symbol="WeatherSunny"
|
||||
IconVariant="Regular"
|
||||
FontSize="22"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center" />
|
||||
</Border>
|
||||
<StackPanel Grid.Column="1"
|
||||
VerticalAlignment="Center"
|
||||
Spacing="2">
|
||||
<TextBlock x:Name="WeatherPreviewTemperatureTextBlock"
|
||||
FontSize="22"
|
||||
FontWeight="SemiBold"
|
||||
Text="--°" />
|
||||
<TextBlock x:Name="WeatherPreviewUpdatedTextBlock"
|
||||
FontSize="12"
|
||||
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
|
||||
Text="-" />
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="2"
|
||||
Orientation="Horizontal"
|
||||
VerticalAlignment="Center"
|
||||
Spacing="8">
|
||||
<Button x:Name="WeatherPreviewButton"
|
||||
Padding="12,8"
|
||||
Click="OnTestWeatherRequestClick"
|
||||
Content="Refresh" />
|
||||
<ui:ProgressRing x:Name="WeatherPreviewProgressRing"
|
||||
Width="20"
|
||||
Height="20"
|
||||
IsActive="True"
|
||||
IsVisible="False" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<TextBlock x:Name="WeatherPreviewResultTextBlock"
|
||||
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
|
||||
TextWrapping="Wrap"
|
||||
Text="Use refresh to verify your weather configuration." />
|
||||
</StackPanel>
|
||||
</ui:SettingsExpander.Footer>
|
||||
</ui:SettingsExpander>
|
||||
</Border>
|
||||
|
||||
<Border Classes="settings-expander-shell">
|
||||
<ui:SettingsExpander x:Name="WeatherLocationSettingsExpander"
|
||||
Header="Location Source"
|
||||
Description="Choose how weather widgets resolve location.">
|
||||
Description="Choose how weather widgets resolve location."
|
||||
IsExpanded="True">
|
||||
<ui:SettingsExpander.Footer>
|
||||
<StackPanel Spacing="10">
|
||||
<ComboBox x:Name="WeatherLocationModeComboBox"
|
||||
Width="220"
|
||||
IsVisible="False"
|
||||
SelectionChanged="OnWeatherLocationModeSelectionChanged">
|
||||
<ComboBoxItem x:Name="WeatherLocationModeCityItem"
|
||||
Tag="CitySearch"
|
||||
@@ -1101,6 +1167,24 @@
|
||||
Tag="Coordinates"
|
||||
Content="Coordinates" />
|
||||
</ComboBox>
|
||||
|
||||
<ListBox x:Name="WeatherLocationModeChipListBox"
|
||||
Classes="settings-chip-list"
|
||||
SelectionMode="Single"
|
||||
SelectionChanged="OnWeatherLocationModeChipSelectionChanged">
|
||||
<ListBox.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<WrapPanel Orientation="Horizontal" />
|
||||
</ItemsPanelTemplate>
|
||||
</ListBox.ItemsPanel>
|
||||
<ListBoxItem x:Name="WeatherLocationModeCityChipItem"
|
||||
Tag="CitySearch"
|
||||
Content="City Search" />
|
||||
<ListBoxItem x:Name="WeatherLocationModeCoordinatesChipItem"
|
||||
Tag="Coordinates"
|
||||
Content="Coordinates" />
|
||||
</ListBox>
|
||||
|
||||
<ToggleSwitch x:Name="WeatherAutoRefreshToggleSwitch"
|
||||
Checked="OnWeatherAutoRefreshToggled"
|
||||
Unchecked="OnWeatherAutoRefreshToggled"
|
||||
@@ -1113,7 +1197,8 @@
|
||||
<Border Classes="settings-expander-shell">
|
||||
<ui:SettingsExpander x:Name="WeatherCitySearchSettingsExpander"
|
||||
Header="City Search"
|
||||
Description="Search cities and apply one weather location.">
|
||||
Description="Search cities and apply one weather location."
|
||||
IsExpanded="True">
|
||||
<ui:SettingsExpander.Footer>
|
||||
<StackPanel Spacing="10">
|
||||
<Grid ColumnDefinitions="*,Auto,Auto"
|
||||
@@ -1155,7 +1240,8 @@
|
||||
<ui:SettingsExpander x:Name="WeatherCoordinateSettingsExpander"
|
||||
Header="Coordinates"
|
||||
Description="Set latitude/longitude and optional key/name."
|
||||
IsVisible="False">
|
||||
IsVisible="False"
|
||||
IsExpanded="True">
|
||||
<ui:SettingsExpander.Footer>
|
||||
<StackPanel Spacing="10">
|
||||
<Grid ColumnDefinitions="*,*"
|
||||
@@ -1200,38 +1286,60 @@
|
||||
</Border>
|
||||
|
||||
<Border Classes="settings-expander-shell">
|
||||
<ui:SettingsExpander x:Name="WeatherPreviewSettingsExpander"
|
||||
Header="Connection Test"
|
||||
Description="Send one test request to verify current settings.">
|
||||
<ui:SettingsExpander x:Name="WeatherAlertFilterSettingsExpander"
|
||||
Header="Excluded Alerts"
|
||||
Description="Alerts containing these words will not be shown. One rule per line."
|
||||
IsExpanded="True">
|
||||
<ui:SettingsExpander.Footer>
|
||||
<StackPanel Spacing="10">
|
||||
<StackPanel Orientation="Horizontal"
|
||||
Spacing="8">
|
||||
<Button x:Name="WeatherPreviewButton"
|
||||
Padding="12,8"
|
||||
Click="OnTestWeatherRequestClick"
|
||||
Content="Test Fetch" />
|
||||
<ui:ProgressRing x:Name="WeatherPreviewProgressRing"
|
||||
Width="24"
|
||||
Height="24"
|
||||
IsActive="True"
|
||||
IsVisible="False" />
|
||||
</StackPanel>
|
||||
|
||||
<TextBlock x:Name="WeatherPreviewResultTextBlock"
|
||||
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
|
||||
TextWrapping="Wrap"
|
||||
Text="Use test fetch to verify your weather configuration." />
|
||||
</StackPanel>
|
||||
<TextBox x:Name="WeatherExcludedAlertsTextBox"
|
||||
MinHeight="96"
|
||||
MaxHeight="220"
|
||||
Width="360"
|
||||
TextWrapping="Wrap"
|
||||
AcceptsReturn="True"
|
||||
LostFocus="OnWeatherExcludedAlertsLostFocus" />
|
||||
</ui:SettingsExpander.Footer>
|
||||
</ui:SettingsExpander>
|
||||
</Border>
|
||||
|
||||
<TextBlock x:Name="WeatherLocationStatusTextBlock"
|
||||
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
|
||||
Text="No city location is configured." />
|
||||
</StackPanel>
|
||||
<Border Classes="settings-expander-shell">
|
||||
<ui:SettingsExpander x:Name="WeatherIconPackSettingsExpander"
|
||||
Header="Weather Icon Style"
|
||||
Description="Choose Fluent Icon style for weather symbols."
|
||||
IsExpanded="True">
|
||||
<ui:SettingsExpander.Footer>
|
||||
<ComboBox x:Name="WeatherIconPackComboBox"
|
||||
Width="240"
|
||||
SelectionChanged="OnWeatherIconPackSelectionChanged">
|
||||
<ComboBoxItem x:Name="WeatherIconPackFluentRegularItem"
|
||||
Tag="FluentRegular"
|
||||
Content="Fluent Regular" />
|
||||
<ComboBoxItem x:Name="WeatherIconPackFluentFilledItem"
|
||||
Tag="FluentFilled"
|
||||
Content="Fluent Filled" />
|
||||
</ComboBox>
|
||||
</ui:SettingsExpander.Footer>
|
||||
</ui:SettingsExpander>
|
||||
</Border>
|
||||
|
||||
<Border Classes="settings-expander-shell">
|
||||
<ui:SettingsExpander x:Name="WeatherNoTlsSettingsExpander"
|
||||
Header="No TLS Weather Request"
|
||||
Description="Not recommended. Enable only for incompatible network environments."
|
||||
IsExpanded="True">
|
||||
<ui:SettingsExpander.Footer>
|
||||
<ToggleSwitch x:Name="WeatherNoTlsToggleSwitch"
|
||||
Checked="OnWeatherNoTlsToggled"
|
||||
Unchecked="OnWeatherNoTlsToggled" />
|
||||
</ui:SettingsExpander.Footer>
|
||||
</ui:SettingsExpander>
|
||||
</Border>
|
||||
|
||||
<TextBlock x:Name="WeatherLocationStatusTextBlock"
|
||||
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
|
||||
Text="No city location is configured." />
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
<StackPanel x:Name="RegionSettingsPanel"
|
||||
IsVisible="False"
|
||||
Spacing="16">
|
||||
@@ -1499,3 +1607,4 @@
|
||||
|
||||
</Window>
|
||||
|
||||
|
||||
|
||||
@@ -147,6 +147,9 @@ public partial class MainWindow : Window
|
||||
private double _weatherLatitude = 39.9042;
|
||||
private double _weatherLongitude = 116.4074;
|
||||
private bool _weatherAutoRefreshLocation;
|
||||
private string _weatherExcludedAlertsRaw = string.Empty;
|
||||
private string _weatherIconPackId = "FluentRegular";
|
||||
private bool _weatherNoTlsRequests;
|
||||
private string _weatherSearchKeyword = string.Empty;
|
||||
private bool _isWeatherSearchInProgress;
|
||||
private bool _isWeatherPreviewInProgress;
|
||||
|
||||
57
LanMontainDesktop/installer/LanMontainDesktop.iss
Normal file
@@ -0,0 +1,57 @@
|
||||
#define MyAppName "LanMontainDesktop"
|
||||
#define MyAppPublisher "LanMontainDesktop Team"
|
||||
#define MyAppExeName "LanMontainDesktop.exe"
|
||||
|
||||
#ifndef MyAppVersion
|
||||
#define MyAppVersion "0.0.0"
|
||||
#endif
|
||||
|
||||
#ifndef PublishDir
|
||||
#define PublishDir "..\artifacts\publish\win-x64"
|
||||
#endif
|
||||
|
||||
#ifndef MyOutputDir
|
||||
#define MyOutputDir "..\artifacts\installer"
|
||||
#endif
|
||||
|
||||
#ifndef MyAppArch
|
||||
#define MyAppArch "x64"
|
||||
#endif
|
||||
|
||||
[Setup]
|
||||
AppId={{5A058B0D-F95D-4A18-B9A0-93F843655DDB}
|
||||
AppName={#MyAppName}
|
||||
AppVersion={#MyAppVersion}
|
||||
AppPublisher={#MyAppPublisher}
|
||||
DefaultDirName={autopf}\{#MyAppName}
|
||||
DefaultGroupName={#MyAppName}
|
||||
UninstallDisplayIcon={app}\{#MyAppExeName}
|
||||
OutputDir={#MyOutputDir}
|
||||
OutputBaseFilename={#MyAppName}-Setup-{#MyAppVersion}
|
||||
Compression=lzma2/ultra64
|
||||
SolidCompression=yes
|
||||
WizardStyle=modern
|
||||
PrivilegesRequired=admin
|
||||
DisableProgramGroupPage=yes
|
||||
|
||||
#if MyAppArch == "x64"
|
||||
ArchitecturesAllowed=x64compatible
|
||||
ArchitecturesInstallIn64BitMode=x64compatible
|
||||
#endif
|
||||
|
||||
[Languages]
|
||||
Name: "english"; MessagesFile: "compiler:Default.isl"
|
||||
Name: "chinesesimp"; MessagesFile: "compiler:Languages\ChineseSimplified.isl"
|
||||
|
||||
[Tasks]
|
||||
Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"
|
||||
|
||||
[Files]
|
||||
Source: "{#PublishDir}\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs
|
||||
|
||||
[Icons]
|
||||
Name: "{autoprograms}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"
|
||||
Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon
|
||||
|
||||
[Run]
|
||||
Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#StringChange(MyAppName, '&', '&&')}}"; Flags: nowait postinstall skipifsilent
|
||||
241
LanMontainDesktop/scripts/package.ps1
Normal file
@@ -0,0 +1,241 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$Project = "LanMontainDesktop.csproj",
|
||||
[string]$Configuration = "Release",
|
||||
[string]$RuntimeIdentifier = "win-x64",
|
||||
[string]$Version = "",
|
||||
[string]$PublishDir = "",
|
||||
[string]$InstallerOutputDir = "",
|
||||
[string]$ArchiveOutputDir = "",
|
||||
[string]$InnoScript = "",
|
||||
[string]$InnoCompiler = "",
|
||||
[switch]$SkipInstaller,
|
||||
[switch]$SkipArchive,
|
||||
[switch]$KeepSymbols
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
function Resolve-ExistingPath {
|
||||
param([Parameter(Mandatory = $true)][string]$PathValue)
|
||||
$resolved = Resolve-Path -LiteralPath $PathValue -ErrorAction Stop
|
||||
return $resolved.Path
|
||||
}
|
||||
|
||||
function Is-WindowsRuntimeIdentifier {
|
||||
param([Parameter(Mandatory = $true)][string]$Rid)
|
||||
return $Rid -like "win-*"
|
||||
}
|
||||
|
||||
function Find-InnoCompiler {
|
||||
param([string]$ExplicitPath = "")
|
||||
|
||||
if ($ExplicitPath) {
|
||||
if (Test-Path -LiteralPath $ExplicitPath) {
|
||||
return (Resolve-ExistingPath -PathValue $ExplicitPath)
|
||||
}
|
||||
throw "Inno compiler not found at explicit path: $ExplicitPath"
|
||||
}
|
||||
|
||||
$fromPath = Get-Command iscc.exe -ErrorAction SilentlyContinue
|
||||
if ($fromPath -and (Test-Path -LiteralPath $fromPath.Source)) {
|
||||
return (Resolve-ExistingPath -PathValue $fromPath.Source)
|
||||
}
|
||||
|
||||
$candidates = @(
|
||||
"C:\Program Files (x86)\Inno Setup 6\ISCC.exe",
|
||||
"C:\Program Files\Inno Setup 6\ISCC.exe"
|
||||
)
|
||||
|
||||
foreach ($candidate in $candidates) {
|
||||
if (Test-Path -LiteralPath $candidate) {
|
||||
return (Resolve-ExistingPath -PathValue $candidate)
|
||||
}
|
||||
}
|
||||
|
||||
throw "ISCC.exe not found. Install Inno Setup 6 or pass -InnoCompiler."
|
||||
}
|
||||
|
||||
function Read-VersionFromProject {
|
||||
param([Parameter(Mandatory = $true)][string]$ProjectFile)
|
||||
|
||||
[xml]$xml = Get-Content -LiteralPath $ProjectFile -Raw
|
||||
$versionNode = $xml.Project.PropertyGroup | ForEach-Object { $_.Version } | Where-Object { $_ } | Select-Object -First 1
|
||||
if ($versionNode) {
|
||||
return $versionNode.Trim()
|
||||
}
|
||||
return "0.1.0"
|
||||
}
|
||||
|
||||
function Remove-LibVlcForOtherArch {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$PublishedDirectory,
|
||||
[Parameter(Mandatory = $true)][string]$Rid
|
||||
)
|
||||
|
||||
$libVlcRoot = Join-Path $PublishedDirectory "libvlc"
|
||||
if (-not (Test-Path -LiteralPath $libVlcRoot)) {
|
||||
return
|
||||
}
|
||||
|
||||
$dirsToDelete = @()
|
||||
if ($Rid -eq "win-x64") {
|
||||
$dirsToDelete += (Join-Path $libVlcRoot "win-x86")
|
||||
} elseif ($Rid -eq "win-x86") {
|
||||
$dirsToDelete += (Join-Path $libVlcRoot "win-x64")
|
||||
} elseif (-not (Is-WindowsRuntimeIdentifier -Rid $Rid)) {
|
||||
$dirsToDelete += (Join-Path $libVlcRoot "win-x64")
|
||||
$dirsToDelete += (Join-Path $libVlcRoot "win-x86")
|
||||
}
|
||||
|
||||
foreach ($dir in $dirsToDelete) {
|
||||
if (Test-Path -LiteralPath $dir) {
|
||||
[System.IO.Directory]::Delete($dir, $true)
|
||||
Write-Host "Pruned: $dir"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Create-PackageArchive {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$SourceDirectory,
|
||||
[Parameter(Mandatory = $true)][string]$DestinationDirectory,
|
||||
[Parameter(Mandatory = $true)][string]$VersionValue,
|
||||
[Parameter(Mandatory = $true)][string]$Rid
|
||||
)
|
||||
|
||||
[System.IO.Directory]::CreateDirectory($DestinationDirectory) | Out-Null
|
||||
|
||||
$archiveName = "LanMontainDesktop-$VersionValue-$Rid.zip"
|
||||
$archivePath = Join-Path $DestinationDirectory $archiveName
|
||||
if (Test-Path -LiteralPath $archivePath) {
|
||||
[System.IO.File]::Delete($archivePath)
|
||||
}
|
||||
|
||||
Compress-Archive -Path (Join-Path $SourceDirectory "*") -DestinationPath $archivePath -Force
|
||||
return $archivePath
|
||||
}
|
||||
|
||||
$scriptRoot = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$repoRoot = Resolve-ExistingPath -PathValue (Join-Path $scriptRoot "..")
|
||||
|
||||
$projectPath = $Project
|
||||
if (-not [System.IO.Path]::IsPathRooted($projectPath)) {
|
||||
$projectPath = Join-Path $repoRoot $projectPath
|
||||
}
|
||||
$projectPath = Resolve-ExistingPath -PathValue $projectPath
|
||||
|
||||
if (-not $Version) {
|
||||
$Version = Read-VersionFromProject -ProjectFile $projectPath
|
||||
}
|
||||
|
||||
if (-not $PublishDir) {
|
||||
$PublishDir = Join-Path $repoRoot "artifacts/publish/$RuntimeIdentifier"
|
||||
}
|
||||
if (-not [System.IO.Path]::IsPathRooted($PublishDir)) {
|
||||
$PublishDir = Join-Path $repoRoot $PublishDir
|
||||
}
|
||||
[System.IO.Directory]::CreateDirectory($PublishDir) | Out-Null
|
||||
|
||||
Write-Host "Publishing project..."
|
||||
$publishArgs = @(
|
||||
"publish",
|
||||
$projectPath,
|
||||
"-c", $Configuration,
|
||||
"-r", $RuntimeIdentifier,
|
||||
"--self-contained", "true",
|
||||
"-p:PublishSingleFile=false",
|
||||
"-p:PublishTrimmed=false",
|
||||
"-p:DebugType=None",
|
||||
"-p:DebugSymbols=false",
|
||||
"-p:Version=$Version",
|
||||
"-o", $PublishDir
|
||||
)
|
||||
|
||||
& dotnet @publishArgs
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "dotnet publish failed with exit code $LASTEXITCODE."
|
||||
}
|
||||
|
||||
Remove-LibVlcForOtherArch -PublishedDirectory $PublishDir -Rid $RuntimeIdentifier
|
||||
|
||||
if (-not $KeepSymbols) {
|
||||
Get-ChildItem -Path $PublishDir -Recurse -File -Filter "*.pdb" | ForEach-Object {
|
||||
[System.IO.File]::Delete($_.FullName)
|
||||
}
|
||||
}
|
||||
|
||||
if (Is-WindowsRuntimeIdentifier -Rid $RuntimeIdentifier) {
|
||||
if (-not $InstallerOutputDir) {
|
||||
$InstallerOutputDir = Join-Path $repoRoot "artifacts/installer"
|
||||
}
|
||||
if (-not [System.IO.Path]::IsPathRooted($InstallerOutputDir)) {
|
||||
$InstallerOutputDir = Join-Path $repoRoot $InstallerOutputDir
|
||||
}
|
||||
[System.IO.Directory]::CreateDirectory($InstallerOutputDir) | Out-Null
|
||||
|
||||
if ($SkipInstaller) {
|
||||
Write-Host "Publish completed. Installer step skipped."
|
||||
Write-Host "Published files: $PublishDir"
|
||||
exit 0
|
||||
}
|
||||
|
||||
if (-not $InnoScript) {
|
||||
$InnoScript = Join-Path $repoRoot "installer/LanMontainDesktop.iss"
|
||||
}
|
||||
if (-not [System.IO.Path]::IsPathRooted($InnoScript)) {
|
||||
$InnoScript = Join-Path $repoRoot $InnoScript
|
||||
}
|
||||
$InnoScript = Resolve-ExistingPath -PathValue $InnoScript
|
||||
|
||||
$archForInstaller = "x64"
|
||||
if ($RuntimeIdentifier -like "*x86*") {
|
||||
$archForInstaller = "x86"
|
||||
}
|
||||
|
||||
$isccPath = Find-InnoCompiler -ExplicitPath $InnoCompiler
|
||||
|
||||
Write-Host "Building installer..."
|
||||
$isccArgs = @(
|
||||
"/DMyAppVersion=$Version",
|
||||
"/DPublishDir=$PublishDir",
|
||||
"/DMyOutputDir=$InstallerOutputDir",
|
||||
"/DMyAppArch=$archForInstaller",
|
||||
$InnoScript
|
||||
)
|
||||
|
||||
& $isccPath @isccArgs
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "ISCC failed with exit code $LASTEXITCODE."
|
||||
}
|
||||
|
||||
$installer = Get-ChildItem -Path $InstallerOutputDir -File -Filter "*.exe" | Sort-Object LastWriteTime -Descending | Select-Object -First 1
|
||||
if ($null -ne $installer) {
|
||||
Write-Host "Installer created: $($installer.FullName)"
|
||||
} else {
|
||||
Write-Host "Installer build finished, but no .exe was found in $InstallerOutputDir"
|
||||
}
|
||||
|
||||
exit 0
|
||||
}
|
||||
|
||||
if ($SkipArchive) {
|
||||
Write-Host "Publish completed. Archive step skipped."
|
||||
Write-Host "Published files: $PublishDir"
|
||||
exit 0
|
||||
}
|
||||
|
||||
if (-not $ArchiveOutputDir) {
|
||||
$ArchiveOutputDir = Join-Path $repoRoot "artifacts/packages"
|
||||
}
|
||||
if (-not [System.IO.Path]::IsPathRooted($ArchiveOutputDir)) {
|
||||
$ArchiveOutputDir = Join-Path $repoRoot $ArchiveOutputDir
|
||||
}
|
||||
|
||||
$archivePath = Create-PackageArchive `
|
||||
-SourceDirectory $PublishDir `
|
||||
-DestinationDirectory $ArchiveOutputDir `
|
||||
-VersionValue $Version `
|
||||
-Rid $RuntimeIdentifier
|
||||
|
||||
Write-Host "Archive created: $archivePath"
|
||||