小白板,天气,时钟
This commit is contained in:
lincube
2026-03-03 04:56:04 +08:00
parent 4c3ec920f9
commit 5dc2d680fb
57 changed files with 8776 additions and 387 deletions

1
.gitignore vendored
View File

@@ -480,3 +480,4 @@ $RECYCLE.BIN/
# Vim temporary swap files
*.swp
nul

View File

@@ -7,6 +7,10 @@
RequestedThemeVariant="Default">
<!-- "Default" ThemeVariant follows system theme variant. "Dark" or "Light" are other available options. -->
<Application.Resources>
<FontFamily x:Key="AppFontFamily">avares://LanMontainDesktop/Assets/Fonts#MiSans</FontFamily>
</Application.Resources>
<Application.DataTemplates>
<local:ViewLocator/>
</Application.DataTemplates>
@@ -15,6 +19,14 @@
<sty:FluentAvaloniaTheme />
<StyleInclude Source="avares://LanMontainDesktop/Styles/GlassModule.axaml" />
<Style Selector="Window">
<Setter Property="FontFamily" Value="{DynamicResource AppFontFamily}" />
</Style>
<Style Selector="UserControl">
<Setter Property="FontFamily" Value="{DynamicResource AppFontFamily}" />
</Style>
<Style Selector="fi|SymbolIcon">
<Setter Property="Foreground" Value="{DynamicResource AdaptiveTextPrimaryBrush}" />
<Setter Property="FontSize" Value="16" />

Binary file not shown.

View File

@@ -0,0 +1,22 @@
# MiSans Font Notice
This app bundles MiSans fonts for consistent cross-device rendering.
## Included files
- `MiSans-Regular.ttf`
- `MiSans-Semibold.ttf`
- `MiSans-Bold.ttf`
## Source
- Upstream package repository: https://github.com/dsrkafuu/misans
- Original font source referenced by upstream: https://hyperos.mi.com/font/zh/
## License and usage notes
- Script/package license in upstream repository: Apache-2.0
- MiSans font copyright and additional usage terms:
https://hyperos.mi.com/font-download/MiSans%E5%AD%97%E4%BD%93%E7%9F%A5%E8%AF%86%E4%BA%A7%E6%9D%83%E8%AE%B8%E5%8F%AF%E5%8D%8F%E8%AE%AE.pdf
Please review and comply with the MiSans font terms when distributing this app.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,29 @@
# Weather Background Assets
Weather card background images are sourced from **Pexels** and used under the Pexels license:
https://www.pexels.com/license/
## Sources
- `clear_sky.jpg`
- https://www.pexels.com/photo/a-clear-blue-sky-with-few-clouds-on-a-sunny-day-29390199/
- `rain.jpg`
- https://www.pexels.com/photo/rain-on-window-with-bokeh-lights-35075853/
- `snow.jpg`
- https://www.pexels.com/photo/mountain-covered-with-snow-209955/
- `storm.jpg`
- https://www.pexels.com/photo/sea-under-a-stormy-sky-4609228/
## Derived Variants (for widget scene mapping)
The following files are generated from the above base assets by color grading/brightness adjustments to match the ColorOS-like weather card style:
- `clear_day.jpg` (from `clear_sky.jpg`)
- `clear_night.jpg` (from `clear_sky.jpg`)
- `cloudy_day.jpg` (from `clear_sky.jpg`)
- `cloudy_night.jpg` (from `clear_sky.jpg`)
- `rain_light.jpg` (from `rain.jpg`)
- `rain_heavy.jpg` (from `rain.jpg`)
- `storm_dark.jpg` (from `storm.jpg`)
- `fog_haze.jpg` (from `storm.jpg`)
- `snow_soft.jpg` (from `snow.jpg`)

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 197 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 244 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

View File

@@ -4,10 +4,16 @@ public static class BuiltInComponentIds
{
public const string Clock = "Clock";
public const string DesktopClock = "DesktopClock";
public const string DesktopWeatherClock = "DesktopWeatherClock";
public const string DesktopTimer = "DesktopTimer";
public const string DesktopWeather = "DesktopWeather";
public const string DesktopHourlyWeather = "DesktopHourlyWeather";
public const string DesktopMultiDayWeather = "DesktopMultiDayWeather";
public const string Blank2x4 = "Blank2x4";
public const string Date = "Date";
public const string MonthCalendar = "MonthCalendar";
public const string LunarCalendar = "LunarCalendar";
public const string HolidayCalendar = "HolidayCalendar";
public const string DesktopWhiteboard = "DesktopWhiteboard";
public const string DesktopBlackboardLandscape = "DesktopBlackboardLandscape";
}

View File

@@ -39,6 +39,15 @@ public sealed class ComponentRegistry
MinHeightCells: 2,
AllowStatusBarPlacement: false,
AllowDesktopPlacement: true),
new DesktopComponentDefinition(
BuiltInComponentIds.DesktopWeatherClock,
"Weather Clock",
"Clock",
"Clock",
MinWidthCells: 2,
MinHeightCells: 1,
AllowStatusBarPlacement: false,
AllowDesktopPlacement: true),
new DesktopComponentDefinition(
BuiltInComponentIds.DesktopTimer,
"Timer",
@@ -48,6 +57,51 @@ public sealed class ComponentRegistry
MinHeightCells: 2,
AllowStatusBarPlacement: false,
AllowDesktopPlacement: true),
new DesktopComponentDefinition(
BuiltInComponentIds.DesktopWeather,
"Weather",
"WeatherSunny",
"Weather",
MinWidthCells: 2,
MinHeightCells: 2,
AllowStatusBarPlacement: false,
AllowDesktopPlacement: true),
new DesktopComponentDefinition(
BuiltInComponentIds.DesktopHourlyWeather,
"Hourly Weather",
"WeatherSunny",
"Weather",
MinWidthCells: 4,
MinHeightCells: 2,
AllowStatusBarPlacement: false,
AllowDesktopPlacement: true),
new DesktopComponentDefinition(
BuiltInComponentIds.DesktopMultiDayWeather,
"Multi-day Weather",
"WeatherSunny",
"Weather",
MinWidthCells: 4,
MinHeightCells: 2,
AllowStatusBarPlacement: false,
AllowDesktopPlacement: true),
new DesktopComponentDefinition(
BuiltInComponentIds.DesktopWhiteboard,
"Blackboard Portrait",
"Edit",
"Board",
MinWidthCells: 2,
MinHeightCells: 4,
AllowStatusBarPlacement: false,
AllowDesktopPlacement: true),
new DesktopComponentDefinition(
BuiltInComponentIds.DesktopBlackboardLandscape,
"Blackboard Landscape",
"Edit",
"Board",
MinWidthCells: 4,
MinHeightCells: 2,
AllowStatusBarPlacement: false,
AllowDesktopPlacement: true),
new DesktopComponentDefinition(
BuiltInComponentIds.Date,
"Calendar",

View File

@@ -26,6 +26,7 @@
<PrivateAssets Condition="'$(Configuration)' != 'Debug'">All</PrivateAssets>
</PackageReference>
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.2.1" />
<PackageReference Include="DotNetCampus.AvaloniaInkCanvas" Version="1.0.1" />
<PackageReference Include="FluentAvaloniaUI" Version="2.5.0" />
<PackageReference Include="FluentIcons.Avalonia" Version="2.0.319" />
<PackageReference Include="FluentIcons.Avalonia.Fluent" Version="2.0.319" />

View File

@@ -10,7 +10,9 @@
"settings.nav.grid": "Grid",
"settings.nav.color": "Color",
"settings.nav.status_bar": "Status Bar",
"settings.nav.weather": "Weather",
"settings.nav.region": "Region",
"settings.nav.about": "About",
"settings.wallpaper.title": "Wallpaper",
"settings.wallpaper.description": "Pick an image or video to apply as the app window wallpaper immediately.",
"settings.wallpaper.current_label": "Current Wallpaper",
@@ -74,13 +76,88 @@
"settings.status_bar.spacing_mode_custom": "Custom",
"settings.status_bar.spacing_custom_label": "Custom spacing (%)",
"settings.status_bar.spacing_custom_px_format": "≈ {0:F1}px",
"settings.weather.title": "Weather",
"settings.weather.location_source_header": "Location Source",
"settings.weather.location_source_desc": "Choose how weather widgets resolve location.",
"settings.weather.mode_city_search": "City Search",
"settings.weather.mode_coordinates": "Coordinates",
"settings.weather.auto_refresh": "Auto refresh location on startup",
"settings.weather.city_search_header": "City Search",
"settings.weather.city_search_desc": "Search cities and apply one weather location.",
"settings.weather.search_placeholder": "e.g. Beijing",
"settings.weather.search_button": "Search",
"settings.weather.apply_city_button": "Apply City",
"settings.weather.search_hint": "Search by city name and apply one location.",
"settings.weather.search_required": "Please enter a city keyword first.",
"settings.weather.search_no_results": "No locations were found.",
"settings.weather.search_failed_format": "Search failed: {0}",
"settings.weather.search_result_count_format": "Found {0} locations.",
"settings.weather.search_select_required": "Please select one location from search results.",
"settings.weather.search_applied_format": "Location applied: {0}",
"settings.weather.coordinates_header": "Coordinates",
"settings.weather.coordinates_desc": "Set latitude/longitude and optional key/name.",
"settings.weather.latitude_label": "Latitude",
"settings.weather.longitude_label": "Longitude",
"settings.weather.location_key_placeholder": "Location key (optional)",
"settings.weather.location_name_placeholder": "Display name (optional)",
"settings.weather.apply_coordinates_button": "Apply Coordinates",
"settings.weather.coordinates_saved_format": "Coordinates saved: {0:F4}, {1:F4}",
"settings.weather.coordinates_default_name_format": "Coordinate {0:F4}, {1:F4}",
"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_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.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}",
"settings.weather.location_header": "Weather Location",
"settings.weather.location_desc": "Set the location used by weather widgets.",
"settings.weather.location_placeholder": "e.g. Beijing",
"settings.weather.location_apply": "Save",
"settings.weather.location_empty": "Weather location is not set.",
"settings.weather.location_required": "Weather location cannot be empty.",
"settings.weather.location_current_format": "Current weather location: {0}",
"settings.weather.location_saved_format": "Weather location saved: {0}",
"weather.widget.location_not_configured": "Weather location is not configured",
"weather.widget.configure_hint": "Open Settings > Weather to configure",
"weather.widget.loading": "Loading...",
"weather.widget.fetch_failed": "Weather fetch failed",
"weather.widget.retrying": "Retrying automatically",
"weather.widget.location_unknown": "Unknown location",
"weather.widget.condition_clear": "Clear",
"weather.widget.condition_cloudy": "Cloudy",
"weather.widget.condition_rain": "Rain",
"weather.widget.condition_storm": "Thunderstorm",
"weather.widget.condition_snow": "Snow",
"weather.widget.condition_fog": "Fog",
"weather.widget.condition_unknown": "Unknown",
"weather.widget.range_unknown": "-- / --",
"weather.widget.range_format": "{0} / {1}",
"weather.widget.aqi_unknown": "AQI --",
"weather.widget.aqi_format": "AQI {0}",
"weather.widget.updated_format": "Updated {0:HH:mm}",
"weather.hourly.now": "Now",
"weather.multiday.today": "Today",
"weather.multiday.tomorrow": "Tomorrow",
"weather.multiday.aqi_format": "Air Quality {0}",
"weather.multiday.aqi_unknown": "Air --",
"settings.region.title": "Region",
"settings.region.description": "Choose language and apply immediately to settings and key UI.",
"settings.region.language_header": "Language",
"settings.region.language_label": "Language",
"settings.region.language_zh": "Chinese",
"settings.region.language_en": "English",
"settings.region.timezone_header": "Time Zone",
"settings.region.timezone_desc": "Select a time zone. Clock and calendar widgets will follow this zone.",
"settings.region.applied_format": "Language switched to: {0}",
"settings.about.title": "About",
"settings.about.version_format": "Version: {0}",
"settings.about.codename_format": "Code Name: {0}",
"settings.about.font_format": "Font: {0}",
"settings.footer": "LanMontainDesktop Settings",
"filepicker.title": "Select wallpaper",
"filepicker.image_files": "Image files",
@@ -106,11 +183,19 @@
"component.edit": "Edit",
"component_category.clock": "Clock",
"component_category.date": "Calendar",
"component_category.weather": "Weather",
"component_category.board": "Board",
"component.date": "Calendar",
"component.month_calendar": "Month Calendar",
"component.lunar_calendar": "Lunar Calendar",
"component.desktop_clock": "Clock",
"component.weather_clock": "Weather Clock",
"component.desktop_timer": "Timer",
"component.desktop_weather": "Weather",
"component.hourly_weather": "Hourly Weather",
"component.multiday_weather": "Multi-day Weather",
"component.whiteboard": "Blackboard (Portrait)",
"component.blackboard_landscape": "Blackboard (Landscape)",
"component.holiday_calendar": "Holiday Calendar",
"desktop.add_page": "Add page",
"desktop.delete_page": "Delete page",

View File

@@ -10,7 +10,9 @@
"settings.nav.grid": "网格",
"settings.nav.color": "颜色",
"settings.nav.status_bar": "状态栏",
"settings.nav.weather": "天气",
"settings.nav.region": "地区",
"settings.nav.about": "关于",
"settings.wallpaper.title": "壁纸",
"settings.wallpaper.description": "选择图片或视频后可立即设为应用窗口壁纸。",
"settings.wallpaper.current_label": "当前壁纸",
@@ -74,13 +76,88 @@
"settings.status_bar.spacing_mode_custom": "自定义",
"settings.status_bar.spacing_custom_label": "自定义间距(%",
"settings.status_bar.spacing_custom_px_format": "≈ {0:F1}px",
"settings.weather.title": "天气",
"settings.weather.location_source_header": "位置来源",
"settings.weather.location_source_desc": "选择天气组件如何解析当前位置。",
"settings.weather.mode_city_search": "城市搜索",
"settings.weather.mode_coordinates": "坐标输入",
"settings.weather.auto_refresh": "启动时自动刷新位置",
"settings.weather.city_search_header": "城市搜索",
"settings.weather.city_search_desc": "搜索城市并应用一个天气位置。",
"settings.weather.search_placeholder": "例如:北京",
"settings.weather.search_button": "搜索",
"settings.weather.apply_city_button": "应用城市",
"settings.weather.search_hint": "输入城市名称进行搜索,然后应用一个结果。",
"settings.weather.search_required": "请先输入城市关键字。",
"settings.weather.search_no_results": "未找到匹配的位置。",
"settings.weather.search_failed_format": "搜索失败:{0}",
"settings.weather.search_result_count_format": "共找到 {0} 个位置。",
"settings.weather.search_select_required": "请先从搜索结果中选择一个位置。",
"settings.weather.search_applied_format": "已应用位置:{0}",
"settings.weather.coordinates_header": "坐标输入",
"settings.weather.coordinates_desc": "设置经纬度,并可选填写位置 key 和显示名。",
"settings.weather.latitude_label": "纬度",
"settings.weather.longitude_label": "经度",
"settings.weather.location_key_placeholder": "位置 key可选",
"settings.weather.location_name_placeholder": "显示名称(可选)",
"settings.weather.apply_coordinates_button": "应用坐标",
"settings.weather.coordinates_saved_format": "坐标已保存:{0:F4}, {1:F4}",
"settings.weather.coordinates_default_name_format": "坐标 {0:F4}, {1:F4}",
"settings.weather.preview_header": "连接测试",
"settings.weather.preview_desc": "发送一次测试请求,验证当前配置是否可用。",
"settings.weather.preview_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.status_city_empty": "尚未配置城市位置。",
"settings.weather.status_city_format": "模式:{0}{1}Key{2}",
"settings.weather.status_coordinates_format": "模式:{0}|纬度 {1:F4},经度 {2:F4}Key{3}",
"settings.weather.location_header": "天气位置",
"settings.weather.location_desc": "设置天气组件使用的位置。",
"settings.weather.location_placeholder": "例如:北京",
"settings.weather.location_apply": "保存",
"settings.weather.location_empty": "尚未设置天气位置。",
"settings.weather.location_required": "天气位置不能为空。",
"settings.weather.location_current_format": "当前天气位置:{0}",
"settings.weather.location_saved_format": "天气位置已保存:{0}",
"weather.widget.location_not_configured": "尚未配置天气位置",
"weather.widget.configure_hint": "请前往 设置 > 天气 完成配置",
"weather.widget.loading": "加载中...",
"weather.widget.fetch_failed": "天气获取失败",
"weather.widget.retrying": "稍后会自动重试",
"weather.widget.location_unknown": "未知位置",
"weather.widget.condition_clear": "晴",
"weather.widget.condition_cloudy": "多云",
"weather.widget.condition_rain": "雨",
"weather.widget.condition_storm": "雷暴",
"weather.widget.condition_snow": "雪",
"weather.widget.condition_fog": "雾",
"weather.widget.condition_unknown": "未知天气",
"weather.widget.range_unknown": "-- / --",
"weather.widget.range_format": "{0} / {1}",
"weather.widget.aqi_unknown": "AQI --",
"weather.widget.aqi_format": "AQI {0}",
"weather.widget.updated_format": "更新于 {0:HH:mm}",
"weather.hourly.now": "现在",
"weather.multiday.today": "今天",
"weather.multiday.tomorrow": "明天",
"weather.multiday.aqi_format": "空气优 {0}",
"weather.multiday.aqi_unknown": "空气 --",
"settings.region.title": "地区",
"settings.region.description": "选择语言并立即应用到设置与主要界面。",
"settings.region.language_header": "语言",
"settings.region.language_label": "语言",
"settings.region.language_zh": "中文",
"settings.region.language_en": "英文",
"settings.region.timezone_header": "时区",
"settings.region.timezone_desc": "选择时区。时钟与日历组件会使用该时区。",
"settings.region.applied_format": "语言已切换为:{0}",
"settings.about.title": "关于",
"settings.about.version_format": "版本号: {0}",
"settings.about.codename_format": "版本代号: {0}",
"settings.about.font_format": "字体: {0}",
"settings.footer": "LanMontainDesktop 设置",
"filepicker.title": "选择壁纸",
"filepicker.image_files": "图片文件",
@@ -106,11 +183,19 @@
"component.edit": "编辑",
"component_category.clock": "时钟",
"component_category.date": "日历",
"component_category.weather": "天气",
"component_category.board": "白板",
"component.date": "日历",
"component.month_calendar": "月历",
"component.lunar_calendar": "农历",
"component.desktop_clock": "时钟",
"component.weather_clock": "天气时钟",
"component.desktop_timer": "计时器",
"component.desktop_weather": "天气",
"component.hourly_weather": "小时天气",
"component.multiday_weather": "多日天气",
"component.whiteboard": "竖向小黑板",
"component.blackboard_landscape": "横向小黑板",
"component.holiday_calendar": "节假日日历",
"desktop.add_page": "新增页面",
"desktop.delete_page": "删除页面",

View File

@@ -24,6 +24,20 @@ public sealed class AppSettingsSnapshot
public string? TimeZoneId { get; set; }
public string WeatherLocationMode { get; set; } = "CitySearch";
public string WeatherLocationKey { get; set; } = string.Empty;
public string WeatherLocationName { get; set; } = string.Empty;
public double WeatherLatitude { get; set; } = 39.9042;
public double WeatherLongitude { get; set; } = 116.4074;
public bool WeatherAutoRefreshLocation { get; set; }
public string WeatherLocationQuery { get; set; } = string.Empty;
public List<string> TopStatusComponentIds { get; set; } = [];
public List<string> PinnedTaskbarActions { get; set; } =

View File

@@ -7,5 +7,6 @@ public enum TaskbarContext
SettingsGrid,
SettingsColor,
SettingsStatusBar,
SettingsWeather,
SettingsRegion
}

View File

@@ -32,6 +32,12 @@ public sealed record WeatherDailyForecast(
string? SunsetTime,
int? PrecipitationProbabilityPercent);
public sealed record WeatherHourlyForecast(
DateTimeOffset Time,
double? TemperatureC,
int? WeatherCode,
string? WeatherText);
public sealed record WeatherSnapshot(
string Provider,
string LocationKey,
@@ -41,5 +47,5 @@ public sealed record WeatherSnapshot(
DateTimeOffset FetchedAt,
DateTimeOffset? ObservationTime,
WeatherCurrentCondition Current,
IReadOnlyList<WeatherDailyForecast> DailyForecasts);
IReadOnlyList<WeatherDailyForecast> DailyForecasts,
IReadOnlyList<WeatherHourlyForecast> HourlyForecasts);

View File

@@ -31,9 +31,13 @@ public sealed record WeatherQueryResult<T>(
}
}
public interface IWeatherDataService
public interface IWeatherInfoService
{
Task<WeatherQueryResult<WeatherSnapshot>> GetWeatherAsync(WeatherQuery query, CancellationToken cancellationToken = default);
}
public interface IWeatherDataService : IWeatherInfoService
{
Task<WeatherQueryResult<IReadOnlyList<WeatherLocation>>> SearchLocationsAsync(
string keyword,

View File

@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Net.Http;
using System.Text.Json;
using System.Threading;
@@ -310,6 +311,9 @@ public sealed class XiaomiWeatherService : IWeatherDataService, IDisposable
var currentNode = TryGetNode(payload, "current") ?? payload;
var cityNode = TryGetNode(payload, "city");
var dailyNode = TryGetNode(payload, "forecastDaily") ?? TryGetNode(payload, "daily");
var hourlyNode = TryGetNode(payload, "forecastHourly") ??
TryGetNode(payload, "hourly") ??
TryGetNode(payload, "hourlyForecast");
var weatherCode = ReadInt(currentNode, "weather", "value") ??
ReadInt(currentNode, "weatherCode") ??
@@ -334,6 +338,7 @@ public sealed class XiaomiWeatherService : IWeatherDataService, IDisposable
WeatherText: weatherText);
var forecasts = ParseDailyForecasts(dailyNode, days, locale);
var hourlyForecasts = ParseHourlyForecasts(hourlyNode, locale);
var locationName = ReadString(cityNode, "name") ??
ReadString(payload, "cityName") ??
@@ -351,7 +356,8 @@ public sealed class XiaomiWeatherService : IWeatherDataService, IDisposable
FetchedAt: DateTimeOffset.UtcNow,
ObservationTime: observationTime,
Current: current,
DailyForecasts: forecasts);
DailyForecasts: forecasts,
HourlyForecasts: hourlyForecasts);
}
private IReadOnlyList<WeatherDailyForecast> ParseDailyForecasts(JsonElement? dailyNode, int days, string locale)
@@ -411,6 +417,151 @@ public sealed class XiaomiWeatherService : IWeatherDataService, IDisposable
return forecasts;
}
private IReadOnlyList<WeatherHourlyForecast> ParseHourlyForecasts(JsonElement? hourlyNode, string locale)
{
var forecasts = new List<WeatherHourlyForecast>();
if (!hourlyNode.HasValue)
{
return forecasts;
}
var root = hourlyNode.Value;
if (root.ValueKind == JsonValueKind.Array)
{
ParseHourlyArray(root, locale, forecasts);
return forecasts
.OrderBy(item => item.Time)
.Take(48)
.ToList();
}
if (root.ValueKind != JsonValueKind.Object)
{
return forecasts;
}
var directArray =
ReadArray(root, "value") ??
ReadArray(root, "list") ??
ReadArray(root, "hourly");
if (directArray.HasValue && directArray.Value.ValueKind == JsonValueKind.Array)
{
ParseHourlyArray(directArray.Value, locale, forecasts);
}
var timeArray =
ReadArray(root, "time", "value") ??
ReadArray(root, "datetime", "value") ??
ReadArray(root, "date", "value") ??
ReadArray(root, "pubTime", "value");
var tempArray =
ReadArray(root, "temperature", "value") ??
ReadArray(root, "temp", "value") ??
ReadArray(root, "temperature");
var weatherArray =
ReadArray(root, "weather", "value") ??
ReadArray(root, "weatherCode", "value") ??
ReadArray(root, "weather");
var count = Math.Max(
timeArray?.GetArrayLength() ?? 0,
Math.Max(
tempArray?.GetArrayLength() ?? 0,
weatherArray?.GetArrayLength() ?? 0));
count = Math.Clamp(count, 0, 72);
for (var i = 0; i < count; i++)
{
var timeItem = GetArrayItem(timeArray, i);
var tempItem = GetArrayItem(tempArray, i);
var weatherItem = GetArrayItem(weatherArray, i);
var time = ParseTime(
ReadString(timeItem, "value") ??
ReadString(timeItem, "datetime") ??
ReadString(timeItem, "time") ??
ReadString(timeItem, "date") ??
ReadString(timeItem));
if (!time.HasValue)
{
continue;
}
var code = ReadInt(weatherItem, "value") ??
ReadInt(weatherItem, "code") ??
ReadInt(weatherItem, "weatherCode") ??
ReadInt(weatherItem, "from") ??
ReadInt(weatherItem);
var weatherText = ReadString(weatherItem, "text") ??
ReadString(weatherItem, "desc") ??
ResolveWeatherDescription(code, locale);
forecasts.Add(new WeatherHourlyForecast(
Time: time.Value,
TemperatureC: ReadDouble(tempItem, "value") ??
ReadDouble(tempItem, "temperature") ??
ReadDouble(tempItem, "temp") ??
ReadDouble(tempItem),
WeatherCode: code,
WeatherText: weatherText));
}
return forecasts
.GroupBy(item => item.Time.ToUnixTimeSeconds())
.Select(group => group.First())
.OrderBy(item => item.Time)
.Take(48)
.ToList();
}
private void ParseHourlyArray(JsonElement array, string locale, ICollection<WeatherHourlyForecast> output)
{
foreach (var item in array.EnumerateArray())
{
if (item.ValueKind is not (JsonValueKind.Object or JsonValueKind.String or JsonValueKind.Number))
{
continue;
}
var time = ParseTime(
ReadString(item, "datetime") ??
ReadString(item, "time") ??
ReadString(item, "date") ??
ReadString(item, "forecastTime") ??
ReadString(item, "pubTime") ??
ReadString(item, "ts") ??
ReadString(item));
if (!time.HasValue)
{
continue;
}
var code = ReadInt(item, "weatherCode") ??
ReadInt(item, "code") ??
ReadInt(item, "weather", "value") ??
ReadInt(item, "weather") ??
ReadInt(item, "from");
var weatherText = ReadString(item, "weatherText") ??
ReadString(item, "weather", "desc") ??
ReadString(item, "weather", "text") ??
ReadString(item, "desc") ??
ResolveWeatherDescription(code, locale);
var temperature = ReadDouble(item, "temperature", "value") ??
ReadDouble(item, "temperature") ??
ReadDouble(item, "temp", "value") ??
ReadDouble(item, "temp") ??
ReadDouble(item, "value");
output.Add(new WeatherHourlyForecast(
Time: time.Value,
TemperatureC: temperature,
WeatherCode: code,
WeatherText: weatherText));
}
}
private static DateOnly? ResolveDateForIndex(JsonElement? dateArray, int index)
{
var item = GetArrayItem(dateArray, index);
@@ -728,4 +879,3 @@ public sealed class XiaomiWeatherService : IWeatherDataService, IDisposable
: $"{text[..maxLength]}...";
}
}

View File

@@ -2,6 +2,15 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:ui="using:FluentAvalonia.UI.Controls">
<Styles.Resources>
<!-- Unified corner radius tokens used across settings and widget panels -->
<CornerRadius x:Key="DesignCornerRadiusXl">32</CornerRadius>
<CornerRadius x:Key="DesignCornerRadiusLg">28</CornerRadius>
<CornerRadius x:Key="DesignCornerRadiusMd">20</CornerRadius>
<CornerRadius x:Key="DesignCornerRadiusSm">14</CornerRadius>
<CornerRadius x:Key="DesignCornerRadiusXs">12</CornerRadius>
</Styles.Resources>
<Style Selector="TextBlock">
<Setter Property="Foreground" Value="{DynamicResource AdaptiveTextPrimaryBrush}" />
</Style>
@@ -55,6 +64,53 @@
<Setter Property="Foreground" Value="{DynamicResource AdaptiveTextPrimaryBrush}" />
</Style>
<Style Selector="Grid.settings-scope Border.settings-expander-shell">
<Setter Property="CornerRadius" Value="{DynamicResource DesignCornerRadiusSm}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="BorderBrush" Value="{DynamicResource AdaptiveGlassPanelBorderBrush}" />
<Setter Property="Background" Value="{DynamicResource AdaptiveButtonBackgroundBrush}" />
<Setter Property="Padding" Value="10,8" />
<Setter Property="Margin" Value="0,0,0,10" />
</Style>
<Style Selector="Grid.settings-scope Button">
<Setter Property="CornerRadius" Value="{DynamicResource DesignCornerRadiusSm}" />
<Setter Property="MinHeight" Value="34" />
</Style>
<Style Selector="Grid.settings-scope ComboBox">
<Setter Property="CornerRadius" Value="{DynamicResource DesignCornerRadiusSm}" />
<Setter Property="MinHeight" Value="34" />
</Style>
<Style Selector="Grid.settings-scope ComboBoxItem">
<Setter Property="CornerRadius" Value="{DynamicResource DesignCornerRadiusXs}" />
<Setter Property="Padding" Value="10,6" />
<Setter Property="Margin" Value="4,2" />
</Style>
<Style Selector="Grid.settings-scope ui|NumberBox">
<Setter Property="CornerRadius" Value="{DynamicResource DesignCornerRadiusSm}" />
<Setter Property="MinHeight" Value="34" />
</Style>
<Style Selector="Grid.settings-scope RadioButton">
<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="10,6" />
<Setter Property="MinHeight" Value="34" />
</Style>
<Style Selector="Grid.settings-scope RadioButton:pointerover">
<Setter Property="Background" Value="{DynamicResource AdaptiveButtonHoverBackgroundBrush}" />
</Style>
<Style Selector="Grid.settings-scope RadioButton:checked">
<Setter Property="Background" Value="{DynamicResource AdaptiveNavItemSelectedBackgroundBrush}" />
</Style>
<Style Selector="Button.swatch-button">
<Setter Property="BorderThickness" Value="0" />
<Setter Property="CornerRadius" Value="16" />

View File

@@ -10,7 +10,7 @@ using LanMontainDesktop.Services;
namespace LanMontainDesktop.Views.Components;
public partial class AnalogClockWidget : UserControl
public partial class AnalogClockWidget : UserControl, IDesktopComponentWidget, ITimeZoneAwareComponentWidget
{
private readonly DispatcherTimer _timer = new()
{

View File

@@ -14,7 +14,7 @@ public enum ClockDisplayFormat
HourMinute // HH:mm
}
public partial class ClockWidget : UserControl
public partial class ClockWidget : UserControl, IDesktopComponentWidget, ITimeZoneAwareComponentWidget
{
private readonly DispatcherTimer _timer = new()
{

View File

@@ -13,7 +13,8 @@
ClipToBounds="True"
Padding="12">
<Viewbox Stretch="Uniform">
<Grid Width="460"
<Grid x:Name="LayoutRoot"
Width="460"
Height="220"
ColumnDefinitions="1.2*,1*"
ColumnSpacing="12">
@@ -24,85 +25,135 @@
RowSpacing="8">
<TextBlock x:Name="GregorianHeadlineTextBlock"
Grid.Row="0"
FontSize="22"
FontSize="30"
FontWeight="Bold"
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
<UniformGrid Grid.Row="1"
<UniformGrid x:Name="WeekdayHeaderGrid"
Grid.Row="1"
Columns="7">
<TextBlock x:Name="WeekdayText0" Text="日" HorizontalAlignment="Center" Foreground="{DynamicResource AdaptiveTextSecondaryBrush}" FontSize="13" FontWeight="SemiBold" />
<TextBlock x:Name="WeekdayText1" Text="一" HorizontalAlignment="Center" Foreground="{DynamicResource AdaptiveTextSecondaryBrush}" FontSize="13" FontWeight="SemiBold" />
<TextBlock x:Name="WeekdayText2" Text="二" HorizontalAlignment="Center" Foreground="{DynamicResource AdaptiveTextSecondaryBrush}" FontSize="13" FontWeight="SemiBold" />
<TextBlock x:Name="WeekdayText3" Text="三" HorizontalAlignment="Center" Foreground="{DynamicResource AdaptiveTextSecondaryBrush}" FontSize="13" FontWeight="SemiBold" />
<TextBlock x:Name="WeekdayText4" Text="四" HorizontalAlignment="Center" Foreground="{DynamicResource AdaptiveTextSecondaryBrush}" FontSize="13" FontWeight="SemiBold" />
<TextBlock x:Name="WeekdayText5" Text="五" HorizontalAlignment="Center" Foreground="{DynamicResource AdaptiveTextSecondaryBrush}" FontSize="13" FontWeight="SemiBold" />
<TextBlock x:Name="WeekdayText6" Text="六" HorizontalAlignment="Center" Foreground="{DynamicResource AdaptiveTextSecondaryBrush}" FontSize="13" FontWeight="SemiBold" />
<TextBlock x:Name="WeekdayText0"
Text="S"
HorizontalAlignment="Center"
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
FontSize="17"
FontWeight="SemiBold" />
<TextBlock x:Name="WeekdayText1"
Text="M"
HorizontalAlignment="Center"
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
FontSize="17"
FontWeight="SemiBold" />
<TextBlock x:Name="WeekdayText2"
Text="T"
HorizontalAlignment="Center"
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
FontSize="17"
FontWeight="SemiBold" />
<TextBlock x:Name="WeekdayText3"
Text="W"
HorizontalAlignment="Center"
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
FontSize="17"
FontWeight="SemiBold" />
<TextBlock x:Name="WeekdayText4"
Text="T"
HorizontalAlignment="Center"
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
FontSize="17"
FontWeight="SemiBold" />
<TextBlock x:Name="WeekdayText5"
Text="F"
HorizontalAlignment="Center"
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
FontSize="17"
FontWeight="SemiBold" />
<TextBlock x:Name="WeekdayText6"
Text="S"
HorizontalAlignment="Center"
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
FontSize="17"
FontWeight="SemiBold" />
</UniformGrid>
<Grid x:Name="CalendarGrid"
Grid.Row="2"
RowDefinitions="*,*,*,*,*"
RowDefinitions="*,*,*,*,*,*"
ColumnDefinitions="*,*,*,*,*,*,*" />
</Grid>
<Border x:Name="LunarCardBorder"
Grid.Column="1"
Background="{DynamicResource AdaptiveLayer2Brush}"
BorderThickness="1"
CornerRadius="24"
BoxShadow="0 8 18 #1A000000"
Padding="14">
<Grid x:Name="RightPanelGrid"
RowDefinitions="Auto,Auto,Auto,Auto,Auto"
RowSpacing="10">
RowDefinitions="Auto,Auto,Auto,*,*"
RowSpacing="8">
<TextBlock x:Name="LunarDateTextBlock"
Grid.Row="0"
FontSize="28"
FontWeight="Bold"
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}"
TextTrimming="CharacterEllipsis"
MaxLines="1" />
<TextBlock x:Name="LunarMetaTextBlock"
Grid.Row="1"
FontSize="14"
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}"
Opacity="0.88"
TextWrapping="Wrap" />
Opacity="0.86"
TextTrimming="CharacterEllipsis"
MaxLines="1" />
<Border Grid.Row="2"
<Border x:Name="DividerBorder"
Grid.Row="2"
Height="1"
Margin="0,2,0,2"
Margin="0,1,0,1"
Background="{DynamicResource AdaptiveStrokeBrush}" />
<Grid Grid.Row="3"
ColumnDefinitions="Auto,*"
ColumnSpacing="8">
ColumnSpacing="8"
VerticalAlignment="Top">
<TextBlock x:Name="YiLabelTextBlock"
Grid.Column="0"
Text=""
Text="Yi"
FontSize="18"
FontWeight="Bold"
Foreground="#4E7D3A" />
Foreground="#4E7D3A"
VerticalAlignment="Top" />
<TextBlock x:Name="YiItemsTextBlock"
Grid.Column="1"
FontSize="16"
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}"
TextWrapping="Wrap"
TextTrimming="CharacterEllipsis" />
TextTrimming="CharacterEllipsis"
MaxLines="2"
VerticalAlignment="Top" />
</Grid>
<Grid Grid.Row="4"
ColumnDefinitions="Auto,*"
ColumnSpacing="8">
ColumnSpacing="8"
VerticalAlignment="Top">
<TextBlock x:Name="JiLabelTextBlock"
Grid.Column="0"
Text=""
Text="Ji"
FontSize="18"
FontWeight="Bold"
Foreground="#A1473E" />
Foreground="#A1473E"
VerticalAlignment="Top" />
<TextBlock x:Name="JiItemsTextBlock"
Grid.Column="1"
FontSize="16"
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}"
TextWrapping="Wrap"
TextTrimming="CharacterEllipsis" />
TextTrimming="CharacterEllipsis"
MaxLines="2"
VerticalAlignment="Top" />
</Grid>
</Grid>
</Border>

View File

@@ -4,26 +4,97 @@ using System.Globalization;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Media;
using Avalonia.Styling;
using Avalonia.Threading;
using LanMontainDesktop.Services;
namespace LanMontainDesktop.Views.Components;
public partial class DateWidget : UserControl
public partial class DateWidget : UserControl, IDesktopComponentWidget, ITimeZoneAwareComponentWidget
{
private readonly DispatcherTimer _timer = new()
{
Interval = TimeSpan.FromMinutes(1)
};
private static readonly LunarCalendarService LunarCalendarService = new();
private static readonly string[] ZhWeekdayHeaders = ["", "", "", "", "", "", ""];
private static readonly string[] ZhWeekdayHeaders = ["\u65e5", "\u4e00", "\u4e8c", "\u4e09", "\u56db", "\u4e94", "\u516d"];
private static readonly string[] EnWeekdayHeaders = ["S", "M", "T", "W", "T", "F", "S"];
private static readonly string[] ZhYiCandidates =
[
"\u796d\u7940",
"\u7948\u798f",
"\u4f1a\u53cb",
"\u51fa\u884c",
"\u6c42\u8d22",
"\u5f00\u5e02",
"\u4ea4\u6613",
"\u5ac1\u5a36",
"\u6c42\u5b66",
"\u4fee\u9020",
"\u5b89\u5e8a",
"\u7eb3\u91c7"
];
private static readonly string[] ZhJiCandidates =
[
"\u52a8\u571f",
"\u8bc9\u8bbc",
"\u8fdc\u822a",
"\u4e89\u6267",
"\u7834\u571f",
"\u5b89\u846c",
"\u4f10\u6728",
"\u6398\u4e95",
"\u8fc1\u5f99",
"\u5f00\u4ed3",
"\u7f6e\u4ea7",
"\u5f00\u6e20"
];
private static readonly string[] EnYiCandidates =
[
"Worship",
"Blessing",
"Travel",
"Meetings",
"Trade",
"Business",
"Study",
"Build",
"Gathering",
"Planning"
];
private static readonly string[] EnJiCandidates =
[
"Dispute",
"Lawsuit",
"Major move",
"Groundwork",
"Burial",
"Long voyage",
"Contract rush",
"Risky purchase",
"Heavy repair",
"Conflict"
];
private TimeZoneService? _timeZoneService;
private double _currentCellSize = 64;
private double _calendarDayFontSize = 14;
private double _calendarTodayDotSize = 28;
private double _weekdayFontSize = 17;
private FontWeight _weekdayFontWeight = FontWeight.SemiBold;
private double _calendarDayFontSize = 18;
private FontWeight _calendarDayFontWeight = FontWeight.SemiBold;
private double _calendarTodayDotSize = 32;
private int _lunarItemCount = 3;
private int _calendarVisibleRows = 6;
private bool? _isNightModeApplied;
private double _weekdayHeaderOpacity = 0.60;
private double _weekdayNumberOpacity = 0.90;
private double _weekendNumberOpacity = 0.58;
public DateWidget()
{
@@ -38,7 +109,7 @@ public partial class DateWidget : UserControl
public void SetTimeZoneService(TimeZoneService timeZoneService)
{
if (_timeZoneService != null)
if (_timeZoneService is not null)
{
_timeZoneService.TimeZoneChanged -= OnTimeZoneChanged;
}
@@ -82,56 +153,67 @@ public partial class DateWidget : UserControl
var lunar = LunarCalendarService.GetLunarInfo(now);
GregorianHeadlineTextBlock.Text = isZh
? $"{now.Month}{now.Day}日 {ToChineseWeekday(now.DayOfWeek)}"
: now.ToString("MMM d ddd", culture);
? $"{now.Month}\u6708{now.Day}\u65e5"
: now.ToString("MMM d", culture);
ApplyAdaptiveTypography();
if (isZh)
{
LunarDateTextBlock.Text = $"农历 {lunar.LunarDateZh}";
LunarMetaTextBlock.Text = $"{lunar.GanzhiYearZh}年({lunar.ZodiacZh}年)";
YiLabelTextBlock.Text = "";
JiLabelTextBlock.Text = "";
YiItemsTextBlock.Text = "祭祀 祈福 出行 会友";
JiItemsTextBlock.Text = "动土 诉讼 远航 争执";
LunarDateTextBlock.Text = lunar.LunarDateZh;
LunarMetaTextBlock.Text = $"{lunar.GanzhiYearZh}\u5e74 {lunar.ZodiacZh}";
YiLabelTextBlock.Text = "\u5b9c";
JiLabelTextBlock.Text = "\u5fcc";
}
else
{
LunarDateTextBlock.Text = $"Lunar {lunar.LunarDateEn}";
LunarMetaTextBlock.Text = $"Ganzhi year: {lunar.GanzhiYearEn} ({lunar.ZodiacEn})";
LunarMetaTextBlock.Text = $"{lunar.GanzhiYearEn} {lunar.ZodiacEn}";
YiLabelTextBlock.Text = "Do";
JiLabelTextBlock.Text = "Avoid";
YiItemsTextBlock.Text = "Worship Blessing Travel Meet";
JiItemsTextBlock.Text = "Groundwork Lawsuit Voyage Dispute";
}
UpdateWeekdayHeaders(isZh);
GenerateCalendar(now);
}
var itemCount = isZh ? _lunarItemCount : Math.Max(1, _lunarItemCount - 1);
YiItemsTextBlock.Text = BuildDailySelection(
now.Date,
isZh ? ZhYiCandidates : EnYiCandidates,
count: itemCount,
salt: 17,
useChineseSpacing: isZh);
JiItemsTextBlock.Text = BuildDailySelection(
now.Date,
isZh ? ZhJiCandidates : EnJiCandidates,
count: itemCount,
salt: 29,
useChineseSpacing: isZh);
private static string ToChineseWeekday(DayOfWeek dayOfWeek)
{
return dayOfWeek switch
{
DayOfWeek.Sunday => "周日",
DayOfWeek.Monday => "周一",
DayOfWeek.Tuesday => "周二",
DayOfWeek.Wednesday => "周三",
DayOfWeek.Thursday => "周四",
DayOfWeek.Friday => "周五",
_ => "周六"
};
UpdateWeekdayHeaders(isZh);
ApplyModeVisualIfNeeded();
GenerateCalendar(now);
}
private void UpdateWeekdayHeaders(bool isZh)
{
var headers = isZh ? ZhWeekdayHeaders : EnWeekdayHeaders;
WeekdayText0.Text = headers[0];
WeekdayText1.Text = headers[1];
WeekdayText2.Text = headers[2];
WeekdayText3.Text = headers[3];
WeekdayText4.Text = headers[4];
WeekdayText5.Text = headers[5];
WeekdayText6.Text = headers[6];
var blocks = GetWeekdayHeaderBlocks();
for (var i = 0; i < blocks.Count; i++)
{
blocks[i].Text = headers[i];
}
}
private IReadOnlyList<TextBlock> GetWeekdayHeaderBlocks()
{
return
[
WeekdayText0,
WeekdayText1,
WeekdayText2,
WeekdayText3,
WeekdayText4,
WeekdayText5,
WeekdayText6
];
}
private void GenerateCalendar(DateTime currentDate)
@@ -139,7 +221,8 @@ public partial class DateWidget : UserControl
var removeList = new List<Control>();
foreach (var child in CalendarGrid.Children)
{
if (child is Control control && control.Tag is string tag &&
if (child is Control control &&
control.Tag is string tag &&
(tag == "day" || tag == "today-dot"))
{
removeList.Add(control);
@@ -158,12 +241,19 @@ public partial class DateWidget : UserControl
var firstDayOfMonth = new DateTime(year, month, 1);
var daysInMonth = DateTime.DaysInMonth(year, month);
var startDayOfWeek = (int)firstDayOfMonth.DayOfWeek;
_calendarVisibleRows = GetCalendarRowCount(startDayOfWeek, daysInMonth);
EnsureCalendarRows(_calendarVisibleRows);
// 4x2 widget has less vertical space than 2x2. Compress only on 6-row months.
var rowDensity = _calendarVisibleRows >= 6 ? 0.84 : 1.0;
var dayFontSize = Math.Clamp(_calendarDayFontSize * rowDensity, 8, 24);
var todayDotSize = Math.Clamp(_calendarTodayDotSize * rowDensity, 13.5, 32);
for (var day = 1; day <= daysInMonth; day++)
{
var row = (day + startDayOfWeek - 1) / 7;
var col = (day + startDayOfWeek - 1) % 7;
if (row > 4)
if (row >= _calendarVisibleRows)
{
continue;
}
@@ -173,8 +263,9 @@ public partial class DateWidget : UserControl
Text = day.ToString(CultureInfo.CurrentCulture),
HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Center,
VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center,
FontSize = _calendarDayFontSize,
FontWeight = FontWeight.SemiBold,
FontSize = dayFontSize,
FontWeight = _calendarDayFontWeight,
LineHeight = dayFontSize * 1.04,
Tag = "day"
};
@@ -190,9 +281,9 @@ public partial class DateWidget : UserControl
dayText.Foreground = onAccentBrush;
var dot = new Border
{
Width = _calendarTodayDotSize,
Height = _calendarTodayDotSize,
CornerRadius = new CornerRadius(_calendarTodayDotSize * 0.5),
Width = todayDotSize,
Height = todayDotSize,
CornerRadius = new CornerRadius(todayDotSize * 0.5),
Background = accentBrush,
HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Center,
VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center,
@@ -208,8 +299,8 @@ public partial class DateWidget : UserControl
{
var isWeekend = col is 0 or 6;
dayText.Foreground = isWeekend
? GetThemeBrush("AdaptiveTextSecondaryBrush", 0.82)
: GetThemeBrush("AdaptiveTextPrimaryBrush", 0.92);
? GetThemeBrush("AdaptiveTextSecondaryBrush", _weekendNumberOpacity)
: GetThemeBrush("AdaptiveTextPrimaryBrush", _weekdayNumberOpacity);
Grid.SetRow(dayText, row);
Grid.SetColumn(dayText, col);
CalendarGrid.Children.Add(dayText);
@@ -220,44 +311,155 @@ public partial class DateWidget : UserControl
public void ApplyCellSize(double cellSize)
{
_currentCellSize = Math.Max(1, cellSize);
UpdateDate();
}
private void ApplyAdaptiveTypography()
{
var scale = ResolveScale();
RootBorder.CornerRadius = new CornerRadius(Math.Clamp(28 * scale, 16, 40));
RootBorder.Padding = new Thickness(Math.Clamp(12 * scale, 8, 18));
RootBorder.Padding = new Thickness(Math.Clamp(11 * scale, 7, 17));
LayoutRoot.ColumnSpacing = Math.Clamp(10 * scale, 6, 16);
LeftPanelGrid.RowSpacing = Math.Clamp(5.2 * scale, 2.5, 10);
WeekdayHeaderGrid.Margin = new Thickness(
0,
Math.Clamp(0.5 * scale, 0, 2),
0,
Math.Clamp(2.4 * scale, 1, 4));
CalendarGrid.Margin = new Thickness(0, 0, 0, Math.Clamp(0.8 * scale, 0, 2));
LeftPanelGrid.RowSpacing = Math.Clamp(8 * scale, 5, 14);
RightPanelGrid.RowSpacing = Math.Clamp(10 * scale, 6, 16);
LunarCardBorder.CornerRadius = new CornerRadius(Math.Clamp(24 * scale, 14, 34));
LunarCardBorder.Padding = new Thickness(Math.Clamp(14 * scale, 9, 20));
LunarCardBorder.Padding = new Thickness(Math.Clamp(14 * scale, 8, 20));
RightPanelGrid.RowSpacing = Math.Clamp(7.5 * scale, 3.5, 11);
DividerBorder.Margin = new Thickness(0, Math.Clamp(1 * scale, 0, 2), 0, Math.Clamp(1 * scale, 0, 2));
GregorianHeadlineTextBlock.FontSize = Math.Clamp(22 * scale, 14, 34);
WeekdayText0.FontSize = Math.Clamp(13 * scale, 9, 18);
WeekdayText1.FontSize = WeekdayText0.FontSize;
WeekdayText2.FontSize = WeekdayText0.FontSize;
WeekdayText3.FontSize = WeekdayText0.FontSize;
WeekdayText4.FontSize = WeekdayText0.FontSize;
WeekdayText5.FontSize = WeekdayText0.FontSize;
WeekdayText6.FontSize = WeekdayText0.FontSize;
var isZh = CultureInfo.CurrentCulture.TwoLetterISOLanguageName.Equals("zh", StringComparison.OrdinalIgnoreCase);
var headerTextLength = Math.Max(1, GregorianHeadlineTextBlock.Text?.Length ?? (isZh ? 5 : 6));
var headerCompression = headerTextLength >= 8 ? 0.90 : headerTextLength >= 6 ? 0.95 : 1.0;
var densityBoost = scale <= 0.74 ? 0.90 : scale <= 0.90 ? 0.95 : scale >= 1.45 ? 1.05 : 1.0;
LunarDateTextBlock.FontSize = Math.Clamp(28 * scale, 17, 44);
LunarMetaTextBlock.FontSize = Math.Clamp(14 * scale, 10, 22);
YiLabelTextBlock.FontSize = Math.Clamp(18 * scale, 12, 28);
GregorianHeadlineTextBlock.FontSize = Math.Clamp(29 * scale * headerCompression * densityBoost, 12.5, 42);
GregorianHeadlineTextBlock.FontWeight = ToVariableWeight(Lerp(560, 720, Math.Clamp((scale - 0.60) / 1.2, 0, 1)));
GregorianHeadlineTextBlock.LineHeight = GregorianHeadlineTextBlock.FontSize * 1.03;
_weekdayFontSize = Math.Clamp(14.8 * scale * densityBoost, 7, 20);
_weekdayFontWeight = ToVariableWeight(Lerp(500, 640, Math.Clamp((scale - 0.60) / 1.2, 0, 1)));
foreach (var block in GetWeekdayHeaderBlocks())
{
block.FontSize = _weekdayFontSize;
block.FontWeight = _weekdayFontWeight;
block.LineHeight = _weekdayFontSize * 1.02;
}
_calendarDayFontSize = Math.Clamp(15.4 * scale * densityBoost, 8, 22);
_calendarDayFontWeight = ToVariableWeight(Lerp(540, 680, Math.Clamp((scale - 0.60) / 1.2, 0, 1)));
_calendarTodayDotSize = Math.Clamp(_calendarDayFontSize * 1.30, 13.5, 31);
var rightDensity = scale <= 0.72 ? 0.90 : scale <= 0.90 ? 0.95 : scale >= 1.38 ? 1.03 : 1.0;
LunarDateTextBlock.FontSize = Math.Clamp(30 * scale * rightDensity, 14, 44);
LunarMetaTextBlock.FontSize = Math.Clamp(12.5 * scale * rightDensity, 8.8, 18);
YiLabelTextBlock.FontSize = Math.Clamp(16.5 * scale * rightDensity, 10, 23);
JiLabelTextBlock.FontSize = YiLabelTextBlock.FontSize;
YiItemsTextBlock.FontSize = Math.Clamp(16 * scale, 11, 24);
YiItemsTextBlock.FontSize = Math.Clamp(13.8 * scale * rightDensity, 8.5, 19);
JiItemsTextBlock.FontSize = YiItemsTextBlock.FontSize;
YiItemsTextBlock.LineHeight = YiItemsTextBlock.FontSize * 1.15;
JiItemsTextBlock.LineHeight = JiItemsTextBlock.FontSize * 1.15;
_calendarDayFontSize = Math.Clamp(14 * scale, 9, 22);
_calendarTodayDotSize = Math.Clamp(28 * scale, 17, 38);
LunarDateTextBlock.FontWeight = ToVariableWeight(Lerp(640, 760, Math.Clamp((scale - 0.60) / 1.2, 0, 1)));
LunarMetaTextBlock.FontWeight = ToVariableWeight(Lerp(500, 620, Math.Clamp((scale - 0.60) / 1.2, 0, 1)));
YiLabelTextBlock.FontWeight = ToVariableWeight(Lerp(620, 740, Math.Clamp((scale - 0.60) / 1.2, 0, 1)));
JiLabelTextBlock.FontWeight = YiLabelTextBlock.FontWeight;
YiItemsTextBlock.FontWeight = ToVariableWeight(Lerp(520, 660, Math.Clamp((scale - 0.60) / 1.2, 0, 1)));
JiItemsTextBlock.FontWeight = YiItemsTextBlock.FontWeight;
UpdateDate();
var maxLines = scale <= 0.82 ? 1 : 2;
YiItemsTextBlock.MaxLines = maxLines;
JiItemsTextBlock.MaxLines = maxLines;
_lunarItemCount = scale switch
{
<= 0.72 => 2,
<= 0.96 => 3,
<= 1.32 => 4,
_ => 5
};
if (maxLines == 1)
{
_lunarItemCount = Math.Min(_lunarItemCount, 3);
}
}
private void ApplyModeVisualIfNeeded()
{
var isNightMode = ResolveIsNightMode();
if (_isNightModeApplied.HasValue && _isNightModeApplied.Value == isNightMode)
{
return;
}
_isNightModeApplied = isNightMode;
ApplyModeVisual(isNightMode);
}
private void ApplyModeVisual(bool isNightMode)
{
LunarCardBorder.BorderBrush = isNightMode
? CreateBrush("#3FFFFFFF")
: CreateBrush("#14000000");
LunarCardBorder.BoxShadow = BoxShadows.Parse(isNightMode
? "0 10 26 #42000000"
: "0 8 20 #1A000000");
_weekdayHeaderOpacity = isNightMode ? 0.66 : 0.60;
_weekdayNumberOpacity = isNightMode ? 0.93 : 0.90;
_weekendNumberOpacity = isNightMode ? 0.68 : 0.58;
GregorianHeadlineTextBlock.Foreground = GetThemeBrush("AdaptiveTextPrimaryBrush", isNightMode ? 0.97 : 0.95);
LunarDateTextBlock.Foreground = GetThemeBrush("AdaptiveTextPrimaryBrush", isNightMode ? 0.97 : 0.95);
LunarMetaTextBlock.Foreground = GetThemeBrush("AdaptiveTextSecondaryBrush", isNightMode ? 0.92 : 0.86);
YiItemsTextBlock.Foreground = GetThemeBrush("AdaptiveTextPrimaryBrush", isNightMode ? 0.95 : 0.92);
JiItemsTextBlock.Foreground = YiItemsTextBlock.Foreground;
foreach (var block in GetWeekdayHeaderBlocks())
{
block.Foreground = GetThemeBrush("AdaptiveTextSecondaryBrush", _weekdayHeaderOpacity);
}
YiLabelTextBlock.Foreground = CreateBrush(isNightMode ? "#8CB57D" : "#4E7D3A");
JiLabelTextBlock.Foreground = CreateBrush(isNightMode ? "#C98981" : "#A1473E");
DividerBorder.Opacity = isNightMode ? 0.48 : 0.72;
}
private bool ResolveIsNightMode()
{
if (ActualThemeVariant == ThemeVariant.Dark)
{
return true;
}
if (ActualThemeVariant == ThemeVariant.Light)
{
return false;
}
if (this.TryFindResource("AdaptiveSurfaceBaseBrush", out var value) &&
value is ISolidColorBrush solidBrush)
{
return CalculateRelativeLuminance(solidBrush.Color) < 0.45;
}
return false;
}
private double ResolveScale()
{
var cellScale = Math.Clamp(_currentCellSize / 48d, 0.72, 1.55);
var heightScale = Bounds.Height > 1 ? Math.Clamp(Bounds.Height / 220d, 0.65, 1.65) : 1;
var widthScale = Bounds.Width > 1 ? Math.Clamp(Bounds.Width / 460d, 0.65, 1.65) : 1;
return Math.Clamp(Math.Min(cellScale, Math.Min(heightScale, widthScale) * 1.08), 0.65, 1.6);
var cellScale = Math.Clamp(_currentCellSize / 48d, 0.62, 1.8);
var heightScale = Bounds.Height > 1 ? Math.Clamp(Bounds.Height / 220d, 0.62, 1.85) : 1;
var widthScale = Bounds.Width > 1 ? Math.Clamp(Bounds.Width / 460d, 0.62, 1.85) : 1;
return Math.Clamp(Math.Min(cellScale, Math.Min(heightScale, widthScale) * 1.08), 0.62, 1.8);
}
private IBrush GetThemeBrush(string key, double opacity)
@@ -274,4 +476,88 @@ public partial class DateWidget : UserControl
return new SolidColorBrush(Colors.Gray, opacity);
}
private static IBrush CreateBrush(string colorHex)
{
return new SolidColorBrush(Color.Parse(colorHex));
}
private static string BuildDailySelection(
DateTime date,
string[] pool,
int count,
int salt,
bool useChineseSpacing)
{
if (pool.Length == 0 || count <= 0)
{
return string.Empty;
}
var target = Math.Min(count, pool.Length);
var selected = new List<string>(target);
var usedIndices = new HashSet<int>();
var cursor = Math.Abs(date.Year * 1009 + date.DayOfYear * 37 + salt * 211);
var step = (salt % Math.Max(1, pool.Length - 1)) + 1;
for (var i = 0; i < pool.Length * 3 && selected.Count < target; i++)
{
var index = (cursor + i * step) % pool.Length;
if (usedIndices.Add(index))
{
selected.Add(pool[index]);
}
}
if (selected.Count == 0)
{
return string.Empty;
}
return string.Join(useChineseSpacing ? " " : ", ", selected);
}
private static double Lerp(double from, double to, double t)
{
return from + ((to - from) * t);
}
private static FontWeight ToVariableWeight(double weight)
{
return (FontWeight)(int)Math.Clamp(Math.Round(weight), 1, 1000);
}
private static int GetCalendarRowCount(int startDayOfWeek, int daysInMonth)
{
return Math.Max(5, (int)Math.Ceiling((startDayOfWeek + daysInMonth) / 7d));
}
private void EnsureCalendarRows(int rowCount)
{
if (CalendarGrid.RowDefinitions.Count == rowCount)
{
return;
}
CalendarGrid.RowDefinitions.Clear();
for (var i = 0; i < rowCount; i++)
{
CalendarGrid.RowDefinitions.Add(new RowDefinition(GridLength.Star));
}
}
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;
}
}

View File

@@ -0,0 +1,179 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Avalonia.Controls;
using LanMontainDesktop.ComponentSystem;
using LanMontainDesktop.Services;
namespace LanMontainDesktop.Views.Components;
public sealed record DesktopComponentRuntimeRegistration(
string ComponentId,
string DisplayNameLocalizationKey,
Func<Control> ControlFactory,
Func<double, double>? CornerRadiusResolver = null);
public sealed class DesktopComponentRuntimeDescriptor
{
private static readonly Func<double, double> DefaultCornerRadiusResolver =
cellSize => Math.Clamp(cellSize * 0.22, 8, 18);
private readonly Func<Control> _controlFactory;
private readonly Func<double, double> _cornerRadiusResolver;
internal DesktopComponentRuntimeDescriptor(
DesktopComponentDefinition definition,
string displayNameLocalizationKey,
Func<Control> controlFactory,
Func<double, double>? cornerRadiusResolver)
{
Definition = definition;
DisplayNameLocalizationKey = displayNameLocalizationKey;
_controlFactory = controlFactory;
_cornerRadiusResolver = cornerRadiusResolver ?? DefaultCornerRadiusResolver;
}
public DesktopComponentDefinition Definition { get; }
public string DisplayNameLocalizationKey { get; }
public Control CreateControl(double cellSize, TimeZoneService timeZoneService, IWeatherInfoService weatherInfoService)
{
var control = _controlFactory();
if (control is IDesktopComponentWidget sizedComponent)
{
sizedComponent.ApplyCellSize(cellSize);
}
if (control is ITimeZoneAwareComponentWidget timeZoneAwareComponent)
{
timeZoneAwareComponent.SetTimeZoneService(timeZoneService);
}
if (control is IWeatherInfoAwareComponentWidget weatherInfoAwareComponent)
{
weatherInfoAwareComponent.SetWeatherInfoService(weatherInfoService);
}
return control;
}
public double ResolveCornerRadius(double cellSize)
{
return _cornerRadiusResolver(Math.Max(1, cellSize));
}
}
public sealed class DesktopComponentRuntimeRegistry
{
private readonly Dictionary<string, DesktopComponentRuntimeDescriptor> _descriptors;
public DesktopComponentRuntimeRegistry(
ComponentRegistry componentRegistry,
IEnumerable<DesktopComponentRuntimeRegistration> registrations)
{
var registrationMap = registrations
.Where(r => !string.IsNullOrWhiteSpace(r.ComponentId) && r.ControlFactory is not null)
.GroupBy(r => r.ComponentId.Trim(), StringComparer.OrdinalIgnoreCase)
.ToDictionary(g => g.Key, g => g.Last(), StringComparer.OrdinalIgnoreCase);
_descriptors = componentRegistry
.GetAll()
.Where(definition => registrationMap.ContainsKey(definition.Id))
.ToDictionary(
definition => definition.Id,
definition =>
{
var registration = registrationMap[definition.Id];
return new DesktopComponentRuntimeDescriptor(
definition,
registration.DisplayNameLocalizationKey,
registration.ControlFactory,
registration.CornerRadiusResolver);
},
StringComparer.OrdinalIgnoreCase);
}
public static DesktopComponentRuntimeRegistry CreateDefault(ComponentRegistry componentRegistry)
{
return new DesktopComponentRuntimeRegistry(
componentRegistry,
new[]
{
new DesktopComponentRuntimeRegistration(
BuiltInComponentIds.Date,
"component.date",
() => new DateWidget(),
_ => 16),
new DesktopComponentRuntimeRegistration(
BuiltInComponentIds.MonthCalendar,
"component.month_calendar",
() => new MonthCalendarWidget(),
cellSize => Math.Clamp(cellSize * 0.26, 10, 22)),
new DesktopComponentRuntimeRegistration(
BuiltInComponentIds.LunarCalendar,
"component.lunar_calendar",
() => new LunarCalendarWidget(),
cellSize => Math.Clamp(cellSize * 0.30, 12, 26)),
new DesktopComponentRuntimeRegistration(
BuiltInComponentIds.DesktopClock,
"component.desktop_clock",
() => new AnalogClockWidget(),
cellSize => Math.Clamp(cellSize * 0.30, 12, 28)),
new DesktopComponentRuntimeRegistration(
BuiltInComponentIds.DesktopWeatherClock,
"component.weather_clock",
() => new WeatherClockWidget(),
cellSize => Math.Clamp(cellSize * 0.34, 14, 30)),
new DesktopComponentRuntimeRegistration(
BuiltInComponentIds.DesktopTimer,
"component.desktop_timer",
() => new TimerWidget(),
cellSize => Math.Clamp(cellSize * 0.30, 12, 28)),
new DesktopComponentRuntimeRegistration(
BuiltInComponentIds.DesktopWeather,
"component.desktop_weather",
() => new WeatherWidget(),
cellSize => Math.Clamp(cellSize * 0.45, 24, 44)),
new DesktopComponentRuntimeRegistration(
BuiltInComponentIds.DesktopHourlyWeather,
"component.hourly_weather",
() => new HourlyWeatherWidget(),
cellSize => Math.Clamp(cellSize * 0.45, 24, 44)),
new DesktopComponentRuntimeRegistration(
BuiltInComponentIds.DesktopMultiDayWeather,
"component.multiday_weather",
() => new MultiDayWeatherWidget(),
cellSize => Math.Clamp(cellSize * 0.45, 24, 44)),
new DesktopComponentRuntimeRegistration(
BuiltInComponentIds.DesktopWhiteboard,
"component.whiteboard",
() => new WhiteboardWidget(),
cellSize => Math.Clamp(cellSize * 0.24, 10, 24)),
new DesktopComponentRuntimeRegistration(
BuiltInComponentIds.DesktopBlackboardLandscape,
"component.blackboard_landscape",
() => new WhiteboardWidget(baseWidthCells: 4),
cellSize => Math.Clamp(cellSize * 0.24, 10, 24)),
new DesktopComponentRuntimeRegistration(
BuiltInComponentIds.HolidayCalendar,
"component.holiday_calendar",
() => new HolidayCalendarWidget(),
cellSize => Math.Clamp(cellSize * 0.32, 12, 28))
});
}
public bool TryGetDescriptor(string componentId, out DesktopComponentRuntimeDescriptor descriptor)
{
return _descriptors.TryGetValue(componentId, out descriptor!);
}
public IReadOnlyList<DesktopComponentRuntimeDescriptor> GetDesktopComponents()
{
return _descriptors.Values
.Where(descriptor => descriptor.Definition.AllowDesktopPlacement)
.OrderBy(descriptor => descriptor.Definition.Category, StringComparer.OrdinalIgnoreCase)
.ThenBy(descriptor => descriptor.Definition.DisplayName, StringComparer.OrdinalIgnoreCase)
.ToList();
}
}

View File

@@ -9,7 +9,7 @@ using LanMontainDesktop.Services;
namespace LanMontainDesktop.Views.Components;
public partial class HolidayCalendarWidget : UserControl
public partial class HolidayCalendarWidget : UserControl, IDesktopComponentWidget, ITimeZoneAwareComponentWidget
{
private readonly DispatcherTimer _timer = new()
{

View File

@@ -0,0 +1,296 @@
<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:fi="using:FluentIcons.Avalonia"
mc:Ignorable="d"
d:DesignWidth="640"
d:DesignHeight="320"
x:Class="LanMontainDesktop.Views.Components.HourlyWeatherWidget">
<Border x:Name="RootBorder"
CornerRadius="28"
ClipToBounds="True"
Background="#68A9EC">
<Grid>
<Border x:Name="BackgroundImageLayer"
CornerRadius="28"
ClipToBounds="True" />
<Border x:Name="BackgroundMotionLayer"
CornerRadius="28"
ClipToBounds="True"
Opacity="0.24"
RenderTransformOrigin="0.5,0.5">
<Border.RenderTransform>
<TransformGroup>
<ScaleTransform ScaleX="1.05"
ScaleY="1.05" />
<TranslateTransform />
</TransformGroup>
</Border.RenderTransform>
</Border>
<Border x:Name="BackgroundTintLayer"
CornerRadius="28"
ClipToBounds="True"
Opacity="0.20" />
<Border x:Name="BackgroundLightLayer"
CornerRadius="28"
ClipToBounds="True"
Opacity="0.66">
<Border.Background>
<LinearGradientBrush StartPoint="0,0"
EndPoint="1,1">
<GradientStop Color="#5BFFFFFF"
Offset="0" />
<GradientStop Color="#1FFFFFFF"
Offset="0.30" />
<GradientStop Color="#00000000"
Offset="0.55" />
</LinearGradientBrush>
</Border.Background>
</Border>
<Border x:Name="BackgroundShadeLayer"
CornerRadius="28"
ClipToBounds="True"
Opacity="0.78">
<Border.Background>
<LinearGradientBrush StartPoint="0,0"
EndPoint="0,1">
<GradientStop Color="#00040A16"
Offset="0.50" />
<GradientStop Color="#2E0B1C34"
Offset="1" />
</LinearGradientBrush>
</Border.Background>
</Border>
<Canvas x:Name="ParticleLayer"
IsHitTestVisible="False"
ClipToBounds="True" />
<Border x:Name="ContentPaddingBorder"
Padding="18"
Background="Transparent">
<Grid x:Name="LayoutRoot">
<Grid x:Name="ContentGrid"
RowDefinitions="Auto,Auto,*"
RowSpacing="10">
<Grid x:Name="TopRowGrid"
Grid.Row="0"
ColumnDefinitions="Auto,*,Auto"
ColumnSpacing="8">
<fi:SymbolIcon x:Name="LocationIcon"
Symbol="Location"
FontSize="20"
VerticalAlignment="Center" />
<TextBlock x:Name="CityTextBlock"
Grid.Column="1"
Text="北京"
FontSize="30"
FontWeight="SemiBold"
VerticalAlignment="Center"
TextTrimming="CharacterEllipsis"
MaxLines="1" />
<fi:SymbolIcon x:Name="WeatherIconSymbol"
Grid.Column="2"
Symbol="WeatherSunny"
IconVariant="Regular"
FontSize="40"
HorizontalAlignment="Right"
VerticalAlignment="Center" />
</Grid>
<Grid Grid.Row="1"
ColumnDefinitions="Auto,*,Auto">
<TextBlock x:Name="TemperatureTextBlock"
Grid.Column="0"
Text="26°"
FontSize="108"
FontWeight="Bold"
FontFeatures="tnum"
VerticalAlignment="Center"
Margin="0,4,0,10"
TextTrimming="CharacterEllipsis"
MaxLines="1" />
<StackPanel x:Name="ConditionRangeStack"
Grid.Column="2"
Orientation="Horizontal"
HorizontalAlignment="Right"
VerticalAlignment="Bottom"
Spacing="8"
Margin="0,0,0,10">
<TextBlock x:Name="ConditionTextBlock"
Text="晴"
FontSize="30"
FontWeight="SemiBold"
TextTrimming="CharacterEllipsis"
MaxLines="1" />
<TextBlock x:Name="RangeTextBlock"
Text="20° / 28°"
FontSize="36"
FontWeight="SemiBold"
FontFeatures="tnum"
TextTrimming="CharacterEllipsis"
MaxLines="1" />
</StackPanel>
</Grid>
<StackPanel x:Name="BottomInfoStack"
Grid.Row="2"
VerticalAlignment="Bottom"
Spacing="4"
Margin="0,0,0,8">
<Border x:Name="HourlyPanelBorder"
Background="#1CFFFFFF"
CornerRadius="18"
ClipToBounds="True"
Padding="12,8">
<Grid x:Name="HourlyGrid"
ColumnDefinitions="*,*,*,*,*,*">
<StackPanel Grid.Column="0"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Spacing="2">
<TextBlock x:Name="HourlyTime0"
Text="现在"
FontSize="24"
FontWeight="SemiBold"
HorizontalAlignment="Center" />
<fi:SymbolIcon x:Name="HourlyIcon0"
Symbol="WeatherSunny"
FontSize="28"
IconVariant="Regular"
HorizontalAlignment="Center" />
<TextBlock x:Name="HourlyTemp0"
Text="26°"
FontSize="30"
FontWeight="SemiBold"
FontFeatures="tnum"
HorizontalAlignment="Center" />
</StackPanel>
<StackPanel Grid.Column="1"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Spacing="2">
<TextBlock x:Name="HourlyTime1"
Text="09:00"
FontSize="24"
FontWeight="SemiBold"
HorizontalAlignment="Center" />
<fi:SymbolIcon x:Name="HourlyIcon1"
Symbol="WeatherSunny"
FontSize="28"
IconVariant="Regular"
HorizontalAlignment="Center" />
<TextBlock x:Name="HourlyTemp1"
Text="28°"
FontSize="30"
FontWeight="SemiBold"
FontFeatures="tnum"
HorizontalAlignment="Center" />
</StackPanel>
<StackPanel Grid.Column="2"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Spacing="2">
<TextBlock x:Name="HourlyTime2"
Text="10:00"
FontSize="24"
FontWeight="SemiBold"
HorizontalAlignment="Center" />
<fi:SymbolIcon x:Name="HourlyIcon2"
Symbol="WeatherSunny"
FontSize="28"
IconVariant="Regular"
HorizontalAlignment="Center" />
<TextBlock x:Name="HourlyTemp2"
Text="26°"
FontSize="30"
FontWeight="SemiBold"
FontFeatures="tnum"
HorizontalAlignment="Center" />
</StackPanel>
<StackPanel Grid.Column="3"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Spacing="2">
<TextBlock x:Name="HourlyTime3"
Text="11:00"
FontSize="24"
FontWeight="SemiBold"
HorizontalAlignment="Center" />
<fi:SymbolIcon x:Name="HourlyIcon3"
Symbol="WeatherSunny"
FontSize="28"
IconVariant="Regular"
HorizontalAlignment="Center" />
<TextBlock x:Name="HourlyTemp3"
Text="24°"
FontSize="30"
FontWeight="SemiBold"
FontFeatures="tnum"
HorizontalAlignment="Center" />
</StackPanel>
<StackPanel Grid.Column="4"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Spacing="2">
<TextBlock x:Name="HourlyTime4"
Text="12:00"
FontSize="24"
FontWeight="SemiBold"
HorizontalAlignment="Center" />
<fi:SymbolIcon x:Name="HourlyIcon4"
Symbol="WeatherSunny"
FontSize="28"
IconVariant="Regular"
HorizontalAlignment="Center" />
<TextBlock x:Name="HourlyTemp4"
Text="24°"
FontSize="30"
FontWeight="SemiBold"
FontFeatures="tnum"
HorizontalAlignment="Center" />
</StackPanel>
<StackPanel Grid.Column="5"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Spacing="2">
<TextBlock x:Name="HourlyTime5"
Text="13:00"
FontSize="24"
FontWeight="SemiBold"
HorizontalAlignment="Center" />
<fi:SymbolIcon x:Name="HourlyIcon5"
Symbol="WeatherSunny"
FontSize="28"
IconVariant="Regular"
HorizontalAlignment="Center" />
<TextBlock x:Name="HourlyTemp5"
Text="23°"
FontSize="30"
FontWeight="SemiBold"
FontFeatures="tnum"
HorizontalAlignment="Center" />
</StackPanel>
</Grid>
</Border>
</StackPanel>
</Grid>
</Grid>
</Border>
</Grid>
</Border>
</UserControl>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,18 @@
using LanMontainDesktop.Services;
namespace LanMontainDesktop.Views.Components;
public interface IDesktopComponentWidget
{
void ApplyCellSize(double cellSize);
}
public interface ITimeZoneAwareComponentWidget
{
void SetTimeZoneService(TimeZoneService timeZoneService);
}
public interface IWeatherInfoAwareComponentWidget
{
void SetWeatherInfoService(IWeatherInfoService weatherInfoService);
}

View File

@@ -3,12 +3,13 @@ using System.Collections.Generic;
using System.Globalization;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Media;
using Avalonia.Threading;
using LanMontainDesktop.Services;
namespace LanMontainDesktop.Views.Components;
public partial class LunarCalendarWidget : UserControl
public partial class LunarCalendarWidget : UserControl, IDesktopComponentWidget, ITimeZoneAwareComponentWidget
{
private readonly DispatcherTimer _timer = new()
{
@@ -79,6 +80,11 @@ public partial class LunarCalendarWidget : UserControl
private TimeZoneService? _timeZoneService;
private double _currentCellSize = 48;
private FontWeight _gregorianLineWeight = FontWeight.SemiBold;
private FontWeight _lunarDateWeight = FontWeight.Bold;
private FontWeight _labelWeight = FontWeight.Bold;
private FontWeight _itemsWeight = FontWeight.SemiBold;
private int _auspiciousItemCount = 4;
public LunarCalendarWidget()
{
@@ -131,6 +137,8 @@ public partial class LunarCalendarWidget : UserControl
private void UpdateContent()
{
ApplyAdaptiveTypography();
var now = _timeZoneService?.GetCurrentTime() ?? DateTime.Now;
var culture = CultureInfo.CurrentCulture;
var isZh = culture.TwoLetterISOLanguageName.Equals("zh", StringComparison.OrdinalIgnoreCase);
@@ -146,13 +154,13 @@ public partial class LunarCalendarWidget : UserControl
YiItemsTextBlock.Text = BuildDailySelection(
now.Date,
isZh ? ZhYiCandidates : EnYiCandidates,
count: 4,
count: _auspiciousItemCount,
salt: 17,
useChineseSpacing: isZh);
JiItemsTextBlock.Text = BuildDailySelection(
now.Date,
isZh ? ZhJiCandidates : EnJiCandidates,
count: 4,
count: _auspiciousItemCount,
salt: 29,
useChineseSpacing: isZh);
}
@@ -160,6 +168,11 @@ public partial class LunarCalendarWidget : UserControl
public void ApplyCellSize(double cellSize)
{
_currentCellSize = Math.Max(1, cellSize);
UpdateContent();
}
private void ApplyAdaptiveTypography()
{
var scale = ResolveScale();
RootBorder.CornerRadius = new CornerRadius(Math.Clamp(30 * scale, 16, 44));
@@ -172,12 +185,33 @@ public partial class LunarCalendarWidget : UserControl
Math.Clamp(2 * scale, 1, 6));
AuspiciousGrid.RowSpacing = Math.Clamp(12 * scale, 6, 20);
GregorianLineTextBlock.FontSize = Math.Clamp(24 * scale, 11, 36);
LunarDateTextBlock.FontSize = Math.Clamp(88 * scale, 30, 130);
YiLabelTextBlock.FontSize = Math.Clamp(30 * scale, 13, 44);
var densityBoost = scale <= 0.72 ? 0.90 : scale <= 0.88 ? 0.95 : scale >= 1.42 ? 1.04 : 1.0;
GregorianLineTextBlock.FontSize = Math.Clamp(24 * scale * densityBoost, 10, 38);
LunarDateTextBlock.FontSize = Math.Clamp(88 * scale * densityBoost, 28, 134);
YiLabelTextBlock.FontSize = Math.Clamp(30 * scale * densityBoost, 12, 46);
JiLabelTextBlock.FontSize = YiLabelTextBlock.FontSize;
YiItemsTextBlock.FontSize = Math.Clamp(24 * scale, 11, 36);
YiItemsTextBlock.FontSize = Math.Clamp(24 * scale * densityBoost, 10, 36);
JiItemsTextBlock.FontSize = YiItemsTextBlock.FontSize;
_gregorianLineWeight = ToVariableWeight(Lerp(500, 640, Math.Clamp((scale - 0.58) / 1.2, 0, 1)));
_lunarDateWeight = ToVariableWeight(Lerp(650, 780, Math.Clamp((scale - 0.58) / 1.2, 0, 1)));
_labelWeight = ToVariableWeight(Lerp(620, 760, Math.Clamp((scale - 0.58) / 1.2, 0, 1)));
_itemsWeight = ToVariableWeight(Lerp(520, 670, Math.Clamp((scale - 0.58) / 1.2, 0, 1)));
GregorianLineTextBlock.FontWeight = _gregorianLineWeight;
LunarDateTextBlock.FontWeight = _lunarDateWeight;
YiLabelTextBlock.FontWeight = _labelWeight;
JiLabelTextBlock.FontWeight = _labelWeight;
YiItemsTextBlock.FontWeight = _itemsWeight;
JiItemsTextBlock.FontWeight = _itemsWeight;
_auspiciousItemCount = scale switch
{
<= 0.72 => 2,
<= 0.92 => 3,
<= 1.30 => 4,
_ => 5
};
}
private double ResolveScale()
@@ -188,6 +222,16 @@ public partial class LunarCalendarWidget : UserControl
return Math.Clamp(Math.Min(cellScale, Math.Min(heightScale, widthScale) * 1.05), 0.58, 1.95);
}
private static double Lerp(double from, double to, double t)
{
return from + ((to - from) * t);
}
private static FontWeight ToVariableWeight(double weight)
{
return (FontWeight)(int)Math.Clamp(Math.Round(weight), 1, 1000);
}
private static string ToChineseWeekday(DayOfWeek dayOfWeek)
{
return dayOfWeek switch

View File

@@ -9,7 +9,7 @@ using LanMontainDesktop.Services;
namespace LanMontainDesktop.Views.Components;
public partial class MonthCalendarWidget : UserControl
public partial class MonthCalendarWidget : UserControl, IDesktopComponentWidget, ITimeZoneAwareComponentWidget
{
private readonly DispatcherTimer _timer = new()
{
@@ -21,7 +21,10 @@ public partial class MonthCalendarWidget : UserControl
private TimeZoneService? _timeZoneService;
private double _currentCellSize = 48;
private double _weekdayFontSize = 20;
private FontWeight _weekdayFontWeight = FontWeight.SemiBold;
private double _calendarDayFontSize = 22;
private FontWeight _calendarDayFontWeight = FontWeight.SemiBold;
private double _calendarTodayDotSize = 44;
public MonthCalendarWidget()
@@ -83,6 +86,8 @@ public partial class MonthCalendarWidget : UserControl
? $"{now.Month}\u6708{now.Day}\u65e5"
: now.ToString("MMM d", culture);
// Locale changes the header width; re-balance typography on every refresh.
ApplyAdaptiveTypography();
UpdateWeekdayHeaders(isZh);
GenerateCalendar(now);
}
@@ -152,7 +157,7 @@ public partial class MonthCalendarWidget : UserControl
HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Center,
VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center,
FontSize = _calendarDayFontSize,
FontWeight = FontWeight.SemiBold,
FontWeight = _calendarDayFontWeight,
Tag = "day"
};
@@ -198,24 +203,40 @@ public partial class MonthCalendarWidget : UserControl
public void ApplyCellSize(double cellSize)
{
_currentCellSize = Math.Max(1, cellSize);
UpdateCalendar();
}
private void ApplyAdaptiveTypography()
{
var scale = ResolveScale();
RootBorder.CornerRadius = new CornerRadius(Math.Clamp(28 * scale, 14, 40));
RootBorder.Padding = new Thickness(Math.Clamp(14 * scale, 8, 22));
LayoutRoot.RowSpacing = Math.Clamp(10 * scale, 5, 16);
LayoutRoot.Width = Math.Clamp(280 * scale, 220, 420);
LayoutRoot.Height = Math.Clamp(280 * scale, 220, 420);
HeaderTextBlock.FontSize = Math.Clamp(42 * scale, 14, 58);
var isZh = CultureInfo.CurrentCulture.TwoLetterISOLanguageName.Equals("zh", StringComparison.OrdinalIgnoreCase);
var headerTextLength = Math.Max(1, HeaderTextBlock.Text?.Length ?? (isZh ? 5 : 6));
var headerCompression = headerTextLength >= 8 ? 0.90 : headerTextLength >= 6 ? 0.95 : 1.0;
var densityBoost = scale <= 0.74 ? 0.90 : scale <= 0.90 ? 0.95 : scale >= 1.45 ? 1.05 : 1.0;
var weekdayFontSize = Math.Clamp(20 * scale, 8, 26);
HeaderTextBlock.FontSize = Math.Clamp(42 * scale * headerCompression * densityBoost, 13, 62);
HeaderTextBlock.FontWeight = ToVariableWeight(Lerp(560, 720, Math.Clamp((scale - 0.62) / 1.2, 0, 1)));
HeaderTextBlock.LineHeight = HeaderTextBlock.FontSize * 1.05;
_weekdayFontSize = Math.Clamp(20 * scale * densityBoost, 7.5, 27);
_weekdayFontWeight = ToVariableWeight(Lerp(500, 640, Math.Clamp((scale - 0.60) / 1.3, 0, 1)));
foreach (var block in GetWeekdayHeaderBlocks())
{
block.FontSize = weekdayFontSize;
block.FontSize = _weekdayFontSize;
block.FontWeight = _weekdayFontWeight;
block.LineHeight = _weekdayFontSize * 1.06;
}
_calendarDayFontSize = Math.Clamp(22 * scale, 8, 30);
_calendarTodayDotSize = Math.Clamp(44 * scale, 16, 58);
UpdateCalendar();
_calendarDayFontSize = Math.Clamp(22 * scale * densityBoost, 8, 32);
_calendarDayFontWeight = ToVariableWeight(Lerp(540, 680, Math.Clamp((scale - 0.60) / 1.3, 0, 1)));
_calendarTodayDotSize = Math.Clamp(_calendarDayFontSize * 1.95, 16, 62);
}
private double ResolveScale()
@@ -240,5 +261,14 @@ public partial class MonthCalendarWidget : UserControl
return new SolidColorBrush(Colors.Gray, opacity);
}
}
private static double Lerp(double from, double to, double t)
{
return from + ((to - from) * t);
}
private static FontWeight ToVariableWeight(double weight)
{
return (FontWeight)(int)Math.Clamp(Math.Round(weight), 1, 1000);
}
}

View File

@@ -0,0 +1,276 @@
<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:fi="using:FluentIcons.Avalonia"
mc:Ignorable="d"
d:DesignWidth="640"
d:DesignHeight="320"
x:Class="LanMontainDesktop.Views.Components.MultiDayWeatherWidget">
<Border x:Name="RootBorder"
CornerRadius="28"
ClipToBounds="True"
Background="#68A9EC">
<Grid>
<Border x:Name="BackgroundImageLayer"
CornerRadius="28"
ClipToBounds="True" />
<Border x:Name="BackgroundMotionLayer"
CornerRadius="28"
ClipToBounds="True"
Opacity="0.24"
RenderTransformOrigin="0.5,0.5">
<Border.RenderTransform>
<TransformGroup>
<ScaleTransform ScaleX="1.05"
ScaleY="1.05" />
<TranslateTransform />
</TransformGroup>
</Border.RenderTransform>
</Border>
<Border x:Name="BackgroundTintLayer"
CornerRadius="28"
ClipToBounds="True"
Opacity="0.20" />
<Border x:Name="BackgroundLightLayer"
CornerRadius="28"
ClipToBounds="True"
Opacity="0.66">
<Border.Background>
<LinearGradientBrush StartPoint="0,0"
EndPoint="1,1">
<GradientStop Color="#5BFFFFFF"
Offset="0" />
<GradientStop Color="#1FFFFFFF"
Offset="0.30" />
<GradientStop Color="#00000000"
Offset="0.55" />
</LinearGradientBrush>
</Border.Background>
</Border>
<Border x:Name="BackgroundShadeLayer"
CornerRadius="28"
ClipToBounds="True"
Opacity="0.78">
<Border.Background>
<LinearGradientBrush StartPoint="0,0"
EndPoint="0,1">
<GradientStop Color="#00040A16"
Offset="0.50" />
<GradientStop Color="#2E0B1C34"
Offset="1" />
</LinearGradientBrush>
</Border.Background>
</Border>
<Canvas x:Name="ParticleLayer"
IsHitTestVisible="False"
ClipToBounds="True" />
<Border x:Name="ContentPaddingBorder"
Padding="18"
Background="Transparent">
<Grid x:Name="LayoutRoot">
<Grid x:Name="ContentGrid"
RowDefinitions="Auto,Auto,*"
RowSpacing="10">
<Grid x:Name="TopRowGrid"
Grid.Row="0"
ColumnDefinitions="Auto,*,Auto"
ColumnSpacing="8">
<fi:SymbolIcon x:Name="LocationIcon"
Symbol="Location"
FontSize="20"
VerticalAlignment="Center" />
<TextBlock x:Name="CityTextBlock"
Grid.Column="1"
Text="&#x5317;&#x4EAC;"
FontSize="30"
FontWeight="SemiBold"
VerticalAlignment="Center"
TextTrimming="CharacterEllipsis"
MaxLines="1" />
<StackPanel x:Name="ConditionIconStack"
Grid.Column="2"
Orientation="Horizontal"
Spacing="6"
HorizontalAlignment="Right"
VerticalAlignment="Center">
<TextBlock x:Name="ConditionTextBlock"
Text="&#x6674;"
FontSize="30"
FontWeight="SemiBold"
TextTrimming="CharacterEllipsis"
MaxLines="1" />
<fi:SymbolIcon x:Name="WeatherIconSymbol"
Symbol="WeatherSunny"
IconVariant="Regular"
FontSize="40"
HorizontalAlignment="Right"
VerticalAlignment="Center" />
</StackPanel>
</Grid>
<Grid Grid.Row="1"
ColumnDefinitions="Auto,*,Auto">
<TextBlock x:Name="TemperatureTextBlock"
Grid.Column="0"
Text="26&#176;"
FontSize="108"
FontWeight="Bold"
FontFeatures="tnum"
VerticalAlignment="Center"
Margin="0,4,0,10"
TextTrimming="CharacterEllipsis"
MaxLines="1" />
<TextBlock x:Name="RangeTextBlock"
Grid.Column="2"
Text="&#x7A7A;&#x6C14;&#x4F18; 22"
FontSize="36"
FontWeight="SemiBold"
FontFeatures="tnum"
HorizontalAlignment="Right"
VerticalAlignment="Bottom"
Margin="0,0,0,10"
TextTrimming="CharacterEllipsis"
MaxLines="1" />
</Grid>
<StackPanel x:Name="BottomInfoStack"
Grid.Row="2"
VerticalAlignment="Bottom"
Spacing="4"
Margin="0,0,0,8">
<Border x:Name="HourlyPanelBorder"
Background="#1CFFFFFF"
CornerRadius="18"
ClipToBounds="True"
Padding="12,8">
<Grid x:Name="HourlyGrid"
ColumnDefinitions="*,*,*,*,*">
<StackPanel Grid.Column="0"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Spacing="2">
<TextBlock x:Name="HourlyTime0"
Text="&#x4ECA;&#x5929;"
FontSize="24"
FontWeight="SemiBold"
HorizontalAlignment="Center" />
<fi:SymbolIcon x:Name="HourlyIcon0"
Symbol="WeatherSunny"
FontSize="28"
IconVariant="Regular"
HorizontalAlignment="Center" />
<TextBlock x:Name="HourlyTemp0"
Text="20&#176; / 28&#176;"
FontSize="30"
FontWeight="SemiBold"
FontFeatures="tnum"
HorizontalAlignment="Center" />
</StackPanel>
<StackPanel Grid.Column="1"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Spacing="2">
<TextBlock x:Name="HourlyTime1"
Text="&#x660E;&#x5929;"
FontSize="24"
FontWeight="SemiBold"
HorizontalAlignment="Center" />
<fi:SymbolIcon x:Name="HourlyIcon1"
Symbol="WeatherSunny"
FontSize="28"
IconVariant="Regular"
HorizontalAlignment="Center" />
<TextBlock x:Name="HourlyTemp1"
Text="20&#176; / 28&#176;"
FontSize="30"
FontWeight="SemiBold"
FontFeatures="tnum"
HorizontalAlignment="Center" />
</StackPanel>
<StackPanel Grid.Column="2"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Spacing="2">
<TextBlock x:Name="HourlyTime2"
Text="&#x5468;&#x516D;"
FontSize="24"
FontWeight="SemiBold"
HorizontalAlignment="Center" />
<fi:SymbolIcon x:Name="HourlyIcon2"
Symbol="WeatherSunny"
FontSize="28"
IconVariant="Regular"
HorizontalAlignment="Center" />
<TextBlock x:Name="HourlyTemp2"
Text="20&#176; / 28&#176;"
FontSize="30"
FontWeight="SemiBold"
FontFeatures="tnum"
HorizontalAlignment="Center" />
</StackPanel>
<StackPanel Grid.Column="3"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Spacing="2">
<TextBlock x:Name="HourlyTime3"
Text="&#x5468;&#x65E5;"
FontSize="24"
FontWeight="SemiBold"
HorizontalAlignment="Center" />
<fi:SymbolIcon x:Name="HourlyIcon3"
Symbol="WeatherSunny"
FontSize="28"
IconVariant="Regular"
HorizontalAlignment="Center" />
<TextBlock x:Name="HourlyTemp3"
Text="20&#176; / 28&#176;"
FontSize="30"
FontWeight="SemiBold"
FontFeatures="tnum"
HorizontalAlignment="Center" />
</StackPanel>
<StackPanel Grid.Column="4"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Spacing="2">
<TextBlock x:Name="HourlyTime4"
Text="&#x5468;&#x4E00;"
FontSize="24"
FontWeight="SemiBold"
HorizontalAlignment="Center" />
<fi:SymbolIcon x:Name="HourlyIcon4"
Symbol="WeatherSunny"
FontSize="28"
IconVariant="Regular"
HorizontalAlignment="Center" />
<TextBlock x:Name="HourlyTemp4"
Text="20&#176; / 28&#176;"
FontSize="30"
FontWeight="SemiBold"
FontFeatures="tnum"
HorizontalAlignment="Center" />
</StackPanel>
</Grid>
</Border>
</StackPanel>
</Grid>
</Grid>
</Border>
</Grid>
</Border>
</UserControl>

File diff suppressed because it is too large Load Diff

View File

@@ -10,7 +10,7 @@ using Avalonia.Threading;
namespace LanMontainDesktop.Views.Components;
public partial class TimerWidget : UserControl
public partial class TimerWidget : UserControl, IDesktopComponentWidget
{
private const int MaxTimerSeconds = 60;
private const double DialSize = 224;

View File

@@ -0,0 +1,98 @@
<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:fi="using:FluentIcons.Avalonia"
mc:Ignorable="d"
d:DesignWidth="260"
d:DesignHeight="120"
x:Class="LanMontainDesktop.Views.Components.WeatherClockWidget">
<Border x:Name="RootBorder"
Background="#FFFFFF"
BorderBrush="#14000000"
BorderThickness="1"
CornerRadius="22"
ClipToBounds="True"
Padding="12,8">
<Grid x:Name="ContentGrid"
ColumnDefinitions="*,Auto"
ColumnSpacing="10">
<StackPanel x:Name="LeftStack"
ClipToBounds="True"
VerticalAlignment="Center"
Spacing="2">
<TextBlock x:Name="TimeTextBlock"
Text="15:07"
FontSize="36"
FontWeight="Bold"
FontFeatures="tnum"
Foreground="#10131A"
MaxLines="1"
TextWrapping="NoWrap"
TextTrimming="CharacterEllipsis" />
<StackPanel x:Name="DateWeatherStack"
Orientation="Horizontal"
Spacing="6"
VerticalAlignment="Center">
<TextBlock x:Name="DateTextBlock"
Text="8&#x6708;14&#x65E5;"
FontSize="18"
FontWeight="SemiBold"
Foreground="#7A7E87"
VerticalAlignment="Center"
MaxLines="1"
TextWrapping="NoWrap"
TextTrimming="CharacterEllipsis" />
<fi:SymbolIcon x:Name="WeatherIconSymbol"
Symbol="WeatherPartlyCloudyDay"
FontSize="18"
Foreground="#5A9CFF"
VerticalAlignment="Center"
IconVariant="Regular" />
</StackPanel>
</StackPanel>
<Border x:Name="AnalogDialBorder"
Grid.Column="1"
Width="52"
Height="52"
CornerRadius="26"
Background="#F8FAFF"
BorderBrush="#12000000"
BorderThickness="1"
VerticalAlignment="Center">
<Viewbox Stretch="Uniform">
<Grid Width="104"
Height="104">
<Canvas x:Name="TickCanvas"
Width="104"
Height="104"
IsHitTestVisible="False" />
<Canvas x:Name="HandsCanvas"
Width="104"
Height="104"
IsHitTestVisible="False" />
<Ellipse x:Name="CenterDotOuter"
Width="12"
Height="12"
Fill="#4F7CC0"
HorizontalAlignment="Center"
VerticalAlignment="Center" />
<Ellipse x:Name="CenterDotInner"
Width="5"
Height="5"
Fill="#1A74F2"
HorizontalAlignment="Center"
VerticalAlignment="Center" />
</Grid>
</Viewbox>
</Border>
</Grid>
</Border>
</UserControl>

View File

@@ -0,0 +1,619 @@
using System;
using System.Globalization;
using System.Threading;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Shapes;
using Avalonia.Media;
using Avalonia.Styling;
using Avalonia.Threading;
using FluentIcons.Common;
using LanMontainDesktop.Models;
using LanMontainDesktop.Services;
namespace LanMontainDesktop.Views.Components;
public partial class WeatherClockWidget : UserControl, IDesktopComponentWidget, ITimeZoneAwareComponentWidget, IWeatherInfoAwareComponentWidget
{
private sealed record WeatherClockConfig(
string LanguageCode,
string Locale,
string LocationKey,
double Latitude,
double Longitude);
private const double DialDesignSize = 104;
private const double DialCenter = DialDesignSize / 2d;
private static readonly IWeatherInfoService DefaultWeatherInfoService = new XiaomiWeatherService();
private readonly DispatcherTimer _clockTimer = new()
{
Interval = TimeSpan.FromSeconds(1)
};
private readonly DispatcherTimer _weatherRefreshTimer = new()
{
Interval = TimeSpan.FromMinutes(12)
};
private readonly AppSettingsService _settingsService = new();
private readonly LocalizationService _localizationService = new();
private readonly Line _hourHandLine = CreateHandLine("#232938", 4.0);
private readonly Line _minuteHandLine = CreateHandLine("#2F3749", 2.8);
private readonly Line _secondHandLine = CreateHandLine("#1A74F2", 1.9);
private IWeatherInfoService _weatherInfoService = DefaultWeatherInfoService;
private TimeZoneService? _timeZoneService;
private CancellationTokenSource? _refreshCts;
private double _currentCellSize = 48;
private bool _isAttached;
private bool _dialInitialized;
private bool _handsInitialized;
private bool _isRefreshing;
private bool? _isNightModeApplied;
private string _languageCode = "zh-CN";
private Symbol _activeWeatherSymbol = Symbol.WeatherPartlyCloudyDay;
public WeatherClockWidget()
{
InitializeComponent();
_clockTimer.Tick += OnClockTimerTick;
_weatherRefreshTimer.Tick += OnWeatherRefreshTick;
AttachedToVisualTree += OnAttachedToVisualTree;
DetachedFromVisualTree += OnDetachedFromVisualTree;
SizeChanged += OnSizeChanged;
InitializeDialIfNeeded();
InitializeHandsIfNeeded();
ApplyCellSize(_currentCellSize);
ApplyDefaultWeatherIcon();
UpdateClockVisual();
}
public void SetTimeZoneService(TimeZoneService timeZoneService)
{
if (_timeZoneService is not null)
{
_timeZoneService.TimeZoneChanged -= OnTimeZoneChanged;
}
_timeZoneService = timeZoneService;
_timeZoneService.TimeZoneChanged += OnTimeZoneChanged;
UpdateClockVisual();
}
public void SetWeatherInfoService(IWeatherInfoService weatherInfoService)
{
_weatherInfoService = weatherInfoService ?? DefaultWeatherInfoService;
if (_isAttached)
{
_ = RefreshWeatherAsync(forceRefresh: false);
}
}
public void ApplyCellSize(double cellSize)
{
_currentCellSize = Math.Max(1, cellSize);
var scale = ResolveScale();
var targetHeight = Bounds.Height > 1
? Math.Clamp(Bounds.Height, 38, 160)
: Math.Clamp(_currentCellSize * 0.92, 38, 120);
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 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);
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;
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);
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.MaxWidth = leftContentWidth;
DateWeatherStack.MaxWidth = leftContentWidth;
TimeTextBlock.MaxWidth = leftContentWidth;
DateTextBlock.MaxWidth = Math.Max(18, leftContentWidth - WeatherIconSymbol.FontSize - DateWeatherStack.Spacing);
AnalogDialBorder.Width = dialSize;
AnalogDialBorder.Height = dialSize;
AnalogDialBorder.CornerRadius = new CornerRadius(dialSize / 2d);
ApplyModeVisualIfNeeded();
}
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
{
_isAttached = true;
UpdateClockVisual();
_clockTimer.Start();
_weatherRefreshTimer.Start();
_ = RefreshWeatherAsync(forceRefresh: false);
}
private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
{
_isAttached = false;
_clockTimer.Stop();
_weatherRefreshTimer.Stop();
CancelRefreshRequest();
}
private void OnSizeChanged(object? sender, SizeChangedEventArgs e)
{
ApplyCellSize(_currentCellSize);
}
private void OnClockTimerTick(object? sender, EventArgs e)
{
UpdateClockVisual();
}
private async void OnWeatherRefreshTick(object? sender, EventArgs e)
{
await RefreshWeatherAsync(forceRefresh: false);
}
private void OnTimeZoneChanged(object? sender, EventArgs e)
{
UpdateClockVisual();
}
private async Task RefreshWeatherAsync(bool forceRefresh)
{
if (!_isAttached || _isRefreshing)
{
return;
}
_isRefreshing = true;
var config = LoadConfig();
_languageCode = config.LanguageCode;
if (string.IsNullOrWhiteSpace(config.LocationKey))
{
ApplyDefaultWeatherIcon();
_isRefreshing = false;
UpdateClockVisual();
return;
}
var cts = new CancellationTokenSource();
var previous = Interlocked.Exchange(ref _refreshCts, cts);
previous?.Cancel();
previous?.Dispose();
try
{
var query = new WeatherQuery(
LocationKey: config.LocationKey,
Latitude: config.Latitude,
Longitude: config.Longitude,
ForecastDays: 1,
Locale: config.Locale,
ForceRefresh: forceRefresh);
var result = await _weatherInfoService.GetWeatherAsync(query, cts.Token);
if (cts.IsCancellationRequested || !_isAttached)
{
return;
}
if (!result.Success || result.Data is null)
{
ApplyDefaultWeatherIcon();
return;
}
ApplyWeatherSnapshot(result.Data);
}
catch (OperationCanceledException)
{
// Ignore canceled refresh requests.
}
catch
{
if (!cts.IsCancellationRequested && _isAttached)
{
ApplyDefaultWeatherIcon();
}
}
finally
{
if (ReferenceEquals(_refreshCts, cts))
{
_refreshCts = null;
}
cts.Dispose();
_isRefreshing = false;
}
}
private void ApplyWeatherSnapshot(WeatherSnapshot snapshot)
{
var isNight = ResolveIsNight(snapshot);
_activeWeatherSymbol = ResolveWeatherSymbol(snapshot.Current.WeatherCode, isNight);
WeatherIconSymbol.Symbol = _activeWeatherSymbol;
WeatherIconSymbol.Foreground = CreateBrush(ResolveWeatherIconColor(_activeWeatherSymbol, isNight));
}
private void ApplyDefaultWeatherIcon()
{
var isNight = IsNightNow();
_activeWeatherSymbol = isNight ? Symbol.WeatherMoon : Symbol.WeatherPartlyCloudyDay;
WeatherIconSymbol.Symbol = _activeWeatherSymbol;
WeatherIconSymbol.Foreground = CreateBrush(ResolveWeatherIconColor(_activeWeatherSymbol, isNight));
}
private void UpdateClockVisual()
{
ApplyModeVisualIfNeeded();
var now = _timeZoneService?.GetCurrentTime() ?? DateTime.Now;
TimeTextBlock.Text = now.ToString("HH:mm", CultureInfo.CurrentCulture);
DateTextBlock.Text = FormatDate(now);
var hourAngle = (now.Hour % 12 + now.Minute / 60d + now.Second / 3600d) * 30d;
var minuteAngle = (now.Minute + now.Second / 60d) * 6d;
var secondAngle = (now.Second + now.Millisecond / 1000d) * 6d;
SetHandGeometry(_hourHandLine, hourAngle, forwardLength: 23.5, backwardLength: 5.0);
SetHandGeometry(_minuteHandLine, minuteAngle, forwardLength: 33.5, backwardLength: 6.5);
SetHandGeometry(_secondHandLine, secondAngle, forwardLength: 39.0, backwardLength: 10.0);
}
private void InitializeDialIfNeeded()
{
if (_dialInitialized)
{
return;
}
BuildTicks(isNightMode: false);
_dialInitialized = true;
}
private void InitializeHandsIfNeeded()
{
if (_handsInitialized)
{
return;
}
HandsCanvas.Children.Clear();
HandsCanvas.Children.Add(_hourHandLine);
HandsCanvas.Children.Add(_minuteHandLine);
HandsCanvas.Children.Add(_secondHandLine);
_handsInitialized = true;
}
private void BuildTicks(bool isNightMode)
{
TickCanvas.Children.Clear();
var tickColor = isNightMode ? "#CED7EA" : "#1C2333";
for (var i = 0; i < 12; i++)
{
var angle = (i * 30 - 90) * Math.PI / 180d;
var isMajor = i % 3 == 0;
var outerRadius = DialCenter - 8;
var innerRadius = outerRadius - (isMajor ? 13.5 : 9.5);
var x1 = DialCenter + Math.Cos(angle) * innerRadius;
var y1 = DialCenter + Math.Sin(angle) * innerRadius;
var x2 = DialCenter + Math.Cos(angle) * outerRadius;
var y2 = DialCenter + Math.Sin(angle) * outerRadius;
TickCanvas.Children.Add(new Line
{
StartPoint = new Point(x1, y1),
EndPoint = new Point(x2, y2),
Stroke = CreateBrush(tickColor),
StrokeThickness = isMajor ? 2.8 : 1.9,
StrokeLineCap = PenLineCap.Round
});
}
}
private void ApplyModeVisualIfNeeded()
{
var isNightMode = ResolveIsNightMode();
if (_isNightModeApplied.HasValue && _isNightModeApplied.Value == isNightMode)
{
return;
}
_isNightModeApplied = isNightMode;
ApplyModeVisual(isNightMode);
}
private void ApplyModeVisual(bool isNightMode)
{
RootBorder.Background = isNightMode
? CreateGradientBrush("#2A3346", "#202A3B")
: CreateGradientBrush("#FFFFFF", "#F6F8FC");
RootBorder.BorderBrush = CreateBrush(isNightMode ? "#36F2F5FF" : "#14000000");
AnalogDialBorder.Background = isNightMode
? CreateBrush("#1B2434")
: CreateBrush("#F8FAFF");
AnalogDialBorder.BorderBrush = CreateBrush(isNightMode ? "#34DDE7FF" : "#12000000");
TimeTextBlock.Foreground = CreateBrush(isNightMode ? "#F8FBFF" : "#10131A");
DateTextBlock.Foreground = CreateBrush(isNightMode ? "#BCC8DD" : "#7A7E87");
_hourHandLine.Stroke = CreateBrush(isNightMode ? "#F1F5FF" : "#232938");
_minuteHandLine.Stroke = CreateBrush(isNightMode ? "#D6E0F2" : "#2F3749");
_secondHandLine.Stroke = CreateBrush("#1A74F2");
CenterDotOuter.Fill = CreateBrush(isNightMode ? "#7BAAE8" : "#4F7CC0");
CenterDotInner.Fill = CreateBrush("#1A74F2");
BuildTicks(isNightMode);
WeatherIconSymbol.Foreground = CreateBrush(ResolveWeatherIconColor(_activeWeatherSymbol, isNightMode));
}
private WeatherClockConfig LoadConfig()
{
var snapshot = _settingsService.Load();
var languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode);
var locale = string.Equals(languageCode, "zh-CN", StringComparison.OrdinalIgnoreCase)
? "zh_cn"
: "en_us";
var latitude = NormalizeLatitude(snapshot.WeatherLatitude);
var longitude = NormalizeLongitude(snapshot.WeatherLongitude);
var locationKey = snapshot.WeatherLocationKey?.Trim() ?? string.Empty;
var modeIsCoordinates = string.Equals(
snapshot.WeatherLocationMode,
"Coordinates",
StringComparison.OrdinalIgnoreCase);
if (modeIsCoordinates && string.IsNullOrWhiteSpace(locationKey))
{
locationKey = BuildCoordinateLocationKey(latitude, longitude);
}
return new WeatherClockConfig(
LanguageCode: languageCode,
Locale: locale,
LocationKey: locationKey,
Latitude: latitude,
Longitude: longitude);
}
private string FormatDate(DateTime dateTime)
{
var isZh = string.Equals(_languageCode, "zh-CN", StringComparison.OrdinalIgnoreCase);
if (isZh)
{
return string.Create(CultureInfo.InvariantCulture, $"{dateTime.Month}\u6708{dateTime.Day}\u65e5");
}
try
{
var culture = CultureInfo.GetCultureInfo(_languageCode);
return dateTime.ToString("MMM d", culture);
}
catch
{
return dateTime.ToString("MMM d", CultureInfo.InvariantCulture);
}
}
private double ResolveScale()
{
var cellScale = Math.Clamp(_currentCellSize / 44d, 0.60, 2.20);
var heightScale = Bounds.Height > 1 ? Math.Clamp(Bounds.Height / 56d, 0.65, 2.80) : 1;
var widthScale = Bounds.Width > 1 ? Math.Clamp(Bounds.Width / 180d, 0.65, 2.80) : 1;
return Math.Clamp(Math.Min(heightScale, widthScale) * 1.02 * cellScale, 0.62, 2.40);
}
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();
}
private bool IsNightNow()
{
var now = _timeZoneService?.GetCurrentTime() ?? DateTime.Now;
return now.Hour < 6 || now.Hour >= 18;
}
private bool ResolveIsNightMode()
{
if (ActualThemeVariant == ThemeVariant.Dark)
{
return true;
}
if (ActualThemeVariant == ThemeVariant.Light)
{
return false;
}
if (this.TryFindResource("AdaptiveSurfaceBaseBrush", out var value) &&
value is ISolidColorBrush solidBrush)
{
return CalculateRelativeLuminance(solidBrush.Color) < 0.45;
}
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;
var cos = Math.Cos(radians);
var sin = Math.Sin(radians);
hand.StartPoint = new Point(
DialCenter - (cos * backwardLength),
DialCenter - (sin * backwardLength));
hand.EndPoint = new Point(
DialCenter + (cos * forwardLength),
DialCenter + (sin * forwardLength));
}
private static Line CreateHandLine(string colorHex, double thickness)
{
return new Line
{
StartPoint = new Point(DialCenter, DialCenter),
EndPoint = new Point(DialCenter, DialCenter - 32),
Stroke = CreateBrush(colorHex),
StrokeThickness = thickness,
StrokeLineCap = PenLineCap.Round
};
}
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)
}
};
}
private static double Lerp(double from, double to, double t)
{
return from + ((to - from) * t);
}
private static FontWeight ToVariableWeight(double weight)
{
return (FontWeight)(int)Math.Clamp(Math.Round(weight), 1, 1000);
}
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 string BuildCoordinateLocationKey(double latitude, double longitude)
{
return string.Create(CultureInfo.InvariantCulture, $"coord:{latitude:F4},{longitude:F4}");
}
private static double NormalizeLatitude(double value)
{
if (double.IsNaN(value) || double.IsInfinity(value))
{
return 39.9042;
}
return Math.Clamp(value, -90, 90);
}
private static double NormalizeLongitude(double value)
{
if (double.IsNaN(value) || double.IsInfinity(value))
{
return 116.4074;
}
return Math.Clamp(value, -180, 180);
}
private void CancelRefreshRequest()
{
var cts = Interlocked.Exchange(ref _refreshCts, null);
cts?.Cancel();
cts?.Dispose();
}
}

View File

@@ -0,0 +1,150 @@
<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:fi="using:FluentIcons.Avalonia"
mc:Ignorable="d"
d:DesignWidth="320"
d:DesignHeight="320"
x:Class="LanMontainDesktop.Views.Components.WeatherWidget">
<Border x:Name="RootBorder"
CornerRadius="28"
ClipToBounds="True"
Background="#68A9EC">
<Grid>
<Border x:Name="BackgroundImageLayer"
CornerRadius="28"
ClipToBounds="True" />
<Border x:Name="BackgroundMotionLayer"
CornerRadius="28"
ClipToBounds="True"
Opacity="0.24"
RenderTransformOrigin="0.5,0.5">
<Border.RenderTransform>
<TransformGroup>
<ScaleTransform ScaleX="1.05"
ScaleY="1.05" />
<TranslateTransform />
</TransformGroup>
</Border.RenderTransform>
</Border>
<Border x:Name="BackgroundTintLayer"
CornerRadius="28"
ClipToBounds="True"
Opacity="0.20" />
<Border x:Name="BackgroundLightLayer"
CornerRadius="28"
ClipToBounds="True"
Opacity="0.66">
<Border.Background>
<LinearGradientBrush StartPoint="0,0"
EndPoint="1,1">
<GradientStop Color="#5BFFFFFF"
Offset="0" />
<GradientStop Color="#1FFFFFFF"
Offset="0.30" />
<GradientStop Color="#00000000"
Offset="0.55" />
</LinearGradientBrush>
</Border.Background>
</Border>
<Border x:Name="BackgroundShadeLayer"
CornerRadius="28"
ClipToBounds="True"
Opacity="0.78">
<Border.Background>
<LinearGradientBrush StartPoint="0,0"
EndPoint="0,1">
<GradientStop Color="#00040A16"
Offset="0.50" />
<GradientStop Color="#2E0B1C34"
Offset="1" />
</LinearGradientBrush>
</Border.Background>
</Border>
<Canvas x:Name="ParticleLayer"
IsHitTestVisible="False"
ClipToBounds="True" />
<Border x:Name="ContentPaddingBorder"
Padding="18"
Background="Transparent">
<Viewbox Stretch="Uniform">
<Grid x:Name="LayoutRoot"
Width="300"
Height="300">
<Grid x:Name="ContentGrid"
RowDefinitions="Auto,*,Auto"
RowSpacing="8">
<Grid x:Name="TopRowGrid"
Grid.Row="0"
ColumnDefinitions="Auto,*,Auto"
ColumnSpacing="8">
<fi:SymbolIcon x:Name="LocationIcon"
Symbol="Location"
FontSize="20"
VerticalAlignment="Center" />
<TextBlock x:Name="CityTextBlock"
Grid.Column="1"
Text="Beijing"
FontSize="30"
FontWeight="SemiBold"
VerticalAlignment="Center"
TextTrimming="CharacterEllipsis"
MaxLines="1" />
<fi:SymbolIcon x:Name="WeatherIconSymbol"
Grid.Column="2"
Symbol="WeatherSunny"
IconVariant="Regular"
FontSize="40"
HorizontalAlignment="Right"
VerticalAlignment="Center" />
</Grid>
<TextBlock x:Name="TemperatureTextBlock"
Grid.Row="1"
Text="26"
FontSize="108"
FontWeight="Bold"
FontFeatures="tnum"
VerticalAlignment="Center"
Margin="0,4,0,10"
TextTrimming="CharacterEllipsis"
MaxLines="1" />
<StackPanel x:Name="BottomInfoStack"
Grid.Row="2"
VerticalAlignment="Bottom"
Spacing="4"
Margin="0,0,0,10">
<TextBlock x:Name="ConditionTextBlock"
Text="Clear"
FontSize="30"
FontWeight="SemiBold"
TextTrimming="CharacterEllipsis"
MaxLines="1" />
<TextBlock x:Name="RangeTextBlock"
Text="20 / 28"
FontSize="36"
FontWeight="SemiBold"
FontFeatures="tnum"
TextTrimming="CharacterEllipsis"
MaxLines="1" />
</StackPanel>
</Grid>
</Grid>
</Viewbox>
</Border>
</Grid>
</Border>
</UserControl>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,94 @@
<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:fi="using:FluentIcons.Avalonia"
xmlns:inking="using:DotNetCampus.Inking"
mc:Ignorable="d"
d:DesignWidth="240"
d:DesignHeight="480"
x:Class="LanMontainDesktop.Views.Components.WhiteboardWidget">
<Border x:Name="RootBorder"
Background="#F1F4F9"
CornerRadius="20"
ClipToBounds="True"
Padding="8">
<Grid RowDefinitions="*,Auto"
RowSpacing="8">
<Border x:Name="CanvasBorder"
Grid.Row="0"
Background="#FFFFFF"
BorderBrush="#24000000"
BorderThickness="1"
CornerRadius="14"
ClipToBounds="True">
<inking:InkCanvas x:Name="InkCanvas" />
</Border>
<Border x:Name="ToolbarBorder"
Grid.Row="1"
HorizontalAlignment="Center"
Background="#E6FFFFFF"
BorderBrush="#16000000"
BorderThickness="1"
CornerRadius="14"
Padding="8,6">
<StackPanel x:Name="ToolbarButtonsPanel"
Orientation="Horizontal"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Spacing="8">
<Button x:Name="PenButton"
Width="30"
Height="30"
Padding="0"
CornerRadius="15"
ToolTip.Tip="Pen"
Click="OnPenButtonClick">
<fi:SymbolIcon x:Name="PenIcon"
Symbol="Pen"
IconVariant="Regular"
FontSize="14" />
</Button>
<Button x:Name="EraserButton"
Width="30"
Height="30"
Padding="0"
CornerRadius="15"
ToolTip.Tip="Eraser"
Click="OnEraserButtonClick">
<fi:SymbolIcon x:Name="EraserIcon"
Symbol="EraserTool"
IconVariant="Regular"
FontSize="14" />
</Button>
<Button x:Name="ClearButton"
Width="30"
Height="30"
Padding="0"
CornerRadius="15"
ToolTip.Tip="Clear"
Click="OnClearButtonClick">
<fi:SymbolIcon x:Name="ClearIcon"
Symbol="Delete"
IconVariant="Regular"
FontSize="14" />
</Button>
<Button x:Name="ExportButton"
Width="30"
Height="30"
Padding="0"
CornerRadius="15"
ToolTip.Tip="Export SVG"
Click="OnExportButtonClick">
<fi:SymbolIcon x:Name="ExportIcon"
Symbol="ArrowExport"
IconVariant="Regular"
FontSize="14" />
</Button>
</StackPanel>
</Border>
</Grid>
</Border>
</UserControl>

View File

@@ -0,0 +1,361 @@
using System;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Media;
using Avalonia.Platform.Storage;
using Avalonia.Styling;
using DotNetCampus.Inking;
using FluentIcons.Avalonia;
using SkiaSharp;
namespace LanMontainDesktop.Views.Components;
public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget
{
private enum WhiteboardToolMode
{
Pen,
Eraser
}
private static readonly PropertyInfo? StrokeColorProperty = typeof(SkiaStroke).GetProperty(nameof(SkiaStroke.Color));
private readonly int _baseWidthCells;
private double _currentCellSize = 48;
private WhiteboardToolMode _toolMode = WhiteboardToolMode.Pen;
private bool? _isNightModeApplied;
private SKColor _currentInkColor = SKColors.Black;
public WhiteboardWidget()
: this(baseWidthCells: 2)
{
}
public WhiteboardWidget(int baseWidthCells)
{
_baseWidthCells = Math.Max(1, baseWidthCells);
InitializeComponent();
AttachedToVisualTree += OnAttachedToVisualTree;
DetachedFromVisualTree += OnDetachedFromVisualTree;
SizeChanged += OnSizeChanged;
ActualThemeVariantChanged += OnActualThemeVariantChanged;
ConfigureInkCanvas();
ApplyCellSize(_currentCellSize);
ApplyThemeVisual(force: true);
SetToolMode(WhiteboardToolMode.Pen);
}
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
{
ApplyThemeVisual(force: true);
}
private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
{
// Keep all state in-memory for lightweight re-attach scenarios.
}
private void OnSizeChanged(object? sender, SizeChangedEventArgs e)
{
ApplyCellSize(_currentCellSize);
}
private void OnActualThemeVariantChanged(object? sender, EventArgs e)
{
ApplyThemeVisual(force: false);
}
private void ConfigureInkCanvas()
{
InkCanvas.EditingMode = InkCanvasEditingMode.Ink;
var settings = InkCanvas.AvaloniaSkiaInkCanvas.Settings;
settings.IgnorePressure = true;
settings.InkThickness = 2.5f;
settings.EraserSize = new Size(20, 20);
settings.IsBitmapCacheEnabled = true;
settings.MaxBitmapCacheSize = 2048;
}
public void ApplyCellSize(double cellSize)
{
_currentCellSize = Math.Max(1, cellSize);
var availableWidth = Bounds.Width > 1 ? Bounds.Width : (_currentCellSize * _baseWidthCells);
var buttonSize = Math.Clamp(availableWidth * 0.15, 24, 40);
var buttonCornerRadius = buttonSize * 0.5;
var toolbarSpacing = Math.Clamp(buttonSize * 0.25, 4, 10);
var toolbarPaddingHorizontal = Math.Clamp(buttonSize * 0.36, 6, 12);
var toolbarPaddingVertical = Math.Clamp(buttonSize * 0.24, 4, 8);
RootBorder.Padding = new Thickness(Math.Clamp(_currentCellSize * 0.14, 6, 14));
RootBorder.CornerRadius = new CornerRadius(Math.Clamp(_currentCellSize * 0.34, 12, 28));
CanvasBorder.CornerRadius = new CornerRadius(Math.Clamp(_currentCellSize * 0.24, 10, 22));
ToolbarBorder.CornerRadius = new CornerRadius(Math.Clamp(_currentCellSize * 0.22, 10, 20));
ToolbarBorder.Padding = new Thickness(toolbarPaddingHorizontal, toolbarPaddingVertical);
ToolbarButtonsPanel.Spacing = toolbarSpacing;
foreach (var button in new[] { PenButton, EraserButton, ClearButton, ExportButton })
{
button.Width = buttonSize;
button.Height = buttonSize;
button.CornerRadius = new CornerRadius(buttonCornerRadius);
}
var settings = InkCanvas.AvaloniaSkiaInkCanvas.Settings;
settings.InkThickness = (float)Math.Clamp(_currentCellSize * 0.06, 2.0, 6.0);
var eraserSize = Math.Clamp(_currentCellSize * 0.42, 12, 44);
settings.EraserSize = new Size(eraserSize, eraserSize);
}
private void ApplyThemeVisual(bool force)
{
var isNightMode = ResolveIsNightMode();
if (!force && _isNightModeApplied.HasValue && _isNightModeApplied.Value == isNightMode)
{
return;
}
_isNightModeApplied = isNightMode;
_currentInkColor = isNightMode ? SKColors.White : SKColors.Black;
RootBorder.Background = new SolidColorBrush(isNightMode ? Color.Parse("#FF181B22") : Color.Parse("#FFF1F4F9"));
CanvasBorder.Background = new SolidColorBrush(isNightMode ? Color.Parse("#FF000000") : Color.Parse("#FFFFFFFF"));
CanvasBorder.BorderBrush = new SolidColorBrush(isNightMode ? Color.Parse("#30FFFFFF") : Color.Parse("#24000000"));
ToolbarBorder.Background = new SolidColorBrush(isNightMode ? Color.Parse("#1AFFFFFF") : Color.Parse("#E6FFFFFF"));
ToolbarBorder.BorderBrush = new SolidColorBrush(isNightMode ? Color.Parse("#26FFFFFF") : Color.Parse("#16000000"));
InkCanvas.AvaloniaSkiaInkCanvas.Settings.InkColor = _currentInkColor;
RecolorAllStrokes(_currentInkColor);
RefreshToolButtonVisuals();
}
private void RecolorAllStrokes(SKColor targetColor)
{
for (var i = 0; i < InkCanvas.Strokes.Count; i++)
{
TrySetStrokeColor(InkCanvas.Strokes[i], targetColor);
}
InkCanvas.AvaloniaSkiaInkCanvas.InvalidateBitmapCache();
InkCanvas.InvalidateVisual();
}
private static void TrySetStrokeColor(SkiaStroke stroke, SKColor color)
{
if (StrokeColorProperty is null)
{
return;
}
try
{
StrokeColorProperty.SetValue(stroke, color);
}
catch
{
// Keep current stroke color when reflection is unavailable.
}
}
private bool ResolveIsNightMode()
{
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 false;
}
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 void SetToolMode(WhiteboardToolMode mode)
{
_toolMode = mode;
InkCanvas.EditingMode = mode == WhiteboardToolMode.Pen
? InkCanvasEditingMode.Ink
: InkCanvasEditingMode.EraseByPoint;
if (mode == WhiteboardToolMode.Pen)
{
InkCanvas.AvaloniaSkiaInkCanvas.Settings.InkColor = _currentInkColor;
}
RefreshToolButtonVisuals();
}
private void RefreshToolButtonVisuals()
{
var isNightMode = _isNightModeApplied ?? ResolveIsNightMode();
var activeBackground = ResolveThemeBrush("AdaptiveAccentBrush", isNightMode ? Color.Parse("#FF93C5FD") : Color.Parse("#FF3B82F6"));
var activeForeground = ResolveThemeBrush("AdaptiveOnAccentBrush", Colors.White);
var idleForeground = ResolveThemeBrush("AdaptiveTextPrimaryBrush", isNightMode ? Color.Parse("#FFE5E7EB") : Color.Parse("#FF0F172A"));
var idleBackground = new SolidColorBrush(isNightMode ? Color.Parse("#33FFFFFF") : Color.Parse("#14000000"));
ApplyToolButtonVisual(PenButton, _toolMode == WhiteboardToolMode.Pen, activeBackground, activeForeground, idleBackground, idleForeground);
ApplyToolButtonVisual(EraserButton, _toolMode == WhiteboardToolMode.Eraser, activeBackground, activeForeground, idleBackground, idleForeground);
ApplyToolButtonVisual(ClearButton, false, activeBackground, activeForeground, idleBackground, idleForeground);
ApplyToolButtonVisual(ExportButton, false, activeBackground, activeForeground, idleBackground, idleForeground);
}
private static void ApplyToolButtonVisual(
Button button,
bool isActive,
IBrush activeBackground,
IBrush activeForeground,
IBrush idleBackground,
IBrush idleForeground)
{
button.Background = isActive ? activeBackground : idleBackground;
button.Foreground = isActive ? activeForeground : idleForeground;
button.BorderThickness = new Thickness(0);
if (button.Content is SymbolIcon symbolIcon)
{
symbolIcon.Foreground = button.Foreground;
}
}
private IBrush ResolveThemeBrush(string key, Color fallback)
{
if (this.TryFindResource(key, out var resource) && resource is IBrush brush)
{
return brush;
}
return new SolidColorBrush(fallback);
}
private void OnPenButtonClick(object? sender, RoutedEventArgs e)
{
SetToolMode(WhiteboardToolMode.Pen);
}
private void OnEraserButtonClick(object? sender, RoutedEventArgs e)
{
SetToolMode(WhiteboardToolMode.Eraser);
}
private void OnClearButtonClick(object? sender, RoutedEventArgs e)
{
var strokeList = InkCanvas.Strokes.ToList();
foreach (var stroke in strokeList)
{
try
{
if (ReferenceEquals(stroke.InkCanvas, InkCanvas.AvaloniaSkiaInkCanvas))
{
InkCanvas.AvaloniaSkiaInkCanvas.RemoveStaticStroke(stroke);
}
}
catch
{
// Keep the widget alive even if one stroke removal fails.
}
}
InkCanvas.AvaloniaSkiaInkCanvas.UseBitmapCache(false);
InkCanvas.AvaloniaSkiaInkCanvas.InvalidateBitmapCache();
InkCanvas.InvalidateVisual();
}
private async void OnExportButtonClick(object? sender, RoutedEventArgs e)
{
var fileName = $"whiteboard-{DateTime.Now:yyyyMMdd-HHmmss}.svg";
var topLevel = TopLevel.GetTopLevel(this);
var storageProvider = topLevel?.StorageProvider;
if (storageProvider is not null)
{
var saveFile = await storageProvider.SaveFilePickerAsync(new FilePickerSaveOptions
{
Title = "Export Whiteboard SVG",
SuggestedFileName = fileName,
DefaultExtension = "svg",
FileTypeChoices =
[
new FilePickerFileType("SVG image")
{
Patterns = ["*.svg"],
MimeTypes = ["image/svg+xml"]
}
]
});
if (saveFile is null)
{
return;
}
await using var saveStream = await saveFile.OpenWriteAsync();
ExportSvgToStream(saveStream);
return;
}
var exportFolder = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"LanMontainDesktop",
"Exports");
Directory.CreateDirectory(exportFolder);
var savePath = Path.Combine(exportFolder, fileName);
await using var fileStream = File.Create(savePath);
ExportSvgToStream(fileStream);
}
private void ExportSvgToStream(Stream stream)
{
var width = Math.Max(1d, CanvasBorder.Bounds.Width);
var height = Math.Max(1d, CanvasBorder.Bounds.Height);
var bounds = SKRect.Create((float)width, (float)height);
using var svgCanvas = SKSvgCanvas.Create(bounds, stream);
using var backgroundPaint = new SKPaint
{
IsAntialias = true,
Style = SKPaintStyle.Fill,
Color = (_isNightModeApplied ?? false) ? SKColors.Black : SKColors.White
};
svgCanvas.DrawRect(bounds, backgroundPaint);
using var strokePaint = new SKPaint
{
IsAntialias = true,
Style = SKPaintStyle.Fill
};
foreach (var stroke in InkCanvas.Strokes)
{
strokePaint.Color = stroke.Color;
svgCanvas.DrawPath(stroke.Path, strokePaint);
}
svgCanvas.Flush();
}
}

View File

@@ -42,7 +42,7 @@ public partial class MainWindow
private TranslateTransform? _componentLibraryCategoryHostTransform;
private TranslateTransform? _componentLibraryComponentHostTransform;
private IReadOnlyList<ComponentLibraryCategory> _componentLibraryCategories = Array.Empty<ComponentLibraryCategory>();
private IReadOnlyList<DesktopComponentDefinition> _componentLibraryActiveComponents = Array.Empty<DesktopComponentDefinition>();
private IReadOnlyList<DesktopComponentRuntimeDescriptor> _componentLibraryActiveComponents = Array.Empty<DesktopComponentRuntimeDescriptor>();
private bool _isComponentLibraryCategoryGestureActive;
private bool _isComponentLibraryComponentGestureActive;
private Point _componentLibraryCategoryGestureStartPoint;
@@ -93,7 +93,9 @@ public partial class MainWindow
string Id,
Symbol Icon,
string Title,
IReadOnlyList<DesktopComponentDefinition> Components);
IReadOnlyList<DesktopComponentRuntimeDescriptor> Components);
private readonly record struct ComponentScaleRule(int WidthUnit, int HeightUnit, int MinScale);
private void OnOpenComponentLibraryClick(object? sender, RoutedEventArgs e)
{
@@ -258,7 +260,8 @@ public partial class MainWindow
1 => TaskbarContext.SettingsGrid,
2 => TaskbarContext.SettingsColor,
3 => TaskbarContext.SettingsStatusBar,
4 => TaskbarContext.SettingsRegion,
4 => TaskbarContext.SettingsWeather,
5 => TaskbarContext.SettingsRegion,
_ => TaskbarContext.Desktop
};
}
@@ -806,7 +809,8 @@ public partial class MainWindow
? Guid.NewGuid().ToString("N")
: placement.PlacementId.Trim();
var componentId = placement.ComponentId.Trim();
if (!_componentRegistry.TryGetDefinition(componentId, out var definition) || !definition.AllowDesktopPlacement)
if (!_componentRuntimeRegistry.TryGetDescriptor(componentId, out var runtimeDescriptor) ||
!runtimeDescriptor.Definition.AllowDesktopPlacement)
{
continue;
}
@@ -814,7 +818,7 @@ public partial class MainWindow
var (widthCells, heightCells) = NormalizeComponentCellSpan(
componentId,
ComponentPlacementRules.EnsureMinimumSize(
definition,
runtimeDescriptor.Definition,
placement.WidthCells,
placement.HeightCells));
@@ -849,7 +853,8 @@ public partial class MainWindow
foreach (var placement in _desktopComponentPlacements.Where(p => p.PageIndex == pageIndex))
{
if (!_componentRegistry.TryGetDefinition(placement.ComponentId, out var definition) || !definition.AllowDesktopPlacement)
if (!_componentRuntimeRegistry.TryGetDescriptor(placement.ComponentId, out var runtimeDescriptor) ||
!runtimeDescriptor.Definition.AllowDesktopPlacement)
{
continue;
}
@@ -857,7 +862,7 @@ public partial class MainWindow
var (widthCells, heightCells) = NormalizeComponentCellSpan(
placement.ComponentId,
ComponentPlacementRules.EnsureMinimumSize(
definition,
runtimeDescriptor.Definition,
placement.WidthCells,
placement.HeightCells));
@@ -890,7 +895,8 @@ public partial class MainWindow
return;
}
if (!_componentRegistry.TryGetDefinition(componentId, out var definition) || !definition.AllowDesktopPlacement)
if (!_componentRuntimeRegistry.TryGetDescriptor(componentId, out var runtimeDescriptor) ||
!runtimeDescriptor.Definition.AllowDesktopPlacement)
{
return;
}
@@ -898,9 +904,9 @@ public partial class MainWindow
var (widthCells, heightCells) = NormalizeComponentCellSpan(
componentId,
ComponentPlacementRules.EnsureMinimumSize(
definition,
definition.MinWidthCells,
definition.MinHeightCells));
runtimeDescriptor.Definition,
runtimeDescriptor.Definition.MinWidthCells,
runtimeDescriptor.Definition.MinHeightCells));
var maxColumns = pageGrid.ColumnDefinitions.Count;
var maxRows = pageGrid.RowDefinitions.Count;
@@ -1046,55 +1052,110 @@ public partial class MainWindow
return host;
}
private static (int WidthCells, int HeightCells) NormalizeComponentCellSpan(
private (int WidthCells, int HeightCells) NormalizeComponentCellSpan(
string componentId,
(int WidthCells, int HeightCells) span)
{
if (string.Equals(componentId, BuiltInComponentIds.Date, StringComparison.OrdinalIgnoreCase))
if (_componentRuntimeRegistry.TryGetDescriptor(componentId, out var runtimeDescriptor))
{
return (Math.Max(4, span.WidthCells), Math.Max(2, span.HeightCells));
var normalized = ComponentPlacementRules.EnsureMinimumSize(
runtimeDescriptor.Definition,
span.WidthCells,
span.HeightCells);
return NormalizeAspectRatioForComponent(componentId, normalized);
}
if (string.Equals(componentId, BuiltInComponentIds.MonthCalendar, StringComparison.OrdinalIgnoreCase))
return NormalizeAspectRatioForComponent(
componentId,
(Math.Max(1, span.WidthCells), Math.Max(1, span.HeightCells)));
}
private static (int WidthCells, int HeightCells) NormalizeAspectRatioForComponent(
string componentId,
(int WidthCells, int HeightCells) span)
{
if (string.Equals(componentId, BuiltInComponentIds.DesktopWhiteboard, StringComparison.OrdinalIgnoreCase))
{
return (Math.Max(2, span.WidthCells), Math.Max(2, span.HeightCells));
// Support both portrait ratios and snap to nearest viable scale tier.
return SnapSpanToScaleRules(
span,
new ComponentScaleRule(WidthUnit: 1, HeightUnit: 2, MinScale: 2), // 2x4, 3x6, 4x8...
new ComponentScaleRule(WidthUnit: 3, HeightUnit: 4, MinScale: 1)); // 3x4, 6x8...
}
if (string.Equals(componentId, BuiltInComponentIds.LunarCalendar, StringComparison.OrdinalIgnoreCase))
if (string.Equals(componentId, BuiltInComponentIds.DesktopBlackboardLandscape, StringComparison.OrdinalIgnoreCase))
{
return (Math.Max(2, span.WidthCells), Math.Max(2, span.HeightCells));
// Support both landscape ratios and snap to nearest viable scale tier.
return SnapSpanToScaleRules(
span,
new ComponentScaleRule(WidthUnit: 2, HeightUnit: 1, MinScale: 2), // 4x2, 6x3, 8x4...
new ComponentScaleRule(WidthUnit: 4, HeightUnit: 3, MinScale: 1)); // 4x3, 8x6...
}
if (string.Equals(componentId, BuiltInComponentIds.DesktopClock, StringComparison.OrdinalIgnoreCase))
return span;
}
private static (int WidthCells, int HeightCells) SnapSpanToScaleRules(
(int WidthCells, int HeightCells) span,
params ComponentScaleRule[] rules)
{
var targetWidth = Math.Max(1, span.WidthCells);
var targetHeight = Math.Max(1, span.HeightCells);
var hasCandidate = false;
var bestWidth = targetWidth;
var bestHeight = targetHeight;
var bestArea = -1;
var bestDistance = double.MaxValue;
foreach (var rule in rules)
{
return (Math.Max(2, span.WidthCells), Math.Max(2, span.HeightCells));
if (rule.WidthUnit <= 0 || rule.HeightUnit <= 0 || rule.MinScale <= 0)
{
continue;
}
var maxScale = Math.Min(targetWidth / rule.WidthUnit, targetHeight / rule.HeightUnit);
if (maxScale < rule.MinScale)
{
continue;
}
for (var scale = rule.MinScale; scale <= maxScale; scale++)
{
var width = rule.WidthUnit * scale;
var height = rule.HeightUnit * scale;
var area = width * height;
var dx = targetWidth - width;
var dy = targetHeight - height;
var distance = dx * dx + dy * dy;
if (!hasCandidate ||
area > bestArea ||
(area == bestArea && distance < bestDistance))
{
hasCandidate = true;
bestWidth = width;
bestHeight = height;
bestArea = area;
bestDistance = distance;
}
}
}
if (string.Equals(componentId, BuiltInComponentIds.DesktopTimer, StringComparison.OrdinalIgnoreCase))
{
return (Math.Max(2, span.WidthCells), Math.Max(2, span.HeightCells));
}
if (string.Equals(componentId, BuiltInComponentIds.HolidayCalendar, StringComparison.OrdinalIgnoreCase))
{
return (Math.Max(2, span.WidthCells), Math.Max(2, span.HeightCells));
}
return (Math.Max(1, span.WidthCells), Math.Max(1, span.HeightCells));
return hasCandidate
? (bestWidth, bestHeight)
: (targetWidth, targetHeight);
}
private double GetComponentCornerRadius(string componentId)
{
return componentId switch
if (_componentRuntimeRegistry.TryGetDescriptor(componentId, out var runtimeDescriptor))
{
BuiltInComponentIds.Date => 16,
BuiltInComponentIds.MonthCalendar => Math.Clamp(_currentDesktopCellSize * 0.26, 10, 22),
BuiltInComponentIds.LunarCalendar => Math.Clamp(_currentDesktopCellSize * 0.30, 12, 26),
BuiltInComponentIds.DesktopClock => Math.Clamp(_currentDesktopCellSize * 0.30, 12, 28),
BuiltInComponentIds.DesktopTimer => Math.Clamp(_currentDesktopCellSize * 0.30, 12, 28),
BuiltInComponentIds.HolidayCalendar => Math.Clamp(_currentDesktopCellSize * 0.32, 12, 28),
_ => Math.Clamp(_currentDesktopCellSize * 0.22, 8, 18)
};
return runtimeDescriptor.ResolveCornerRadius(_currentDesktopCellSize);
}
return Math.Clamp(_currentDesktopCellSize * 0.22, 8, 18);
}
private Thickness GetDesktopComponentVisualInset(int widthCells, int heightCells)
@@ -1172,60 +1233,14 @@ public partial class MainWindow
private Control? CreateDesktopComponentControl(string componentId)
{
if (componentId == BuiltInComponentIds.Date)
if (!_componentRuntimeRegistry.TryGetDescriptor(componentId, out var runtimeDescriptor))
{
var widget = new DateWidget();
widget.SetTimeZoneService(_timeZoneService);
widget.ApplyCellSize(_currentDesktopCellSize);
widget.Classes.Add(DesktopComponentClass);
return widget;
return null;
}
if (componentId == BuiltInComponentIds.MonthCalendar)
{
var widget = new MonthCalendarWidget();
widget.SetTimeZoneService(_timeZoneService);
widget.ApplyCellSize(_currentDesktopCellSize);
widget.Classes.Add(DesktopComponentClass);
return widget;
}
if (componentId == BuiltInComponentIds.LunarCalendar)
{
var widget = new LunarCalendarWidget();
widget.SetTimeZoneService(_timeZoneService);
widget.ApplyCellSize(_currentDesktopCellSize);
widget.Classes.Add(DesktopComponentClass);
return widget;
}
if (componentId == BuiltInComponentIds.DesktopClock)
{
var widget = new AnalogClockWidget();
widget.SetTimeZoneService(_timeZoneService);
widget.ApplyCellSize(_currentDesktopCellSize);
widget.Classes.Add(DesktopComponentClass);
return widget;
}
if (componentId == BuiltInComponentIds.DesktopTimer)
{
var widget = new TimerWidget();
widget.ApplyCellSize(_currentDesktopCellSize);
widget.Classes.Add(DesktopComponentClass);
return widget;
}
if (componentId == BuiltInComponentIds.HolidayCalendar)
{
var widget = new HolidayCalendarWidget();
widget.SetTimeZoneService(_timeZoneService);
widget.ApplyCellSize(_currentDesktopCellSize);
widget.Classes.Add(DesktopComponentClass);
return widget;
}
return null;
var component = runtimeDescriptor.CreateControl(_currentDesktopCellSize, _timeZoneService, _weatherDataService);
component.Classes.Add(DesktopComponentClass);
return component;
}
private void CollapseComponentLibraryPanel()
@@ -1372,7 +1387,7 @@ public partial class MainWindow
DesktopEditDragLayer is null ||
DesktopPagesViewport is null ||
_currentDesktopCellSize <= 0 ||
!_componentRegistry.TryGetDefinition(placement.ComponentId, out var definition))
!_componentRuntimeRegistry.TryGetDescriptor(placement.ComponentId, out var runtimeDescriptor))
{
return;
}
@@ -1380,7 +1395,7 @@ public partial class MainWindow
var (widthCells, heightCells) = NormalizeComponentCellSpan(
placement.ComponentId,
ComponentPlacementRules.EnsureMinimumSize(
definition,
runtimeDescriptor.Definition,
placement.WidthCells,
placement.HeightCells));
@@ -1418,8 +1433,8 @@ public partial class MainWindow
DesktopEditDragLayer is null ||
DesktopPagesViewport is null ||
_currentDesktopCellSize <= 0 ||
!_componentRegistry.TryGetDefinition(componentId, out var definition) ||
!definition.AllowDesktopPlacement)
!_componentRuntimeRegistry.TryGetDescriptor(componentId, out var runtimeDescriptor) ||
!runtimeDescriptor.Definition.AllowDesktopPlacement)
{
return;
}
@@ -1427,9 +1442,9 @@ public partial class MainWindow
var (widthCells, heightCells) = NormalizeComponentCellSpan(
componentId,
ComponentPlacementRules.EnsureMinimumSize(
definition,
definition.MinWidthCells,
definition.MinHeightCells));
runtimeDescriptor.Definition,
runtimeDescriptor.Definition.MinWidthCells,
runtimeDescriptor.Definition.MinHeightCells));
// Center the component under the pointer while dragging from the library.
var ghostWidth = Math.Max(1, widthCells * _currentDesktopCellSize + Math.Max(0, widthCells - 1) * _currentDesktopCellGap);
@@ -1534,7 +1549,7 @@ public partial class MainWindow
{
if (DesktopPagesViewport is null ||
_currentDesktopCellSize <= 0 ||
!_componentRegistry.TryGetDefinition(placement.ComponentId, out var definition) ||
!_componentRuntimeRegistry.TryGetDescriptor(placement.ComponentId, out var runtimeDescriptor) ||
!_desktopPageComponentGrids.TryGetValue(placement.PageIndex, out var pageGrid))
{
return;
@@ -1543,16 +1558,16 @@ public partial class MainWindow
var startSpan = NormalizeComponentCellSpan(
placement.ComponentId,
ComponentPlacementRules.EnsureMinimumSize(
definition,
runtimeDescriptor.Definition,
placement.WidthCells,
placement.HeightCells));
var minSpan = NormalizeComponentCellSpan(
placement.ComponentId,
ComponentPlacementRules.EnsureMinimumSize(
definition,
definition.MinWidthCells,
definition.MinHeightCells));
runtimeDescriptor.Definition,
runtimeDescriptor.Definition.MinWidthCells,
runtimeDescriptor.Definition.MinHeightCells));
var maxWidthCells = Math.Max(startSpan.WidthCells, pageGrid.ColumnDefinitions.Count - placement.Column);
var maxHeightCells = Math.Max(startSpan.HeightCells, pageGrid.RowDefinitions.Count - placement.Row);
@@ -2052,24 +2067,20 @@ public partial class MainWindow
private IReadOnlyList<ComponentLibraryCategory> GetComponentLibraryCategories()
{
var definitions = _componentRegistry
.GetAll()
.Where(definition => definition.AllowDesktopPlacement)
.ToList();
if (definitions.Count == 0)
var descriptors = _componentRuntimeRegistry.GetDesktopComponents();
if (descriptors.Count == 0)
{
return Array.Empty<ComponentLibraryCategory>();
}
return definitions
.GroupBy(definition => definition.Category, StringComparer.OrdinalIgnoreCase)
return descriptors
.GroupBy(descriptor => descriptor.Definition.Category, StringComparer.OrdinalIgnoreCase)
.OrderBy(group => group.Key, StringComparer.OrdinalIgnoreCase)
.Select(group =>
{
var categoryId = string.IsNullOrWhiteSpace(group.Key) ? "Other" : group.Key.Trim();
var components = group
.OrderBy(definition => definition.DisplayName, StringComparer.OrdinalIgnoreCase)
.OrderBy(descriptor => descriptor.Definition.DisplayName, StringComparer.OrdinalIgnoreCase)
.ToList();
return new ComponentLibraryCategory(
categoryId,
@@ -2092,6 +2103,16 @@ public partial class MainWindow
return Symbol.CalendarDate;
}
if (string.Equals(categoryId, "Weather", StringComparison.OrdinalIgnoreCase))
{
return Symbol.WeatherSunny;
}
if (string.Equals(categoryId, "Board", StringComparison.OrdinalIgnoreCase))
{
return Symbol.Edit;
}
return Symbol.Apps;
}
@@ -2107,6 +2128,16 @@ public partial class MainWindow
return L("component_category.date", "Calendar");
}
if (string.Equals(categoryId, "Weather", StringComparison.OrdinalIgnoreCase))
{
return L("component_category.weather", "Weather");
}
if (string.Equals(categoryId, "Board", StringComparison.OrdinalIgnoreCase))
{
return L("component_category.board", "Board");
}
return categoryId;
}
@@ -2236,8 +2267,9 @@ public partial class MainWindow
for (var i = 0; i < componentCount; i++)
{
var definition = _componentLibraryActiveComponents[i];
if (!_componentRegistry.TryGetDefinition(definition.Id, out var resolved) || !resolved.AllowDesktopPlacement)
var descriptor = _componentLibraryActiveComponents[i];
var definition = descriptor.Definition;
if (!definition.AllowDesktopPlacement)
{
continue;
}
@@ -2253,8 +2285,8 @@ public partial class MainWindow
var previewMaxWidth = _componentLibraryComponentPageWidth * 0.94;
var previewMaxHeight = viewportHeight * 0.86;
var previewSpan = NormalizeComponentCellSpan(
resolved.Id,
(resolved.MinWidthCells, resolved.MinHeightCells));
definition.Id,
(definition.MinWidthCells, definition.MinHeightCells));
var previewCellSize = Math.Min(
previewMaxWidth / Math.Max(1, previewSpan.WidthCells),
previewMaxHeight / Math.Max(1, previewSpan.HeightCells));
@@ -2264,11 +2296,7 @@ public partial class MainWindow
var previewHeight = previewSpan.HeightCells * previewCellSize;
var renderCellSize = Math.Clamp(previewCellSize * 1.15, 26, 110);
var previewControl = CreateComponentLibraryPreviewControl(resolved.Id, renderCellSize);
if (previewControl is null)
{
continue;
}
var previewControl = descriptor.CreateControl(renderCellSize, _timeZoneService, _weatherDataService);
var previewSurface = new Border
{
@@ -2294,13 +2322,13 @@ public partial class MainWindow
Background = Brushes.Transparent,
BorderThickness = new Thickness(0),
Child = previewViewbox,
Tag = resolved.Id
Tag = definition.Id
};
previewBorder.PointerPressed += OnComponentLibraryComponentPreviewPointerPressed;
var label = new TextBlock
{
Text = GetLocalizedComponentDisplayName(resolved),
Text = GetLocalizedComponentDisplayName(descriptor),
FontSize = 14,
FontWeight = FontWeight.SemiBold,
Foreground = GetThemeBrush("AdaptiveTextPrimaryBrush"),
@@ -2346,91 +2374,9 @@ public partial class MainWindow
UpdateComponentLibraryComponentNavigationButtons();
}
private Control? CreateComponentLibraryPreviewControl(string componentId, double cellSize)
private string GetLocalizedComponentDisplayName(DesktopComponentRuntimeDescriptor descriptor)
{
if (componentId == BuiltInComponentIds.Date)
{
var widget = new DateWidget();
widget.SetTimeZoneService(_timeZoneService);
widget.ApplyCellSize(cellSize);
return widget;
}
if (componentId == BuiltInComponentIds.MonthCalendar)
{
var widget = new MonthCalendarWidget();
widget.SetTimeZoneService(_timeZoneService);
widget.ApplyCellSize(cellSize);
return widget;
}
if (componentId == BuiltInComponentIds.LunarCalendar)
{
var widget = new LunarCalendarWidget();
widget.SetTimeZoneService(_timeZoneService);
widget.ApplyCellSize(cellSize);
return widget;
}
if (componentId == BuiltInComponentIds.DesktopClock)
{
var widget = new AnalogClockWidget();
widget.SetTimeZoneService(_timeZoneService);
widget.ApplyCellSize(cellSize);
return widget;
}
if (componentId == BuiltInComponentIds.DesktopTimer)
{
var widget = new TimerWidget();
widget.ApplyCellSize(cellSize);
return widget;
}
if (componentId == BuiltInComponentIds.HolidayCalendar)
{
var widget = new HolidayCalendarWidget();
widget.SetTimeZoneService(_timeZoneService);
widget.ApplyCellSize(cellSize);
return widget;
}
return null;
}
private string GetLocalizedComponentDisplayName(DesktopComponentDefinition definition)
{
if (string.Equals(definition.Id, BuiltInComponentIds.Date, StringComparison.OrdinalIgnoreCase))
{
return L("component.date", definition.DisplayName);
}
if (string.Equals(definition.Id, BuiltInComponentIds.MonthCalendar, StringComparison.OrdinalIgnoreCase))
{
return L("component.month_calendar", definition.DisplayName);
}
if (string.Equals(definition.Id, BuiltInComponentIds.LunarCalendar, StringComparison.OrdinalIgnoreCase))
{
return L("component.lunar_calendar", definition.DisplayName);
}
if (string.Equals(definition.Id, BuiltInComponentIds.DesktopClock, StringComparison.OrdinalIgnoreCase))
{
return L("component.desktop_clock", definition.DisplayName);
}
if (string.Equals(definition.Id, BuiltInComponentIds.DesktopTimer, StringComparison.OrdinalIgnoreCase))
{
return L("component.desktop_timer", definition.DisplayName);
}
if (string.Equals(definition.Id, BuiltInComponentIds.HolidayCalendar, StringComparison.OrdinalIgnoreCase))
{
return L("component.holiday_calendar", definition.DisplayName);
}
return definition.DisplayName;
return L(descriptor.DisplayNameLocalizationKey, descriptor.Definition.DisplayName);
}
private void OnComponentLibraryComponentPreviewPointerPressed(object? sender, PointerPressedEventArgs e)

View File

@@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Layout;
@@ -9,6 +10,29 @@ namespace LanMontainDesktop.Views;
public partial class MainWindow
{
private const string AppCodeName = "Administrate";
private const string AppFontName = "MiSans";
private const string FallbackAppVersion = "1.0.0";
private static readonly IReadOnlyDictionary<string, string> ZhTimeZoneNames =
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["China Standard Time"] = "中国标准时间",
["Asia/Shanghai"] = "中国标准时间",
["Tokyo Standard Time"] = "日本标准时间",
["Asia/Tokyo"] = "日本标准时间",
["Pacific Standard Time"] = "太平洋标准时间",
["America/Los_Angeles"] = "太平洋标准时间",
["Eastern Standard Time"] = "美国东部标准时间",
["America/New_York"] = "美国东部标准时间",
["Central European Standard Time"] = "中欧标准时间",
["Europe/Berlin"] = "中欧标准时间",
["GMT Standard Time"] = "格林威治标准时间",
["Europe/London"] = "格林威治标准时间",
["UTC"] = "协调世界时",
["Etc/UTC"] = "协调世界时"
};
private void InitializeLocalization(string? languageCode)
{
_languageCode = _localizationService.NormalizeLanguageCode(languageCode);
@@ -82,6 +106,7 @@ public partial class MainWindow
SettingsNavGridTextBlock.Text = L("settings.nav.grid", "Grid");
SettingsNavColorTextBlock.Text = L("settings.nav.color", "Color");
SettingsNavStatusBarTextBlock.Text = L("settings.nav.status_bar", "Status Bar");
SettingsNavWeatherTextBlock.Text = L("settings.nav.weather", "Weather");
SettingsNavRegionTextBlock.Text = L("settings.nav.region", "Region");
WallpaperPanelTitleTextBlock.Text = L("settings.wallpaper.title", "个性化我们的背景");
@@ -141,10 +166,78 @@ public partial class MainWindow
StatusBarSpacingModeCustomItem.Content = L("settings.status_bar.spacing_mode_custom", "Custom");
StatusBarSpacingCustomLabelTextBlock.Text = L("settings.status_bar.spacing_custom_label", "Custom spacing (%)");
WeatherPanelTitleTextBlock.Text = L("settings.weather.title", "Weather");
WeatherLocationSettingsExpander.Header = L("settings.weather.location_source_header", "Location Source");
WeatherLocationSettingsExpander.Description = L(
"settings.weather.location_source_desc",
"Choose how weather widgets resolve location.");
WeatherLocationModeCityItem.Content = L("settings.weather.mode_city_search", "City Search");
WeatherLocationModeCoordinatesItem.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");
WeatherCitySearchSettingsExpander.Description = L(
"settings.weather.city_search_desc",
"Search cities and apply one weather location.");
WeatherCitySearchTextBox.Watermark = L("settings.weather.search_placeholder", "e.g. Beijing");
WeatherSearchButton.Content = L("settings.weather.search_button", "Search");
WeatherApplyCityButton.Content = L("settings.weather.apply_city_button", "Apply City");
WeatherCoordinateSettingsExpander.Header = L("settings.weather.coordinates_header", "Coordinates");
WeatherCoordinateSettingsExpander.Description = L(
"settings.weather.coordinates_desc",
"Set latitude/longitude and optional key/name.");
WeatherLatitudeNumberBox.Header = L("settings.weather.latitude_label", "Latitude");
WeatherLongitudeNumberBox.Header = L("settings.weather.longitude_label", "Longitude");
WeatherLocationKeyTextBox.Watermark = L("settings.weather.location_key_placeholder", "Location key (optional)");
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.Description = L(
"settings.weather.preview_desc",
"Send one test request to verify current settings.");
WeatherPreviewButton.Content = L("settings.weather.preview_button", "Test Fetch");
if (string.IsNullOrWhiteSpace(_weatherSearchKeyword))
{
WeatherSearchStatusTextBlock.Text = L(
"settings.weather.search_hint",
"Search by city name and apply one location.");
}
if (!_isWeatherPreviewInProgress)
{
WeatherPreviewResultTextBlock.Text = L(
"settings.weather.preview_hint",
"Use test fetch to verify your weather configuration.");
}
UpdateWeatherLocationStatusText();
RegionPanelTitleTextBlock.Text = L("settings.region.title", "Region");
LanguageSettingsExpander.Header = L("settings.region.language_header", "Language");
LanguageChineseItem.Content = L("settings.region.language_zh", "Chinese");
LanguageEnglishItem.Content = L("settings.region.language_en", "English");
TimeZoneSettingsExpander.Header = L("settings.region.timezone_header", "Time Zone");
TimeZoneSettingsExpander.Description = L(
"settings.region.timezone_desc",
"Select a time zone. Clock and calendar widgets will follow this zone.");
SettingsNavAboutTextBlock.Text = L("settings.nav.about", "About");
AboutPanelTitleTextBlock.Text = L("settings.about.title", "About");
VersionTextBlock.Text = Lf(
"settings.about.version_format",
"Version: {0}",
GetAppVersionText());
CodeNameTextBlock.Text = Lf(
"settings.about.codename_format",
"Code Name: {0}",
AppCodeName);
FontInfoTextBlock.Text = Lf(
"settings.about.font_format",
"Font: {0}",
AppFontName);
if (WallpaperPlacementComboBox?.ItemCount >= 5)
{
@@ -163,12 +256,48 @@ public partial class MainWindow
DesktopGrid.RowDefinitions.Count,
DesktopGrid.RowDefinitions.Count > 0 ? DesktopGrid.RowDefinitions[0].Height.Value : 0d);
InitializeTimeZoneSettings();
BuildComponentLibraryCategoryPages();
RenderLauncherRootTiles();
UpdateOpenSettingsActionVisualState();
UpdateWallpaperDisplay();
}
private string GetLocalizedTimeZoneDisplayName(TimeZoneInfo timeZone)
{
var offset = timeZone.BaseUtcOffset;
var sign = offset >= TimeSpan.Zero ? "+" : "-";
var hours = Math.Abs(offset.Hours);
var minutes = Math.Abs(offset.Minutes);
var name = string.IsNullOrWhiteSpace(timeZone.StandardName)
? timeZone.DisplayName
: timeZone.StandardName;
if (string.Equals(_languageCode, "zh-CN", StringComparison.OrdinalIgnoreCase) &&
ZhTimeZoneNames.TryGetValue(timeZone.Id, out var localizedName))
{
name = localizedName;
}
if (string.IsNullOrWhiteSpace(name))
{
name = timeZone.Id;
}
return $"(UTC{sign}{hours:D2}:{minutes:D2}) {name}";
}
private static string GetAppVersionText()
{
var version = typeof(MainWindow).Assembly.GetName().Version;
if (version is null || version.Major < 0 || version.Minor < 0 || version.Build < 0)
{
return FallbackAppVersion;
}
return $"{version.Major}.{version.Minor}.{version.Build}";
}
private void OnLanguageSelectionChanged(object? sender, SelectionChangedEventArgs e)
{
if (_suppressLanguageSelectionEvents || LanguageComboBox?.SelectedItem is not ComboBoxItem item)
@@ -185,4 +314,48 @@ public partial class MainWindow
GetLanguageDisplayName(_languageCode));
PersistSettings();
}
private void UpdateWeatherLocationStatusText()
{
if (WeatherLocationStatusTextBlock is null)
{
return;
}
var modeText = _weatherLocationMode == WeatherLocationMode.Coordinates
? L("settings.weather.mode_coordinates", "Coordinates")
: L("settings.weather.mode_city_search", "City Search");
if (_weatherLocationMode == WeatherLocationMode.CitySearch)
{
if (string.IsNullOrWhiteSpace(_weatherLocationKey))
{
WeatherLocationStatusTextBlock.Text = L(
"settings.weather.status_city_empty",
"No city location is configured.");
return;
}
var locationName = string.IsNullOrWhiteSpace(_weatherLocationName)
? _weatherLocationKey
: _weatherLocationName;
WeatherLocationStatusTextBlock.Text = Lf(
"settings.weather.status_city_format",
"Mode: {0} | {1} | Key: {2}",
modeText,
locationName,
_weatherLocationKey);
return;
}
WeatherLocationStatusTextBlock.Text = Lf(
"settings.weather.status_coordinates_format",
"Mode: {0} | Lat {1:F4}, Lon {2:F4} | Key: {3}",
modeText,
_weatherLatitude,
_weatherLongitude,
string.IsNullOrWhiteSpace(_weatherLocationKey)
? BuildCoordinateLocationKey(_weatherLatitude, _weatherLongitude)
: _weatherLocationKey);
}
}

View File

@@ -4,6 +4,7 @@ using FluentIcons.Common;
using LanMontainDesktop.Views.Components;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Runtime.InteropServices;
@@ -61,8 +62,9 @@ public partial class MainWindow
WallpaperSettingsPanel is null ||
ColorSettingsPanel is null ||
StatusBarSettingsPanel is null ||
WeatherSettingsPanel is null ||
RegionSettingsPanel is null ||
AboutSettingsPanel is null)
AboutSettingsPanel is null)
{
return;
}
@@ -72,8 +74,9 @@ public partial class MainWindow
GridSettingsPanel.IsVisible = selectedIndex == 1;
ColorSettingsPanel.IsVisible = selectedIndex == 2;
StatusBarSettingsPanel.IsVisible = selectedIndex == 3;
RegionSettingsPanel.IsVisible = selectedIndex == 4;
AboutSettingsPanel.IsVisible = selectedIndex == 5;
WeatherSettingsPanel.IsVisible = selectedIndex == 4;
RegionSettingsPanel.IsVisible = selectedIndex == 5;
AboutSettingsPanel.IsVisible = selectedIndex == 6;
if (selectedIndex == 1)
{
@@ -645,6 +648,13 @@ public partial class MainWindow
SettingsTabIndex = Math.Max(0, SettingsNavListBox?.SelectedIndex ?? 0),
LanguageCode = _languageCode,
TimeZoneId = _timeZoneService.CurrentTimeZone.Id,
WeatherLocationMode = ToWeatherLocationModeTag(_weatherLocationMode),
WeatherLocationKey = _weatherLocationKey,
WeatherLocationName = _weatherLocationName,
WeatherLatitude = _weatherLatitude,
WeatherLongitude = _weatherLongitude,
WeatherAutoRefreshLocation = _weatherAutoRefreshLocation,
WeatherLocationQuery = BuildLegacyWeatherLocationQuery(),
TopStatusComponentIds = _topStatusComponentIds.ToList(),
PinnedTaskbarActions = _pinnedTaskbarActions.Select(action => action.ToString()).ToList(),
EnableDynamicTaskbarActions = _enableDynamicTaskbarActions,
@@ -677,6 +687,560 @@ public partial class MainWindow
}, TimeSpan.FromMilliseconds(Math.Max(0, delayMs)));
}
private void InitializeWeatherSettings(AppSettingsSnapshot snapshot)
{
_suppressWeatherLocationEvents = true;
try
{
_weatherLocationMode = ParseWeatherLocationMode(snapshot.WeatherLocationMode);
_weatherLocationKey = snapshot.WeatherLocationKey?.Trim() ?? string.Empty;
_weatherLocationName = snapshot.WeatherLocationName?.Trim() ?? string.Empty;
_weatherLatitude = NormalizeLatitude(snapshot.WeatherLatitude);
_weatherLongitude = NormalizeLongitude(snapshot.WeatherLongitude);
_weatherAutoRefreshLocation = snapshot.WeatherAutoRefreshLocation;
_weatherSearchKeyword = string.Empty;
var legacyQuery = snapshot.WeatherLocationQuery?.Trim() ?? string.Empty;
if (string.IsNullOrWhiteSpace(_weatherLocationKey) && !string.IsNullOrWhiteSpace(legacyQuery))
{
_weatherLocationKey = legacyQuery;
}
if (string.IsNullOrWhiteSpace(_weatherLocationName) && !string.IsNullOrWhiteSpace(legacyQuery))
{
_weatherLocationName = legacyQuery;
}
SelectWeatherLocationModeInUi(_weatherLocationMode);
if (WeatherAutoRefreshToggleSwitch is not null)
{
WeatherAutoRefreshToggleSwitch.IsChecked = _weatherAutoRefreshLocation;
}
if (WeatherCitySearchTextBox is not null)
{
WeatherCitySearchTextBox.Text = string.Empty;
}
if (WeatherCityResultsComboBox is not null)
{
WeatherCityResultsComboBox.Items.Clear();
}
if (WeatherLocationKeyTextBox is not null)
{
WeatherLocationKeyTextBox.Text = _weatherLocationKey;
}
if (WeatherLocationNameTextBox is not null)
{
WeatherLocationNameTextBox.Text = _weatherLocationName;
}
if (WeatherLatitudeNumberBox is not null)
{
WeatherLatitudeNumberBox.Value = _weatherLatitude;
}
if (WeatherLongitudeNumberBox is not null)
{
WeatherLongitudeNumberBox.Value = _weatherLongitude;
}
if (WeatherSearchStatusTextBlock is not null)
{
WeatherSearchStatusTextBlock.Text = L(
"settings.weather.search_hint",
"Search by city name and apply one location.");
}
if (WeatherCoordinateStatusTextBlock is not null)
{
WeatherCoordinateStatusTextBlock.Text = string.Empty;
}
if (WeatherPreviewResultTextBlock is not null)
{
WeatherPreviewResultTextBlock.Text = L(
"settings.weather.preview_hint",
"Use test fetch to verify your weather configuration.");
}
UpdateWeatherLocationModePanels();
UpdateWeatherLocationStatusText();
}
finally
{
_suppressWeatherLocationEvents = false;
}
}
private static WeatherLocationMode ParseWeatherLocationMode(string? value)
{
return string.Equals(value, "Coordinates", StringComparison.OrdinalIgnoreCase)
? WeatherLocationMode.Coordinates
: WeatherLocationMode.CitySearch;
}
private static string ToWeatherLocationModeTag(WeatherLocationMode mode)
{
return mode == WeatherLocationMode.Coordinates ? "Coordinates" : "CitySearch";
}
private static double NormalizeLatitude(double value)
{
if (double.IsNaN(value) || double.IsInfinity(value))
{
return 39.9042;
}
return Math.Clamp(value, -90, 90);
}
private static double NormalizeLongitude(double value)
{
if (double.IsNaN(value) || double.IsInfinity(value))
{
return 116.4074;
}
return Math.Clamp(value, -180, 180);
}
private string BuildLegacyWeatherLocationQuery()
{
if (!string.IsNullOrWhiteSpace(_weatherLocationName))
{
return _weatherLocationName;
}
if (!string.IsNullOrWhiteSpace(_weatherLocationKey))
{
return _weatherLocationKey;
}
return string.Create(
CultureInfo.InvariantCulture,
$"{_weatherLatitude:F4},{_weatherLongitude:F4}");
}
private void SelectWeatherLocationModeInUi(WeatherLocationMode mode)
{
if (WeatherLocationModeComboBox is null)
{
return;
}
foreach (var item in WeatherLocationModeComboBox.Items.OfType<ComboBoxItem>())
{
if (string.Equals(item.Tag?.ToString(), ToWeatherLocationModeTag(mode), StringComparison.OrdinalIgnoreCase))
{
WeatherLocationModeComboBox.SelectedItem = item;
return;
}
}
WeatherLocationModeComboBox.SelectedIndex = mode == WeatherLocationMode.Coordinates ? 1 : 0;
}
private void UpdateWeatherLocationModePanels()
{
if (WeatherCitySearchSettingsExpander is not null)
{
WeatherCitySearchSettingsExpander.IsVisible = _weatherLocationMode == WeatherLocationMode.CitySearch;
}
if (WeatherCoordinateSettingsExpander is not null)
{
WeatherCoordinateSettingsExpander.IsVisible = _weatherLocationMode == WeatherLocationMode.Coordinates;
}
}
private void OnWeatherLocationModeSelectionChanged(object? sender, SelectionChangedEventArgs e)
{
if (_suppressWeatherLocationEvents || WeatherLocationModeComboBox?.SelectedItem is not ComboBoxItem item)
{
return;
}
_weatherLocationMode = ParseWeatherLocationMode(item.Tag?.ToString());
UpdateWeatherLocationModePanels();
UpdateWeatherLocationStatusText();
PersistSettings();
}
private void OnWeatherAutoRefreshToggled(object? sender, RoutedEventArgs e)
{
if (_suppressWeatherLocationEvents || WeatherAutoRefreshToggleSwitch is null)
{
return;
}
_weatherAutoRefreshLocation = WeatherAutoRefreshToggleSwitch.IsChecked == true;
PersistSettings();
}
private async void OnSearchWeatherCityClick(object? sender, RoutedEventArgs e)
{
if (_isWeatherSearchInProgress || WeatherCitySearchTextBox is null || WeatherCityResultsComboBox is null)
{
return;
}
var keyword = WeatherCitySearchTextBox.Text?.Trim() ?? string.Empty;
if (string.IsNullOrWhiteSpace(keyword))
{
if (WeatherSearchStatusTextBlock is not null)
{
WeatherSearchStatusTextBlock.Text = L(
"settings.weather.search_required",
"Please enter a city keyword first.");
}
return;
}
_weatherSearchKeyword = keyword;
_isWeatherSearchInProgress = true;
SetWeatherSearchBusy(isBusy: true);
try
{
var result = await _weatherDataService.SearchLocationsAsync(keyword, ResolveWeatherApiLocale());
if (!result.Success || result.Data is null)
{
WeatherCityResultsComboBox.Items.Clear();
if (WeatherSearchStatusTextBlock is not null)
{
WeatherSearchStatusTextBlock.Text = Lf(
"settings.weather.search_failed_format",
"Search failed: {0}",
result.ErrorMessage ?? result.ErrorCode ?? "Unknown error");
}
return;
}
var locations = result.Data
.Where(location => !string.IsNullOrWhiteSpace(location.LocationKey))
.Take(80)
.ToList();
WeatherCityResultsComboBox.Items.Clear();
foreach (var location in locations)
{
WeatherCityResultsComboBox.Items.Add(new ComboBoxItem
{
Content = FormatWeatherLocationDisplayName(location),
Tag = location
});
}
if (WeatherSearchStatusTextBlock is not null)
{
WeatherSearchStatusTextBlock.Text = locations.Count == 0
? L("settings.weather.search_no_results", "No locations were found.")
: Lf(
"settings.weather.search_result_count_format",
"Found {0} locations.",
locations.Count);
}
if (locations.Count > 0)
{
WeatherCityResultsComboBox.SelectedIndex = 0;
}
}
catch (Exception ex)
{
if (WeatherSearchStatusTextBlock is not null)
{
WeatherSearchStatusTextBlock.Text = Lf(
"settings.weather.search_failed_format",
"Search failed: {0}",
ex.Message);
}
}
finally
{
_isWeatherSearchInProgress = false;
SetWeatherSearchBusy(isBusy: false);
}
}
private static string FormatWeatherLocationDisplayName(WeatherLocation location)
{
var affiliation = string.IsNullOrWhiteSpace(location.Affiliation)
? string.Empty
: $" ({location.Affiliation})";
return string.Create(
CultureInfo.InvariantCulture,
$"{location.Name}{affiliation} · {location.LocationKey}");
}
private static string BuildWeatherLocationName(WeatherLocation location)
{
if (string.IsNullOrWhiteSpace(location.Affiliation))
{
return location.Name;
}
return string.Create(
CultureInfo.InvariantCulture,
$"{location.Name} ({location.Affiliation})");
}
private void OnApplyWeatherCitySelectionClick(object? sender, RoutedEventArgs e)
{
if (WeatherCityResultsComboBox?.SelectedItem is not ComboBoxItem item ||
item.Tag is not WeatherLocation location)
{
if (WeatherSearchStatusTextBlock is not null)
{
WeatherSearchStatusTextBlock.Text = L(
"settings.weather.search_select_required",
"Please select one location from search results.");
}
return;
}
_weatherLocationMode = WeatherLocationMode.CitySearch;
_weatherLocationKey = location.LocationKey.Trim();
_weatherLocationName = BuildWeatherLocationName(location);
_weatherLatitude = NormalizeLatitude(location.Latitude);
_weatherLongitude = NormalizeLongitude(location.Longitude);
_suppressWeatherLocationEvents = true;
try
{
SelectWeatherLocationModeInUi(_weatherLocationMode);
if (WeatherLocationKeyTextBox is not null)
{
WeatherLocationKeyTextBox.Text = _weatherLocationKey;
}
if (WeatherLocationNameTextBox is not null)
{
WeatherLocationNameTextBox.Text = _weatherLocationName;
}
if (WeatherLatitudeNumberBox is not null)
{
WeatherLatitudeNumberBox.Value = _weatherLatitude;
}
if (WeatherLongitudeNumberBox is not null)
{
WeatherLongitudeNumberBox.Value = _weatherLongitude;
}
}
finally
{
_suppressWeatherLocationEvents = false;
}
if (WeatherSearchStatusTextBlock is not null)
{
WeatherSearchStatusTextBlock.Text = Lf(
"settings.weather.search_applied_format",
"Location applied: {0}",
_weatherLocationName);
}
UpdateWeatherLocationModePanels();
UpdateWeatherLocationStatusText();
PersistSettings();
}
private void OnApplyWeatherCoordinatesClick(object? sender, RoutedEventArgs e)
{
if (WeatherLatitudeNumberBox is null || WeatherLongitudeNumberBox is null)
{
return;
}
var latitude = NormalizeLatitude(WeatherLatitudeNumberBox.Value);
var longitude = NormalizeLongitude(WeatherLongitudeNumberBox.Value);
var keyInput = WeatherLocationKeyTextBox?.Text?.Trim() ?? string.Empty;
var nameInput = WeatherLocationNameTextBox?.Text?.Trim() ?? string.Empty;
_weatherLocationMode = WeatherLocationMode.Coordinates;
_weatherLatitude = latitude;
_weatherLongitude = longitude;
_weatherLocationKey = string.IsNullOrWhiteSpace(keyInput)
? BuildCoordinateLocationKey(latitude, longitude)
: keyInput;
_weatherLocationName = string.IsNullOrWhiteSpace(nameInput)
? Lf(
"settings.weather.coordinates_default_name_format",
"Coordinate {0:F4}, {1:F4}",
latitude,
longitude)
: nameInput;
_suppressWeatherLocationEvents = true;
try
{
SelectWeatherLocationModeInUi(_weatherLocationMode);
if (WeatherLocationKeyTextBox is not null && string.IsNullOrWhiteSpace(keyInput))
{
WeatherLocationKeyTextBox.Text = _weatherLocationKey;
}
if (WeatherLocationNameTextBox is not null && string.IsNullOrWhiteSpace(nameInput))
{
WeatherLocationNameTextBox.Text = _weatherLocationName;
}
}
finally
{
_suppressWeatherLocationEvents = false;
}
if (WeatherCoordinateStatusTextBlock is not null)
{
WeatherCoordinateStatusTextBlock.Text = Lf(
"settings.weather.coordinates_saved_format",
"Coordinates saved: {0:F4}, {1:F4}",
_weatherLatitude,
_weatherLongitude);
}
UpdateWeatherLocationModePanels();
UpdateWeatherLocationStatusText();
PersistSettings();
}
private static string BuildCoordinateLocationKey(double latitude, double longitude)
{
return string.Create(
CultureInfo.InvariantCulture,
$"coord:{latitude:F4},{longitude:F4}");
}
private async void OnTestWeatherRequestClick(object? sender, RoutedEventArgs e)
{
if (_isWeatherPreviewInProgress)
{
return;
}
if (string.IsNullOrWhiteSpace(_weatherLocationKey))
{
if (_weatherLocationMode == WeatherLocationMode.Coordinates)
{
_weatherLocationKey = BuildCoordinateLocationKey(_weatherLatitude, _weatherLongitude);
}
else
{
if (WeatherPreviewResultTextBlock is not null)
{
WeatherPreviewResultTextBlock.Text = L(
"settings.weather.preview_missing_location",
"Please apply one weather location before testing.");
}
return;
}
}
_isWeatherPreviewInProgress = true;
SetWeatherPreviewBusy(isBusy: true);
try
{
var query = new WeatherQuery(
LocationKey: _weatherLocationKey,
Latitude: _weatherLatitude,
Longitude: _weatherLongitude,
ForecastDays: 3,
Locale: ResolveWeatherApiLocale(),
IsGlobal: false,
ForceRefresh: true);
var result = await _weatherDataService.GetWeatherAsync(query);
if (!result.Success || result.Data is null)
{
if (WeatherPreviewResultTextBlock is not null)
{
WeatherPreviewResultTextBlock.Text = Lf(
"settings.weather.preview_failed_format",
"Test fetch failed: {0}",
result.ErrorMessage ?? result.ErrorCode ?? "Unknown error");
}
return;
}
var snapshot = result.Data;
var location = string.IsNullOrWhiteSpace(snapshot.LocationName)
? (!string.IsNullOrWhiteSpace(_weatherLocationName) ? _weatherLocationName : _weatherLocationKey)
: snapshot.LocationName;
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")
: "--";
if (WeatherPreviewResultTextBlock is not null)
{
WeatherPreviewResultTextBlock.Text = Lf(
"settings.weather.preview_success_format",
"Test success: {0} · {1} · {2}",
location,
weather,
temperature);
}
}
catch (Exception ex)
{
if (WeatherPreviewResultTextBlock is not null)
{
WeatherPreviewResultTextBlock.Text = Lf(
"settings.weather.preview_failed_format",
"Test fetch failed: {0}",
ex.Message);
}
}
finally
{
_isWeatherPreviewInProgress = false;
SetWeatherPreviewBusy(isBusy: false);
}
}
private void SetWeatherSearchBusy(bool isBusy)
{
if (WeatherSearchButton is not null)
{
WeatherSearchButton.IsEnabled = !isBusy;
}
if (WeatherSearchProgressRing is not null)
{
WeatherSearchProgressRing.IsVisible = isBusy;
}
}
private void SetWeatherPreviewBusy(bool isBusy)
{
if (WeatherPreviewButton is not null)
{
WeatherPreviewButton.IsEnabled = !isBusy;
}
if (WeatherPreviewProgressRing is not null)
{
WeatherPreviewProgressRing.IsVisible = isBusy;
}
}
private string ResolveWeatherApiLocale()
{
return string.Equals(_languageCode, "zh-CN", StringComparison.OrdinalIgnoreCase)
? "zh_cn"
: "en_us";
}
private void UpdateAdaptiveTextSystem()
{
var isLightBackground = _isSettingsOpen
@@ -1110,6 +1674,15 @@ public partial class MainWindow
};
}
if (WeatherLocationSettingsExpander is not null)
{
WeatherLocationSettingsExpander.IconSource = new FluentIcons.Avalonia.Fluent.SymbolIconSource
{
Symbol = Symbol.WeatherSunny,
IconVariant = variant
};
}
if (LanguageSettingsExpander is not null)
{
LanguageSettingsExpander.IconSource = new FluentIcons.Avalonia.Fluent.SymbolIconSource

View File

@@ -1,4 +1,4 @@
<Window xmlns="https://github.com/avaloniaui"
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:LanMontainDesktop.ViewModels"
xmlns:ui="using:FluentAvalonia.UI.Controls"
@@ -133,10 +133,10 @@
<TextBlock x:Name="LauncherTitleTextBlock"
FontSize="24"
FontWeight="SemiBold"
Text="应用启动台" />
Text="App Launcher" />
<TextBlock x:Name="LauncherSubtitleTextBlock"
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
Text="按 Windows 开始菜单结构显示所有应用与文件夹" />
Text="Apps and folders from Windows Start Menu." />
</StackPanel>
<Grid Grid.Row="1"
@@ -183,7 +183,7 @@
HorizontalAlignment="Center"
VerticalAlignment="Center"
FontWeight="SemiBold"
Text="文件夹" />
Text="Folder" />
<Button x:Name="LauncherFolderCloseButton"
Grid.Column="2"
Width="38"
@@ -346,6 +346,7 @@
</Grid>
<Grid x:Name="SettingsPage"
Classes="settings-scope"
IsVisible="False"
Opacity="0"
HorizontalAlignment="Stretch"
@@ -375,14 +376,14 @@
</Border.RenderTransform>
<Border Classes="mica-strong"
CornerRadius="32"
CornerRadius="{DynamicResource DesignCornerRadiusXl}"
Padding="18">
<Grid ColumnDefinitions="220,*"
ColumnSpacing="16">
<Border x:Name="SettingsNavPanelBorder"
Classes="glass-panel"
Grid.Column="0"
CornerRadius="28"
CornerRadius="{DynamicResource DesignCornerRadiusLg}"
Padding="10">
<Border.Styles>
<Style Selector="ListBox#SettingsNavListBox">
@@ -395,7 +396,7 @@
<Setter Property="BorderThickness" Value="0" />
<Setter Property="Padding" Value="10,8" />
<Setter Property="Margin" Value="0,2" />
<Setter Property="CornerRadius" Value="12" />
<Setter Property="CornerRadius" Value="{DynamicResource DesignCornerRadiusXs}" />
</Style>
<Style Selector="ListBox#SettingsNavListBox ListBoxItem:pointerover">
<Setter Property="Background" Value="{DynamicResource AdaptiveNavItemHoverBackgroundBrush}" />
@@ -438,6 +439,12 @@
<TextBlock x:Name="SettingsNavStatusBarTextBlock" Text="&#29366;&#24577;&#26639;" VerticalAlignment="Center" />
</StackPanel>
</ListBoxItem>
<ListBoxItem x:Name="SettingsNavWeatherItem" ToolTip.Tip="&#22825;&#27668;">
<StackPanel Orientation="Horizontal" Spacing="12">
<fi:SymbolIcon x:Name="SettingsNavWeatherIcon" Symbol="WeatherSunny" IconVariant="Regular" />
<TextBlock x:Name="SettingsNavWeatherTextBlock" Text="&#22825;&#27668;" VerticalAlignment="Center" />
</StackPanel>
</ListBoxItem>
<ListBoxItem x:Name="SettingsNavRegionItem" ToolTip.Tip="&#22320;&#21306;">
<StackPanel Orientation="Horizontal" Spacing="12">
<fi:SymbolIcon x:Name="SettingsNavRegionIcon" Symbol="Globe" IconVariant="Regular" />
@@ -456,7 +463,7 @@
<Border Grid.Column="1"
Classes="glass-panel"
CornerRadius="20"
CornerRadius="{DynamicResource DesignCornerRadiusMd}"
Padding="14">
<Grid>
<Grid x:Name="WallpaperSettingsPanel"
@@ -469,7 +476,7 @@
FontWeight="SemiBold"
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}"
Margin="0,0,0,24"
Text="个性化您的背景" />
Text="涓€у寲鎮ㄧ殑鑳屾櫙" />
<!-- Left Column: Monitor Preview -->
<Border x:Name="WallpaperPreviewHost"
@@ -520,7 +527,7 @@
<Border x:Name="WallpaperPreviewTaskbarFixedActionsHost" Grid.Column="0">
<StackPanel x:Name="WallpaperPreviewBackButtonVisual" Orientation="Horizontal" Spacing="3">
<fi:SymbolIcon Classes="icon-s" Symbol="Window" />
<TextBlock x:Name="WallpaperPreviewBackButtonTextBlock" Text="回到Windows" VerticalAlignment="Center" />
<TextBlock x:Name="WallpaperPreviewBackButtonTextBlock" Text="鍥炲埌Windows" VerticalAlignment="Center" />
</StackPanel>
</Border>
<StackPanel x:Name="WallpaperPreviewTaskbarDynamicActionsHost"
@@ -532,7 +539,7 @@
<StackPanel Orientation="Horizontal" Spacing="3">
<StackPanel x:Name="WallpaperPreviewComponentLibraryVisual" IsVisible="False" Orientation="Horizontal" Spacing="3">
<fi:FluentIcon Classes="icon-s" Icon="Apps" />
<TextBlock x:Name="WallpaperPreviewComponentLibraryTextBlock" Text="组件库" VerticalAlignment="Center" />
<TextBlock x:Name="WallpaperPreviewComponentLibraryTextBlock" Text="Widget library" VerticalAlignment="Center" />
</StackPanel>
<fi:SymbolIcon x:Name="WallpaperPreviewSettingsButtonIcon" Classes="icon-s" Symbol="Settings" />
</StackPanel>
@@ -550,22 +557,22 @@
Margin="16,0,0,0"
Spacing="16">
<StackPanel Spacing="8">
<TextBlock Text="预览状态" FontSize="12" Foreground="{DynamicResource AdaptiveTextSecondaryBrush}" />
<TextBlock Text="Preview status" FontSize="12" Foreground="{DynamicResource AdaptiveTextSecondaryBrush}" />
<TextBlock x:Name="WallpaperPathTextBlock"
FontSize="14"
FontWeight="Medium"
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}"
TextTrimming="CharacterEllipsis"
Text="未选择文件" />
Text="鏈€夋嫨鏂囦欢" />
<TextBlock x:Name="WallpaperStatusTextBlock"
FontSize="12"
Foreground="{DynamicResource AdaptiveTextMutedBrush}"
Text="就绪" />
Text="灏辩华" />
</StackPanel>
<Separator Background="{DynamicResource SurfaceStrokeColorDefaultBrush}" Height="1" Margin="0,8" />
<TextBlock Text="选择图片或视频" FontSize="16" FontWeight="SemiBold" Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
<TextBlock Text="Choose image or video" FontSize="16" FontWeight="SemiBold" Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
<Grid ColumnDefinitions="*, *" ColumnSpacing="12">
<Button x:Name="PickWallpaperButton"
@@ -574,17 +581,18 @@
HorizontalAlignment="Stretch"
Padding="0,10"
Click="OnPickWallpaperClick"
Content="浏览照片" />
Content="娴忚鐓х墖" />
<Button x:Name="ClearWallpaperButton"
Grid.Column="1"
HorizontalAlignment="Stretch"
Padding="0,10"
Click="OnClearWallpaperClick"
Content="重置" />
Content="閲嶇疆" />
</Grid>
<ui:SettingsExpander x:Name="WallpaperPlacementSettingsExpander"
Header="选择契合度"
<Border Classes="settings-expander-shell">
<ui:SettingsExpander x:Name="WallpaperPlacementSettingsExpander"
Header="Placement"
Padding="12,8">
<ui:SettingsExpander.Footer>
<ComboBox x:Name="WallpaperPlacementComboBox"
@@ -598,6 +606,7 @@
</ComboBox>
</ui:SettingsExpander.Footer>
</ui:SettingsExpander>
</Border>
</StackPanel>
</Grid>
@@ -611,7 +620,7 @@
FontWeight="SemiBold"
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}"
Margin="0,0,0,24"
Text="调整网格布局" />
Text="璋冩暣缃戞牸甯冨眬" />
<!-- Left Column: Grid Preview -->
<Border x:Name="GridPreviewHost"
@@ -657,7 +666,7 @@
<Border x:Name="GridPreviewTaskbarFixedActionsHost" Grid.Column="0">
<StackPanel x:Name="GridPreviewBackButtonVisual" Orientation="Horizontal" Spacing="3">
<fi:SymbolIcon Classes="icon-s" Symbol="Window" />
<TextBlock x:Name="GridPreviewBackButtonTextBlock" Text="回到Windows" VerticalAlignment="Center" />
<TextBlock x:Name="GridPreviewBackButtonTextBlock" Text="鍥炲埌Windows" VerticalAlignment="Center" />
</StackPanel>
</Border>
<StackPanel x:Name="GridPreviewTaskbarDynamicActionsHost"
@@ -669,7 +678,7 @@
<StackPanel Orientation="Horizontal" Spacing="3">
<StackPanel x:Name="GridPreviewComponentLibraryVisual" IsVisible="False" Orientation="Horizontal" Spacing="3">
<fi:FluentIcon x:Name="GridPreviewComponentLibraryIcon" Classes="icon-s" Icon="Apps" />
<TextBlock x:Name="GridPreviewComponentLibraryTextBlock" Text="组件库" VerticalAlignment="Center" />
<TextBlock x:Name="GridPreviewComponentLibraryTextBlock" Text="Widget library" VerticalAlignment="Center" />
</StackPanel>
<fi:SymbolIcon x:Name="GridPreviewSettingsButtonIcon" Classes="icon-s" Symbol="Settings" />
</StackPanel>
@@ -686,7 +695,7 @@
<StackPanel Grid.Row="1" Grid.Column="1"
Margin="16,0,0,0"
Spacing="16">
<TextBlock Text="竖排格数" FontSize="16" FontWeight="SemiBold" Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
<TextBlock Text="绔栨帓鏍兼暟" FontSize="16" FontWeight="SemiBold" Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
<Grid ColumnDefinitions="*,Auto" ColumnSpacing="12">
<Slider x:Name="GridSizeSlider"
@@ -745,7 +754,7 @@
<TextBlock x:Name="GridEdgeInsetComputedPxTextBlock"
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
Text="0 px" />
Text="鈮?0 px" />
<Button x:Name="ApplyGridButton"
HorizontalAlignment="Stretch"
@@ -769,7 +778,8 @@
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}"
Text="Color" />
<ui:SettingsExpander x:Name="ThemeModeSettingsExpander"
<Border Classes="settings-expander-shell">
<ui:SettingsExpander x:Name="ThemeModeSettingsExpander"
Header="&#26085;&#22812;&#27169;&#24335;"
Description="&#20999;&#25442;&#24212;&#29992;&#30340;&#27973;&#33394;&#25110;&#28145;&#33394;&#20027;&#39064;&#12290;">
<ui:SettingsExpander.IconSource>
@@ -783,8 +793,10 @@
Unchecked="OnNightModeUnchecked" />
</ui:SettingsExpander.Footer>
</ui:SettingsExpander>
</Border>
<ui:SettingsExpander x:Name="ThemeColorSettingsExpander"
<Border Classes="settings-expander-shell">
<ui:SettingsExpander x:Name="ThemeColorSettingsExpander"
Header="&#20027;&#39064;&#33394;"
Description="&#36873;&#25321;&#24212;&#29992;&#30340;&#20027;&#39064;&#28857;&#32512;&#33394;&#12290;">
<ui:SettingsExpander.IconSource>
@@ -961,6 +973,7 @@
</ui:SettingsExpanderItem.Footer>
</ui:SettingsExpanderItem>
</ui:SettingsExpander>
</Border>
<TextBlock x:Name="ThemeColorStatusTextBlock"
Foreground="{DynamicResource AdaptiveTextMutedBrush}"
@@ -976,7 +989,8 @@
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}"
Text="Status Bar" />
<ui:SettingsExpander x:Name="StatusBarClockSettingsExpander"
<Border Classes="settings-expander-shell">
<ui:SettingsExpander x:Name="StatusBarClockSettingsExpander"
Header="&#26102;&#38388;&#32452;&#20214;"
Description="&#22312;&#39030;&#37096;&#29366;&#24577;&#26639;&#26174;&#31034;&#26102;&#38047;&#12290;"
IsExpanded="False">
@@ -991,23 +1005,25 @@
Unchecked="OnStatusBarClockUnchecked" />
</ui:SettingsExpander.Footer>
<StackPanel Margin="0,8,0,0" Spacing="12">
<TextBlock Text="显示格式" FontSize="14" FontWeight="SemiBold" Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
<TextBlock Text="鏄剧ず鏍煎紡" FontSize="14" FontWeight="SemiBold" Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
<StackPanel Orientation="Horizontal" Spacing="8">
<RadioButton x:Name="ClockFormatHMSSRadio"
Content="时分秒 (HH:mm:ss)"
Content="鏃跺垎绉?(HH:mm:ss)"
GroupName="ClockFormat"
Checked="OnClockFormatChanged"
Tag="Hms" />
<RadioButton x:Name="ClockFormatHMRadio"
Content="时分 (HH:mm)"
Content="鏃跺垎 (HH:mm)"
GroupName="ClockFormat"
Checked="OnClockFormatChanged"
Tag="Hm" />
</StackPanel>
</StackPanel>
</ui:SettingsExpander>
</Border>
<ui:SettingsExpander x:Name="StatusBarSpacingSettingsExpander"
<Border Classes="settings-expander-shell">
<ui:SettingsExpander x:Name="StatusBarSpacingSettingsExpander"
Header="Component spacing"
Description="Adjust spacing between status bar components."
IsExpanded="False">
@@ -1054,11 +1070,168 @@
<TextBlock x:Name="StatusBarSpacingComputedPxTextBlock"
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
Text="0 px" />
Text="鈮?0 px" />
</StackPanel>
</ui:SettingsExpander>
</Border>
</StackPanel>
<StackPanel x:Name="WeatherSettingsPanel"
IsVisible="False"
Spacing="16">
<TextBlock x:Name="WeatherPanelTitleTextBlock"
FontSize="24"
FontWeight="SemiBold"
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}"
Text="&#22825;&#27668;" />
<Border Classes="settings-expander-shell">
<ui:SettingsExpander x:Name="WeatherLocationSettingsExpander"
Header="Location Source"
Description="Choose how weather widgets resolve location.">
<ui:SettingsExpander.Footer>
<StackPanel Spacing="10">
<ComboBox x:Name="WeatherLocationModeComboBox"
Width="220"
SelectionChanged="OnWeatherLocationModeSelectionChanged">
<ComboBoxItem x:Name="WeatherLocationModeCityItem"
Tag="CitySearch"
Content="City Search" />
<ComboBoxItem x:Name="WeatherLocationModeCoordinatesItem"
Tag="Coordinates"
Content="Coordinates" />
</ComboBox>
<ToggleSwitch x:Name="WeatherAutoRefreshToggleSwitch"
Checked="OnWeatherAutoRefreshToggled"
Unchecked="OnWeatherAutoRefreshToggled"
Content="Auto refresh location on startup" />
</StackPanel>
</ui:SettingsExpander.Footer>
</ui:SettingsExpander>
</Border>
<Border Classes="settings-expander-shell">
<ui:SettingsExpander x:Name="WeatherCitySearchSettingsExpander"
Header="City Search"
Description="Search cities and apply one weather location.">
<ui:SettingsExpander.Footer>
<StackPanel Spacing="10">
<Grid ColumnDefinitions="*,Auto,Auto"
ColumnSpacing="8">
<TextBox x:Name="WeatherCitySearchTextBox"
Grid.Column="0"
Watermark="e.g. Beijing" />
<ui:ProgressRing x:Name="WeatherSearchProgressRing"
Grid.Column="1"
Width="24"
Height="24"
IsActive="True"
IsVisible="False" />
<Button x:Name="WeatherSearchButton"
Grid.Column="2"
Padding="12,8"
Click="OnSearchWeatherCityClick"
Content="Search" />
</Grid>
<ComboBox x:Name="WeatherCityResultsComboBox"
MinWidth="320" />
<Button x:Name="WeatherApplyCityButton"
HorizontalAlignment="Left"
Padding="12,8"
Click="OnApplyWeatherCitySelectionClick"
Content="Apply City" />
<TextBlock x:Name="WeatherSearchStatusTextBlock"
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
Text="Search by city name and apply one location." />
</StackPanel>
</ui:SettingsExpander.Footer>
</ui:SettingsExpander>
</Border>
<Border Classes="settings-expander-shell">
<ui:SettingsExpander x:Name="WeatherCoordinateSettingsExpander"
Header="Coordinates"
Description="Set latitude/longitude and optional key/name."
IsVisible="False">
<ui:SettingsExpander.Footer>
<StackPanel Spacing="10">
<Grid ColumnDefinitions="*,*"
ColumnSpacing="10">
<ui:NumberBox x:Name="WeatherLatitudeNumberBox"
Grid.Column="0"
Header="Latitude"
Minimum="-90"
Maximum="90"
SpinButtonPlacementMode="Inline"
SmallChange="0.1"
LargeChange="1"
Value="39.9042" />
<ui:NumberBox x:Name="WeatherLongitudeNumberBox"
Grid.Column="1"
Header="Longitude"
Minimum="-180"
Maximum="180"
SpinButtonPlacementMode="Inline"
SmallChange="0.1"
LargeChange="1"
Value="116.4074" />
</Grid>
<TextBox x:Name="WeatherLocationKeyTextBox"
Watermark="Location key (optional)" />
<TextBox x:Name="WeatherLocationNameTextBox"
Watermark="Display name (optional)" />
<Button x:Name="WeatherApplyCoordinatesButton"
HorizontalAlignment="Left"
Padding="12,8"
Click="OnApplyWeatherCoordinatesClick"
Content="Apply Coordinates" />
<TextBlock x:Name="WeatherCoordinateStatusTextBlock"
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}" />
</StackPanel>
</ui:SettingsExpander.Footer>
</ui:SettingsExpander>
</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.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>
</ui:SettingsExpander.Footer>
</ui:SettingsExpander>
</Border>
<TextBlock x:Name="WeatherLocationStatusTextBlock"
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
Text="No city location is configured." />
</StackPanel>
<StackPanel x:Name="RegionSettingsPanel"
IsVisible="False"
Spacing="16">
@@ -1068,7 +1241,8 @@
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}"
Text="&#22320;&#21306;" />
<ui:SettingsExpander x:Name="LanguageSettingsExpander"
<Border Classes="settings-expander-shell">
<ui:SettingsExpander x:Name="LanguageSettingsExpander"
Header="&#35821;&#35328;"
Description="&#36873;&#25321;&#35821;&#35328;&#65292;&#31435;&#21363;&#24212;&#29992;&#21040;&#35774;&#32622;&#19982;&#20027;&#35201;&#30028;&#38754;&#12290;">
<ui:SettingsExpander.IconSource>
@@ -1083,9 +1257,11 @@
</ComboBox>
</ui:SettingsExpander.Footer>
</ui:SettingsExpander>
<ui:SettingsExpander x:Name="TimeZoneSettingsExpander"
Header="时区"
Description="选择时区,时钟和日历将根据此时区显示时间。">
</Border>
<Border Classes="settings-expander-shell">
<ui:SettingsExpander x:Name="TimeZoneSettingsExpander"
Header="Time Zone"
Description="Select a time zone. Clock and calendar widgets will follow this zone.">
<ui:SettingsExpander.IconSource>
</ui:SettingsExpander.IconSource>
@@ -1095,17 +1271,19 @@
SelectionChanged="OnTimeZoneSelectionChanged" />
</ui:SettingsExpander.Footer>
</ui:SettingsExpander>
</Border>
</StackPanel>
<StackPanel x:Name="AboutSettingsPanel" IsVisible="False" Spacing="20">
<TextBlock x:Name="AboutPanelTitleTextBlock" FontSize="24" FontWeight="SemiBold" Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" Text="关于" />
<Border Background="{DynamicResource AdaptiveSurfaceRaisedBrush}" CornerRadius="20" Padding="20">
<TextBlock x:Name="AboutPanelTitleTextBlock" FontSize="24" FontWeight="SemiBold" Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" Text="鍏充簬" />
<Border Background="{DynamicResource AdaptiveSurfaceRaisedBrush}" CornerRadius="{DynamicResource DesignCornerRadiusMd}" Padding="20">
<StackPanel Spacing="12">
<TextBlock Text="LanMontainDesktop" FontSize="20" FontWeight="SemiBold" Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
<TextBlock Text="现代化桌面壳层应用" FontSize="13" Foreground="{DynamicResource AdaptiveTextSecondaryBrush}" />
<TextBlock Text="Modern desktop shell experience." FontSize="13" Foreground="{DynamicResource AdaptiveTextSecondaryBrush}" />
<Separator Background="{DynamicResource AdaptiveButtonBorderBrush}" Margin="0,8" />
<TextBlock x:Name="VersionTextBlock" Text="版本号: 1.0.0" FontSize="13" Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
<TextBlock x:Name="CodeNameTextBlock" Text="版本代号: Administrate" FontSize="13" FontWeight="SemiBold" Foreground="{DynamicResource AdaptiveAccentBrush}" />
<TextBlock x:Name="VersionTextBlock" Text="鐗堟湰鍙? 1.0.0" FontSize="13" Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
<TextBlock x:Name="CodeNameTextBlock" Text="鐗堟湰浠e彿: Administrate" FontSize="13" FontWeight="SemiBold" Foreground="{DynamicResource AdaptiveAccentBrush}" />
<TextBlock x:Name="FontInfoTextBlock" Text="字体: MiSans" FontSize="12" Foreground="{DynamicResource AdaptiveTextSecondaryBrush}" />
</StackPanel>
</Border>
</StackPanel>
@@ -1136,7 +1314,7 @@
CornerRadius="36,36,0,0"
Padding="16,12">
<Grid ColumnDefinitions="*,Auto">
<TextBlock Text="组件设置"
<TextBlock Text="缁勪欢璁剧疆"
FontSize="16"
FontWeight="SemiBold"
Foreground="White"
@@ -1188,7 +1366,7 @@
FontSize="16"
FontWeight="SemiBold"
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}"
Text="小组件" />
Text="Widgets" />
<Button x:Name="CloseComponentLibraryButton"
Grid.Column="1"
Padding="8"
@@ -1320,3 +1498,4 @@
</Grid>
</Window>

View File

@@ -46,6 +46,12 @@ public partial class MainWindow : Window
Video
}
private enum WeatherLocationMode
{
CitySearch,
Coordinates
}
private const int StatusBarRowIndex = 0;
private const int MinShortSideCells = 6;
private const int MaxShortSideCells = 96;
@@ -83,12 +89,14 @@ public partial class MainWindow : Window
private readonly MonetColorService _monetColorService = new();
private readonly AppSettingsService _appSettingsService = new();
private readonly LocalizationService _localizationService = new();
private readonly TimeZoneService _timeZoneService = new();
private readonly TimeZoneService _timeZoneService = new();
private readonly IWeatherDataService _weatherDataService = new XiaomiWeatherService();
private readonly ComponentRegistry _componentRegistry = ComponentRegistry
.CreateDefault()
.RegisterExtensions(
JsonComponentExtensionProvider.LoadProvidersFromDirectory(
Path.Combine(AppContext.BaseDirectory, "Extensions", "Components")));
private readonly DesktopComponentRuntimeRegistry _componentRuntimeRegistry;
private readonly FluentAvaloniaTheme? _fluentAvaloniaTheme;
private readonly HashSet<string> _topStatusComponentIds = new(StringComparer.OrdinalIgnoreCase);
private readonly HashSet<TaskbarActionId> _pinnedTaskbarActions = [];
@@ -99,6 +107,8 @@ public partial class MainWindow : Window
private bool _suppressThemeToggleEvents;
private bool _suppressStatusBarToggleEvents;
private bool _suppressLanguageSelectionEvents;
private bool _suppressTimeZoneSelectionEvents;
private bool _suppressWeatherLocationEvents;
private bool _suppressSettingsPersistence;
private bool _isUpdatingWallpaperPreviewLayout;
private bool _isComponentLibraryOpen;
@@ -131,6 +141,15 @@ public partial class MainWindow : Window
private int _desktopEdgeInsetPercent = DefaultEdgeInsetPercent;
private string _taskbarLayoutMode = TaskbarLayoutBottomFullRowMacStyle;
private string _languageCode = "zh-CN";
private WeatherLocationMode _weatherLocationMode = WeatherLocationMode.CitySearch;
private string _weatherLocationKey = string.Empty;
private string _weatherLocationName = string.Empty;
private double _weatherLatitude = 39.9042;
private double _weatherLongitude = 116.4074;
private bool _weatherAutoRefreshLocation;
private string _weatherSearchKeyword = string.Empty;
private bool _isWeatherSearchInProgress;
private bool _isWeatherPreviewInProgress;
private ClockDisplayFormat _clockDisplayFormat = ClockDisplayFormat.HourMinuteSecond;
private double CurrentDesktopPitch => _currentDesktopCellSize + _currentDesktopCellGap;
@@ -138,6 +157,7 @@ public partial class MainWindow : Window
public MainWindow()
{
InitializeComponent();
_componentRuntimeRegistry = DesktopComponentRuntimeRegistry.CreateDefault(_componentRegistry);
_fluentAvaloniaTheme = Application.Current?.Styles.OfType<FluentAvaloniaTheme>().FirstOrDefault();
PropertyChanged += OnWindowPropertyChanged;
InitializeDesktopComponentDragHandlers();
@@ -192,13 +212,14 @@ public partial class MainWindow : Window
GridSizeSlider.ValueChanged += OnGridSizeSliderChanged;
GridSizeNumberBox.ValueChanged += OnGridSizeNumberBoxChanged;
SettingsNavListBox.SelectedIndex = Math.Clamp(snapshot.SettingsTabIndex, 0, 5);
SettingsNavListBox.SelectedIndex = Math.Clamp(snapshot.SettingsTabIndex, 0, 6);
UpdateSettingsTabContent();
WallpaperPlacementComboBox.SelectedIndex = GetPlacementIndexFromSetting(snapshot.WallpaperPlacement);
_defaultDesktopBackground = DesktopWallpaperLayer.Background;
ApplyTaskbarSettings(snapshot);
InitializeLocalization(snapshot.LanguageCode);
InitializeWeatherSettings(snapshot);
InitializeDesktopSurfaceState(snapshot);
InitializeDesktopComponentPlacements(snapshot);
InitializeSettingsIcons();
@@ -247,6 +268,10 @@ public partial class MainWindow : Window
_videoWallpaperPlayer = null;
_libVlc?.Dispose();
_libVlc = null;
if (_weatherDataService is IDisposable weatherServiceDisposable)
{
weatherServiceDisposable.Dispose();
}
_wallpaperBitmap?.Dispose();
_wallpaperBitmap = null;
PropertyChanged -= OnWindowPropertyChanged;
@@ -1241,14 +1266,15 @@ public partial class MainWindow : Window
});
}
private void InitializeTimeZoneSettings()
{
private void InitializeTimeZoneSettings()
{
// Populate timezone dropdown items before selecting current timezone.
_suppressTimeZoneSelectionEvents = true;
TimeZoneComboBox.Items.Clear();
var timeZones = _timeZoneService.GetAllTimeZones();
foreach (var tz in timeZones)
{
var displayText = _timeZoneService.GetTimeZoneDisplayName(tz);
var displayText = GetLocalizedTimeZoneDisplayName(tz);
var item = new ComboBoxItem
{
Content = displayText,
@@ -1262,11 +1288,12 @@ public partial class MainWindow : Window
TimeZoneComboBox.SelectedItem = item;
}
}
}
_suppressTimeZoneSelectionEvents = false;
}
private void OnTimeZoneSelectionChanged(object? sender, SelectionChangedEventArgs e)
{
if (_suppressLanguageSelectionEvents || TimeZoneComboBox.SelectedItem is not ComboBoxItem item)
private void OnTimeZoneSelectionChanged(object? sender, SelectionChangedEventArgs e)
{
if (_suppressTimeZoneSelectionEvents || TimeZoneComboBox.SelectedItem is not ComboBoxItem item)
{
return;
}
@@ -1279,6 +1306,6 @@ public partial class MainWindow : Window
_timeZoneService.SetTimeZoneById(timeZoneId);
PersistSettings();
}
}
}
}

View File

@@ -0,0 +1,46 @@
#-------------------------------------------------------------------------------#
# Qodana analysis is configured by qodana.yaml file #
# https://www.jetbrains.com/help/qodana/qodana-yaml.html #
#-------------------------------------------------------------------------------#
#################################################################################
# WARNING: Do not store sensitive information in this file, #
# as its contents will be included in the Qodana report. #
#################################################################################
version: "1.0"
#Specify IDE code to run analysis without container (Applied in CI/CD pipeline)
ide: QDNET
#Specify inspection profile for code analysis
profile:
name: qodana.starter
#Enable inspections
#include:
# - name: <SomeEnabledInspectionId>
#Disable inspections
#exclude:
# - name: <SomeDisabledInspectionId>
# paths:
# - <path/where/not/run/inspection>
#Execute shell command before Qodana execution (Applied in CI/CD pipeline)
#bootstrap: sh ./prepare-qodana.sh
#Install IDE plugins before Qodana execution (Applied in CI/CD pipeline)
#plugins:
# - id: <plugin.id> #(plugin id can be found at https://plugins.jetbrains.com)
# Quality gate. Will fail the CI/CD pipeline if any condition is not met
# severityThresholds - configures maximum thresholds for different problem severities
# testCoverageThresholds - configures minimum code coverage on a whole project and newly added code
# Code Coverage is available in Ultimate and Ultimate Plus plans
#failureConditions:
# severityThresholds:
# any: 15
# critical: 5
# testCoverageThresholds:
# fresh: 70
# total: 50