课表组件、天气组件全面升级。
This commit is contained in:
lincube
2026-03-03 15:09:49 +08:00
parent 2d09c1aca2
commit 478ed115a1
47 changed files with 4876 additions and 771 deletions

View 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

View File

@@ -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}" />

View 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.

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 477 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 683 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

View 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();
}
}

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

View File

@@ -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";

View File

@@ -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",

View File

@@ -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>

View File

@@ -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",

View File

@@ -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": "节假日日历",

View File

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

View 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);

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

View File

@@ -18,6 +18,7 @@ public sealed record WeatherCurrentCondition(
double? WindSpeedKph,
double? WindDirectionDegree,
int? WeatherCode,
bool? IsDaylight,
string? WeatherText);
public sealed record WeatherDailyForecast(

View 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
```

File diff suppressed because it is too large Load Diff

View 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))

View 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>

View File

@@ -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>

View File

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

View 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>

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

View File

@@ -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",

View File

@@ -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>

View File

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

View File

@@ -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()

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

View File

@@ -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()

View File

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

View File

@@ -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()

View File

@@ -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(() =>

View File

@@ -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))
{

View File

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

View File

@@ -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="&#22825;&#27668;" />
<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>

View File

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

View 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

View 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"