mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-29 06:04:25 +08:00
0.2.2
时钟组件的完善。
This commit is contained in:
@@ -3,8 +3,11 @@ namespace LanMontainDesktop.ComponentSystem;
|
|||||||
public static class BuiltInComponentIds
|
public static class BuiltInComponentIds
|
||||||
{
|
{
|
||||||
public const string Clock = "Clock";
|
public const string Clock = "Clock";
|
||||||
|
public const string DesktopClock = "DesktopClock";
|
||||||
|
public const string DesktopTimer = "DesktopTimer";
|
||||||
public const string Blank2x4 = "Blank2x4";
|
public const string Blank2x4 = "Blank2x4";
|
||||||
public const string Date = "Date";
|
public const string Date = "Date";
|
||||||
public const string MonthCalendar = "MonthCalendar";
|
public const string MonthCalendar = "MonthCalendar";
|
||||||
public const string LunarCalendar = "LunarCalendar";
|
public const string LunarCalendar = "LunarCalendar";
|
||||||
|
public const string HolidayCalendar = "HolidayCalendar";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,24 @@ public sealed class ComponentRegistry
|
|||||||
MinHeightCells: 1,
|
MinHeightCells: 1,
|
||||||
AllowStatusBarPlacement: true,
|
AllowStatusBarPlacement: true,
|
||||||
AllowDesktopPlacement: false),
|
AllowDesktopPlacement: false),
|
||||||
|
new DesktopComponentDefinition(
|
||||||
|
BuiltInComponentIds.DesktopClock,
|
||||||
|
"Clock",
|
||||||
|
"Clock",
|
||||||
|
"Clock",
|
||||||
|
MinWidthCells: 2,
|
||||||
|
MinHeightCells: 2,
|
||||||
|
AllowStatusBarPlacement: false,
|
||||||
|
AllowDesktopPlacement: true),
|
||||||
|
new DesktopComponentDefinition(
|
||||||
|
BuiltInComponentIds.DesktopTimer,
|
||||||
|
"Timer",
|
||||||
|
"Timer",
|
||||||
|
"Clock",
|
||||||
|
MinWidthCells: 2,
|
||||||
|
MinHeightCells: 2,
|
||||||
|
AllowStatusBarPlacement: false,
|
||||||
|
AllowDesktopPlacement: true),
|
||||||
new DesktopComponentDefinition(
|
new DesktopComponentDefinition(
|
||||||
BuiltInComponentIds.Date,
|
BuiltInComponentIds.Date,
|
||||||
"Calendar",
|
"Calendar",
|
||||||
@@ -56,6 +74,15 @@ public sealed class ComponentRegistry
|
|||||||
MinWidthCells: 2,
|
MinWidthCells: 2,
|
||||||
MinHeightCells: 2,
|
MinHeightCells: 2,
|
||||||
AllowStatusBarPlacement: false,
|
AllowStatusBarPlacement: false,
|
||||||
|
AllowDesktopPlacement: true),
|
||||||
|
new DesktopComponentDefinition(
|
||||||
|
BuiltInComponentIds.HolidayCalendar,
|
||||||
|
"Holiday Countdown",
|
||||||
|
"Calendar",
|
||||||
|
"Date",
|
||||||
|
MinWidthCells: 2,
|
||||||
|
MinHeightCells: 2,
|
||||||
|
AllowStatusBarPlacement: false,
|
||||||
AllowDesktopPlacement: true)
|
AllowDesktopPlacement: true)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -104,10 +104,14 @@
|
|||||||
"component_library.drag_hint": "Drag to place",
|
"component_library.drag_hint": "Drag to place",
|
||||||
"component.delete": "Delete",
|
"component.delete": "Delete",
|
||||||
"component.edit": "Edit",
|
"component.edit": "Edit",
|
||||||
|
"component_category.clock": "Clock",
|
||||||
"component_category.date": "Calendar",
|
"component_category.date": "Calendar",
|
||||||
"component.date": "Calendar",
|
"component.date": "Calendar",
|
||||||
"component.month_calendar": "Month Calendar",
|
"component.month_calendar": "Month Calendar",
|
||||||
"component.lunar_calendar": "Lunar Calendar",
|
"component.lunar_calendar": "Lunar Calendar",
|
||||||
|
"component.desktop_clock": "Clock",
|
||||||
|
"component.desktop_timer": "Timer",
|
||||||
|
"component.holiday_calendar": "Holiday Calendar",
|
||||||
"desktop.add_page": "Add page",
|
"desktop.add_page": "Add page",
|
||||||
"desktop.delete_page": "Delete page",
|
"desktop.delete_page": "Delete page",
|
||||||
"placement.fill": "Fill",
|
"placement.fill": "Fill",
|
||||||
|
|||||||
@@ -104,10 +104,14 @@
|
|||||||
"component_library.drag_hint": "拖动放置",
|
"component_library.drag_hint": "拖动放置",
|
||||||
"component.delete": "删除",
|
"component.delete": "删除",
|
||||||
"component.edit": "编辑",
|
"component.edit": "编辑",
|
||||||
|
"component_category.clock": "时钟",
|
||||||
"component_category.date": "日历",
|
"component_category.date": "日历",
|
||||||
"component.date": "日历",
|
"component.date": "日历",
|
||||||
"component.month_calendar": "月历",
|
"component.month_calendar": "月历",
|
||||||
"component.lunar_calendar": "农历",
|
"component.lunar_calendar": "农历",
|
||||||
|
"component.desktop_clock": "时钟",
|
||||||
|
"component.desktop_timer": "计时器",
|
||||||
|
"component.holiday_calendar": "节假日日历",
|
||||||
"desktop.add_page": "新增页面",
|
"desktop.add_page": "新增页面",
|
||||||
"desktop.delete_page": "删除页面",
|
"desktop.delete_page": "删除页面",
|
||||||
"placement.fill": "填充",
|
"placement.fill": "填充",
|
||||||
|
|||||||
45
LanMontainDesktop/Models/WeatherDataModels.cs
Normal file
45
LanMontainDesktop/Models/WeatherDataModels.cs
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace LanMontainDesktop.Models;
|
||||||
|
|
||||||
|
public sealed record WeatherLocation(
|
||||||
|
string Name,
|
||||||
|
string LocationKey,
|
||||||
|
double Latitude,
|
||||||
|
double Longitude,
|
||||||
|
string? Affiliation = null);
|
||||||
|
|
||||||
|
public sealed record WeatherCurrentCondition(
|
||||||
|
double? TemperatureC,
|
||||||
|
double? FeelsLikeC,
|
||||||
|
int? RelativeHumidityPercent,
|
||||||
|
int? AirQualityIndex,
|
||||||
|
double? WindSpeedKph,
|
||||||
|
double? WindDirectionDegree,
|
||||||
|
int? WeatherCode,
|
||||||
|
string? WeatherText);
|
||||||
|
|
||||||
|
public sealed record WeatherDailyForecast(
|
||||||
|
DateOnly Date,
|
||||||
|
double? LowTemperatureC,
|
||||||
|
double? HighTemperatureC,
|
||||||
|
int? DayWeatherCode,
|
||||||
|
string? DayWeatherText,
|
||||||
|
int? NightWeatherCode,
|
||||||
|
string? NightWeatherText,
|
||||||
|
string? SunriseTime,
|
||||||
|
string? SunsetTime,
|
||||||
|
int? PrecipitationProbabilityPercent);
|
||||||
|
|
||||||
|
public sealed record WeatherSnapshot(
|
||||||
|
string Provider,
|
||||||
|
string LocationKey,
|
||||||
|
string? LocationName,
|
||||||
|
double? Latitude,
|
||||||
|
double? Longitude,
|
||||||
|
DateTimeOffset FetchedAt,
|
||||||
|
DateTimeOffset? ObservationTime,
|
||||||
|
WeatherCurrentCondition Current,
|
||||||
|
IReadOnlyList<WeatherDailyForecast> DailyForecasts);
|
||||||
|
|
||||||
678
LanMontainDesktop/Services/HolidayCalendarService.cs
Normal file
678
LanMontainDesktop/Services/HolidayCalendarService.cs
Normal file
@@ -0,0 +1,678 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Net.Http;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace LanMontainDesktop.Services;
|
||||||
|
|
||||||
|
public enum HolidayDayType
|
||||||
|
{
|
||||||
|
Workday = 0,
|
||||||
|
Weekend = 1,
|
||||||
|
LegalHoliday = 2,
|
||||||
|
AdjustedWorkday = 3,
|
||||||
|
Unknown = 99
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record HolidayDayStatus(
|
||||||
|
DateOnly Date,
|
||||||
|
HolidayDayType DayType,
|
||||||
|
string TypeNameZh,
|
||||||
|
bool IsHoliday,
|
||||||
|
bool IsAdjustedWorkday,
|
||||||
|
string? NameZh,
|
||||||
|
string? NameEn,
|
||||||
|
string? TargetHolidayZh);
|
||||||
|
|
||||||
|
public sealed record HolidayDisplayInfo(
|
||||||
|
HolidayCountdownInfo? NextHoliday,
|
||||||
|
HolidayDayStatus TodayStatus,
|
||||||
|
bool UsesOnlineData);
|
||||||
|
|
||||||
|
public sealed class HolidayCalendarService : IDisposable
|
||||||
|
{
|
||||||
|
private static readonly ChineseLunisolarCalendar LunarCalendar = new();
|
||||||
|
|
||||||
|
private sealed record HolidayTemplate(
|
||||||
|
string NameZh,
|
||||||
|
string NameEn,
|
||||||
|
Func<int, DateOnly?> ResolveDateForYear);
|
||||||
|
|
||||||
|
private sealed record HolidayArrangementDay(
|
||||||
|
DateOnly Date,
|
||||||
|
bool IsHoliday,
|
||||||
|
bool IsAdjustedWorkday,
|
||||||
|
string NameZh,
|
||||||
|
string NameEn,
|
||||||
|
string? TargetHolidayZh,
|
||||||
|
bool? IsAfterAdjust);
|
||||||
|
|
||||||
|
private sealed record CacheEntry<T>(T Value, DateTimeOffset ExpireAt);
|
||||||
|
|
||||||
|
private static readonly IReadOnlyDictionary<string, string> HolidayNameMapZhToEn =
|
||||||
|
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
["\u5143\u65e6"] = "New Year's Day",
|
||||||
|
["\u6625\u8282"] = "Spring Festival",
|
||||||
|
["\u6e05\u660e\u8282"] = "Tomb-Sweeping Day",
|
||||||
|
["\u52b3\u52a8\u8282"] = "Labor Day",
|
||||||
|
["\u7aef\u5348\u8282"] = "Dragon Boat Festival",
|
||||||
|
["\u4e2d\u79cb\u8282"] = "Mid-Autumn Festival",
|
||||||
|
["\u56fd\u5e86\u8282"] = "National Day",
|
||||||
|
["\u9664\u5915"] = "Lunar New Year's Eve",
|
||||||
|
["\u521d\u4e00"] = "Spring Festival Day 1",
|
||||||
|
["\u521d\u4e8c"] = "Spring Festival Day 2",
|
||||||
|
["\u521d\u4e09"] = "Spring Festival Day 3",
|
||||||
|
["\u521d\u56db"] = "Spring Festival Day 4",
|
||||||
|
["\u521d\u4e94"] = "Spring Festival Day 5",
|
||||||
|
["\u521d\u516d"] = "Spring Festival Day 6",
|
||||||
|
["\u521d\u4e03"] = "Spring Festival Day 7"
|
||||||
|
};
|
||||||
|
|
||||||
|
private static readonly IReadOnlyList<HolidayTemplate> HolidayTemplates =
|
||||||
|
[
|
||||||
|
new HolidayTemplate(
|
||||||
|
"\u5143\u65e6",
|
||||||
|
"New Year's Day",
|
||||||
|
year => new DateOnly(year, 1, 1)),
|
||||||
|
new HolidayTemplate(
|
||||||
|
"\u6625\u8282",
|
||||||
|
"Spring Festival",
|
||||||
|
year => TryResolveLunarHolidayDate(year, lunarMonth: 1, lunarDay: 1)),
|
||||||
|
new HolidayTemplate(
|
||||||
|
"\u52b3\u52a8\u8282",
|
||||||
|
"Labor Day",
|
||||||
|
year => new DateOnly(year, 5, 1)),
|
||||||
|
new HolidayTemplate(
|
||||||
|
"\u7aef\u5348\u8282",
|
||||||
|
"Dragon Boat Festival",
|
||||||
|
year => TryResolveLunarHolidayDate(year, lunarMonth: 5, lunarDay: 5)),
|
||||||
|
new HolidayTemplate(
|
||||||
|
"\u4e2d\u79cb\u8282",
|
||||||
|
"Mid-Autumn Festival",
|
||||||
|
year => TryResolveLunarHolidayDate(year, lunarMonth: 8, lunarDay: 15)),
|
||||||
|
new HolidayTemplate(
|
||||||
|
"\u56fd\u5e86\u8282",
|
||||||
|
"National Day",
|
||||||
|
year => new DateOnly(year, 10, 1))
|
||||||
|
];
|
||||||
|
|
||||||
|
private readonly HttpClient _httpClient;
|
||||||
|
private readonly bool _ownsHttpClient;
|
||||||
|
private readonly string _baseUrl;
|
||||||
|
private readonly TimeSpan _yearCacheDuration;
|
||||||
|
private readonly TimeSpan _dayCacheDuration;
|
||||||
|
private readonly object _cacheGate = new();
|
||||||
|
private readonly Dictionary<int, CacheEntry<IReadOnlyList<HolidayArrangementDay>>> _yearHolidayCache = new();
|
||||||
|
private readonly Dictionary<DateOnly, CacheEntry<HolidayDayStatus>> _dayStatusCache = new();
|
||||||
|
|
||||||
|
public HolidayCalendarService(
|
||||||
|
HttpClient? httpClient = null,
|
||||||
|
string baseUrl = "https://timor.tech",
|
||||||
|
TimeSpan? yearCacheDuration = null,
|
||||||
|
TimeSpan? dayCacheDuration = null,
|
||||||
|
TimeSpan? requestTimeout = null)
|
||||||
|
{
|
||||||
|
_baseUrl = string.IsNullOrWhiteSpace(baseUrl) ? "https://timor.tech" : baseUrl.Trim();
|
||||||
|
_yearCacheDuration = yearCacheDuration ?? TimeSpan.FromHours(12);
|
||||||
|
_dayCacheDuration = dayCacheDuration ?? TimeSpan.FromHours(3);
|
||||||
|
|
||||||
|
if (httpClient is null)
|
||||||
|
{
|
||||||
|
_httpClient = new HttpClient
|
||||||
|
{
|
||||||
|
Timeout = requestTimeout ?? TimeSpan.FromSeconds(8)
|
||||||
|
};
|
||||||
|
_ownsHttpClient = true;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_httpClient = httpClient;
|
||||||
|
_ownsHttpClient = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (_ownsHttpClient)
|
||||||
|
{
|
||||||
|
_httpClient.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<HolidayDisplayInfo> GetDisplayInfoAsync(
|
||||||
|
DateTime dateTime,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var today = DateOnly.FromDateTime(dateTime.Date);
|
||||||
|
var todayStatus = BuildFallbackDayStatus(today);
|
||||||
|
var usesOnlineData = false;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
todayStatus = await GetDayStatusOnlineAsync(today, cancellationToken);
|
||||||
|
usesOnlineData = true;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Keep local fallback status.
|
||||||
|
}
|
||||||
|
|
||||||
|
HolidayCountdownInfo? nextHoliday = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
nextHoliday = await GetNextHolidayOnlineAsync(today, cancellationToken);
|
||||||
|
if (nextHoliday is not null)
|
||||||
|
{
|
||||||
|
usesOnlineData = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Keep local fallback countdown.
|
||||||
|
}
|
||||||
|
|
||||||
|
nextHoliday ??= GetNextHoliday(dateTime);
|
||||||
|
return new HolidayDisplayInfo(nextHoliday, todayStatus, usesOnlineData);
|
||||||
|
}
|
||||||
|
|
||||||
|
public HolidayCountdownInfo? GetNextHoliday(DateTime dateTime)
|
||||||
|
{
|
||||||
|
var today = DateOnly.FromDateTime(dateTime.Date);
|
||||||
|
var candidates = BuildHolidayCandidates(today.Year - 1, today.Year + 3);
|
||||||
|
HolidayCountdownInfo? best = null;
|
||||||
|
|
||||||
|
foreach (var holiday in candidates)
|
||||||
|
{
|
||||||
|
if (holiday.Date < today)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (best is null || holiday.Date < best.Date)
|
||||||
|
{
|
||||||
|
best = holiday;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<HolidayCountdownInfo?> GetNextHolidayOnlineAsync(DateOnly today, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var candidates = new List<HolidayCountdownInfo>();
|
||||||
|
for (var year = today.Year; year <= today.Year + 2; year++)
|
||||||
|
{
|
||||||
|
var yearData = await GetYearHolidayDataOnlineAsync(year, cancellationToken);
|
||||||
|
foreach (var item in yearData)
|
||||||
|
{
|
||||||
|
if (!item.IsHoliday || item.Date < today)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
candidates.Add(new HolidayCountdownInfo(
|
||||||
|
NameZh: item.NameZh,
|
||||||
|
NameEn: item.NameEn,
|
||||||
|
Date: item.Date));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return candidates.Count == 0
|
||||||
|
? null
|
||||||
|
: candidates.OrderBy(item => item.Date).First();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<HolidayDayStatus> GetDayStatusOnlineAsync(DateOnly date, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (TryGetDayStatusFromCache(date, out var cached))
|
||||||
|
{
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
var uri = BuildRequestUri($"/api/holiday/info/{date:yyyy-MM-dd}");
|
||||||
|
var responseText = await FetchAsync(uri, cancellationToken);
|
||||||
|
|
||||||
|
using var document = JsonDocument.Parse(responseText);
|
||||||
|
var root = document.RootElement;
|
||||||
|
var code = ReadInt(root, "code");
|
||||||
|
if (code.HasValue && code.Value != 0)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"Holiday API returned error code {code.Value}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var typeNode = TryGetNode(root, "type");
|
||||||
|
var holidayNode = TryGetNode(root, "holiday");
|
||||||
|
|
||||||
|
var apiType = ReadInt(typeNode, "type");
|
||||||
|
var typeNameZh = ReadString(typeNode, "name") ?? string.Empty;
|
||||||
|
var dayType = MapDayType(apiType);
|
||||||
|
|
||||||
|
var holidayFlag = ReadBool(holidayNode, "holiday");
|
||||||
|
var isHoliday = holidayFlag ?? dayType == HolidayDayType.LegalHoliday;
|
||||||
|
var nameZh = ReadString(holidayNode, "name");
|
||||||
|
var targetZh = ReadString(holidayNode, "target");
|
||||||
|
var isAdjustedWorkday =
|
||||||
|
dayType == HolidayDayType.AdjustedWorkday ||
|
||||||
|
(!isHoliday &&
|
||||||
|
(!string.IsNullOrWhiteSpace(targetZh) ||
|
||||||
|
(nameZh?.Contains("\u8865\u73ed", StringComparison.Ordinal) ?? false)));
|
||||||
|
|
||||||
|
var nameEn = ResolveHolidayEnName(nameZh, targetZh, isHoliday, isAdjustedWorkday);
|
||||||
|
var result = new HolidayDayStatus(
|
||||||
|
Date: date,
|
||||||
|
DayType: dayType,
|
||||||
|
TypeNameZh: typeNameZh,
|
||||||
|
IsHoliday: isHoliday,
|
||||||
|
IsAdjustedWorkday: isAdjustedWorkday,
|
||||||
|
NameZh: nameZh,
|
||||||
|
NameEn: nameEn,
|
||||||
|
TargetHolidayZh: targetZh);
|
||||||
|
|
||||||
|
SetDayStatusCache(date, result);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<IReadOnlyList<HolidayArrangementDay>> GetYearHolidayDataOnlineAsync(int year, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (TryGetYearHolidayDataFromCache(year, out var cached))
|
||||||
|
{
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
var uri = BuildRequestUri($"/api/holiday/year/{year}/");
|
||||||
|
var responseText = await FetchAsync(uri, cancellationToken);
|
||||||
|
|
||||||
|
using var document = JsonDocument.Parse(responseText);
|
||||||
|
var root = document.RootElement;
|
||||||
|
var code = ReadInt(root, "code");
|
||||||
|
if (code.HasValue && code.Value != 0)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"Holiday year API returned error code {code.Value}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = new List<HolidayArrangementDay>();
|
||||||
|
var holidayRoot = TryGetNode(root, "holiday");
|
||||||
|
if (holidayRoot is not null && holidayRoot.Value.ValueKind == JsonValueKind.Object)
|
||||||
|
{
|
||||||
|
foreach (var property in holidayRoot.Value.EnumerateObject())
|
||||||
|
{
|
||||||
|
var node = property.Value;
|
||||||
|
var date = ParseDateOnly(ReadString(node, "date")) ??
|
||||||
|
ParseDateOnly(string.Create(CultureInfo.InvariantCulture, $"{year}-{property.Name}"));
|
||||||
|
if (!date.HasValue)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var isHoliday = ReadBool(node, "holiday") ?? false;
|
||||||
|
var nameZh = ReadString(node, "name");
|
||||||
|
if (string.IsNullOrWhiteSpace(nameZh))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var targetZh = ReadString(node, "target");
|
||||||
|
var isAfterAdjust = ReadBool(node, "after");
|
||||||
|
var isAdjustedWorkday = !isHoliday &&
|
||||||
|
(!string.IsNullOrWhiteSpace(targetZh) ||
|
||||||
|
nameZh.Contains("\u8865\u73ed", StringComparison.Ordinal));
|
||||||
|
|
||||||
|
result.Add(new HolidayArrangementDay(
|
||||||
|
Date: date.Value,
|
||||||
|
IsHoliday: isHoliday,
|
||||||
|
IsAdjustedWorkday: isAdjustedWorkday,
|
||||||
|
NameZh: nameZh,
|
||||||
|
NameEn: ResolveHolidayEnName(nameZh, targetZh, isHoliday, isAdjustedWorkday),
|
||||||
|
TargetHolidayZh: targetZh,
|
||||||
|
IsAfterAdjust: isAfterAdjust));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Sort((left, right) => left.Date.CompareTo(right.Date));
|
||||||
|
SetYearHolidayDataCache(year, result);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<HolidayCountdownInfo> BuildHolidayCandidates(int yearFrom, int yearToInclusive)
|
||||||
|
{
|
||||||
|
var results = new List<HolidayCountdownInfo>();
|
||||||
|
if (yearToInclusive < yearFrom)
|
||||||
|
{
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var year = yearFrom; year <= yearToInclusive; year++)
|
||||||
|
{
|
||||||
|
foreach (var template in HolidayTemplates)
|
||||||
|
{
|
||||||
|
var date = template.ResolveDateForYear(year);
|
||||||
|
if (!date.HasValue)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
results.Add(new HolidayCountdownInfo(
|
||||||
|
NameZh: template.NameZh,
|
||||||
|
NameEn: template.NameEn,
|
||||||
|
Date: date.Value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
results.Sort((left, right) => left.Date.CompareTo(right.Date));
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DateOnly? TryResolveLunarHolidayDate(int lunarYear, int lunarMonth, int lunarDay)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var mappedMonth = MapRegularLunarMonthToRawMonth(lunarYear, lunarMonth);
|
||||||
|
var gregorian = LunarCalendar.ToDateTime(lunarYear, mappedMonth, lunarDay, 0, 0, 0, 0);
|
||||||
|
return DateOnly.FromDateTime(gregorian);
|
||||||
|
}
|
||||||
|
catch (ArgumentOutOfRangeException)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int MapRegularLunarMonthToRawMonth(int lunarYear, int regularMonth)
|
||||||
|
{
|
||||||
|
var leapMonth = LunarCalendar.GetLeapMonth(lunarYear);
|
||||||
|
if (leapMonth == 0)
|
||||||
|
{
|
||||||
|
return regularMonth;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChineseLunisolarCalendar month index inserts leap month:
|
||||||
|
// if leap month is after regular month N, GetLeapMonth returns N + 1.
|
||||||
|
// Months after that slot shift by +1.
|
||||||
|
return leapMonth <= regularMonth ? regularMonth + 1 : regularMonth;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static HolidayDayStatus BuildFallbackDayStatus(DateOnly date)
|
||||||
|
{
|
||||||
|
var dayOfWeek = date.DayOfWeek;
|
||||||
|
var isWeekend = dayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday;
|
||||||
|
return new HolidayDayStatus(
|
||||||
|
Date: date,
|
||||||
|
DayType: isWeekend ? HolidayDayType.Weekend : HolidayDayType.Workday,
|
||||||
|
TypeNameZh: isWeekend ? "\u5468\u672b" : "\u5de5\u4f5c\u65e5",
|
||||||
|
IsHoliday: false,
|
||||||
|
IsAdjustedWorkday: false,
|
||||||
|
NameZh: null,
|
||||||
|
NameEn: null,
|
||||||
|
TargetHolidayZh: null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static HolidayDayType MapDayType(int? apiType)
|
||||||
|
{
|
||||||
|
return apiType switch
|
||||||
|
{
|
||||||
|
0 => HolidayDayType.Workday,
|
||||||
|
1 => HolidayDayType.Weekend,
|
||||||
|
2 => HolidayDayType.LegalHoliday,
|
||||||
|
3 => HolidayDayType.AdjustedWorkday,
|
||||||
|
_ => HolidayDayType.Unknown
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ResolveHolidayEnName(
|
||||||
|
string? holidayNameZh,
|
||||||
|
string? targetHolidayZh,
|
||||||
|
bool isHoliday,
|
||||||
|
bool isAdjustedWorkday)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(holidayNameZh) &&
|
||||||
|
HolidayNameMapZhToEn.TryGetValue(holidayNameZh, out var holidayEn))
|
||||||
|
{
|
||||||
|
return holidayEn;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(targetHolidayZh) &&
|
||||||
|
HolidayNameMapZhToEn.TryGetValue(targetHolidayZh, out var targetEn))
|
||||||
|
{
|
||||||
|
return isAdjustedWorkday
|
||||||
|
? $"{targetEn} Make-up Workday"
|
||||||
|
: targetEn;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isAdjustedWorkday)
|
||||||
|
{
|
||||||
|
return "Make-up Workday";
|
||||||
|
}
|
||||||
|
|
||||||
|
return isHoliday ? "Holiday" : "Workday";
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<string> FetchAsync(Uri requestUri, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
using var response = await _httpClient.GetAsync(requestUri, cancellationToken);
|
||||||
|
var content = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
throw new HttpRequestException($"HTTP {(int)response.StatusCode}: {Truncate(content, 180)}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Uri BuildRequestUri(string path)
|
||||||
|
{
|
||||||
|
var baseUrl = _baseUrl.TrimEnd('/');
|
||||||
|
var normalizedPath = path.StartsWith("/", StringComparison.Ordinal) ? path : $"/{path}";
|
||||||
|
return new Uri($"{baseUrl}{normalizedPath}", UriKind.Absolute);
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool TryGetYearHolidayDataFromCache(int year, out IReadOnlyList<HolidayArrangementDay> value)
|
||||||
|
{
|
||||||
|
lock (_cacheGate)
|
||||||
|
{
|
||||||
|
if (_yearHolidayCache.TryGetValue(year, out var entry) &&
|
||||||
|
entry.ExpireAt > DateTimeOffset.UtcNow)
|
||||||
|
{
|
||||||
|
value = entry.Value;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
value = Array.Empty<HolidayArrangementDay>();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SetYearHolidayDataCache(int year, IReadOnlyList<HolidayArrangementDay> value)
|
||||||
|
{
|
||||||
|
lock (_cacheGate)
|
||||||
|
{
|
||||||
|
_yearHolidayCache[year] = new CacheEntry<IReadOnlyList<HolidayArrangementDay>>(
|
||||||
|
value,
|
||||||
|
DateTimeOffset.UtcNow.Add(_yearCacheDuration));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool TryGetDayStatusFromCache(DateOnly date, out HolidayDayStatus value)
|
||||||
|
{
|
||||||
|
lock (_cacheGate)
|
||||||
|
{
|
||||||
|
if (_dayStatusCache.TryGetValue(date, out var entry) &&
|
||||||
|
entry.ExpireAt > DateTimeOffset.UtcNow)
|
||||||
|
{
|
||||||
|
value = entry.Value;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
value = BuildFallbackDayStatus(date);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SetDayStatusCache(DateOnly date, HolidayDayStatus value)
|
||||||
|
{
|
||||||
|
lock (_cacheGate)
|
||||||
|
{
|
||||||
|
_dayStatusCache[date] = new CacheEntry<HolidayDayStatus>(
|
||||||
|
value,
|
||||||
|
DateTimeOffset.UtcNow.Add(_dayCacheDuration));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static JsonElement? TryGetNode(JsonElement node, params string[] path)
|
||||||
|
{
|
||||||
|
var current = node;
|
||||||
|
foreach (var segment in path)
|
||||||
|
{
|
||||||
|
if (current.ValueKind != JsonValueKind.Object || !current.TryGetProperty(segment, out var next))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
current = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? ReadString(JsonElement? node, params string[] path)
|
||||||
|
{
|
||||||
|
if (!node.HasValue)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var target = path.Length == 0 ? node : TryGetNode(node.Value, path);
|
||||||
|
if (!target.HasValue)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return target.Value.ValueKind switch
|
||||||
|
{
|
||||||
|
JsonValueKind.String => target.Value.GetString(),
|
||||||
|
JsonValueKind.Number => target.Value.GetRawText(),
|
||||||
|
JsonValueKind.True => "true",
|
||||||
|
JsonValueKind.False => "false",
|
||||||
|
_ => null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int? ReadInt(JsonElement? node, params string[] path)
|
||||||
|
{
|
||||||
|
if (!node.HasValue)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var target = path.Length == 0 ? node : TryGetNode(node.Value, path);
|
||||||
|
if (!target.HasValue)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (target.Value.ValueKind == JsonValueKind.Number && target.Value.TryGetInt32(out var number))
|
||||||
|
{
|
||||||
|
return number;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (target.Value.ValueKind == JsonValueKind.String &&
|
||||||
|
int.TryParse(target.Value.GetString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed))
|
||||||
|
{
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool? ReadBool(JsonElement? node, params string[] path)
|
||||||
|
{
|
||||||
|
if (!node.HasValue)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var target = path.Length == 0 ? node : TryGetNode(node.Value, path);
|
||||||
|
if (!target.HasValue)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (target.Value.ValueKind == JsonValueKind.True)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (target.Value.ValueKind == JsonValueKind.False)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (target.Value.ValueKind == JsonValueKind.Number && target.Value.TryGetInt32(out var number))
|
||||||
|
{
|
||||||
|
return number != 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (target.Value.ValueKind == JsonValueKind.String)
|
||||||
|
{
|
||||||
|
var value = target.Value.GetString();
|
||||||
|
if (bool.TryParse(value, out var parsedBool))
|
||||||
|
{
|
||||||
|
return parsedBool;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedInt))
|
||||||
|
{
|
||||||
|
return parsedInt != 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DateOnly? ParseDateOnly(string? value)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(value))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (DateOnly.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.None, out var dateOnly))
|
||||||
|
{
|
||||||
|
return dateOnly;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (DateTime.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.None, out var dateTime))
|
||||||
|
{
|
||||||
|
return DateOnly.FromDateTime(dateTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Truncate(string? text, int maxLength)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(text))
|
||||||
|
{
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
return text.Length <= maxLength
|
||||||
|
? text
|
||||||
|
: $"{text[..maxLength]}...";
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string FormatDate(DateOnly date, bool isZh)
|
||||||
|
{
|
||||||
|
return isZh
|
||||||
|
? string.Create(CultureInfo.InvariantCulture, $"{date.Year}\u5e74{date.Month}\u6708{date.Day}\u65e5")
|
||||||
|
: date.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record HolidayCountdownInfo(
|
||||||
|
string NameZh,
|
||||||
|
string NameEn,
|
||||||
|
DateOnly Date);
|
||||||
44
LanMontainDesktop/Services/IWeatherDataService.cs
Normal file
44
LanMontainDesktop/Services/IWeatherDataService.cs
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using LanMontainDesktop.Models;
|
||||||
|
|
||||||
|
namespace LanMontainDesktop.Services;
|
||||||
|
|
||||||
|
public sealed record WeatherQuery(
|
||||||
|
string LocationKey,
|
||||||
|
double Latitude,
|
||||||
|
double Longitude,
|
||||||
|
int ForecastDays = 7,
|
||||||
|
string? Locale = null,
|
||||||
|
bool? IsGlobal = null,
|
||||||
|
bool ForceRefresh = false);
|
||||||
|
|
||||||
|
public sealed record WeatherQueryResult<T>(
|
||||||
|
bool Success,
|
||||||
|
T? Data,
|
||||||
|
string? ErrorCode = null,
|
||||||
|
string? ErrorMessage = null)
|
||||||
|
{
|
||||||
|
public static WeatherQueryResult<T> Ok(T data)
|
||||||
|
{
|
||||||
|
return new WeatherQueryResult<T>(true, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static WeatherQueryResult<T> Fail(string errorCode, string errorMessage)
|
||||||
|
{
|
||||||
|
return new WeatherQueryResult<T>(false, default, errorCode, errorMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface IWeatherDataService
|
||||||
|
{
|
||||||
|
Task<WeatherQueryResult<WeatherSnapshot>> GetWeatherAsync(WeatherQuery query, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<WeatherQueryResult<IReadOnlyList<WeatherLocation>>> SearchLocationsAsync(
|
||||||
|
string keyword,
|
||||||
|
string? locale = null,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
void ClearCache();
|
||||||
|
}
|
||||||
731
LanMontainDesktop/Services/XiaomiWeatherService.cs
Normal file
731
LanMontainDesktop/Services/XiaomiWeatherService.cs
Normal file
@@ -0,0 +1,731 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Net.Http;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using LanMontainDesktop.Models;
|
||||||
|
|
||||||
|
namespace LanMontainDesktop.Services;
|
||||||
|
|
||||||
|
public sealed record XiaomiWeatherApiOptions
|
||||||
|
{
|
||||||
|
public string BaseUrl { get; init; } = "https://weatherapi.market.xiaomi.com";
|
||||||
|
|
||||||
|
public string WeatherAllPath { get; init; } = "/wtr-v3/weather/all";
|
||||||
|
|
||||||
|
public string CitySearchPath { get; init; } = "/wtr-v3/location/city/search";
|
||||||
|
|
||||||
|
public string AppKey { get; init; } = "weather20151024";
|
||||||
|
|
||||||
|
public string Sign { get; init; } = "zUFJoAR2ZVrDy1vF3D07";
|
||||||
|
|
||||||
|
public string Source { get; init; } = "xiaomi";
|
||||||
|
|
||||||
|
public string Locale { get; init; } = "zh_cn";
|
||||||
|
|
||||||
|
public bool IsGlobal { get; init; }
|
||||||
|
|
||||||
|
public TimeSpan CacheDuration { get; init; } = TimeSpan.FromMinutes(10);
|
||||||
|
|
||||||
|
public TimeSpan RequestTimeout { get; init; } = TimeSpan.FromSeconds(8);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class XiaomiWeatherService : IWeatherDataService, IDisposable
|
||||||
|
{
|
||||||
|
private sealed record CacheEntry(WeatherSnapshot Snapshot, DateTimeOffset ExpireAt);
|
||||||
|
|
||||||
|
private static readonly IReadOnlyDictionary<int, string> ZhWeatherDescriptions = new Dictionary<int, string>
|
||||||
|
{
|
||||||
|
[0] = "\u6674",
|
||||||
|
[1] = "\u591a\u4e91",
|
||||||
|
[2] = "\u9634",
|
||||||
|
[3] = "\u9635\u96e8",
|
||||||
|
[4] = "\u96f7\u9635\u96e8",
|
||||||
|
[7] = "\u5c0f\u96e8",
|
||||||
|
[8] = "\u4e2d\u96e8",
|
||||||
|
[9] = "\u5927\u96e8",
|
||||||
|
[13] = "\u9635\u96ea",
|
||||||
|
[14] = "\u5c0f\u96ea",
|
||||||
|
[15] = "\u4e2d\u96ea",
|
||||||
|
[16] = "\u5927\u96ea",
|
||||||
|
[18] = "\u96fe",
|
||||||
|
[32] = "\u973e"
|
||||||
|
};
|
||||||
|
|
||||||
|
private static readonly IReadOnlyDictionary<int, string> EnWeatherDescriptions = new Dictionary<int, string>
|
||||||
|
{
|
||||||
|
[0] = "Clear",
|
||||||
|
[1] = "Partly Cloudy",
|
||||||
|
[2] = "Cloudy",
|
||||||
|
[3] = "Shower",
|
||||||
|
[4] = "Thunder Shower",
|
||||||
|
[7] = "Light Rain",
|
||||||
|
[8] = "Moderate Rain",
|
||||||
|
[9] = "Heavy Rain",
|
||||||
|
[13] = "Snow Flurry",
|
||||||
|
[14] = "Light Snow",
|
||||||
|
[15] = "Moderate Snow",
|
||||||
|
[16] = "Heavy Snow",
|
||||||
|
[18] = "Fog",
|
||||||
|
[32] = "Haze"
|
||||||
|
};
|
||||||
|
|
||||||
|
private readonly XiaomiWeatherApiOptions _options;
|
||||||
|
private readonly HttpClient _httpClient;
|
||||||
|
private readonly bool _ownsHttpClient;
|
||||||
|
private readonly object _cacheGate = new();
|
||||||
|
private readonly Dictionary<string, CacheEntry> _cache = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
public XiaomiWeatherService(
|
||||||
|
XiaomiWeatherApiOptions? options = null,
|
||||||
|
HttpClient? httpClient = null)
|
||||||
|
{
|
||||||
|
_options = options ?? new XiaomiWeatherApiOptions();
|
||||||
|
if (httpClient is null)
|
||||||
|
{
|
||||||
|
_httpClient = new HttpClient
|
||||||
|
{
|
||||||
|
Timeout = _options.RequestTimeout
|
||||||
|
};
|
||||||
|
_ownsHttpClient = true;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_httpClient = httpClient;
|
||||||
|
_ownsHttpClient = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (_ownsHttpClient)
|
||||||
|
{
|
||||||
|
_httpClient.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ClearCache()
|
||||||
|
{
|
||||||
|
lock (_cacheGate)
|
||||||
|
{
|
||||||
|
_cache.Clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<WeatherQueryResult<IReadOnlyList<WeatherLocation>>> SearchLocationsAsync(
|
||||||
|
string keyword,
|
||||||
|
string? locale = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(keyword))
|
||||||
|
{
|
||||||
|
return WeatherQueryResult<IReadOnlyList<WeatherLocation>>.Fail("invalid_keyword", "Keyword cannot be empty.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var normalizedLocale = string.IsNullOrWhiteSpace(locale) ? _options.Locale : locale.Trim();
|
||||||
|
var parameters = new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["name"] = keyword.Trim(),
|
||||||
|
["locale"] = normalizedLocale
|
||||||
|
};
|
||||||
|
|
||||||
|
var requestUri = BuildUri(_options.CitySearchPath, parameters);
|
||||||
|
string responseText;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var response = await _httpClient.GetAsync(requestUri, cancellationToken);
|
||||||
|
responseText = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
return WeatherQueryResult<IReadOnlyList<WeatherLocation>>.Fail(
|
||||||
|
"http_error",
|
||||||
|
$"HTTP {(int)response.StatusCode}: {Truncate(responseText, 180)}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return WeatherQueryResult<IReadOnlyList<WeatherLocation>>.Fail("network_error", ex.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var document = JsonDocument.Parse(responseText);
|
||||||
|
var root = document.RootElement;
|
||||||
|
if (TryGetProperty(root, out var dataNode, "data"))
|
||||||
|
{
|
||||||
|
root = dataNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
var locations = ParseLocationArray(root);
|
||||||
|
return WeatherQueryResult<IReadOnlyList<WeatherLocation>>.Ok(locations);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return WeatherQueryResult<IReadOnlyList<WeatherLocation>>.Fail("parse_error", ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<WeatherQueryResult<WeatherSnapshot>> GetWeatherAsync(
|
||||||
|
WeatherQuery query,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(query.LocationKey))
|
||||||
|
{
|
||||||
|
return WeatherQueryResult<WeatherSnapshot>.Fail("invalid_location", "LocationKey is required.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var normalizedDays = Math.Clamp(query.ForecastDays, 1, 15);
|
||||||
|
var normalizedLocale = string.IsNullOrWhiteSpace(query.Locale) ? _options.Locale : query.Locale.Trim();
|
||||||
|
var isGlobal = query.IsGlobal ?? _options.IsGlobal;
|
||||||
|
var cacheKey = BuildCacheKey(query.LocationKey, query.Latitude, query.Longitude, normalizedDays, normalizedLocale, isGlobal);
|
||||||
|
|
||||||
|
if (!query.ForceRefresh && TryGetCached(cacheKey, out var cached))
|
||||||
|
{
|
||||||
|
return WeatherQueryResult<WeatherSnapshot>.Ok(cached);
|
||||||
|
}
|
||||||
|
|
||||||
|
var parameters = new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["locationKey"] = query.LocationKey.Trim(),
|
||||||
|
["latitude"] = query.Latitude.ToString("F6", CultureInfo.InvariantCulture),
|
||||||
|
["longitude"] = query.Longitude.ToString("F6", CultureInfo.InvariantCulture),
|
||||||
|
["days"] = normalizedDays.ToString(CultureInfo.InvariantCulture),
|
||||||
|
["appKey"] = _options.AppKey,
|
||||||
|
["sign"] = _options.Sign,
|
||||||
|
["locale"] = normalizedLocale,
|
||||||
|
["isGlobal"] = isGlobal ? "true" : "false",
|
||||||
|
["ts"] = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture)
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(_options.Source))
|
||||||
|
{
|
||||||
|
parameters["source"] = _options.Source;
|
||||||
|
}
|
||||||
|
|
||||||
|
var requestUri = BuildUri(_options.WeatherAllPath, parameters);
|
||||||
|
string responseText;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var response = await _httpClient.GetAsync(requestUri, cancellationToken);
|
||||||
|
responseText = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
return WeatherQueryResult<WeatherSnapshot>.Fail(
|
||||||
|
"http_error",
|
||||||
|
$"HTTP {(int)response.StatusCode}: {Truncate(responseText, 220)}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return WeatherQueryResult<WeatherSnapshot>.Fail("network_error", ex.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var document = JsonDocument.Parse(responseText);
|
||||||
|
var snapshot = ParseWeatherSnapshot(
|
||||||
|
document.RootElement,
|
||||||
|
query.LocationKey.Trim(),
|
||||||
|
query.Latitude,
|
||||||
|
query.Longitude,
|
||||||
|
normalizedDays,
|
||||||
|
normalizedLocale);
|
||||||
|
|
||||||
|
SetCache(cacheKey, snapshot);
|
||||||
|
return WeatherQueryResult<WeatherSnapshot>.Ok(snapshot);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return WeatherQueryResult<WeatherSnapshot>.Fail("parse_error", ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<WeatherLocation> ParseLocationArray(JsonElement root)
|
||||||
|
{
|
||||||
|
var results = new List<WeatherLocation>();
|
||||||
|
if (!TryResolveLocationArray(root, out var locationArray))
|
||||||
|
{
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var item in locationArray.EnumerateArray())
|
||||||
|
{
|
||||||
|
var locationKey = ReadString(item, "locationKey") ??
|
||||||
|
ReadString(item, "key") ??
|
||||||
|
ReadString(item, "id");
|
||||||
|
if (string.IsNullOrWhiteSpace(locationKey))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var name = ReadString(item, "name") ??
|
||||||
|
ReadString(item, "city") ??
|
||||||
|
locationKey;
|
||||||
|
var affiliation = ReadString(item, "affiliation") ?? ReadString(item, "province");
|
||||||
|
|
||||||
|
var latitude = ReadDouble(item, "latitude") ?? 0;
|
||||||
|
var longitude = ReadDouble(item, "longitude") ?? 0;
|
||||||
|
|
||||||
|
results.Add(new WeatherLocation(name, locationKey, latitude, longitude, affiliation));
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
private WeatherSnapshot ParseWeatherSnapshot(
|
||||||
|
JsonElement root,
|
||||||
|
string locationKey,
|
||||||
|
double latitude,
|
||||||
|
double longitude,
|
||||||
|
int days,
|
||||||
|
string locale)
|
||||||
|
{
|
||||||
|
var payload = root;
|
||||||
|
if (TryGetProperty(payload, out var dataNode, "data"))
|
||||||
|
{
|
||||||
|
payload = dataNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
var errorCode = ReadInt(root, "code");
|
||||||
|
if (errorCode.HasValue && errorCode.Value is not (0 or 200))
|
||||||
|
{
|
||||||
|
var message = ReadString(root, "description") ??
|
||||||
|
ReadString(root, "msg") ??
|
||||||
|
$"Weather API returned error code {errorCode.Value}.";
|
||||||
|
throw new InvalidOperationException(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
var currentNode = TryGetNode(payload, "current") ?? payload;
|
||||||
|
var cityNode = TryGetNode(payload, "city");
|
||||||
|
var dailyNode = TryGetNode(payload, "forecastDaily") ?? TryGetNode(payload, "daily");
|
||||||
|
|
||||||
|
var weatherCode = ReadInt(currentNode, "weather", "value") ??
|
||||||
|
ReadInt(currentNode, "weatherCode") ??
|
||||||
|
ReadInt(currentNode, "code");
|
||||||
|
|
||||||
|
var weatherText = ReadString(currentNode, "weather", "desc") ??
|
||||||
|
ReadString(currentNode, "weather", "text") ??
|
||||||
|
ResolveWeatherDescription(weatherCode, locale);
|
||||||
|
|
||||||
|
var current = new WeatherCurrentCondition(
|
||||||
|
TemperatureC: ReadDouble(currentNode, "temperature", "value") ?? ReadDouble(currentNode, "temperature"),
|
||||||
|
FeelsLikeC: ReadDouble(currentNode, "feelsLike", "value") ?? ReadDouble(currentNode, "apparentTemperature", "value"),
|
||||||
|
RelativeHumidityPercent: ReadInt(currentNode, "humidity", "value") ?? ReadInt(currentNode, "humidity"),
|
||||||
|
AirQualityIndex: ReadInt(payload, "aqi", "value") ??
|
||||||
|
ReadInt(currentNode, "aqi", "value") ??
|
||||||
|
ReadInt(payload, "aqi", "index"),
|
||||||
|
WindSpeedKph: ReadDouble(currentNode, "wind", "speed", "value") ??
|
||||||
|
ReadDouble(currentNode, "windSpeed", "value"),
|
||||||
|
WindDirectionDegree: ReadDouble(currentNode, "wind", "angle", "value") ??
|
||||||
|
ReadDouble(currentNode, "wind", "direction", "value"),
|
||||||
|
WeatherCode: weatherCode,
|
||||||
|
WeatherText: weatherText);
|
||||||
|
|
||||||
|
var forecasts = ParseDailyForecasts(dailyNode, days, locale);
|
||||||
|
|
||||||
|
var locationName = ReadString(cityNode, "name") ??
|
||||||
|
ReadString(payload, "cityName") ??
|
||||||
|
ReadString(payload, "locationName");
|
||||||
|
var observationTime = ParseTime(ReadString(currentNode, "pubTime")) ??
|
||||||
|
ParseTime(ReadString(payload, "pubTime")) ??
|
||||||
|
ParseTime(ReadString(payload, "serverTime"));
|
||||||
|
|
||||||
|
return new WeatherSnapshot(
|
||||||
|
Provider: "Xiaomi",
|
||||||
|
LocationKey: locationKey,
|
||||||
|
LocationName: locationName,
|
||||||
|
Latitude: latitude,
|
||||||
|
Longitude: longitude,
|
||||||
|
FetchedAt: DateTimeOffset.UtcNow,
|
||||||
|
ObservationTime: observationTime,
|
||||||
|
Current: current,
|
||||||
|
DailyForecasts: forecasts);
|
||||||
|
}
|
||||||
|
|
||||||
|
private IReadOnlyList<WeatherDailyForecast> ParseDailyForecasts(JsonElement? dailyNode, int days, string locale)
|
||||||
|
{
|
||||||
|
var forecasts = new List<WeatherDailyForecast>();
|
||||||
|
if (!dailyNode.HasValue || dailyNode.Value.ValueKind != JsonValueKind.Object)
|
||||||
|
{
|
||||||
|
return forecasts;
|
||||||
|
}
|
||||||
|
|
||||||
|
var root = dailyNode.Value;
|
||||||
|
var temperatureArray = ReadArray(root, "temperature", "value");
|
||||||
|
var weatherArray = ReadArray(root, "weather", "value");
|
||||||
|
var sunArray = ReadArray(root, "sunRiseSet", "value") ?? ReadArray(root, "sunriseSunset", "value");
|
||||||
|
var precipitationArray = ReadArray(root, "precipitationProbability", "value");
|
||||||
|
var dateArray = ReadArray(root, "date", "value") ?? ReadArray(root, "date");
|
||||||
|
|
||||||
|
var count = Math.Max(
|
||||||
|
Math.Max(temperatureArray?.GetArrayLength() ?? 0, weatherArray?.GetArrayLength() ?? 0),
|
||||||
|
Math.Max(sunArray?.GetArrayLength() ?? 0, precipitationArray?.GetArrayLength() ?? 0));
|
||||||
|
count = Math.Max(count, dateArray?.GetArrayLength() ?? 0);
|
||||||
|
count = Math.Clamp(count, 0, days);
|
||||||
|
|
||||||
|
for (var i = 0; i < count; i++)
|
||||||
|
{
|
||||||
|
var forecastDate = ResolveDateForIndex(dateArray, i);
|
||||||
|
if (forecastDate is null)
|
||||||
|
{
|
||||||
|
forecastDate = DateOnly.FromDateTime(DateTime.Today.AddDays(i));
|
||||||
|
}
|
||||||
|
|
||||||
|
var tempItem = GetArrayItem(temperatureArray, i);
|
||||||
|
var weatherItem = GetArrayItem(weatherArray, i);
|
||||||
|
var sunItem = GetArrayItem(sunArray, i);
|
||||||
|
var precipitationItem = GetArrayItem(precipitationArray, i);
|
||||||
|
|
||||||
|
var dayCode = ReadInt(weatherItem, "from") ?? ReadInt(weatherItem, "day");
|
||||||
|
var nightCode = ReadInt(weatherItem, "to") ?? ReadInt(weatherItem, "night");
|
||||||
|
var dayText = ResolveWeatherDescription(dayCode, locale);
|
||||||
|
var nightText = ResolveWeatherDescription(nightCode, locale);
|
||||||
|
|
||||||
|
forecasts.Add(new WeatherDailyForecast(
|
||||||
|
Date: forecastDate.Value,
|
||||||
|
LowTemperatureC: ReadDouble(tempItem, "from") ?? ReadDouble(tempItem, "min"),
|
||||||
|
HighTemperatureC: ReadDouble(tempItem, "to") ?? ReadDouble(tempItem, "max"),
|
||||||
|
DayWeatherCode: dayCode,
|
||||||
|
DayWeatherText: dayText,
|
||||||
|
NightWeatherCode: nightCode,
|
||||||
|
NightWeatherText: nightText,
|
||||||
|
SunriseTime: ReadString(sunItem, "from") ?? ReadString(sunItem, "sunrise"),
|
||||||
|
SunsetTime: ReadString(sunItem, "to") ?? ReadString(sunItem, "sunset"),
|
||||||
|
PrecipitationProbabilityPercent: ReadInt(precipitationItem, "from") ??
|
||||||
|
ReadInt(precipitationItem, "value") ??
|
||||||
|
ReadInt(precipitationItem, "probability")));
|
||||||
|
}
|
||||||
|
|
||||||
|
return forecasts;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DateOnly? ResolveDateForIndex(JsonElement? dateArray, int index)
|
||||||
|
{
|
||||||
|
var item = GetArrayItem(dateArray, index);
|
||||||
|
if (item is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.Value.ValueKind == JsonValueKind.String)
|
||||||
|
{
|
||||||
|
var text = item.Value.GetString();
|
||||||
|
if (DateOnly.TryParse(text, out var dateOnly))
|
||||||
|
{
|
||||||
|
return dateOnly;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (DateTime.TryParse(text, out var dateTime))
|
||||||
|
{
|
||||||
|
return DateOnly.FromDateTime(dateTime);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool TryGetCached(string key, out WeatherSnapshot snapshot)
|
||||||
|
{
|
||||||
|
lock (_cacheGate)
|
||||||
|
{
|
||||||
|
if (_cache.TryGetValue(key, out var entry))
|
||||||
|
{
|
||||||
|
if (entry.ExpireAt > DateTimeOffset.UtcNow)
|
||||||
|
{
|
||||||
|
snapshot = entry.Snapshot;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
_cache.Remove(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
snapshot = null!;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SetCache(string key, WeatherSnapshot snapshot)
|
||||||
|
{
|
||||||
|
var expireAt = DateTimeOffset.UtcNow.Add(_options.CacheDuration);
|
||||||
|
lock (_cacheGate)
|
||||||
|
{
|
||||||
|
_cache[key] = new CacheEntry(snapshot, expireAt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildCacheKey(
|
||||||
|
string locationKey,
|
||||||
|
double latitude,
|
||||||
|
double longitude,
|
||||||
|
int days,
|
||||||
|
string locale,
|
||||||
|
bool isGlobal)
|
||||||
|
{
|
||||||
|
return string.Create(
|
||||||
|
CultureInfo.InvariantCulture,
|
||||||
|
$"{locationKey.Trim()}|{latitude:F4}|{longitude:F4}|{days}|{locale}|{isGlobal}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private Uri BuildUri(string path, IReadOnlyDictionary<string, string> query)
|
||||||
|
{
|
||||||
|
var baseUrl = _options.BaseUrl.TrimEnd('/');
|
||||||
|
var requestPath = path.StartsWith("/", StringComparison.Ordinal) ? path : $"/{path}";
|
||||||
|
|
||||||
|
var builder = new System.Text.StringBuilder(baseUrl.Length + requestPath.Length + 128);
|
||||||
|
builder.Append(baseUrl);
|
||||||
|
builder.Append(requestPath);
|
||||||
|
|
||||||
|
var first = true;
|
||||||
|
foreach (var pair in query)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(pair.Key))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
builder.Append(first ? '?' : '&');
|
||||||
|
first = false;
|
||||||
|
builder.Append(Uri.EscapeDataString(pair.Key));
|
||||||
|
builder.Append('=');
|
||||||
|
builder.Append(Uri.EscapeDataString(pair.Value ?? string.Empty));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Uri(builder.ToString(), UriKind.Absolute);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryResolveLocationArray(JsonElement root, out JsonElement array)
|
||||||
|
{
|
||||||
|
if (root.ValueKind == JsonValueKind.Array)
|
||||||
|
{
|
||||||
|
array = root;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (TryGetProperty(root, out array, "cities") && array.ValueKind == JsonValueKind.Array)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (TryGetProperty(root, out array, "city") && array.ValueKind == JsonValueKind.Array)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (TryGetProperty(root, out array, "location") && array.ValueKind == JsonValueKind.Array)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (TryGetProperty(root, out var data) && data.ValueKind == JsonValueKind.Array)
|
||||||
|
{
|
||||||
|
array = data;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
array = default;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryGetProperty(JsonElement node, out JsonElement value, string propertyName = "data")
|
||||||
|
{
|
||||||
|
value = default;
|
||||||
|
return node.ValueKind == JsonValueKind.Object &&
|
||||||
|
node.TryGetProperty(propertyName, out value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static JsonElement? TryGetNode(JsonElement node, params string[] path)
|
||||||
|
{
|
||||||
|
var current = node;
|
||||||
|
foreach (var segment in path)
|
||||||
|
{
|
||||||
|
if (current.ValueKind != JsonValueKind.Object || !current.TryGetProperty(segment, out var next))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
current = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static JsonElement? ReadArray(JsonElement node, params string[] path)
|
||||||
|
{
|
||||||
|
var target = TryGetNode(node, path);
|
||||||
|
if (target is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (target.Value.ValueKind == JsonValueKind.Array)
|
||||||
|
{
|
||||||
|
return target.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (target.Value.ValueKind == JsonValueKind.Object &&
|
||||||
|
target.Value.TryGetProperty("value", out var value) &&
|
||||||
|
value.ValueKind == JsonValueKind.Array)
|
||||||
|
{
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static JsonElement? GetArrayItem(JsonElement? array, int index)
|
||||||
|
{
|
||||||
|
if (!array.HasValue || array.Value.ValueKind != JsonValueKind.Array)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (index < 0 || index >= array.Value.GetArrayLength())
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return array.Value[index];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? ReadString(JsonElement? node, params string[] path)
|
||||||
|
{
|
||||||
|
if (!node.HasValue)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var target = path.Length == 0 ? node : TryGetNode(node.Value, path);
|
||||||
|
if (!target.HasValue)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return target.Value.ValueKind switch
|
||||||
|
{
|
||||||
|
JsonValueKind.String => target.Value.GetString(),
|
||||||
|
JsonValueKind.Number => target.Value.GetRawText(),
|
||||||
|
JsonValueKind.True => "true",
|
||||||
|
JsonValueKind.False => "false",
|
||||||
|
_ => null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int? ReadInt(JsonElement? node, params string[] path)
|
||||||
|
{
|
||||||
|
if (!node.HasValue)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var target = path.Length == 0 ? node : TryGetNode(node.Value, path);
|
||||||
|
if (!target.HasValue)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (target.Value.ValueKind == JsonValueKind.Number && target.Value.TryGetInt32(out var number))
|
||||||
|
{
|
||||||
|
return number;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (target.Value.ValueKind == JsonValueKind.String &&
|
||||||
|
int.TryParse(target.Value.GetString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed))
|
||||||
|
{
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double? ReadDouble(JsonElement? node, params string[] path)
|
||||||
|
{
|
||||||
|
if (!node.HasValue)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var target = path.Length == 0 ? node : TryGetNode(node.Value, path);
|
||||||
|
if (!target.HasValue)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (target.Value.ValueKind == JsonValueKind.Number && target.Value.TryGetDouble(out var number))
|
||||||
|
{
|
||||||
|
return number;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (target.Value.ValueKind == JsonValueKind.String &&
|
||||||
|
double.TryParse(target.Value.GetString(), NumberStyles.Float, CultureInfo.InvariantCulture, out var parsed))
|
||||||
|
{
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DateTimeOffset? ParseTime(string? raw)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(raw))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (DateTimeOffset.TryParse(raw, CultureInfo.InvariantCulture, DateTimeStyles.AssumeLocal, out var dto))
|
||||||
|
{
|
||||||
|
return dto;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (long.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var epoch))
|
||||||
|
{
|
||||||
|
// Xiaomi endpoints may return second or millisecond Unix timestamps.
|
||||||
|
return epoch > 1_000_000_000_000
|
||||||
|
? DateTimeOffset.FromUnixTimeMilliseconds(epoch)
|
||||||
|
: DateTimeOffset.FromUnixTimeSeconds(epoch);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? ResolveWeatherDescription(int? code, string locale)
|
||||||
|
{
|
||||||
|
if (!code.HasValue)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var isZh = locale.StartsWith("zh", StringComparison.OrdinalIgnoreCase);
|
||||||
|
var source = isZh ? ZhWeatherDescriptions : EnWeatherDescriptions;
|
||||||
|
if (source.TryGetValue(code.Value, out var text))
|
||||||
|
{
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
return isZh ? $"\u5929\u6c14\u7801 {code.Value}" : $"Weather {code.Value}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Truncate(string? text, int maxLength)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(text))
|
||||||
|
{
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
return text.Length <= maxLength
|
||||||
|
? text
|
||||||
|
: $"{text[..maxLength]}...";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
78
LanMontainDesktop/Views/Components/AnalogClockWidget.axaml
Normal file
78
LanMontainDesktop/Views/Components/AnalogClockWidget.axaml
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
<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"
|
||||||
|
mc:Ignorable="d"
|
||||||
|
d:DesignWidth="320"
|
||||||
|
d:DesignHeight="320"
|
||||||
|
x:Class="LanMontainDesktop.Views.Components.AnalogClockWidget">
|
||||||
|
|
||||||
|
<Border x:Name="RootBorder"
|
||||||
|
CornerRadius="42"
|
||||||
|
ClipToBounds="True"
|
||||||
|
Padding="14">
|
||||||
|
<Border.Background>
|
||||||
|
<LinearGradientBrush StartPoint="0,0"
|
||||||
|
EndPoint="1,1">
|
||||||
|
<GradientStop Color="#1F2C4B"
|
||||||
|
Offset="0" />
|
||||||
|
<GradientStop Color="#131B33"
|
||||||
|
Offset="1" />
|
||||||
|
</LinearGradientBrush>
|
||||||
|
</Border.Background>
|
||||||
|
|
||||||
|
<Viewbox Stretch="Uniform">
|
||||||
|
<Grid x:Name="LayoutRoot"
|
||||||
|
Width="300"
|
||||||
|
Height="300">
|
||||||
|
<Grid Width="258"
|
||||||
|
Height="258"
|
||||||
|
HorizontalAlignment="Center"
|
||||||
|
VerticalAlignment="Center">
|
||||||
|
<Border x:Name="DialBorder"
|
||||||
|
CornerRadius="129"
|
||||||
|
Background="#F4F4F4"
|
||||||
|
BorderBrush="#E5E5E5"
|
||||||
|
BorderThickness="1" />
|
||||||
|
|
||||||
|
<Canvas x:Name="TickCanvas"
|
||||||
|
Width="258"
|
||||||
|
Height="258"
|
||||||
|
IsHitTestVisible="False" />
|
||||||
|
|
||||||
|
<Canvas x:Name="NumberCanvas"
|
||||||
|
Width="258"
|
||||||
|
Height="258"
|
||||||
|
IsHitTestVisible="False" />
|
||||||
|
|
||||||
|
<Canvas x:Name="HandsCanvas"
|
||||||
|
Width="258"
|
||||||
|
Height="258"
|
||||||
|
IsHitTestVisible="False" />
|
||||||
|
|
||||||
|
<TextBlock x:Name="CityTextBlock"
|
||||||
|
Text="北京"
|
||||||
|
FontSize="21"
|
||||||
|
FontWeight="SemiBold"
|
||||||
|
Foreground="#757575"
|
||||||
|
HorizontalAlignment="Center"
|
||||||
|
Margin="0,76,0,0" />
|
||||||
|
|
||||||
|
<Ellipse x:Name="CenterDotOuter"
|
||||||
|
Width="18"
|
||||||
|
Height="18"
|
||||||
|
Fill="#1E3C6A"
|
||||||
|
HorizontalAlignment="Center"
|
||||||
|
VerticalAlignment="Center" />
|
||||||
|
|
||||||
|
<Ellipse x:Name="CenterDotInner"
|
||||||
|
Width="8"
|
||||||
|
Height="8"
|
||||||
|
Fill="#1A74F2"
|
||||||
|
HorizontalAlignment="Center"
|
||||||
|
VerticalAlignment="Center" />
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</Viewbox>
|
||||||
|
</Border>
|
||||||
|
</UserControl>
|
||||||
330
LanMontainDesktop/Views/Components/AnalogClockWidget.axaml.cs
Normal file
330
LanMontainDesktop/Views/Components/AnalogClockWidget.axaml.cs
Normal file
@@ -0,0 +1,330 @@
|
|||||||
|
using System;
|
||||||
|
using System.Globalization;
|
||||||
|
using Avalonia;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Controls.Shapes;
|
||||||
|
using Avalonia.Media;
|
||||||
|
using Avalonia.Styling;
|
||||||
|
using Avalonia.Threading;
|
||||||
|
using LanMontainDesktop.Services;
|
||||||
|
|
||||||
|
namespace LanMontainDesktop.Views.Components;
|
||||||
|
|
||||||
|
public partial class AnalogClockWidget : UserControl
|
||||||
|
{
|
||||||
|
private readonly DispatcherTimer _timer = new()
|
||||||
|
{
|
||||||
|
Interval = TimeSpan.FromSeconds(1)
|
||||||
|
};
|
||||||
|
|
||||||
|
private const double DialSize = 258;
|
||||||
|
private const double Center = DialSize / 2;
|
||||||
|
|
||||||
|
private TimeZoneService? _timeZoneService;
|
||||||
|
private double _currentCellSize = 48;
|
||||||
|
private bool _dialInitialized;
|
||||||
|
private bool _handsInitialized;
|
||||||
|
private bool? _isNightModeApplied;
|
||||||
|
private readonly Line _hourHandLine = CreateHandLine("#1A2A46", 12);
|
||||||
|
private readonly Line _minuteHandLine = CreateHandLine("#29406B", 8);
|
||||||
|
private readonly Line _secondHandLine = CreateHandLine("#1A74F2", 4);
|
||||||
|
|
||||||
|
public AnalogClockWidget()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
|
||||||
|
_timer.Tick += OnTimerTick;
|
||||||
|
AttachedToVisualTree += OnAttachedToVisualTree;
|
||||||
|
DetachedFromVisualTree += OnDetachedFromVisualTree;
|
||||||
|
SizeChanged += OnSizeChanged;
|
||||||
|
|
||||||
|
InitializeDialIfNeeded();
|
||||||
|
InitializeHandsIfNeeded();
|
||||||
|
UpdateClock();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SetTimeZoneService(TimeZoneService timeZoneService)
|
||||||
|
{
|
||||||
|
if (_timeZoneService is not null)
|
||||||
|
{
|
||||||
|
_timeZoneService.TimeZoneChanged -= OnTimeZoneChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
_timeZoneService = timeZoneService;
|
||||||
|
_timeZoneService.TimeZoneChanged += OnTimeZoneChanged;
|
||||||
|
UpdateClock();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
||||||
|
{
|
||||||
|
InitializeDialIfNeeded();
|
||||||
|
InitializeHandsIfNeeded();
|
||||||
|
UpdateClock();
|
||||||
|
_timer.Start();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
||||||
|
{
|
||||||
|
_timer.Stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnSizeChanged(object? sender, SizeChangedEventArgs e)
|
||||||
|
{
|
||||||
|
ApplyCellSize(_currentCellSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnTimerTick(object? sender, EventArgs e)
|
||||||
|
{
|
||||||
|
UpdateClock();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnTimeZoneChanged(object? sender, EventArgs e)
|
||||||
|
{
|
||||||
|
UpdateClock();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void InitializeDialIfNeeded()
|
||||||
|
{
|
||||||
|
if (_dialInitialized)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
BuildTicks(isNightMode: true);
|
||||||
|
BuildNumbers(isNightMode: true);
|
||||||
|
_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 majorBrush = CreateBrush(isNightMode ? "#1A1A1A" : "#1E2430");
|
||||||
|
var minorBrush = CreateBrush(isNightMode ? "#D0D0D0" : "#D7DCE5");
|
||||||
|
var majorThickness = isNightMode ? 3.0 : 2.8;
|
||||||
|
var minorThickness = isNightMode ? 1.4 : 1.2;
|
||||||
|
|
||||||
|
for (var i = 0; i < 60; i++)
|
||||||
|
{
|
||||||
|
var angle = (i * 6 - 90) * Math.PI / 180d;
|
||||||
|
var isHourTick = i % 5 == 0;
|
||||||
|
var outerRadius = Center - 7;
|
||||||
|
var innerRadius = outerRadius - (isHourTick ? 16 : 8);
|
||||||
|
|
||||||
|
var x1 = Center + Math.Cos(angle) * innerRadius;
|
||||||
|
var y1 = Center + Math.Sin(angle) * innerRadius;
|
||||||
|
var x2 = Center + Math.Cos(angle) * outerRadius;
|
||||||
|
var y2 = Center + Math.Sin(angle) * outerRadius;
|
||||||
|
|
||||||
|
var tick = new Line
|
||||||
|
{
|
||||||
|
StartPoint = new Point(x1, y1),
|
||||||
|
EndPoint = new Point(x2, y2),
|
||||||
|
Stroke = isHourTick ? majorBrush : minorBrush,
|
||||||
|
StrokeThickness = isHourTick ? majorThickness : minorThickness,
|
||||||
|
StrokeLineCap = PenLineCap.Round
|
||||||
|
};
|
||||||
|
|
||||||
|
TickCanvas.Children.Add(tick);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void BuildNumbers(bool isNightMode)
|
||||||
|
{
|
||||||
|
NumberCanvas.Children.Clear();
|
||||||
|
var foreground = CreateBrush(isNightMode ? "#101010" : "#0F131A");
|
||||||
|
var fontWeight = isNightMode ? FontWeight.Bold : FontWeight.SemiBold;
|
||||||
|
|
||||||
|
for (var number = 1; number <= 12; number++)
|
||||||
|
{
|
||||||
|
var angle = (number * 30 - 90) * Math.PI / 180d;
|
||||||
|
var radius = 88;
|
||||||
|
var x = Center + Math.Cos(angle) * radius;
|
||||||
|
var y = Center + Math.Sin(angle) * radius;
|
||||||
|
var isDoubleDigit = number >= 10;
|
||||||
|
var width = isDoubleDigit ? 44 : 28;
|
||||||
|
var height = 34;
|
||||||
|
|
||||||
|
var text = new TextBlock
|
||||||
|
{
|
||||||
|
Text = number.ToString(CultureInfo.InvariantCulture),
|
||||||
|
Width = width,
|
||||||
|
Height = height,
|
||||||
|
TextAlignment = TextAlignment.Center,
|
||||||
|
VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center,
|
||||||
|
FontSize = 18,
|
||||||
|
FontWeight = fontWeight,
|
||||||
|
Foreground = foreground
|
||||||
|
};
|
||||||
|
|
||||||
|
Canvas.SetLeft(text, x - width / 2d);
|
||||||
|
Canvas.SetTop(text, y - height / 2d);
|
||||||
|
NumberCanvas.Children.Add(text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateClock()
|
||||||
|
{
|
||||||
|
ApplyModeVisualIfNeeded();
|
||||||
|
|
||||||
|
var now = _timeZoneService?.GetCurrentTime() ?? DateTime.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: 52, backwardLength: 6);
|
||||||
|
SetHandGeometry(_minuteHandLine, minuteAngle, forwardLength: 76, backwardLength: 8);
|
||||||
|
SetHandGeometry(_secondHandLine, secondAngle, forwardLength: 94, backwardLength: 18);
|
||||||
|
|
||||||
|
var isZh = CultureInfo.CurrentCulture.TwoLetterISOLanguageName.Equals("zh", StringComparison.OrdinalIgnoreCase);
|
||||||
|
CityTextBlock.Text = isZh ? "\u5317\u4eac" : "Beijing";
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
? CreateLinearGradientBrush("#1F2C4B", "#131B33")
|
||||||
|
: CreateLinearGradientBrush("#EEF2FA", "#E7ECF6");
|
||||||
|
|
||||||
|
DialBorder.Background = CreateBrush(isNightMode ? "#F4F4F4" : "#FEFEFF");
|
||||||
|
DialBorder.BorderBrush = CreateBrush(isNightMode ? "#E5E5E5" : "#DCE2EB");
|
||||||
|
|
||||||
|
CityTextBlock.Foreground = CreateBrush(isNightMode ? "#757575" : "#7E8593");
|
||||||
|
CenterDotOuter.Fill = CreateBrush(isNightMode ? "#1E3C6A" : "#30486E");
|
||||||
|
CenterDotInner.Fill = CreateBrush("#1A74F2");
|
||||||
|
|
||||||
|
_hourHandLine.Stroke = CreateBrush(isNightMode ? "#1A2A46" : "#2E3F5F");
|
||||||
|
_minuteHandLine.Stroke = CreateBrush(isNightMode ? "#29406B" : "#3E557E");
|
||||||
|
_secondHandLine.Stroke = CreateBrush("#1A74F2");
|
||||||
|
|
||||||
|
BuildTicks(isNightMode);
|
||||||
|
BuildNumbers(isNightMode);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
var start = new Point(
|
||||||
|
Center - cos * backwardLength,
|
||||||
|
Center - sin * backwardLength);
|
||||||
|
var end = new Point(
|
||||||
|
Center + cos * forwardLength,
|
||||||
|
Center + sin * forwardLength);
|
||||||
|
|
||||||
|
hand.StartPoint = start;
|
||||||
|
hand.EndPoint = end;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ApplyCellSize(double cellSize)
|
||||||
|
{
|
||||||
|
_currentCellSize = Math.Max(1, cellSize);
|
||||||
|
var scale = ResolveScale();
|
||||||
|
|
||||||
|
RootBorder.CornerRadius = new CornerRadius(Math.Clamp(42 * scale, 16, 56));
|
||||||
|
RootBorder.Padding = new Thickness(Math.Clamp(14 * scale, 8, 26));
|
||||||
|
ApplyModeVisualIfNeeded();
|
||||||
|
}
|
||||||
|
|
||||||
|
private double ResolveScale()
|
||||||
|
{
|
||||||
|
var cellScale = Math.Clamp(_currentCellSize / 44d, 0.60, 1.90);
|
||||||
|
var heightScale = Bounds.Height > 1 ? Math.Clamp(Bounds.Height / 300d, 0.58, 2.0) : 1;
|
||||||
|
var widthScale = Bounds.Width > 1 ? Math.Clamp(Bounds.Width / 300d, 0.58, 2.0) : 1;
|
||||||
|
return Math.Clamp(Math.Min(cellScale, Math.Min(heightScale, widthScale) * 1.05), 0.58, 1.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IBrush CreateBrush(string colorHex)
|
||||||
|
{
|
||||||
|
return new SolidColorBrush(Color.Parse(colorHex));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IBrush CreateLinearGradientBrush(string fromColorHex, string toColorHex)
|
||||||
|
{
|
||||||
|
return new LinearGradientBrush
|
||||||
|
{
|
||||||
|
StartPoint = new RelativePoint(0, 0, RelativeUnit.Relative),
|
||||||
|
EndPoint = new RelativePoint(1, 1, RelativeUnit.Relative),
|
||||||
|
GradientStops = new GradientStops
|
||||||
|
{
|
||||||
|
new GradientStop(Color.Parse(fromColorHex), 0),
|
||||||
|
new GradientStop(Color.Parse(toColorHex), 1)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Line CreateHandLine(string strokeHex, double thickness)
|
||||||
|
{
|
||||||
|
return new Line
|
||||||
|
{
|
||||||
|
StartPoint = new Point(Center, Center),
|
||||||
|
EndPoint = new Point(Center, Center - 40),
|
||||||
|
Stroke = CreateBrush(strokeHex),
|
||||||
|
StrokeThickness = thickness,
|
||||||
|
StrokeLineCap = PenLineCap.Round
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
<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"
|
||||||
|
mc:Ignorable="d"
|
||||||
|
d:DesignWidth="300"
|
||||||
|
d:DesignHeight="300"
|
||||||
|
x:Class="LanMontainDesktop.Views.Components.HolidayCalendarWidget">
|
||||||
|
|
||||||
|
<Border x:Name="RootBorder"
|
||||||
|
Background="#DCE7FA"
|
||||||
|
CornerRadius="34"
|
||||||
|
ClipToBounds="True"
|
||||||
|
Padding="14">
|
||||||
|
<Viewbox Stretch="Uniform">
|
||||||
|
<Grid x:Name="LayoutRoot"
|
||||||
|
Width="300"
|
||||||
|
Height="300"
|
||||||
|
RowDefinitions="Auto,Auto,Auto,Auto,Auto"
|
||||||
|
RowSpacing="8">
|
||||||
|
<TextBlock x:Name="TitleTextBlock"
|
||||||
|
Grid.Row="0"
|
||||||
|
Text="Holiday countdown"
|
||||||
|
FontSize="24"
|
||||||
|
FontWeight="SemiBold"
|
||||||
|
Foreground="#61697C"
|
||||||
|
HorizontalAlignment="Center"
|
||||||
|
Margin="0,10,0,0" />
|
||||||
|
|
||||||
|
<TextBlock x:Name="CountTextBlock"
|
||||||
|
Grid.Row="1"
|
||||||
|
Text="0"
|
||||||
|
FontFeatures="tnum"
|
||||||
|
FontSize="120"
|
||||||
|
FontWeight="Bold"
|
||||||
|
Foreground="#0A0A0A"
|
||||||
|
HorizontalAlignment="Center" />
|
||||||
|
|
||||||
|
<Canvas Grid.Row="2"
|
||||||
|
Width="260"
|
||||||
|
Height="40"
|
||||||
|
HorizontalAlignment="Center">
|
||||||
|
<Path Data="M 10,16 C 68,11 192,11 250,16"
|
||||||
|
Stroke="#1A73F0"
|
||||||
|
StrokeThickness="12"
|
||||||
|
Opacity="0.97"
|
||||||
|
StrokeLineCap="Round" />
|
||||||
|
<Path Data="M 30,19 C 96,22 164,22 230,19"
|
||||||
|
Stroke="#5C9AF7"
|
||||||
|
StrokeThickness="3.2"
|
||||||
|
Opacity="0.55"
|
||||||
|
StrokeLineCap="Round" />
|
||||||
|
<Path Data="M 104,28 C 118,23 142,23 156,28 C 146,32 114,33 104,28 Z"
|
||||||
|
Fill="#1A73F0"
|
||||||
|
Opacity="0.92" />
|
||||||
|
<Ellipse Width="58"
|
||||||
|
Height="4.5"
|
||||||
|
Fill="#5C9AF7"
|
||||||
|
Canvas.Left="101"
|
||||||
|
Canvas.Top="27.5"
|
||||||
|
Opacity="0.35" />
|
||||||
|
</Canvas>
|
||||||
|
|
||||||
|
<Grid Grid.Row="3"
|
||||||
|
ColumnDefinitions="*,Auto,*"
|
||||||
|
Margin="8,0,8,0"
|
||||||
|
VerticalAlignment="Center">
|
||||||
|
<Border Grid.Column="0"
|
||||||
|
Height="2"
|
||||||
|
Margin="0,0,10,0"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Background="#B0B9CB" />
|
||||||
|
<TextBlock x:Name="DayUnitTextBlock"
|
||||||
|
Grid.Column="1"
|
||||||
|
Text="Days"
|
||||||
|
FontSize="56"
|
||||||
|
Foreground="#7D869A"
|
||||||
|
FontWeight="Medium"
|
||||||
|
VerticalAlignment="Center" />
|
||||||
|
<Border Grid.Column="2"
|
||||||
|
Height="2"
|
||||||
|
Margin="10,0,0,0"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Background="#B0B9CB" />
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<TextBlock x:Name="DateTextBlock"
|
||||||
|
Grid.Row="4"
|
||||||
|
Text="2024-10-01"
|
||||||
|
FontSize="34"
|
||||||
|
Foreground="#596177"
|
||||||
|
HorizontalAlignment="Center"
|
||||||
|
Margin="0,2,0,8" />
|
||||||
|
</Grid>
|
||||||
|
</Viewbox>
|
||||||
|
</Border>
|
||||||
|
</UserControl>
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
using System;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Avalonia;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Threading;
|
||||||
|
using LanMontainDesktop.Services;
|
||||||
|
|
||||||
|
namespace LanMontainDesktop.Views.Components;
|
||||||
|
|
||||||
|
public partial class HolidayCalendarWidget : UserControl
|
||||||
|
{
|
||||||
|
private readonly DispatcherTimer _timer = new()
|
||||||
|
{
|
||||||
|
Interval = TimeSpan.FromMinutes(15)
|
||||||
|
};
|
||||||
|
|
||||||
|
private static readonly HolidayCalendarService HolidayService = new();
|
||||||
|
|
||||||
|
private TimeZoneService? _timeZoneService;
|
||||||
|
private double _currentCellSize = 48;
|
||||||
|
private CancellationTokenSource? _refreshCts;
|
||||||
|
private long _refreshVersion;
|
||||||
|
|
||||||
|
public HolidayCalendarWidget()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
|
||||||
|
_timer.Tick += OnTimerTick;
|
||||||
|
AttachedToVisualTree += OnAttachedToVisualTree;
|
||||||
|
DetachedFromVisualTree += OnDetachedFromVisualTree;
|
||||||
|
SizeChanged += OnSizeChanged;
|
||||||
|
|
||||||
|
TriggerContentRefresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SetTimeZoneService(TimeZoneService timeZoneService)
|
||||||
|
{
|
||||||
|
if (_timeZoneService is not null)
|
||||||
|
{
|
||||||
|
_timeZoneService.TimeZoneChanged -= OnTimeZoneChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
_timeZoneService = timeZoneService;
|
||||||
|
_timeZoneService.TimeZoneChanged += OnTimeZoneChanged;
|
||||||
|
TriggerContentRefresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
||||||
|
{
|
||||||
|
TriggerContentRefresh();
|
||||||
|
_timer.Start();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
||||||
|
{
|
||||||
|
_timer.Stop();
|
||||||
|
_refreshCts?.Cancel();
|
||||||
|
_refreshCts?.Dispose();
|
||||||
|
_refreshCts = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnSizeChanged(object? sender, SizeChangedEventArgs e)
|
||||||
|
{
|
||||||
|
ApplyCellSize(_currentCellSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnTimerTick(object? sender, EventArgs e)
|
||||||
|
{
|
||||||
|
TriggerContentRefresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnTimeZoneChanged(object? sender, EventArgs e)
|
||||||
|
{
|
||||||
|
TriggerContentRefresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void TriggerContentRefresh()
|
||||||
|
{
|
||||||
|
_refreshCts?.Cancel();
|
||||||
|
_refreshCts?.Dispose();
|
||||||
|
_refreshCts = new CancellationTokenSource();
|
||||||
|
|
||||||
|
var now = _timeZoneService?.GetCurrentTime() ?? DateTime.Now;
|
||||||
|
var isZh = CultureInfo.CurrentCulture.TwoLetterISOLanguageName.Equals("zh", StringComparison.OrdinalIgnoreCase);
|
||||||
|
var version = Interlocked.Increment(ref _refreshVersion);
|
||||||
|
_ = UpdateContentAsync(now, isZh, version, _refreshCts.Token);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task UpdateContentAsync(DateTime now, bool isZh, long refreshVersion, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
HolidayDisplayInfo displayInfo;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
displayInfo = await HolidayService.GetDisplayInfoAsync(now, cancellationToken);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
var fallbackDayType = now.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday
|
||||||
|
? HolidayDayType.Weekend
|
||||||
|
: HolidayDayType.Workday;
|
||||||
|
|
||||||
|
displayInfo = new HolidayDisplayInfo(
|
||||||
|
NextHoliday: HolidayService.GetNextHoliday(now),
|
||||||
|
TodayStatus: new HolidayDayStatus(
|
||||||
|
Date: DateOnly.FromDateTime(now.Date),
|
||||||
|
DayType: fallbackDayType,
|
||||||
|
TypeNameZh: fallbackDayType == HolidayDayType.Weekend ? "\u5468\u672b" : "\u5de5\u4f5c\u65e5",
|
||||||
|
IsHoliday: false,
|
||||||
|
IsAdjustedWorkday: false,
|
||||||
|
NameZh: null,
|
||||||
|
NameEn: null,
|
||||||
|
TargetHolidayZh: null),
|
||||||
|
UsesOnlineData: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cancellationToken.IsCancellationRequested ||
|
||||||
|
refreshVersion != Volatile.Read(ref _refreshVersion))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var holiday = displayInfo.NextHoliday;
|
||||||
|
|
||||||
|
if (holiday is null)
|
||||||
|
{
|
||||||
|
TitleTextBlock.Text = isZh
|
||||||
|
? "\u6682\u65e0\u8282\u5047\u65e5\u6570\u636e"
|
||||||
|
: "No holiday data";
|
||||||
|
CountTextBlock.Text = "--";
|
||||||
|
DayUnitTextBlock.Text = isZh ? "\u5929" : "Days";
|
||||||
|
DateTextBlock.Text = "--";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var today = DateOnly.FromDateTime(now.Date);
|
||||||
|
var remainDays = Math.Max(0, holiday.Date.DayNumber - today.DayNumber);
|
||||||
|
CountTextBlock.Text = remainDays.ToString(CultureInfo.InvariantCulture);
|
||||||
|
|
||||||
|
if (isZh)
|
||||||
|
{
|
||||||
|
if (remainDays == 0)
|
||||||
|
{
|
||||||
|
TitleTextBlock.Text = $"{holiday.NameZh}\u4eca\u5929";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var adjustPrefix = displayInfo.TodayStatus.IsAdjustedWorkday
|
||||||
|
? string.IsNullOrWhiteSpace(displayInfo.TodayStatus.NameZh)
|
||||||
|
? "\u4eca\u65e5\u8c03\u4f11\u8865\u73ed\uff0c"
|
||||||
|
: string.Create(CultureInfo.InvariantCulture, $"\u4eca\u65e5{displayInfo.TodayStatus.NameZh}\uff0c")
|
||||||
|
: string.Empty;
|
||||||
|
TitleTextBlock.Text = string.Create(
|
||||||
|
CultureInfo.InvariantCulture,
|
||||||
|
$"{adjustPrefix}\u8ddd{holiday.NameZh}\u8fd8\u6709");
|
||||||
|
}
|
||||||
|
|
||||||
|
DayUnitTextBlock.Text = "\u5929";
|
||||||
|
|
||||||
|
var holidayDateText = HolidayCalendarService.FormatDate(holiday.Date, isZh: true);
|
||||||
|
DateTextBlock.Text = displayInfo.TodayStatus.IsAdjustedWorkday && remainDays > 0
|
||||||
|
? string.Create(CultureInfo.InvariantCulture, $"{holidayDateText} \u00b7 \u4eca\u65e5\u8865\u73ed")
|
||||||
|
: holidayDateText;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (remainDays == 0)
|
||||||
|
{
|
||||||
|
TitleTextBlock.Text = $"{holiday.NameEn} is today";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var adjustPrefix = displayInfo.TodayStatus.IsAdjustedWorkday
|
||||||
|
? "Make-up workday today, "
|
||||||
|
: string.Empty;
|
||||||
|
TitleTextBlock.Text = $"{adjustPrefix}Days to {holiday.NameEn}";
|
||||||
|
}
|
||||||
|
|
||||||
|
DayUnitTextBlock.Text = "Days";
|
||||||
|
|
||||||
|
var holidayDateText = HolidayCalendarService.FormatDate(holiday.Date, isZh: false);
|
||||||
|
DateTextBlock.Text = displayInfo.TodayStatus.IsAdjustedWorkday && remainDays > 0
|
||||||
|
? $"{holidayDateText} - make-up workday"
|
||||||
|
: holidayDateText;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ApplyCellSize(double cellSize)
|
||||||
|
{
|
||||||
|
_currentCellSize = Math.Max(1, cellSize);
|
||||||
|
var scale = ResolveScale();
|
||||||
|
|
||||||
|
RootBorder.CornerRadius = new CornerRadius(Math.Clamp(34 * scale, 15, 50));
|
||||||
|
RootBorder.Padding = new Thickness(Math.Clamp(14 * scale, 7, 22));
|
||||||
|
LayoutRoot.RowSpacing = Math.Clamp(8 * scale, 4, 14);
|
||||||
|
|
||||||
|
TitleTextBlock.FontSize = Math.Clamp(24 * scale, 11, 36);
|
||||||
|
CountTextBlock.FontSize = Math.Clamp(120 * scale, 36, 160);
|
||||||
|
DayUnitTextBlock.FontSize = Math.Clamp(56 * scale, 16, 78);
|
||||||
|
DateTextBlock.FontSize = Math.Clamp(34 * scale, 12, 50);
|
||||||
|
}
|
||||||
|
|
||||||
|
private double ResolveScale()
|
||||||
|
{
|
||||||
|
var cellScale = Math.Clamp(_currentCellSize / 44d, 0.60, 1.95);
|
||||||
|
var heightScale = Bounds.Height > 1 ? Math.Clamp(Bounds.Height / 300d, 0.58, 2.0) : 1;
|
||||||
|
var widthScale = Bounds.Width > 1 ? Math.Clamp(Bounds.Width / 300d, 0.58, 2.0) : 1;
|
||||||
|
return Math.Clamp(Math.Min(cellScale, Math.Min(heightScale, widthScale) * 1.05), 0.58, 1.95);
|
||||||
|
}
|
||||||
|
}
|
||||||
160
LanMontainDesktop/Views/Components/TimerWidget.axaml
Normal file
160
LanMontainDesktop/Views/Components/TimerWidget.axaml
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
<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"
|
||||||
|
mc:Ignorable="d"
|
||||||
|
d:DesignWidth="320"
|
||||||
|
d:DesignHeight="320"
|
||||||
|
x:Class="LanMontainDesktop.Views.Components.TimerWidget">
|
||||||
|
|
||||||
|
<Border x:Name="RootBorder"
|
||||||
|
CornerRadius="34"
|
||||||
|
ClipToBounds="True"
|
||||||
|
Padding="14"
|
||||||
|
Background="#E8EAEE">
|
||||||
|
<Viewbox Stretch="Uniform">
|
||||||
|
<Grid x:Name="LayoutRoot"
|
||||||
|
Width="300"
|
||||||
|
Height="300">
|
||||||
|
<Border x:Name="TimerPanelBorder"
|
||||||
|
Width="224"
|
||||||
|
Height="224"
|
||||||
|
HorizontalAlignment="Center"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
CornerRadius="32"
|
||||||
|
BorderThickness="1">
|
||||||
|
<Grid ColumnDefinitions="96,2,*">
|
||||||
|
<Grid Grid.Column="0"
|
||||||
|
Margin="18,18,8,18"
|
||||||
|
RowDefinitions="Auto,Auto,Auto,Auto,*">
|
||||||
|
<TextBlock x:Name="TopNumberTextBlock"
|
||||||
|
Grid.Row="0"
|
||||||
|
Text="60"
|
||||||
|
FontSize="38"
|
||||||
|
FontWeight="SemiBold"
|
||||||
|
Foreground="#AEB4C1"
|
||||||
|
Margin="0,0,0,10" />
|
||||||
|
<TextBlock x:Name="MainNumberTextBlock"
|
||||||
|
Grid.Row="1"
|
||||||
|
Text="0"
|
||||||
|
FontSize="64"
|
||||||
|
FontWeight="Bold"
|
||||||
|
Foreground="#0F141C" />
|
||||||
|
<TextBlock x:Name="NextNumberTextBlock"
|
||||||
|
Grid.Row="2"
|
||||||
|
Text="1"
|
||||||
|
FontSize="34"
|
||||||
|
FontWeight="Medium"
|
||||||
|
Foreground="#B2B8C4"
|
||||||
|
Margin="0,8,0,0" />
|
||||||
|
<TextBlock x:Name="NextNextNumberTextBlock"
|
||||||
|
Grid.Row="3"
|
||||||
|
Text="2"
|
||||||
|
FontSize="26"
|
||||||
|
FontWeight="Medium"
|
||||||
|
Foreground="#C8CDD7"
|
||||||
|
Margin="0,2,0,0" />
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Border x:Name="CenterDivider"
|
||||||
|
Grid.Column="1"
|
||||||
|
Width="2"
|
||||||
|
Margin="0,12"
|
||||||
|
Background="#D5DAE3" />
|
||||||
|
|
||||||
|
<Grid Grid.Column="2"
|
||||||
|
Margin="10,18,16,16"
|
||||||
|
RowDefinitions="Auto,Auto,Auto,Auto,*,Auto">
|
||||||
|
<Border x:Name="ScaleMark1"
|
||||||
|
Grid.Row="0"
|
||||||
|
Height="3"
|
||||||
|
Width="18"
|
||||||
|
CornerRadius="2"
|
||||||
|
HorizontalAlignment="Left"
|
||||||
|
Background="#D0D6E1" />
|
||||||
|
<Border x:Name="ScaleMark2"
|
||||||
|
Grid.Row="1"
|
||||||
|
Height="3"
|
||||||
|
Width="16"
|
||||||
|
CornerRadius="2"
|
||||||
|
Margin="0,18,0,0"
|
||||||
|
HorizontalAlignment="Left"
|
||||||
|
Background="#D0D6E1" />
|
||||||
|
<Border x:Name="ScaleMark3"
|
||||||
|
Grid.Row="2"
|
||||||
|
Height="3"
|
||||||
|
Width="14"
|
||||||
|
CornerRadius="2"
|
||||||
|
Margin="0,18,0,0"
|
||||||
|
HorizontalAlignment="Left"
|
||||||
|
Background="#D0D6E1" />
|
||||||
|
<Border x:Name="ScaleMark4"
|
||||||
|
Grid.Row="3"
|
||||||
|
Height="3"
|
||||||
|
Width="12"
|
||||||
|
CornerRadius="2"
|
||||||
|
Margin="0,18,0,0"
|
||||||
|
HorizontalAlignment="Left"
|
||||||
|
Background="#D0D6E1" />
|
||||||
|
|
||||||
|
<Border x:Name="PlayButtonBorder"
|
||||||
|
Grid.Row="5"
|
||||||
|
Width="42"
|
||||||
|
Height="42"
|
||||||
|
CornerRadius="21"
|
||||||
|
HorizontalAlignment="Right"
|
||||||
|
VerticalAlignment="Bottom"
|
||||||
|
Background="#00000000"
|
||||||
|
BorderBrush="#D3D9E4"
|
||||||
|
BorderThickness="2"
|
||||||
|
Cursor="Hand">
|
||||||
|
<Viewbox Width="16"
|
||||||
|
Height="16"
|
||||||
|
Stretch="Uniform">
|
||||||
|
<Path x:Name="PlayIconPath"
|
||||||
|
Data="M 0,0 L 0,14 L 11,7 Z"
|
||||||
|
Fill="#98A2B8"
|
||||||
|
Stretch="Uniform" />
|
||||||
|
</Viewbox>
|
||||||
|
</Border>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<Canvas Width="224"
|
||||||
|
Height="224"
|
||||||
|
HorizontalAlignment="Center"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
IsHitTestVisible="False">
|
||||||
|
<Line x:Name="HandGlowLine"
|
||||||
|
StartPoint="112,112"
|
||||||
|
EndPoint="186,112"
|
||||||
|
Stroke="#FF7A78"
|
||||||
|
StrokeThickness="9"
|
||||||
|
Opacity="0.20"
|
||||||
|
StrokeLineCap="Round" />
|
||||||
|
<Line x:Name="HandLine"
|
||||||
|
StartPoint="112,112"
|
||||||
|
EndPoint="180,112"
|
||||||
|
Stroke="#FF4D63"
|
||||||
|
StrokeThickness="6"
|
||||||
|
StrokeLineCap="Round" />
|
||||||
|
<Ellipse x:Name="CenterDotRing"
|
||||||
|
Width="22"
|
||||||
|
Height="22"
|
||||||
|
Fill="#FDFEFF"
|
||||||
|
Stroke="#E3E8F0"
|
||||||
|
StrokeThickness="2"
|
||||||
|
Canvas.Left="101"
|
||||||
|
Canvas.Top="101" />
|
||||||
|
<Ellipse x:Name="CenterDotCore"
|
||||||
|
Width="8"
|
||||||
|
Height="8"
|
||||||
|
Fill="#FF4D63"
|
||||||
|
Canvas.Left="108"
|
||||||
|
Canvas.Top="108" />
|
||||||
|
</Canvas>
|
||||||
|
</Grid>
|
||||||
|
</Viewbox>
|
||||||
|
</Border>
|
||||||
|
</UserControl>
|
||||||
273
LanMontainDesktop/Views/Components/TimerWidget.axaml.cs
Normal file
273
LanMontainDesktop/Views/Components/TimerWidget.axaml.cs
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
using System;
|
||||||
|
using System.Globalization;
|
||||||
|
using Avalonia;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Controls.Shapes;
|
||||||
|
using Avalonia.Input;
|
||||||
|
using Avalonia.Media;
|
||||||
|
using Avalonia.Styling;
|
||||||
|
using Avalonia.Threading;
|
||||||
|
|
||||||
|
namespace LanMontainDesktop.Views.Components;
|
||||||
|
|
||||||
|
public partial class TimerWidget : UserControl
|
||||||
|
{
|
||||||
|
private const int MaxTimerSeconds = 60;
|
||||||
|
private const double DialSize = 224;
|
||||||
|
private const double Center = DialSize / 2;
|
||||||
|
|
||||||
|
private readonly DispatcherTimer _timer = new()
|
||||||
|
{
|
||||||
|
Interval = TimeSpan.FromSeconds(1)
|
||||||
|
};
|
||||||
|
|
||||||
|
private double _currentCellSize = 48;
|
||||||
|
private bool _isRunning;
|
||||||
|
private int _remainingSeconds;
|
||||||
|
private bool? _isNightModeApplied;
|
||||||
|
|
||||||
|
public TimerWidget()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
|
||||||
|
_timer.Tick += OnTimerTick;
|
||||||
|
AttachedToVisualTree += OnAttachedToVisualTree;
|
||||||
|
DetachedFromVisualTree += OnDetachedFromVisualTree;
|
||||||
|
SizeChanged += OnSizeChanged;
|
||||||
|
PlayButtonBorder.PointerPressed += OnPlayButtonPointerPressed;
|
||||||
|
|
||||||
|
UpdateVisual();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
||||||
|
{
|
||||||
|
UpdateVisual();
|
||||||
|
_timer.Start();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
||||||
|
{
|
||||||
|
_timer.Stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnSizeChanged(object? sender, SizeChangedEventArgs e)
|
||||||
|
{
|
||||||
|
ApplyCellSize(_currentCellSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnTimerTick(object? sender, EventArgs e)
|
||||||
|
{
|
||||||
|
ApplyModeVisualIfNeeded();
|
||||||
|
|
||||||
|
if (!_isRunning)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_remainingSeconds > 0)
|
||||||
|
{
|
||||||
|
_remainingSeconds--;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_remainingSeconds <= 0)
|
||||||
|
{
|
||||||
|
_remainingSeconds = 0;
|
||||||
|
_isRunning = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
UpdateVisual();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnPlayButtonPointerPressed(object? sender, PointerPressedEventArgs e)
|
||||||
|
{
|
||||||
|
if (!e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_isRunning)
|
||||||
|
{
|
||||||
|
_isRunning = false;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (_remainingSeconds <= 0)
|
||||||
|
{
|
||||||
|
_remainingSeconds = MaxTimerSeconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
_isRunning = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
UpdateVisual();
|
||||||
|
e.Handled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateVisual()
|
||||||
|
{
|
||||||
|
ApplyModeVisualIfNeeded();
|
||||||
|
UpdateNumberVisual();
|
||||||
|
UpdateHandGeometry();
|
||||||
|
UpdatePlayButtonVisual();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateNumberVisual()
|
||||||
|
{
|
||||||
|
var current = Math.Clamp(_remainingSeconds, 0, MaxTimerSeconds);
|
||||||
|
var top = current == 0 ? MaxTimerSeconds : current - 1;
|
||||||
|
var next = (current + 1) % (MaxTimerSeconds + 1);
|
||||||
|
var nextNext = (current + 2) % (MaxTimerSeconds + 1);
|
||||||
|
|
||||||
|
TopNumberTextBlock.Text = top.ToString(CultureInfo.InvariantCulture);
|
||||||
|
MainNumberTextBlock.Text = current.ToString(CultureInfo.InvariantCulture);
|
||||||
|
NextNumberTextBlock.Text = next.ToString(CultureInfo.InvariantCulture);
|
||||||
|
NextNextNumberTextBlock.Text = nextNext.ToString(CultureInfo.InvariantCulture);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateHandGeometry()
|
||||||
|
{
|
||||||
|
var angleDeg = (_remainingSeconds % (MaxTimerSeconds + 1)) / 60d * 360d;
|
||||||
|
var radians = angleDeg * Math.PI / 180d;
|
||||||
|
var cos = Math.Cos(radians);
|
||||||
|
var sin = Math.Sin(radians);
|
||||||
|
|
||||||
|
var start = new Point(Center - cos * 2, Center - sin * 2);
|
||||||
|
var end = new Point(Center + cos * 68, Center + sin * 68);
|
||||||
|
var glowEnd = new Point(Center + cos * 74, Center + sin * 74);
|
||||||
|
|
||||||
|
HandLine.StartPoint = start;
|
||||||
|
HandLine.EndPoint = end;
|
||||||
|
HandGlowLine.StartPoint = start;
|
||||||
|
HandGlowLine.EndPoint = glowEnd;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdatePlayButtonVisual()
|
||||||
|
{
|
||||||
|
PlayIconPath.Data = Geometry.Parse(_isRunning
|
||||||
|
? "M 0,0 L 4,0 L 4,14 L 0,14 Z M 8,0 L 12,0 L 12,14 L 8,14 Z"
|
||||||
|
: "M 0,0 L 0,14 L 11,7 Z");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ApplyModeVisualIfNeeded()
|
||||||
|
{
|
||||||
|
var isNightMode = ResolveIsNightMode();
|
||||||
|
if (_isNightModeApplied.HasValue && _isNightModeApplied.Value == isNightMode)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_isNightModeApplied = isNightMode;
|
||||||
|
ApplyModeVisual(isNightMode);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ApplyModeVisual(bool isNightMode)
|
||||||
|
{
|
||||||
|
RootBorder.Background = CreateBrush(isNightMode ? "#313540" : "#E8EAEE");
|
||||||
|
TimerPanelBorder.Background = isNightMode
|
||||||
|
? CreateLinearGradientBrush("#2F3441", "#202632")
|
||||||
|
: CreateLinearGradientBrush("#FBFCFE", "#F3F5F9");
|
||||||
|
TimerPanelBorder.BorderBrush = CreateBrush(isNightMode ? "#3B4353" : "#E2E7F0");
|
||||||
|
|
||||||
|
CenterDivider.Background = CreateBrush(isNightMode ? "#434B5C" : "#D5DAE3");
|
||||||
|
|
||||||
|
TopNumberTextBlock.Foreground = CreateBrush(isNightMode ? "#7A8397" : "#AEB4C1");
|
||||||
|
MainNumberTextBlock.Foreground = CreateBrush(isNightMode ? "#F3F6FE" : "#0F141C");
|
||||||
|
NextNumberTextBlock.Foreground = CreateBrush(isNightMode ? "#8089A0" : "#B2B8C4");
|
||||||
|
NextNextNumberTextBlock.Foreground = CreateBrush(isNightMode ? "#6A7388" : "#C8CDD7");
|
||||||
|
|
||||||
|
var markBrush = CreateBrush(isNightMode ? "#5A657D" : "#D0D6E1");
|
||||||
|
ScaleMark1.Background = markBrush;
|
||||||
|
ScaleMark2.Background = markBrush;
|
||||||
|
ScaleMark3.Background = markBrush;
|
||||||
|
ScaleMark4.Background = markBrush;
|
||||||
|
|
||||||
|
PlayButtonBorder.BorderBrush = CreateBrush(isNightMode ? "#4A5367" : "#D3D9E4");
|
||||||
|
PlayIconPath.Fill = CreateBrush(isNightMode ? "#8E98AF" : "#98A2B8");
|
||||||
|
|
||||||
|
CenterDotRing.Fill = CreateBrush(isNightMode ? "#EAF0FF" : "#FDFEFF");
|
||||||
|
CenterDotRing.Stroke = CreateBrush(isNightMode ? "#A9B8D5" : "#E3E8F0");
|
||||||
|
CenterDotCore.Fill = CreateBrush("#FF4D63");
|
||||||
|
HandLine.Stroke = CreateBrush("#FF4D63");
|
||||||
|
HandGlowLine.Stroke = CreateBrush(isNightMode ? "#FF6A6E" : "#FF7A78");
|
||||||
|
HandGlowLine.Opacity = isNightMode ? 0.28 : 0.20;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ApplyCellSize(double cellSize)
|
||||||
|
{
|
||||||
|
_currentCellSize = Math.Max(1, cellSize);
|
||||||
|
var scale = ResolveScale();
|
||||||
|
|
||||||
|
RootBorder.CornerRadius = new CornerRadius(Math.Clamp(34 * scale, 12, 48));
|
||||||
|
RootBorder.Padding = new Thickness(Math.Clamp(14 * scale, 7, 22));
|
||||||
|
TimerPanelBorder.CornerRadius = new CornerRadius(Math.Clamp(32 * scale, 12, 42));
|
||||||
|
|
||||||
|
PlayButtonBorder.Width = Math.Clamp(42 * scale, 28, 58);
|
||||||
|
PlayButtonBorder.Height = Math.Clamp(42 * scale, 28, 58);
|
||||||
|
PlayButtonBorder.CornerRadius = new CornerRadius(PlayButtonBorder.Width / 2d);
|
||||||
|
|
||||||
|
ApplyModeVisualIfNeeded();
|
||||||
|
}
|
||||||
|
|
||||||
|
private double ResolveScale()
|
||||||
|
{
|
||||||
|
var cellScale = Math.Clamp(_currentCellSize / 44d, 0.60, 1.90);
|
||||||
|
var heightScale = Bounds.Height > 1 ? Math.Clamp(Bounds.Height / 300d, 0.58, 2.0) : 1;
|
||||||
|
var widthScale = Bounds.Width > 1 ? Math.Clamp(Bounds.Width / 300d, 0.58, 2.0) : 1;
|
||||||
|
return Math.Clamp(Math.Min(cellScale, Math.Min(heightScale, widthScale) * 1.05), 0.58, 1.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 IBrush CreateBrush(string colorHex)
|
||||||
|
{
|
||||||
|
return new SolidColorBrush(Color.Parse(colorHex));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IBrush CreateLinearGradientBrush(string fromColorHex, string toColorHex)
|
||||||
|
{
|
||||||
|
return new LinearGradientBrush
|
||||||
|
{
|
||||||
|
StartPoint = new RelativePoint(0, 0, RelativeUnit.Relative),
|
||||||
|
EndPoint = new RelativePoint(1, 1, RelativeUnit.Relative),
|
||||||
|
GradientStops = new GradientStops
|
||||||
|
{
|
||||||
|
new GradientStop(Color.Parse(fromColorHex), 0),
|
||||||
|
new GradientStop(Color.Parse(toColorHex), 1)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1065,6 +1065,21 @@ public partial class MainWindow
|
|||||||
return (Math.Max(2, span.WidthCells), Math.Max(2, span.HeightCells));
|
return (Math.Max(2, span.WidthCells), Math.Max(2, span.HeightCells));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (string.Equals(componentId, BuiltInComponentIds.DesktopClock, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return (Math.Max(2, span.WidthCells), Math.Max(2, span.HeightCells));
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (Math.Max(1, span.WidthCells), Math.Max(1, span.HeightCells));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1075,6 +1090,9 @@ public partial class MainWindow
|
|||||||
BuiltInComponentIds.Date => 16,
|
BuiltInComponentIds.Date => 16,
|
||||||
BuiltInComponentIds.MonthCalendar => Math.Clamp(_currentDesktopCellSize * 0.26, 10, 22),
|
BuiltInComponentIds.MonthCalendar => Math.Clamp(_currentDesktopCellSize * 0.26, 10, 22),
|
||||||
BuiltInComponentIds.LunarCalendar => Math.Clamp(_currentDesktopCellSize * 0.30, 12, 26),
|
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)
|
_ => Math.Clamp(_currentDesktopCellSize * 0.22, 8, 18)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -1181,6 +1199,32 @@ public partial class MainWindow
|
|||||||
return widget;
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1911,38 +1955,19 @@ public partial class MainWindow
|
|||||||
ComponentLibraryCategoryPagesContainer.Children.Clear();
|
ComponentLibraryCategoryPagesContainer.Children.Clear();
|
||||||
ComponentLibraryCategoryPagesContainer.RowDefinitions.Clear();
|
ComponentLibraryCategoryPagesContainer.RowDefinitions.Clear();
|
||||||
ComponentLibraryCategoryPagesContainer.ColumnDefinitions.Clear();
|
ComponentLibraryCategoryPagesContainer.ColumnDefinitions.Clear();
|
||||||
|
ComponentLibraryCategoryPagesContainer.Width = double.NaN;
|
||||||
|
ComponentLibraryCategoryPagesContainer.Height = double.NaN;
|
||||||
|
ComponentLibraryCategoryPagesHost.Width = double.NaN;
|
||||||
|
ComponentLibraryCategoryPagesHost.Height = double.NaN;
|
||||||
|
|
||||||
if (categoryCount == 0)
|
if (categoryCount == 0)
|
||||||
{
|
{
|
||||||
_componentLibraryCategoryIndex = 0;
|
_componentLibraryCategoryIndex = 0;
|
||||||
_componentLibraryActiveCategoryId = null;
|
_componentLibraryActiveCategoryId = null;
|
||||||
|
UpdateComponentLibraryComponentNavigationButtons();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var viewportWidth = ComponentLibraryCategoryViewport.Bounds.Width;
|
|
||||||
if (viewportWidth <= 1 && ComponentLibraryWindow is not null)
|
|
||||||
{
|
|
||||||
viewportWidth = Math.Max(1, ComponentLibraryWindow.Bounds.Width - 48);
|
|
||||||
}
|
|
||||||
|
|
||||||
var viewportHeight = ComponentLibraryCategoryViewport.Bounds.Height;
|
|
||||||
if (viewportHeight <= 1 && ComponentLibraryWindow is not null)
|
|
||||||
{
|
|
||||||
viewportHeight = Math.Max(1, ComponentLibraryWindow.Bounds.Height - 120);
|
|
||||||
}
|
|
||||||
|
|
||||||
_componentLibraryCategoryPageWidth = Math.Max(1, viewportWidth);
|
|
||||||
ComponentLibraryCategoryPagesHost.Width = _componentLibraryCategoryPageWidth * categoryCount;
|
|
||||||
ComponentLibraryCategoryPagesHost.Height = viewportHeight;
|
|
||||||
ComponentLibraryCategoryPagesContainer.Width = ComponentLibraryCategoryPagesHost.Width;
|
|
||||||
ComponentLibraryCategoryPagesContainer.Height = viewportHeight;
|
|
||||||
|
|
||||||
ComponentLibraryCategoryPagesContainer.RowDefinitions.Add(new RowDefinition(new GridLength(viewportHeight, GridUnitType.Pixel)));
|
|
||||||
for (var i = 0; i < categoryCount; i++)
|
|
||||||
{
|
|
||||||
ComponentLibraryCategoryPagesContainer.ColumnDefinitions.Add(
|
|
||||||
new ColumnDefinition(new GridLength(_componentLibraryCategoryPageWidth, GridUnitType.Pixel)));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(_componentLibraryActiveCategoryId))
|
if (!string.IsNullOrWhiteSpace(_componentLibraryActiveCategoryId))
|
||||||
{
|
{
|
||||||
var activeIndex = _componentLibraryCategories
|
var activeIndex = _componentLibraryCategories
|
||||||
@@ -1959,71 +1984,65 @@ public partial class MainWindow
|
|||||||
|
|
||||||
_componentLibraryActiveCategoryId = _componentLibraryCategories[_componentLibraryCategoryIndex].Id;
|
_componentLibraryActiveCategoryId = _componentLibraryCategories[_componentLibraryCategoryIndex].Id;
|
||||||
|
|
||||||
|
ComponentLibraryCategoryPagesContainer.ColumnDefinitions.Add(new ColumnDefinition(new GridLength(1, GridUnitType.Star)));
|
||||||
for (var i = 0; i < categoryCount; i++)
|
for (var i = 0; i < categoryCount; i++)
|
||||||
{
|
{
|
||||||
var category = _componentLibraryCategories[i];
|
var category = _componentLibraryCategories[i];
|
||||||
var page = new Grid
|
var isSelected = i == _componentLibraryCategoryIndex;
|
||||||
|
var row = new RowDefinition(GridLength.Auto);
|
||||||
|
ComponentLibraryCategoryPagesContainer.RowDefinitions.Add(row);
|
||||||
|
|
||||||
|
var icon = new SymbolIcon
|
||||||
{
|
{
|
||||||
Width = _componentLibraryCategoryPageWidth,
|
Symbol = category.Icon,
|
||||||
Height = viewportHeight,
|
IconVariant = IconVariant.Regular,
|
||||||
Background = Brushes.Transparent
|
FontSize = 18,
|
||||||
|
VerticalAlignment = VerticalAlignment.Center
|
||||||
};
|
};
|
||||||
|
|
||||||
var cardWidth = Math.Clamp(_componentLibraryCategoryPageWidth * 0.64, 160, 260);
|
var title = new TextBlock
|
||||||
var cardHeight = Math.Clamp(viewportHeight * 0.70, 140, 220);
|
|
||||||
|
|
||||||
var iconSize = Math.Clamp(cardHeight * 0.34, 30, 56);
|
|
||||||
|
|
||||||
var card = new Border
|
|
||||||
{
|
{
|
||||||
Classes = { "glass-panel" },
|
Text = category.Title,
|
||||||
Width = cardWidth,
|
FontSize = 15,
|
||||||
Height = cardHeight,
|
FontWeight = isSelected ? FontWeight.Bold : FontWeight.SemiBold,
|
||||||
CornerRadius = new CornerRadius(36),
|
Foreground = GetThemeBrush("AdaptiveTextPrimaryBrush"),
|
||||||
Padding = new Thickness(18),
|
|
||||||
HorizontalAlignment = HorizontalAlignment.Center,
|
|
||||||
VerticalAlignment = VerticalAlignment.Center,
|
VerticalAlignment = VerticalAlignment.Center,
|
||||||
Child = new StackPanel
|
TextTrimming = TextTrimming.CharacterEllipsis
|
||||||
{
|
|
||||||
Spacing = 12,
|
|
||||||
HorizontalAlignment = HorizontalAlignment.Center,
|
|
||||||
VerticalAlignment = VerticalAlignment.Center,
|
|
||||||
Children =
|
|
||||||
{
|
|
||||||
new SymbolIcon
|
|
||||||
{
|
|
||||||
Symbol = category.Icon,
|
|
||||||
IconVariant = IconVariant.Regular,
|
|
||||||
FontSize = iconSize,
|
|
||||||
HorizontalAlignment = HorizontalAlignment.Center
|
|
||||||
},
|
|
||||||
new TextBlock
|
|
||||||
{
|
|
||||||
Text = category.Title,
|
|
||||||
FontSize = Math.Clamp(cardHeight * 0.14, 12, 18),
|
|
||||||
FontWeight = FontWeight.SemiBold,
|
|
||||||
HorizontalAlignment = HorizontalAlignment.Center,
|
|
||||||
Foreground = GetThemeBrush("AdaptiveTextPrimaryBrush")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
page.Children.Add(card);
|
var contentGrid = new Grid
|
||||||
|
{
|
||||||
|
ColumnDefinitions = new ColumnDefinitions("Auto,*"),
|
||||||
|
ColumnSpacing = 10,
|
||||||
|
Children = { icon, title }
|
||||||
|
};
|
||||||
|
Grid.SetColumn(icon, 0);
|
||||||
|
Grid.SetColumn(title, 1);
|
||||||
|
|
||||||
Grid.SetRow(page, 0);
|
var itemButton = new Button
|
||||||
Grid.SetColumn(page, i);
|
{
|
||||||
ComponentLibraryCategoryPagesContainer.Children.Add(page);
|
Tag = i,
|
||||||
|
Margin = new Thickness(0, 0, 0, i < categoryCount - 1 ? 8 : 0),
|
||||||
|
Padding = new Thickness(12, 10),
|
||||||
|
HorizontalAlignment = HorizontalAlignment.Stretch,
|
||||||
|
HorizontalContentAlignment = HorizontalAlignment.Stretch,
|
||||||
|
VerticalContentAlignment = VerticalAlignment.Center,
|
||||||
|
Background = isSelected
|
||||||
|
? GetThemeBrush("AdaptiveNavItemSelectedBackgroundBrush")
|
||||||
|
: GetThemeBrush("AdaptiveNavItemBackgroundBrush"),
|
||||||
|
BorderBrush = GetThemeBrush("AdaptiveButtonBorderBrush"),
|
||||||
|
BorderThickness = new Thickness(isSelected ? 1.5 : 1),
|
||||||
|
Content = contentGrid
|
||||||
|
};
|
||||||
|
itemButton.Click += OnComponentLibraryCategoryItemClick;
|
||||||
|
|
||||||
|
Grid.SetRow(itemButton, i);
|
||||||
|
Grid.SetColumn(itemButton, 0);
|
||||||
|
ComponentLibraryCategoryPagesContainer.Children.Add(itemButton);
|
||||||
}
|
}
|
||||||
|
|
||||||
_componentLibraryCategoryHostTransform = ComponentLibraryCategoryPagesHost.RenderTransform as TranslateTransform;
|
_componentLibraryCategoryHostTransform = null;
|
||||||
if (_componentLibraryCategoryHostTransform is null)
|
_componentLibraryCategoryPageWidth = 0;
|
||||||
{
|
|
||||||
_componentLibraryCategoryHostTransform = new TranslateTransform();
|
|
||||||
ComponentLibraryCategoryPagesHost.RenderTransform = _componentLibraryCategoryHostTransform;
|
|
||||||
}
|
|
||||||
|
|
||||||
ApplyComponentLibraryCategoryOffset();
|
|
||||||
|
|
||||||
if (ComponentLibraryBackTextBlock is not null)
|
if (ComponentLibraryBackTextBlock is not null)
|
||||||
{
|
{
|
||||||
@@ -2063,6 +2082,11 @@ public partial class MainWindow
|
|||||||
|
|
||||||
private Symbol ResolveComponentLibraryCategoryIcon(string categoryId)
|
private Symbol ResolveComponentLibraryCategoryIcon(string categoryId)
|
||||||
{
|
{
|
||||||
|
if (string.Equals(categoryId, "Clock", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return Symbol.Clock;
|
||||||
|
}
|
||||||
|
|
||||||
if (string.Equals(categoryId, "Date", StringComparison.OrdinalIgnoreCase))
|
if (string.Equals(categoryId, "Date", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
return Symbol.CalendarDate;
|
return Symbol.CalendarDate;
|
||||||
@@ -2073,6 +2097,11 @@ public partial class MainWindow
|
|||||||
|
|
||||||
private string GetLocalizedComponentLibraryCategoryTitle(string categoryId)
|
private string GetLocalizedComponentLibraryCategoryTitle(string categoryId)
|
||||||
{
|
{
|
||||||
|
if (string.Equals(categoryId, "Clock", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return L("component_category.clock", "Clock");
|
||||||
|
}
|
||||||
|
|
||||||
if (string.Equals(categoryId, "Date", StringComparison.OrdinalIgnoreCase))
|
if (string.Equals(categoryId, "Date", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
return L("component_category.date", "Calendar");
|
return L("component_category.date", "Calendar");
|
||||||
@@ -2099,6 +2128,31 @@ public partial class MainWindow
|
|||||||
}
|
}
|
||||||
|
|
||||||
_componentLibraryComponentHostTransform.X = -_componentLibraryComponentIndex * _componentLibraryComponentPageWidth;
|
_componentLibraryComponentHostTransform.X = -_componentLibraryComponentIndex * _componentLibraryComponentPageWidth;
|
||||||
|
UpdateComponentLibraryComponentNavigationButtons();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateComponentLibraryComponentNavigationButtons()
|
||||||
|
{
|
||||||
|
if (ComponentLibraryPrevComponentButton is null || ComponentLibraryNextComponentButton is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var maxIndex = Math.Max(0, _componentLibraryActiveComponents.Count - 1);
|
||||||
|
var hasMultiplePages = maxIndex > 0;
|
||||||
|
|
||||||
|
ComponentLibraryPrevComponentButton.IsVisible = hasMultiplePages;
|
||||||
|
ComponentLibraryNextComponentButton.IsVisible = hasMultiplePages;
|
||||||
|
|
||||||
|
if (!hasMultiplePages)
|
||||||
|
{
|
||||||
|
ComponentLibraryPrevComponentButton.IsEnabled = false;
|
||||||
|
ComponentLibraryNextComponentButton.IsEnabled = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ComponentLibraryPrevComponentButton.IsEnabled = _componentLibraryComponentIndex > 0;
|
||||||
|
ComponentLibraryNextComponentButton.IsEnabled = _componentLibraryComponentIndex < maxIndex;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OpenComponentLibraryCurrentCategory()
|
private void OpenComponentLibraryCurrentCategory()
|
||||||
@@ -2134,19 +2188,35 @@ public partial class MainWindow
|
|||||||
if (componentCount == 0)
|
if (componentCount == 0)
|
||||||
{
|
{
|
||||||
_componentLibraryComponentIndex = 0;
|
_componentLibraryComponentIndex = 0;
|
||||||
|
UpdateComponentLibraryComponentNavigationButtons();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var viewportWidth = ComponentLibraryComponentViewport.Bounds.Width;
|
var viewportWidth = ComponentLibraryComponentViewport.Bounds.Width;
|
||||||
if (viewportWidth <= 1 && ComponentLibraryWindow is not null)
|
if (viewportWidth <= 1)
|
||||||
{
|
{
|
||||||
viewportWidth = Math.Max(1, ComponentLibraryWindow.Bounds.Width - 48);
|
if (ComponentLibraryComponentViewport.Parent is Control parent && parent.Bounds.Width > 1)
|
||||||
|
{
|
||||||
|
// Parent includes left/right nav buttons; reserve space to get true viewport width.
|
||||||
|
viewportWidth = Math.Max(1, parent.Bounds.Width - 96);
|
||||||
|
}
|
||||||
|
else if (ComponentLibraryWindow is not null)
|
||||||
|
{
|
||||||
|
viewportWidth = Math.Max(1, ComponentLibraryWindow.Bounds.Width - 150);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var viewportHeight = ComponentLibraryComponentViewport.Bounds.Height;
|
var viewportHeight = ComponentLibraryComponentViewport.Bounds.Height;
|
||||||
if (viewportHeight <= 1 && ComponentLibraryWindow is not null)
|
if (viewportHeight <= 1)
|
||||||
{
|
{
|
||||||
viewportHeight = Math.Max(1, ComponentLibraryWindow.Bounds.Height - 160);
|
if (ComponentLibraryComponentViewport.Parent is Control parent && parent.Bounds.Height > 1)
|
||||||
|
{
|
||||||
|
viewportHeight = Math.Max(1, parent.Bounds.Height);
|
||||||
|
}
|
||||||
|
else if (ComponentLibraryWindow is not null)
|
||||||
|
{
|
||||||
|
viewportHeight = Math.Max(1, ComponentLibraryWindow.Bounds.Height - 170);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_componentLibraryComponentPageWidth = Math.Max(1, viewportWidth);
|
_componentLibraryComponentPageWidth = Math.Max(1, viewportWidth);
|
||||||
@@ -2180,19 +2250,19 @@ public partial class MainWindow
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Fit the preview to the page while preserving component cell span proportions.
|
// Fit the preview to the page while preserving component cell span proportions.
|
||||||
var previewMaxWidth = _componentLibraryComponentPageWidth * 0.86;
|
var previewMaxWidth = _componentLibraryComponentPageWidth * 0.94;
|
||||||
var previewMaxHeight = viewportHeight * 0.72;
|
var previewMaxHeight = viewportHeight * 0.86;
|
||||||
var previewSpan = NormalizeComponentCellSpan(
|
var previewSpan = NormalizeComponentCellSpan(
|
||||||
resolved.Id,
|
resolved.Id,
|
||||||
(resolved.MinWidthCells, resolved.MinHeightCells));
|
(resolved.MinWidthCells, resolved.MinHeightCells));
|
||||||
var previewCellSize = Math.Min(
|
var previewCellSize = Math.Min(
|
||||||
previewMaxWidth / Math.Max(1, previewSpan.WidthCells),
|
previewMaxWidth / Math.Max(1, previewSpan.WidthCells),
|
||||||
previewMaxHeight / Math.Max(1, previewSpan.HeightCells));
|
previewMaxHeight / Math.Max(1, previewSpan.HeightCells));
|
||||||
previewCellSize = Math.Clamp(previewCellSize, 20, 72);
|
previewCellSize = Math.Clamp(previewCellSize, 24, 96);
|
||||||
|
|
||||||
var previewWidth = previewSpan.WidthCells * previewCellSize;
|
var previewWidth = previewSpan.WidthCells * previewCellSize;
|
||||||
var previewHeight = previewSpan.HeightCells * previewCellSize;
|
var previewHeight = previewSpan.HeightCells * previewCellSize;
|
||||||
var renderCellSize = Math.Clamp(previewCellSize * 1.35, 28, 82);
|
var renderCellSize = Math.Clamp(previewCellSize * 1.15, 26, 110);
|
||||||
|
|
||||||
var previewControl = CreateComponentLibraryPreviewControl(resolved.Id, renderCellSize);
|
var previewControl = CreateComponentLibraryPreviewControl(resolved.Id, renderCellSize);
|
||||||
if (previewControl is null)
|
if (previewControl is null)
|
||||||
@@ -2220,8 +2290,7 @@ public partial class MainWindow
|
|||||||
{
|
{
|
||||||
Width = previewWidth,
|
Width = previewWidth,
|
||||||
Height = previewHeight,
|
Height = previewHeight,
|
||||||
CornerRadius = new CornerRadius(20),
|
ClipToBounds = false,
|
||||||
ClipToBounds = true,
|
|
||||||
Background = Brushes.Transparent,
|
Background = Brushes.Transparent,
|
||||||
BorderThickness = new Thickness(0),
|
BorderThickness = new Thickness(0),
|
||||||
Child = previewViewbox,
|
Child = previewViewbox,
|
||||||
@@ -2248,18 +2317,12 @@ public partial class MainWindow
|
|||||||
|
|
||||||
var stack = new StackPanel
|
var stack = new StackPanel
|
||||||
{
|
{
|
||||||
Spacing = 10,
|
Spacing = 8,
|
||||||
HorizontalAlignment = HorizontalAlignment.Center,
|
HorizontalAlignment = HorizontalAlignment.Center,
|
||||||
VerticalAlignment = VerticalAlignment.Center,
|
VerticalAlignment = VerticalAlignment.Center,
|
||||||
Children =
|
Children =
|
||||||
{
|
{
|
||||||
new Border
|
previewBorder,
|
||||||
{
|
|
||||||
Classes = { "glass-panel" },
|
|
||||||
CornerRadius = new CornerRadius(28),
|
|
||||||
Padding = new Thickness(12),
|
|
||||||
Child = previewBorder
|
|
||||||
},
|
|
||||||
label,
|
label,
|
||||||
hint
|
hint
|
||||||
}
|
}
|
||||||
@@ -2280,6 +2343,7 @@ public partial class MainWindow
|
|||||||
}
|
}
|
||||||
|
|
||||||
ApplyComponentLibraryComponentOffset();
|
ApplyComponentLibraryComponentOffset();
|
||||||
|
UpdateComponentLibraryComponentNavigationButtons();
|
||||||
}
|
}
|
||||||
|
|
||||||
private Control? CreateComponentLibraryPreviewControl(string componentId, double cellSize)
|
private Control? CreateComponentLibraryPreviewControl(string componentId, double cellSize)
|
||||||
@@ -2308,6 +2372,29 @@ public partial class MainWindow
|
|||||||
return widget;
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2328,6 +2415,21 @@ public partial class MainWindow
|
|||||||
return L("component.lunar_calendar", definition.DisplayName);
|
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 definition.DisplayName;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2418,6 +2520,42 @@ public partial class MainWindow
|
|||||||
BuildComponentLibraryCategoryPages();
|
BuildComponentLibraryCategoryPages();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void OnComponentLibraryCategoryItemClick(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (sender is not Button button ||
|
||||||
|
button.Tag is not int categoryIndex ||
|
||||||
|
_componentLibraryCategories.Count == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_componentLibraryCategoryIndex = Math.Clamp(categoryIndex, 0, Math.Max(0, _componentLibraryCategories.Count - 1));
|
||||||
|
OpenComponentLibraryCurrentCategory();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnComponentLibraryPrevComponentClick(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (_componentLibraryActiveComponents.Count <= 1)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_componentLibraryComponentIndex = Math.Max(0, _componentLibraryComponentIndex - 1);
|
||||||
|
ApplyComponentLibraryComponentOffset();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnComponentLibraryNextComponentClick(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
var maxIndex = Math.Max(0, _componentLibraryActiveComponents.Count - 1);
|
||||||
|
if (maxIndex <= 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_componentLibraryComponentIndex = Math.Min(maxIndex, _componentLibraryComponentIndex + 1);
|
||||||
|
ApplyComponentLibraryComponentOffset();
|
||||||
|
}
|
||||||
|
|
||||||
private void OnComponentLibraryCategoryViewportPointerPressed(object? sender, PointerPressedEventArgs e)
|
private void OnComponentLibraryCategoryViewportPointerPressed(object? sender, PointerPressedEventArgs e)
|
||||||
{
|
{
|
||||||
if (!_isComponentLibraryOpen ||
|
if (!_isComponentLibraryOpen ||
|
||||||
|
|||||||
@@ -1163,11 +1163,11 @@
|
|||||||
Classes="glass-strong"
|
Classes="glass-strong"
|
||||||
HorizontalAlignment="Center"
|
HorizontalAlignment="Center"
|
||||||
VerticalAlignment="Bottom"
|
VerticalAlignment="Bottom"
|
||||||
Width="520"
|
Width="620"
|
||||||
MinWidth="360"
|
MinWidth="420"
|
||||||
MaxWidth="720"
|
MaxWidth="860"
|
||||||
Height="260"
|
Height="320"
|
||||||
MinHeight="220"
|
MinHeight="260"
|
||||||
Margin="24,24,24,100"
|
Margin="24,24,24,100"
|
||||||
CornerRadius="36"
|
CornerRadius="36"
|
||||||
Padding="14"
|
Padding="14"
|
||||||
@@ -1211,37 +1211,26 @@
|
|||||||
<Grid>
|
<Grid>
|
||||||
<!-- Category picker (outer) -->
|
<!-- Category picker (outer) -->
|
||||||
<Grid x:Name="ComponentLibraryCategoriesView">
|
<Grid x:Name="ComponentLibraryCategoriesView">
|
||||||
<Border x:Name="ComponentLibraryCategoryViewport"
|
<Grid RowDefinitions="*">
|
||||||
Background="Transparent"
|
<Border x:Name="ComponentLibraryCategoryViewport"
|
||||||
ClipToBounds="True"
|
Background="Transparent"
|
||||||
PointerPressed="OnComponentLibraryCategoryViewportPointerPressed"
|
ClipToBounds="True">
|
||||||
PointerMoved="OnComponentLibraryCategoryViewportPointerMoved"
|
<ScrollViewer VerticalScrollBarVisibility="Auto"
|
||||||
PointerReleased="OnComponentLibraryCategoryViewportPointerReleased"
|
HorizontalScrollBarVisibility="Disabled">
|
||||||
PointerCaptureLost="OnComponentLibraryCategoryViewportPointerCaptureLost">
|
<Grid x:Name="ComponentLibraryCategoryPagesHost"
|
||||||
<Grid>
|
HorizontalAlignment="Stretch"
|
||||||
<Grid x:Name="ComponentLibraryCategoryPagesHost"
|
VerticalAlignment="Top">
|
||||||
HorizontalAlignment="Left"
|
<Grid x:Name="ComponentLibraryCategoryPagesContainer" />
|
||||||
VerticalAlignment="Top">
|
</Grid>
|
||||||
<Grid.RenderTransform>
|
</ScrollViewer>
|
||||||
<TranslateTransform>
|
</Border>
|
||||||
<TranslateTransform.Transitions>
|
|
||||||
<Transitions>
|
|
||||||
<DoubleTransition Property="X" Duration="0:0:0.22" />
|
|
||||||
</Transitions>
|
|
||||||
</TranslateTransform.Transitions>
|
|
||||||
</TranslateTransform>
|
|
||||||
</Grid.RenderTransform>
|
|
||||||
|
|
||||||
<Grid x:Name="ComponentLibraryCategoryPagesContainer" />
|
<TextBlock x:Name="ComponentLibraryEmptyTextBlock"
|
||||||
</Grid>
|
VerticalAlignment="Center"
|
||||||
|
HorizontalAlignment="Center"
|
||||||
<TextBlock x:Name="ComponentLibraryEmptyTextBlock"
|
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
|
||||||
VerticalAlignment="Center"
|
Text="No components." />
|
||||||
HorizontalAlignment="Center"
|
</Grid>
|
||||||
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
|
|
||||||
Text="No components." />
|
|
||||||
</Grid>
|
|
||||||
</Border>
|
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<!-- Component picker (inner) -->
|
<!-- Component picker (inner) -->
|
||||||
@@ -1265,32 +1254,64 @@
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Border x:Name="ComponentLibraryComponentViewport"
|
<Grid Grid.Row="1"
|
||||||
Grid.Row="1"
|
ColumnDefinitions="Auto,*,Auto"
|
||||||
Background="Transparent"
|
ColumnSpacing="8">
|
||||||
ClipToBounds="True"
|
<Button x:Name="ComponentLibraryPrevComponentButton"
|
||||||
PointerPressed="OnComponentLibraryComponentViewportPointerPressed"
|
Grid.Column="0"
|
||||||
PointerMoved="OnComponentLibraryComponentViewportPointerMoved"
|
Width="36"
|
||||||
PointerReleased="OnComponentLibraryComponentViewportPointerReleased"
|
Height="36"
|
||||||
PointerCaptureLost="OnComponentLibraryComponentViewportPointerCaptureLost">
|
Padding="0"
|
||||||
<Grid>
|
HorizontalAlignment="Center"
|
||||||
<Grid x:Name="ComponentLibraryComponentPagesHost"
|
VerticalAlignment="Center"
|
||||||
HorizontalAlignment="Left"
|
CornerRadius="18"
|
||||||
VerticalAlignment="Top">
|
Click="OnComponentLibraryPrevComponentClick"
|
||||||
<Grid.RenderTransform>
|
IsVisible="False">
|
||||||
<TranslateTransform>
|
<fi:SymbolIcon Symbol="ChevronLeft"
|
||||||
<TranslateTransform.Transitions>
|
IconVariant="Regular" />
|
||||||
<Transitions>
|
</Button>
|
||||||
<DoubleTransition Property="X" Duration="0:0:0.22" />
|
|
||||||
</Transitions>
|
|
||||||
</TranslateTransform.Transitions>
|
|
||||||
</TranslateTransform>
|
|
||||||
</Grid.RenderTransform>
|
|
||||||
|
|
||||||
<Grid x:Name="ComponentLibraryComponentPagesContainer" />
|
<Border x:Name="ComponentLibraryComponentViewport"
|
||||||
|
Grid.Column="1"
|
||||||
|
Background="Transparent"
|
||||||
|
ClipToBounds="True"
|
||||||
|
PointerPressed="OnComponentLibraryComponentViewportPointerPressed"
|
||||||
|
PointerMoved="OnComponentLibraryComponentViewportPointerMoved"
|
||||||
|
PointerReleased="OnComponentLibraryComponentViewportPointerReleased"
|
||||||
|
PointerCaptureLost="OnComponentLibraryComponentViewportPointerCaptureLost">
|
||||||
|
<Grid>
|
||||||
|
<Grid x:Name="ComponentLibraryComponentPagesHost"
|
||||||
|
HorizontalAlignment="Left"
|
||||||
|
VerticalAlignment="Top">
|
||||||
|
<Grid.RenderTransform>
|
||||||
|
<TranslateTransform>
|
||||||
|
<TranslateTransform.Transitions>
|
||||||
|
<Transitions>
|
||||||
|
<DoubleTransition Property="X" Duration="0:0:0.22" />
|
||||||
|
</Transitions>
|
||||||
|
</TranslateTransform.Transitions>
|
||||||
|
</TranslateTransform>
|
||||||
|
</Grid.RenderTransform>
|
||||||
|
|
||||||
|
<Grid x:Name="ComponentLibraryComponentPagesContainer" />
|
||||||
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Grid>
|
</Border>
|
||||||
</Border>
|
|
||||||
|
<Button x:Name="ComponentLibraryNextComponentButton"
|
||||||
|
Grid.Column="2"
|
||||||
|
Width="36"
|
||||||
|
Height="36"
|
||||||
|
Padding="0"
|
||||||
|
HorizontalAlignment="Center"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
CornerRadius="18"
|
||||||
|
Click="OnComponentLibraryNextComponentClick"
|
||||||
|
IsVisible="False">
|
||||||
|
<fi:SymbolIcon Symbol="ChevronRight"
|
||||||
|
IconVariant="Regular" />
|
||||||
|
</Button>
|
||||||
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
|
|||||||
Reference in New Issue
Block a user