From 4c3ec920f9badc0e320608dd5aec5185e7bfb4a3 Mon Sep 17 00:00:00 2001 From: lincube Date: Mon, 2 Mar 2026 22:46:10 +0800 Subject: [PATCH] 0.2.2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 时钟组件的完善。 --- .../ComponentSystem/BuiltInComponentIds.cs | 3 + .../ComponentSystem/ComponentRegistry.cs | 27 + LanMontainDesktop/Localization/en-US.json | 4 + LanMontainDesktop/Localization/zh-CN.json | 4 + LanMontainDesktop/Models/WeatherDataModels.cs | 45 ++ .../Services/HolidayCalendarService.cs | 678 ++++++++++++++++ .../Services/IWeatherDataService.cs | 44 ++ .../Services/XiaomiWeatherService.cs | 731 ++++++++++++++++++ .../Views/Components/AnalogClockWidget.axaml | 78 ++ .../Components/AnalogClockWidget.axaml.cs | 330 ++++++++ .../Components/HolidayCalendarWidget.axaml | 97 +++ .../Components/HolidayCalendarWidget.axaml.cs | 215 ++++++ .../Views/Components/TimerWidget.axaml | 160 ++++ .../Views/Components/TimerWidget.axaml.cs | 273 +++++++ .../Views/MainWindow.ComponentSystem.cs | 328 +++++--- LanMontainDesktop/Views/MainWindow.axaml | 139 ++-- 16 files changed, 3002 insertions(+), 154 deletions(-) create mode 100644 LanMontainDesktop/Models/WeatherDataModels.cs create mode 100644 LanMontainDesktop/Services/HolidayCalendarService.cs create mode 100644 LanMontainDesktop/Services/IWeatherDataService.cs create mode 100644 LanMontainDesktop/Services/XiaomiWeatherService.cs create mode 100644 LanMontainDesktop/Views/Components/AnalogClockWidget.axaml create mode 100644 LanMontainDesktop/Views/Components/AnalogClockWidget.axaml.cs create mode 100644 LanMontainDesktop/Views/Components/HolidayCalendarWidget.axaml create mode 100644 LanMontainDesktop/Views/Components/HolidayCalendarWidget.axaml.cs create mode 100644 LanMontainDesktop/Views/Components/TimerWidget.axaml create mode 100644 LanMontainDesktop/Views/Components/TimerWidget.axaml.cs diff --git a/LanMontainDesktop/ComponentSystem/BuiltInComponentIds.cs b/LanMontainDesktop/ComponentSystem/BuiltInComponentIds.cs index 4ba2ade..7d80c2b 100644 --- a/LanMontainDesktop/ComponentSystem/BuiltInComponentIds.cs +++ b/LanMontainDesktop/ComponentSystem/BuiltInComponentIds.cs @@ -3,8 +3,11 @@ namespace LanMontainDesktop.ComponentSystem; public static class BuiltInComponentIds { public const string Clock = "Clock"; + public const string DesktopClock = "DesktopClock"; + public const string DesktopTimer = "DesktopTimer"; public const string Blank2x4 = "Blank2x4"; public const string Date = "Date"; public const string MonthCalendar = "MonthCalendar"; public const string LunarCalendar = "LunarCalendar"; + public const string HolidayCalendar = "HolidayCalendar"; } diff --git a/LanMontainDesktop/ComponentSystem/ComponentRegistry.cs b/LanMontainDesktop/ComponentSystem/ComponentRegistry.cs index b7cad36..de633b1 100644 --- a/LanMontainDesktop/ComponentSystem/ComponentRegistry.cs +++ b/LanMontainDesktop/ComponentSystem/ComponentRegistry.cs @@ -30,6 +30,24 @@ public sealed class ComponentRegistry MinHeightCells: 1, AllowStatusBarPlacement: true, 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( BuiltInComponentIds.Date, "Calendar", @@ -56,6 +74,15 @@ public sealed class ComponentRegistry MinWidthCells: 2, MinHeightCells: 2, AllowStatusBarPlacement: false, + AllowDesktopPlacement: true), + new DesktopComponentDefinition( + BuiltInComponentIds.HolidayCalendar, + "Holiday Countdown", + "Calendar", + "Date", + MinWidthCells: 2, + MinHeightCells: 2, + AllowStatusBarPlacement: false, AllowDesktopPlacement: true) }; diff --git a/LanMontainDesktop/Localization/en-US.json b/LanMontainDesktop/Localization/en-US.json index 47cf348..72ca1a6 100644 --- a/LanMontainDesktop/Localization/en-US.json +++ b/LanMontainDesktop/Localization/en-US.json @@ -104,10 +104,14 @@ "component_library.drag_hint": "Drag to place", "component.delete": "Delete", "component.edit": "Edit", + "component_category.clock": "Clock", "component_category.date": "Calendar", "component.date": "Calendar", "component.month_calendar": "Month 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.delete_page": "Delete page", "placement.fill": "Fill", diff --git a/LanMontainDesktop/Localization/zh-CN.json b/LanMontainDesktop/Localization/zh-CN.json index 579335f..59b9305 100644 --- a/LanMontainDesktop/Localization/zh-CN.json +++ b/LanMontainDesktop/Localization/zh-CN.json @@ -104,10 +104,14 @@ "component_library.drag_hint": "拖动放置", "component.delete": "删除", "component.edit": "编辑", + "component_category.clock": "时钟", "component_category.date": "日历", "component.date": "日历", "component.month_calendar": "月历", "component.lunar_calendar": "农历", + "component.desktop_clock": "时钟", + "component.desktop_timer": "计时器", + "component.holiday_calendar": "节假日日历", "desktop.add_page": "新增页面", "desktop.delete_page": "删除页面", "placement.fill": "填充", diff --git a/LanMontainDesktop/Models/WeatherDataModels.cs b/LanMontainDesktop/Models/WeatherDataModels.cs new file mode 100644 index 0000000..7ae24e5 --- /dev/null +++ b/LanMontainDesktop/Models/WeatherDataModels.cs @@ -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 DailyForecasts); + diff --git a/LanMontainDesktop/Services/HolidayCalendarService.cs b/LanMontainDesktop/Services/HolidayCalendarService.cs new file mode 100644 index 0000000..2a3c82e --- /dev/null +++ b/LanMontainDesktop/Services/HolidayCalendarService.cs @@ -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 ResolveDateForYear); + + private sealed record HolidayArrangementDay( + DateOnly Date, + bool IsHoliday, + bool IsAdjustedWorkday, + string NameZh, + string NameEn, + string? TargetHolidayZh, + bool? IsAfterAdjust); + + private sealed record CacheEntry(T Value, DateTimeOffset ExpireAt); + + private static readonly IReadOnlyDictionary HolidayNameMapZhToEn = + new Dictionary(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 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>> _yearHolidayCache = new(); + private readonly Dictionary> _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 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 GetNextHolidayOnlineAsync(DateOnly today, CancellationToken cancellationToken) + { + var candidates = new List(); + 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 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> 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(); + 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 BuildHolidayCandidates(int yearFrom, int yearToInclusive) + { + var results = new List(); + 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 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 value) + { + lock (_cacheGate) + { + if (_yearHolidayCache.TryGetValue(year, out var entry) && + entry.ExpireAt > DateTimeOffset.UtcNow) + { + value = entry.Value; + return true; + } + } + + value = Array.Empty(); + return false; + } + + private void SetYearHolidayDataCache(int year, IReadOnlyList value) + { + lock (_cacheGate) + { + _yearHolidayCache[year] = new CacheEntry>( + 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( + 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); diff --git a/LanMontainDesktop/Services/IWeatherDataService.cs b/LanMontainDesktop/Services/IWeatherDataService.cs new file mode 100644 index 0000000..7f263fd --- /dev/null +++ b/LanMontainDesktop/Services/IWeatherDataService.cs @@ -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( + bool Success, + T? Data, + string? ErrorCode = null, + string? ErrorMessage = null) +{ + public static WeatherQueryResult Ok(T data) + { + return new WeatherQueryResult(true, data); + } + + public static WeatherQueryResult Fail(string errorCode, string errorMessage) + { + return new WeatherQueryResult(false, default, errorCode, errorMessage); + } +} + +public interface IWeatherDataService +{ + Task> GetWeatherAsync(WeatherQuery query, CancellationToken cancellationToken = default); + + Task>> SearchLocationsAsync( + string keyword, + string? locale = null, + CancellationToken cancellationToken = default); + + void ClearCache(); +} diff --git a/LanMontainDesktop/Services/XiaomiWeatherService.cs b/LanMontainDesktop/Services/XiaomiWeatherService.cs new file mode 100644 index 0000000..99b3032 --- /dev/null +++ b/LanMontainDesktop/Services/XiaomiWeatherService.cs @@ -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 ZhWeatherDescriptions = new Dictionary + { + [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 EnWeatherDescriptions = new Dictionary + { + [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 _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>> SearchLocationsAsync( + string keyword, + string? locale = null, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(keyword)) + { + return WeatherQueryResult>.Fail("invalid_keyword", "Keyword cannot be empty."); + } + + var normalizedLocale = string.IsNullOrWhiteSpace(locale) ? _options.Locale : locale.Trim(); + var parameters = new Dictionary + { + ["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>.Fail( + "http_error", + $"HTTP {(int)response.StatusCode}: {Truncate(responseText, 180)}"); + } + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + return WeatherQueryResult>.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>.Ok(locations); + } + catch (Exception ex) + { + return WeatherQueryResult>.Fail("parse_error", ex.Message); + } + } + + public async Task> GetWeatherAsync( + WeatherQuery query, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(query.LocationKey)) + { + return WeatherQueryResult.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.Ok(cached); + } + + var parameters = new Dictionary + { + ["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.Fail( + "http_error", + $"HTTP {(int)response.StatusCode}: {Truncate(responseText, 220)}"); + } + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + return WeatherQueryResult.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.Ok(snapshot); + } + catch (Exception ex) + { + return WeatherQueryResult.Fail("parse_error", ex.Message); + } + } + + private static IReadOnlyList ParseLocationArray(JsonElement root) + { + var results = new List(); + 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 ParseDailyForecasts(JsonElement? dailyNode, int days, string locale) + { + var forecasts = new List(); + 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 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]}..."; + } +} + diff --git a/LanMontainDesktop/Views/Components/AnalogClockWidget.axaml b/LanMontainDesktop/Views/Components/AnalogClockWidget.axaml new file mode 100644 index 0000000..826df28 --- /dev/null +++ b/LanMontainDesktop/Views/Components/AnalogClockWidget.axaml @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/LanMontainDesktop/Views/Components/AnalogClockWidget.axaml.cs b/LanMontainDesktop/Views/Components/AnalogClockWidget.axaml.cs new file mode 100644 index 0000000..01bde65 --- /dev/null +++ b/LanMontainDesktop/Views/Components/AnalogClockWidget.axaml.cs @@ -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; + } +} diff --git a/LanMontainDesktop/Views/Components/HolidayCalendarWidget.axaml b/LanMontainDesktop/Views/Components/HolidayCalendarWidget.axaml new file mode 100644 index 0000000..9f656de --- /dev/null +++ b/LanMontainDesktop/Views/Components/HolidayCalendarWidget.axaml @@ -0,0 +1,97 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/LanMontainDesktop/Views/Components/HolidayCalendarWidget.axaml.cs b/LanMontainDesktop/Views/Components/HolidayCalendarWidget.axaml.cs new file mode 100644 index 0000000..402faec --- /dev/null +++ b/LanMontainDesktop/Views/Components/HolidayCalendarWidget.axaml.cs @@ -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); + } +} diff --git a/LanMontainDesktop/Views/Components/TimerWidget.axaml b/LanMontainDesktop/Views/Components/TimerWidget.axaml new file mode 100644 index 0000000..854c07d --- /dev/null +++ b/LanMontainDesktop/Views/Components/TimerWidget.axaml @@ -0,0 +1,160 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/LanMontainDesktop/Views/Components/TimerWidget.axaml.cs b/LanMontainDesktop/Views/Components/TimerWidget.axaml.cs new file mode 100644 index 0000000..39f4b4c --- /dev/null +++ b/LanMontainDesktop/Views/Components/TimerWidget.axaml.cs @@ -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; + } +} diff --git a/LanMontainDesktop/Views/MainWindow.ComponentSystem.cs b/LanMontainDesktop/Views/MainWindow.ComponentSystem.cs index 5eb3aeb..3111e11 100644 --- a/LanMontainDesktop/Views/MainWindow.ComponentSystem.cs +++ b/LanMontainDesktop/Views/MainWindow.ComponentSystem.cs @@ -1065,6 +1065,21 @@ public partial class MainWindow 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)); } @@ -1075,6 +1090,9 @@ public partial class MainWindow BuiltInComponentIds.Date => 16, BuiltInComponentIds.MonthCalendar => Math.Clamp(_currentDesktopCellSize * 0.26, 10, 22), BuiltInComponentIds.LunarCalendar => Math.Clamp(_currentDesktopCellSize * 0.30, 12, 26), + BuiltInComponentIds.DesktopClock => Math.Clamp(_currentDesktopCellSize * 0.30, 12, 28), + BuiltInComponentIds.DesktopTimer => Math.Clamp(_currentDesktopCellSize * 0.30, 12, 28), + BuiltInComponentIds.HolidayCalendar => Math.Clamp(_currentDesktopCellSize * 0.32, 12, 28), _ => Math.Clamp(_currentDesktopCellSize * 0.22, 8, 18) }; } @@ -1181,6 +1199,32 @@ public partial class MainWindow 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; } @@ -1911,38 +1955,19 @@ public partial class MainWindow ComponentLibraryCategoryPagesContainer.Children.Clear(); ComponentLibraryCategoryPagesContainer.RowDefinitions.Clear(); ComponentLibraryCategoryPagesContainer.ColumnDefinitions.Clear(); + ComponentLibraryCategoryPagesContainer.Width = double.NaN; + ComponentLibraryCategoryPagesContainer.Height = double.NaN; + ComponentLibraryCategoryPagesHost.Width = double.NaN; + ComponentLibraryCategoryPagesHost.Height = double.NaN; + if (categoryCount == 0) { _componentLibraryCategoryIndex = 0; _componentLibraryActiveCategoryId = null; + UpdateComponentLibraryComponentNavigationButtons(); 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)) { var activeIndex = _componentLibraryCategories @@ -1959,71 +1984,65 @@ public partial class MainWindow _componentLibraryActiveCategoryId = _componentLibraryCategories[_componentLibraryCategoryIndex].Id; + ComponentLibraryCategoryPagesContainer.ColumnDefinitions.Add(new ColumnDefinition(new GridLength(1, GridUnitType.Star))); for (var i = 0; i < categoryCount; 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, - Height = viewportHeight, - Background = Brushes.Transparent + Symbol = category.Icon, + IconVariant = IconVariant.Regular, + FontSize = 18, + VerticalAlignment = VerticalAlignment.Center }; - var cardWidth = Math.Clamp(_componentLibraryCategoryPageWidth * 0.64, 160, 260); - var cardHeight = Math.Clamp(viewportHeight * 0.70, 140, 220); - - var iconSize = Math.Clamp(cardHeight * 0.34, 30, 56); - - var card = new Border + var title = new TextBlock { - Classes = { "glass-panel" }, - Width = cardWidth, - Height = cardHeight, - CornerRadius = new CornerRadius(36), - Padding = new Thickness(18), - HorizontalAlignment = HorizontalAlignment.Center, + Text = category.Title, + FontSize = 15, + FontWeight = isSelected ? FontWeight.Bold : FontWeight.SemiBold, + Foreground = GetThemeBrush("AdaptiveTextPrimaryBrush"), VerticalAlignment = VerticalAlignment.Center, - Child = new StackPanel - { - 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") - } - } - } + TextTrimming = TextTrimming.CharacterEllipsis }; - 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); - Grid.SetColumn(page, i); - ComponentLibraryCategoryPagesContainer.Children.Add(page); + var itemButton = new Button + { + 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; - if (_componentLibraryCategoryHostTransform is null) - { - _componentLibraryCategoryHostTransform = new TranslateTransform(); - ComponentLibraryCategoryPagesHost.RenderTransform = _componentLibraryCategoryHostTransform; - } - - ApplyComponentLibraryCategoryOffset(); + _componentLibraryCategoryHostTransform = null; + _componentLibraryCategoryPageWidth = 0; if (ComponentLibraryBackTextBlock is not null) { @@ -2063,6 +2082,11 @@ public partial class MainWindow private Symbol ResolveComponentLibraryCategoryIcon(string categoryId) { + if (string.Equals(categoryId, "Clock", StringComparison.OrdinalIgnoreCase)) + { + return Symbol.Clock; + } + if (string.Equals(categoryId, "Date", StringComparison.OrdinalIgnoreCase)) { return Symbol.CalendarDate; @@ -2073,6 +2097,11 @@ public partial class MainWindow 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)) { return L("component_category.date", "Calendar"); @@ -2099,6 +2128,31 @@ public partial class MainWindow } _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() @@ -2134,19 +2188,35 @@ public partial class MainWindow if (componentCount == 0) { _componentLibraryComponentIndex = 0; + UpdateComponentLibraryComponentNavigationButtons(); return; } 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; - 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); @@ -2180,19 +2250,19 @@ public partial class MainWindow }; // Fit the preview to the page while preserving component cell span proportions. - var previewMaxWidth = _componentLibraryComponentPageWidth * 0.86; - var previewMaxHeight = viewportHeight * 0.72; + var previewMaxWidth = _componentLibraryComponentPageWidth * 0.94; + var previewMaxHeight = viewportHeight * 0.86; var previewSpan = NormalizeComponentCellSpan( resolved.Id, (resolved.MinWidthCells, resolved.MinHeightCells)); var previewCellSize = Math.Min( previewMaxWidth / Math.Max(1, previewSpan.WidthCells), previewMaxHeight / Math.Max(1, previewSpan.HeightCells)); - previewCellSize = Math.Clamp(previewCellSize, 20, 72); + previewCellSize = Math.Clamp(previewCellSize, 24, 96); var previewWidth = previewSpan.WidthCells * 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); if (previewControl is null) @@ -2220,8 +2290,7 @@ public partial class MainWindow { Width = previewWidth, Height = previewHeight, - CornerRadius = new CornerRadius(20), - ClipToBounds = true, + ClipToBounds = false, Background = Brushes.Transparent, BorderThickness = new Thickness(0), Child = previewViewbox, @@ -2248,18 +2317,12 @@ public partial class MainWindow var stack = new StackPanel { - Spacing = 10, + Spacing = 8, HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center, Children = { - new Border - { - Classes = { "glass-panel" }, - CornerRadius = new CornerRadius(28), - Padding = new Thickness(12), - Child = previewBorder - }, + previewBorder, label, hint } @@ -2280,6 +2343,7 @@ public partial class MainWindow } ApplyComponentLibraryComponentOffset(); + UpdateComponentLibraryComponentNavigationButtons(); } private Control? CreateComponentLibraryPreviewControl(string componentId, double cellSize) @@ -2308,6 +2372,29 @@ public partial class MainWindow 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; } @@ -2328,6 +2415,21 @@ public partial class MainWindow 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; } @@ -2418,6 +2520,42 @@ public partial class MainWindow 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) { if (!_isComponentLibraryOpen || diff --git a/LanMontainDesktop/Views/MainWindow.axaml b/LanMontainDesktop/Views/MainWindow.axaml index 4f85dfe..328f373 100644 --- a/LanMontainDesktop/Views/MainWindow.axaml +++ b/LanMontainDesktop/Views/MainWindow.axaml @@ -1163,11 +1163,11 @@ Classes="glass-strong" HorizontalAlignment="Center" VerticalAlignment="Bottom" - Width="520" - MinWidth="360" - MaxWidth="720" - Height="260" - MinHeight="220" + Width="620" + MinWidth="420" + MaxWidth="860" + Height="320" + MinHeight="260" Margin="24,24,24,100" CornerRadius="36" Padding="14" @@ -1211,37 +1211,26 @@ - - - - - - - - - - - - + + + + + + + + - - - - - - + + @@ -1265,32 +1254,64 @@ - - - - - - - - - - - - + + - + + + + + + + + + + + + + + + - - + + + +