diff --git a/LanMountainDesktop/App.axaml b/LanMountainDesktop/App.axaml index 1ba1c7e..b7099f2 100644 --- a/LanMountainDesktop/App.axaml +++ b/LanMountainDesktop/App.axaml @@ -19,7 +19,7 @@ - + diff --git a/LanMountainDesktop/Behaviors/PanelIntroAnimationBehavior.cs b/LanMountainDesktop/Behaviors/PanelIntroAnimationBehavior.cs index 3c6358b..328e64d 100644 --- a/LanMountainDesktop/Behaviors/PanelIntroAnimationBehavior.cs +++ b/LanMountainDesktop/Behaviors/PanelIntroAnimationBehavior.cs @@ -110,7 +110,7 @@ public class PanelIntroAnimationBehavior var index = 0; var timer = new DispatcherTimer(DispatcherPriority.Background) { - Interval = UiMotionTokens.StaggerStepInterval + Interval = FluttermotionToken.StaggerStepInterval }; timer.Tick += (_, _) => { diff --git a/LanMountainDesktop/Behaviors/PopupIntroAnimationBehavior.cs b/LanMountainDesktop/Behaviors/PopupIntroAnimationBehavior.cs index ddb5754..ed2f505 100644 --- a/LanMountainDesktop/Behaviors/PopupIntroAnimationBehavior.cs +++ b/LanMountainDesktop/Behaviors/PopupIntroAnimationBehavior.cs @@ -10,7 +10,7 @@ namespace LanMountainDesktop.Behaviors; public class PopupIntroAnimationBehavior { - private static readonly Easing StandardEasing = Easing.Parse(UiMotionTokens.StandardBezier); + private static readonly Easing StandardEasing = Easing.Parse(FluttermotionToken.StandardBezier); public static readonly AttachedProperty IsEnabledProperty = AvaloniaProperty.RegisterAttached("IsEnabled"); @@ -97,14 +97,14 @@ public class PopupIntroAnimationBehavior var opacityAnimation = compositor.CreateScalarKeyFrameAnimation(); opacityAnimation.Target = nameof(compositionVisual.Opacity); - opacityAnimation.Duration = UiMotionTokens.Standard; + opacityAnimation.Duration = FluttermotionToken.Standard; opacityAnimation.InsertKeyFrame(0f, 0f); opacityAnimation.InsertKeyFrame(1f, 1f, StandardEasing); compositionVisual.StartAnimation(nameof(compositionVisual.Opacity), opacityAnimation); var scaleAnimation = compositor.CreateVector3DKeyFrameAnimation(); scaleAnimation.Target = nameof(compositionVisual.Scale); - scaleAnimation.Duration = UiMotionTokens.Standard; + scaleAnimation.Duration = FluttermotionToken.Standard; scaleAnimation.InsertKeyFrame(0f, compositionVisual.Scale with { X = 0.94, Y = 0.94 }); scaleAnimation.InsertKeyFrame(1f, compositionVisual.Scale with { X = 1, Y = 1 }, StandardEasing); compositionVisual.StartAnimation(nameof(compositionVisual.Scale), scaleAnimation); diff --git a/LanMountainDesktop/ComponentSystem/BuiltInComponentIds.cs b/LanMountainDesktop/ComponentSystem/BuiltInComponentIds.cs index 39d5b27..8154489 100644 --- a/LanMountainDesktop/ComponentSystem/BuiltInComponentIds.cs +++ b/LanMountainDesktop/ComponentSystem/BuiltInComponentIds.cs @@ -5,6 +5,7 @@ public static class BuiltInComponentIds public const string Clock = "Clock"; public const string DesktopClock = "DesktopClock"; public const string DesktopWeatherClock = "DesktopWeatherClock"; + public const string DesktopWorldClock = "DesktopWorldClock"; public const string DesktopTimer = "DesktopTimer"; public const string DesktopWeather = "DesktopWeather"; public const string DesktopHourlyWeather = "DesktopHourlyWeather"; diff --git a/LanMountainDesktop/ComponentSystem/ComponentRegistry.cs b/LanMountainDesktop/ComponentSystem/ComponentRegistry.cs index 9ecb747..282215c 100644 --- a/LanMountainDesktop/ComponentSystem/ComponentRegistry.cs +++ b/LanMountainDesktop/ComponentSystem/ComponentRegistry.cs @@ -48,6 +48,15 @@ public sealed class ComponentRegistry MinHeightCells: 1, AllowStatusBarPlacement: false, AllowDesktopPlacement: true), + new DesktopComponentDefinition( + BuiltInComponentIds.DesktopWorldClock, + "World Clock", + "Clock", + "Clock", + MinWidthCells: 4, + MinHeightCells: 2, + AllowStatusBarPlacement: false, + AllowDesktopPlacement: true), new DesktopComponentDefinition( BuiltInComponentIds.DesktopTimer, "Timer", diff --git a/LanMountainDesktop/Localization/en-US.json b/LanMountainDesktop/Localization/en-US.json index 075aec3..3d468eb 100644 --- a/LanMountainDesktop/Localization/en-US.json +++ b/LanMountainDesktop/Localization/en-US.json @@ -162,6 +162,21 @@ "schedule.settings.delete": "Delete", "schedule.settings.picker_title": "Select ClassIsland schedule file", "schedule.settings.picker_file_type": "ClassIsland CSES schedule", + "worldclock.settings.title": "World Clock Settings", + "worldclock.settings.desc": "Choose a time zone for each of the four clocks.", + "worldclock.settings.clock_1": "Clock 1", + "worldclock.settings.clock_2": "Clock 2", + "worldclock.settings.clock_3": "Clock 3", + "worldclock.settings.clock_4": "Clock 4", + "worldclock.settings.second_mode_label": "Second Hand", + "worldclock.widget.today": "Today", + "worldclock.widget.yesterday": "Yesterday", + "worldclock.widget.tomorrow": "Tomorrow", + "worldclock.widget.offset_same": "0h", + "worldclock.widget.offset_ahead_hours": "Ahead {0}h", + "worldclock.widget.offset_behind_hours": "Behind {0}h", + "worldclock.widget.offset_ahead_hm": "Ahead {0}h {1}m", + "worldclock.widget.offset_behind_hm": "Behind {0}h {1}m", "weather.widget.aqi_unknown": "AQI --", "weather.widget.aqi_format": "AQI {0}", "weather.widget.updated_format": "Updated {0:HH:mm}", @@ -222,6 +237,7 @@ "component.lunar_calendar": "Lunar Calendar", "component.desktop_clock": "Clock", "component.weather_clock": "Weather Clock", + "component.world_clock": "World Clock", "component.desktop_timer": "Timer", "component.desktop_weather": "Weather", "component.hourly_weather": "Hourly Weather", @@ -244,6 +260,12 @@ "component.study_score_overview": "Study Score Overview", "component.study_deduction_reasons": "Deduction Reasons", "component.study_interrupt_density": "Interrupt Density", + "desktop_clock.settings.title": "Clock Settings", + "desktop_clock.settings.desc": "Choose the time zone for the single clock.", + "desktop_clock.settings.timezone_label": "Time Zone", + "desktop_clock.settings.second_mode_label": "Second Hand", + "clock.second_mode.tick": "Tick", + "clock.second_mode.sweep": "Sweep", "poetry.widget.loading_content": "Loading poetry...", "poetry.widget.loading_author": "Loading...", "poetry.widget.fetch_failed": "Poetry fetch failed", diff --git a/LanMountainDesktop/Localization/zh-CN.json b/LanMountainDesktop/Localization/zh-CN.json index 2ffcb4f..197381b 100644 --- a/LanMountainDesktop/Localization/zh-CN.json +++ b/LanMountainDesktop/Localization/zh-CN.json @@ -162,6 +162,21 @@ "schedule.settings.delete": "删除", "schedule.settings.picker_title": "选择 ClassIsland 课表文件", "schedule.settings.picker_file_type": "ClassIsland CSES 课表", + "worldclock.settings.title": "世界时钟设置", + "worldclock.settings.desc": "分别为四个时钟选择时区。", + "worldclock.settings.clock_1": "时钟 1", + "worldclock.settings.clock_2": "时钟 2", + "worldclock.settings.clock_3": "时钟 3", + "worldclock.settings.clock_4": "时钟 4", + "worldclock.settings.second_mode_label": "秒针方式", + "worldclock.widget.today": "今天", + "worldclock.widget.yesterday": "昨天", + "worldclock.widget.tomorrow": "明天", + "worldclock.widget.offset_same": "0 小时", + "worldclock.widget.offset_ahead_hours": "早 {0} 小时", + "worldclock.widget.offset_behind_hours": "晚 {0} 小时", + "worldclock.widget.offset_ahead_hm": "早 {0} 小时 {1} 分", + "worldclock.widget.offset_behind_hm": "晚 {0} 小时 {1} 分", "weather.widget.aqi_unknown": "AQI --", "weather.widget.aqi_format": "AQI {0}", "weather.widget.updated_format": "更新于 {0:HH:mm}", @@ -222,6 +237,7 @@ "component.lunar_calendar": "农历", "component.desktop_clock": "时钟", "component.weather_clock": "天气时钟", + "component.world_clock": "世界时钟", "component.desktop_timer": "计时器", "component.desktop_weather": "天气", "component.hourly_weather": "小时天气", @@ -244,6 +260,12 @@ "component.study_score_overview": "自习评分总览", "component.study_deduction_reasons": "扣分原因", "component.study_interrupt_density": "打断密度", + "desktop_clock.settings.title": "时钟设置", + "desktop_clock.settings.desc": "为单时钟选择时区。", + "desktop_clock.settings.timezone_label": "时区", + "desktop_clock.settings.second_mode_label": "秒针方式", + "clock.second_mode.tick": "跳针", + "clock.second_mode.sweep": "扫针", "poetry.widget.loading_content": "正在加载诗词", "poetry.widget.loading_author": "加载中", "poetry.widget.fetch_failed": "诗词获取失败", diff --git a/LanMountainDesktop/Models/AppSettingsSnapshot.cs b/LanMountainDesktop/Models/AppSettingsSnapshot.cs index 7e5418d..b7462dc 100644 --- a/LanMountainDesktop/Models/AppSettingsSnapshot.cs +++ b/LanMountainDesktop/Models/AppSettingsSnapshot.cs @@ -80,6 +80,18 @@ public sealed class AppSettingsSnapshot public bool StudyEnvironmentShowDbfs { get; set; } + public string DesktopClockTimeZoneId { get; set; } = "China Standard Time"; + public string DesktopClockSecondHandMode { get; set; } = "Tick"; + + public List WorldClockTimeZoneIds { get; set; } = + [ + "China Standard Time", + "GMT Standard Time", + "AUS Eastern Standard Time", + "Eastern Standard Time" + ]; + public string WorldClockSecondHandMode { get; set; } = "Tick"; + public AppSettingsSnapshot Clone() { var clone = (AppSettingsSnapshot)MemberwiseClone(); @@ -135,6 +147,10 @@ public sealed class AppSettingsSnapshot } clone.ImportedClassSchedules = schedules; + clone.WorldClockTimeZoneIds = WorldClockTimeZoneIds is { Count: > 0 } + ? new List(WorldClockTimeZoneIds) + : []; + return clone; } } diff --git a/LanMountainDesktop/Services/ClockSecondHandMode.cs b/LanMountainDesktop/Services/ClockSecondHandMode.cs new file mode 100644 index 0000000..0a2afeb --- /dev/null +++ b/LanMountainDesktop/Services/ClockSecondHandMode.cs @@ -0,0 +1,21 @@ +using System; + +namespace LanMountainDesktop.Services; + +public static class ClockSecondHandMode +{ + public const string Tick = "Tick"; + public const string Sweep = "Sweep"; + + public static string Normalize(string? mode) + { + return string.Equals(mode?.Trim(), Sweep, StringComparison.OrdinalIgnoreCase) + ? Sweep + : Tick; + } + + public static bool IsSweep(string? mode) + { + return string.Equals(Normalize(mode), Sweep, StringComparison.OrdinalIgnoreCase); + } +} diff --git a/LanMountainDesktop/Services/WorldClockTimeZoneCatalog.cs b/LanMountainDesktop/Services/WorldClockTimeZoneCatalog.cs new file mode 100644 index 0000000..1411b4d --- /dev/null +++ b/LanMountainDesktop/Services/WorldClockTimeZoneCatalog.cs @@ -0,0 +1,187 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace LanMountainDesktop.Services; + +public static class WorldClockTimeZoneCatalog +{ + public const int ClockCount = 4; + + private static readonly string[][] DefaultTimeZoneCandidates = + [ + ["China Standard Time", "Asia/Shanghai"], + ["GMT Standard Time", "Europe/London", "UTC"], + ["AUS Eastern Standard Time", "Australia/Sydney"], + ["Eastern Standard Time", "America/New_York"] + ]; + + private static readonly Dictionary CrossPlatformAliases = + new(StringComparer.OrdinalIgnoreCase) + { + ["China Standard Time"] = ["Asia/Shanghai"], + ["Asia/Shanghai"] = ["China Standard Time"], + ["GMT Standard Time"] = ["Europe/London", "UTC"], + ["Europe/London"] = ["GMT Standard Time", "UTC"], + ["AUS Eastern Standard Time"] = ["Australia/Sydney"], + ["Australia/Sydney"] = ["AUS Eastern Standard Time"], + ["Eastern Standard Time"] = ["America/New_York"], + ["America/New_York"] = ["Eastern Standard Time"], + ["UTC"] = ["Etc/UTC"], + ["Etc/UTC"] = ["UTC"], + ["Tokyo Standard Time"] = ["Asia/Tokyo"], + ["Asia/Tokyo"] = ["Tokyo Standard Time"] + }; + + public static IReadOnlyList NormalizeTimeZoneIds(IEnumerable? configuredIds) + { + var available = TimeZoneInfo.GetSystemTimeZones(); + return NormalizeTimeZoneIds(configuredIds, available); + } + + public static IReadOnlyList NormalizeTimeZoneIds( + IEnumerable? configuredIds, + IReadOnlyList availableTimeZones) + { + var availableById = BuildAvailableTimeZoneLookup(availableTimeZones); + var requested = configuredIds? + .Where(id => !string.IsNullOrWhiteSpace(id)) + .Select(id => id.Trim()) + .ToList() ?? []; + + var normalized = new List(ClockCount); + for (var index = 0; index < ClockCount; index++) + { + var requestedId = index < requested.Count ? requested[index] : null; + var resolved = ResolveAvailableId(requestedId, availableById) ?? + ResolveDefaultId(index, availableById) ?? + TimeZoneInfo.Local.Id; + normalized.Add(resolved); + } + + return normalized; + } + + public static TimeZoneInfo ResolveTimeZoneOrLocal(string? timeZoneId) + { + if (TryResolveTimeZone(timeZoneId, out var resolved)) + { + return resolved; + } + + return TimeZoneInfo.Local; + } + + private static Dictionary BuildAvailableTimeZoneLookup( + IReadOnlyList availableTimeZones) + { + return availableTimeZones + .Where(zone => !string.IsNullOrWhiteSpace(zone.Id)) + .GroupBy(zone => zone.Id, StringComparer.OrdinalIgnoreCase) + .ToDictionary(group => group.Key, group => group.First(), StringComparer.OrdinalIgnoreCase); + } + + private static string? ResolveDefaultId( + int slotIndex, + IReadOnlyDictionary availableById) + { + var clampedIndex = Math.Clamp(slotIndex, 0, ClockCount - 1); + foreach (var candidateId in DefaultTimeZoneCandidates[clampedIndex]) + { + var resolved = ResolveAvailableId(candidateId, availableById); + if (!string.IsNullOrWhiteSpace(resolved)) + { + return resolved; + } + } + + return null; + } + + private static string? ResolveAvailableId( + string? candidateId, + IReadOnlyDictionary availableById) + { + if (string.IsNullOrWhiteSpace(candidateId)) + { + return null; + } + + var normalizedCandidate = candidateId.Trim(); + if (availableById.TryGetValue(normalizedCandidate, out var exact)) + { + return exact.Id; + } + + if (TryResolveTimeZone(normalizedCandidate, out var resolvedZone) && + availableById.TryGetValue(resolvedZone.Id, out var resolved)) + { + return resolved.Id; + } + + if (!CrossPlatformAliases.TryGetValue(normalizedCandidate, out var aliases)) + { + return null; + } + + foreach (var alias in aliases) + { + if (availableById.TryGetValue(alias, out var aliasZone)) + { + return aliasZone.Id; + } + + if (TryResolveTimeZone(alias, out var aliasResolvedZone) && + availableById.TryGetValue(aliasResolvedZone.Id, out var mappedAlias)) + { + return mappedAlias.Id; + } + } + + return null; + } + + private static bool TryResolveTimeZone(string? timeZoneId, out TimeZoneInfo timeZone) + { + timeZone = TimeZoneInfo.Local; + if (string.IsNullOrWhiteSpace(timeZoneId)) + { + return false; + } + + var normalizedId = timeZoneId.Trim(); + if (TryFindTimeZone(normalizedId, out timeZone)) + { + return true; + } + + if (!CrossPlatformAliases.TryGetValue(normalizedId, out var aliases)) + { + return false; + } + + foreach (var alias in aliases) + { + if (TryFindTimeZone(alias, out timeZone)) + { + return true; + } + } + + return false; + } + + private static bool TryFindTimeZone(string timeZoneId, out TimeZoneInfo timeZone) + { + timeZone = TimeZoneInfo.Local; + try + { + timeZone = TimeZoneInfo.FindSystemTimeZoneById(timeZoneId); + return true; + } + catch + { + return false; + } + } +} diff --git a/LanMountainDesktop/Styles/FluttermotionToken.axaml b/LanMountainDesktop/Styles/FluttermotionToken.axaml new file mode 100644 index 0000000..17cbcbd --- /dev/null +++ b/LanMountainDesktop/Styles/FluttermotionToken.axaml @@ -0,0 +1,12 @@ + + + 0:0:0.12 + 0:0:0.16 + 0:0:0.20 + 0:0:0.24 + 0:0:0.32 + + 30 + + diff --git a/LanMountainDesktop/Styles/GlassModule.axaml b/LanMountainDesktop/Styles/GlassModule.axaml index 9663801..5f5ea1b 100644 --- a/LanMountainDesktop/Styles/GlassModule.axaml +++ b/LanMountainDesktop/Styles/GlassModule.axaml @@ -25,9 +25,9 @@ - - - + + + @@ -150,7 +150,7 @@ - + diff --git a/LanMountainDesktop/Styles/MotionTokens.axaml b/LanMountainDesktop/Styles/MotionTokens.axaml deleted file mode 100644 index 1de1963..0000000 --- a/LanMountainDesktop/Styles/MotionTokens.axaml +++ /dev/null @@ -1,14 +0,0 @@ - - - 0.22,1,0.36,1 - - 0:0:0.12 - 0:0:0.16 - 0:0:0.20 - 0:0:0.24 - 0:0:0.32 - - 30 - - diff --git a/LanMountainDesktop/Styles/SettingsAnimations.axaml b/LanMountainDesktop/Styles/SettingsAnimations.axaml index 1302666..f69a8b2 100644 --- a/LanMountainDesktop/Styles/SettingsAnimations.axaml +++ b/LanMountainDesktop/Styles/SettingsAnimations.axaml @@ -21,7 +21,7 @@ @@ -74,8 +74,8 @@ @@ -87,8 +87,8 @@ diff --git a/LanMountainDesktop/Theme/UiMotionTokens.cs b/LanMountainDesktop/Theme/FluttermotionToken.cs similarity index 94% rename from LanMountainDesktop/Theme/UiMotionTokens.cs rename to LanMountainDesktop/Theme/FluttermotionToken.cs index c0c92f6..8a30ba9 100644 --- a/LanMountainDesktop/Theme/UiMotionTokens.cs +++ b/LanMountainDesktop/Theme/FluttermotionToken.cs @@ -2,7 +2,7 @@ using System; namespace LanMountainDesktop.Theme; -public static class UiMotionTokens +public static class FluttermotionToken { public static readonly TimeSpan Fast = TimeSpan.FromMilliseconds(120); public static readonly TimeSpan Standard = TimeSpan.FromMilliseconds(160); diff --git a/LanMountainDesktop/Views/Components/AnalogClockWidget.axaml.cs b/LanMountainDesktop/Views/Components/AnalogClockWidget.axaml.cs index a438c94..1427f09 100644 --- a/LanMountainDesktop/Views/Components/AnalogClockWidget.axaml.cs +++ b/LanMountainDesktop/Views/Components/AnalogClockWidget.axaml.cs @@ -1,5 +1,6 @@ using System; using System.Globalization; +using System.Collections.Generic; using Avalonia; using Avalonia.Controls; using Avalonia.Controls.Shapes; @@ -12,6 +13,40 @@ namespace LanMountainDesktop.Views.Components; public partial class AnalogClockWidget : UserControl, IDesktopComponentWidget, ITimeZoneAwareComponentWidget { + private static readonly IReadOnlyDictionary ZhCityNames = + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["China Standard Time"] = "\u5317\u4EAC", + ["Asia/Shanghai"] = "\u5317\u4EAC", + ["GMT Standard Time"] = "\u4F26\u6566", + ["Europe/London"] = "\u4F26\u6566", + ["AUS Eastern Standard Time"] = "\u6089\u5C3C", + ["Australia/Sydney"] = "\u6089\u5C3C", + ["Eastern Standard Time"] = "\u7EBD\u7EA6", + ["America/New_York"] = "\u7EBD\u7EA6", + ["Tokyo Standard Time"] = "\u4E1C\u4EAC", + ["Asia/Tokyo"] = "\u4E1C\u4EAC", + ["UTC"] = "\u534F\u8C03\u4E16\u754C\u65F6", + ["Etc/UTC"] = "\u534F\u8C03\u4E16\u754C\u65F6" + }; + + private static readonly IReadOnlyDictionary EnCityNames = + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["China Standard Time"] = "Beijing", + ["Asia/Shanghai"] = "Beijing", + ["GMT Standard Time"] = "London", + ["Europe/London"] = "London", + ["AUS Eastern Standard Time"] = "Sydney", + ["Australia/Sydney"] = "Sydney", + ["Eastern Standard Time"] = "New York", + ["America/New_York"] = "New York", + ["Tokyo Standard Time"] = "Tokyo", + ["Asia/Tokyo"] = "Tokyo", + ["UTC"] = "UTC", + ["Etc/UTC"] = "UTC" + }; + private readonly DispatcherTimer _timer = new() { Interval = TimeSpan.FromSeconds(1) @@ -20,11 +55,16 @@ public partial class AnalogClockWidget : UserControl, IDesktopComponentWidget, I private const double DialSize = 258; private const double Center = DialSize / 2; + private readonly AppSettingsService _settingsService = new(); + private readonly LocalizationService _localizationService = new(); private TimeZoneService? _timeZoneService; private double _currentCellSize = 48; private bool _dialInitialized; private bool _handsInitialized; private bool? _isNightModeApplied; + private TimeZoneInfo _clockTimeZone = WorldClockTimeZoneCatalog.ResolveTimeZoneOrLocal("China Standard Time"); + private string _languageCode = "zh-CN"; + private string _secondHandMode = ClockSecondHandMode.Tick; private readonly Line _hourHandLine = CreateHandLine("#1A2A46", 12); private readonly Line _minuteHandLine = CreateHandLine("#29406B", 8); private readonly Line _secondHandLine = CreateHandLine("#1A74F2", 4); @@ -40,6 +80,8 @@ public partial class AnalogClockWidget : UserControl, IDesktopComponentWidget, I InitializeDialIfNeeded(); InitializeHandsIfNeeded(); + LoadClockSettings(); + ApplySecondHandTimerInterval(); UpdateClock(); } @@ -62,10 +104,19 @@ public partial class AnalogClockWidget : UserControl, IDesktopComponentWidget, I _timeZoneService = null; } + public void RefreshFromSettings() + { + LoadClockSettings(); + ApplySecondHandTimerInterval(); + UpdateClock(); + } + private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e) { InitializeDialIfNeeded(); InitializeHandsIfNeeded(); + LoadClockSettings(); + ApplySecondHandTimerInterval(); UpdateClock(); _timer.Start(); } @@ -187,17 +238,22 @@ public partial class AnalogClockWidget : UserControl, IDesktopComponentWidget, I { 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; + var now = TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, _clockTimeZone); + var secondValue = ClockSecondHandMode.IsSweep(_secondHandMode) + ? now.Second + now.Millisecond / 1000d + : now.Second; + var minuteValue = now.Minute + secondValue / 60d; + var hourValue = (now.Hour % 12) + minuteValue / 60d; + + var hourAngle = hourValue * 30d; + var minuteAngle = minuteValue * 6d; + var secondAngle = secondValue * 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"; + CityTextBlock.Text = ResolveCityName(_clockTimeZone); } private void ApplyModeVisualIfNeeded() @@ -299,6 +355,53 @@ public partial class AnalogClockWidget : UserControl, IDesktopComponentWidget, I }; } + private void LoadClockSettings() + { + var snapshot = _settingsService.Load(); + _languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode); + + var configuredTimeZoneId = string.IsNullOrWhiteSpace(snapshot.DesktopClockTimeZoneId) + ? "China Standard Time" + : snapshot.DesktopClockTimeZoneId.Trim(); + + _clockTimeZone = WorldClockTimeZoneCatalog.ResolveTimeZoneOrLocal(configuredTimeZoneId); + _secondHandMode = ClockSecondHandMode.Normalize(snapshot.DesktopClockSecondHandMode); + } + + private void ApplySecondHandTimerInterval() + { + _timer.Interval = ClockSecondHandMode.IsSweep(_secondHandMode) + ? TimeSpan.FromMilliseconds(16) + : TimeSpan.FromSeconds(1); + } + + private string ResolveCityName(TimeZoneInfo timeZone) + { + var cityNames = string.Equals(_languageCode, "zh-CN", StringComparison.OrdinalIgnoreCase) + ? ZhCityNames + : EnCityNames; + if (cityNames.TryGetValue(timeZone.Id, out var cityName)) + { + return cityName; + } + + var normalized = timeZone.Id; + var slashIndex = normalized.LastIndexOf('/'); + if (slashIndex >= 0 && slashIndex < normalized.Length - 1) + { + normalized = normalized[(slashIndex + 1)..]; + } + + normalized = normalized.Replace('_', ' ').Trim(); + normalized = normalized + .Replace("Standard Time", string.Empty, StringComparison.OrdinalIgnoreCase) + .Replace("Daylight Time", string.Empty, StringComparison.OrdinalIgnoreCase) + .Replace("Time", string.Empty, StringComparison.OrdinalIgnoreCase) + .Trim(); + + return string.IsNullOrWhiteSpace(normalized) ? timeZone.Id : normalized; + } + private bool ResolveIsNightMode() { if (ActualThemeVariant == ThemeVariant.Dark) diff --git a/LanMountainDesktop/Views/Components/AnalogClockWidgetSettingsWindow.axaml b/LanMountainDesktop/Views/Components/AnalogClockWidgetSettingsWindow.axaml new file mode 100644 index 0000000..84f2c15 --- /dev/null +++ b/LanMountainDesktop/Views/Components/AnalogClockWidgetSettingsWindow.axaml @@ -0,0 +1,73 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/LanMountainDesktop/Views/Components/AnalogClockWidgetSettingsWindow.axaml.cs b/LanMountainDesktop/Views/Components/AnalogClockWidgetSettingsWindow.axaml.cs new file mode 100644 index 0000000..0547ee7 --- /dev/null +++ b/LanMountainDesktop/Views/Components/AnalogClockWidgetSettingsWindow.axaml.cs @@ -0,0 +1,206 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Interactivity; +using LanMountainDesktop.Services; + +namespace LanMountainDesktop.Views.Components; + +public partial class AnalogClockWidgetSettingsWindow : UserControl +{ + private static readonly IReadOnlyDictionary ZhTimeZoneNames = + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["China Standard Time"] = "中国标准时间", + ["Asia/Shanghai"] = "中国标准时间", + ["GMT Standard Time"] = "格林威治标准时间", + ["Europe/London"] = "格林威治标准时间", + ["AUS Eastern Standard Time"] = "澳大利亚东部标准时间", + ["Australia/Sydney"] = "澳大利亚东部标准时间", + ["Eastern Standard Time"] = "美国东部标准时间", + ["America/New_York"] = "美国东部标准时间", + ["Tokyo Standard Time"] = "日本标准时间", + ["Asia/Tokyo"] = "日本标准时间", + ["UTC"] = "协调世界时", + ["Etc/UTC"] = "协调世界时" + }; + + private readonly AppSettingsService _appSettingsService = new(); + private readonly LocalizationService _localizationService = new(); + private readonly TimeZoneService _timeZoneService = new(); + private bool _suppressEvents; + private string _languageCode = "zh-CN"; + private IReadOnlyList _allTimeZones = Array.Empty(); + private string _selectedTimeZoneId = string.Empty; + private string _secondHandMode = ClockSecondHandMode.Tick; + + public event EventHandler? SettingsChanged; + + public AnalogClockWidgetSettingsWindow() + { + InitializeComponent(); + LoadState(); + ApplyLocalization(); + PopulateTimeZoneComboBox(); + } + + private void LoadState() + { + var snapshot = _appSettingsService.Load(); + _languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode); + _selectedTimeZoneId = string.IsNullOrWhiteSpace(snapshot.DesktopClockTimeZoneId) + ? "China Standard Time" + : snapshot.DesktopClockTimeZoneId.Trim(); + _secondHandMode = ClockSecondHandMode.Normalize(snapshot.DesktopClockSecondHandMode); + + _allTimeZones = _timeZoneService + .GetAllTimeZones() + .OrderBy(zone => zone.GetUtcOffset(DateTime.UtcNow)) + .ThenBy(zone => zone.DisplayName, StringComparer.OrdinalIgnoreCase) + .ToList(); + } + + private void ApplyLocalization() + { + TitleTextBlock.Text = L("desktop_clock.settings.title", "时钟设置"); + DescriptionTextBlock.Text = L("desktop_clock.settings.desc", "为单时钟选择时区。"); + TimeZoneLabelTextBlock.Text = L("desktop_clock.settings.timezone_label", "时区"); + SecondHandModeLabelTextBlock.Text = L("desktop_clock.settings.second_mode_label", "秒针方式"); + SecondHandTickRadioButton.Content = L("clock.second_mode.tick", "跳针"); + SecondHandSweepRadioButton.Content = L("clock.second_mode.sweep", "扫针"); + } + + private void PopulateTimeZoneComboBox() + { + _suppressEvents = true; + try + { + TimeZoneComboBox.Items.Clear(); + foreach (var timeZone in _allTimeZones) + { + TimeZoneComboBox.Items.Add(new ComboBoxItem + { + Tag = timeZone.Id, + Content = GetLocalizedTimeZoneDisplayName(timeZone) + }); + } + + var normalizedId = WorldClockTimeZoneCatalog.NormalizeTimeZoneIds( + new[] { _selectedTimeZoneId }, + _allTimeZones)[0]; + _selectedTimeZoneId = normalizedId; + + var selected = TimeZoneComboBox.Items + .OfType() + .FirstOrDefault(item => string.Equals(item.Tag as string, normalizedId, StringComparison.OrdinalIgnoreCase)); + + TimeZoneComboBox.SelectedItem = selected ?? TimeZoneComboBox.Items.OfType().FirstOrDefault(); + + var normalizedMode = ClockSecondHandMode.Normalize(_secondHandMode); + SecondHandTickRadioButton.IsChecked = string.Equals( + normalizedMode, + ClockSecondHandMode.Tick, + StringComparison.OrdinalIgnoreCase); + SecondHandSweepRadioButton.IsChecked = string.Equals( + normalizedMode, + ClockSecondHandMode.Sweep, + StringComparison.OrdinalIgnoreCase); + } + finally + { + _suppressEvents = false; + } + } + + private void OnTimeZoneSelectionChanged(object? sender, SelectionChangedEventArgs e) + { + _ = sender; + _ = e; + if (_suppressEvents) + { + return; + } + + SaveState(); + } + + private void OnSecondHandModeChanged(object? sender, RoutedEventArgs e) + { + _ = sender; + _ = e; + if (_suppressEvents) + { + return; + } + + SaveState(); + } + + private void SaveState() + { + var selectedId = (TimeZoneComboBox.SelectedItem as ComboBoxItem)?.Tag as string; + var normalizedId = WorldClockTimeZoneCatalog.NormalizeTimeZoneIds( + new[] { selectedId ?? _selectedTimeZoneId }, + _allTimeZones)[0]; + _selectedTimeZoneId = normalizedId; + _secondHandMode = GetSelectedSecondHandMode(); + + var snapshot = _appSettingsService.Load(); + snapshot.DesktopClockTimeZoneId = normalizedId; + snapshot.DesktopClockSecondHandMode = _secondHandMode; + _appSettingsService.Save(snapshot); + + SettingsChanged?.Invoke(this, EventArgs.Empty); + } + + private string GetSelectedSecondHandMode() + { + return SecondHandSweepRadioButton.IsChecked == true + ? ClockSecondHandMode.Sweep + : ClockSecondHandMode.Tick; + } + + private string GetLocalizedTimeZoneDisplayName(TimeZoneInfo timeZone) + { + var offset = timeZone.GetUtcOffset(DateTime.UtcNow); + var sign = offset >= TimeSpan.Zero ? "+" : "-"; + var totalMinutes = Math.Abs((int)offset.TotalMinutes); + var hours = totalMinutes / 60; + var minutes = totalMinutes % 60; + + var displayName = string.Equals(_languageCode, "zh-CN", StringComparison.OrdinalIgnoreCase) + ? ResolveZhDisplayName(timeZone) + : ResolveEnDisplayName(timeZone); + + return $"(UTC{sign}{hours:D2}:{minutes:D2}) {displayName}"; + } + + private static string ResolveZhDisplayName(TimeZoneInfo timeZone) + { + if (ZhTimeZoneNames.TryGetValue(timeZone.Id, out var localizedName)) + { + return localizedName; + } + + return string.IsNullOrWhiteSpace(timeZone.StandardName) + ? timeZone.DisplayName + : timeZone.StandardName; + } + + private static string ResolveEnDisplayName(TimeZoneInfo timeZone) + { + if (!string.IsNullOrWhiteSpace(timeZone.StandardName)) + { + return timeZone.StandardName; + } + + return timeZone.DisplayName; + } + + private string L(string key, string fallback) + { + return _localizationService.GetString(_languageCode, key, fallback); + } +} diff --git a/LanMountainDesktop/Views/Components/DesktopComponentRuntimeRegistry.cs b/LanMountainDesktop/Views/Components/DesktopComponentRuntimeRegistry.cs index 09140f7..ff15111 100644 --- a/LanMountainDesktop/Views/Components/DesktopComponentRuntimeRegistry.cs +++ b/LanMountainDesktop/Views/Components/DesktopComponentRuntimeRegistry.cs @@ -134,6 +134,11 @@ public sealed class DesktopComponentRuntimeRegistry "component.weather_clock", () => new WeatherClockWidget(), cellSize => Math.Clamp(cellSize * 0.34, 14, 30)), + new DesktopComponentRuntimeRegistration( + BuiltInComponentIds.DesktopWorldClock, + "component.world_clock", + () => new WorldClockWidget(), + cellSize => Math.Clamp(cellSize * 0.30, 10, 24)), new DesktopComponentRuntimeRegistration( BuiltInComponentIds.DesktopTimer, "component.desktop_timer", diff --git a/LanMountainDesktop/Views/Components/ExtendedWeatherWidget.axaml.cs b/LanMountainDesktop/Views/Components/ExtendedWeatherWidget.axaml.cs index 75c1537..c50640e 100644 --- a/LanMountainDesktop/Views/Components/ExtendedWeatherWidget.axaml.cs +++ b/LanMountainDesktop/Views/Components/ExtendedWeatherWidget.axaml.cs @@ -20,7 +20,7 @@ public partial class ExtendedWeatherWidget : UserControl, IDesktopComponentWidge private static readonly IWeatherInfoService DefaultWeatherInfoService = new XiaomiWeatherService(); private readonly DispatcherTimer _refreshTimer = new() { Interval = TimeSpan.FromMinutes(12) }; - private readonly DispatcherTimer _animationTimer = new() { Interval = UiMotionTokens.WeatherAnimationFrameInterval }; + private readonly DispatcherTimer _animationTimer = new() { Interval = FluttermotionToken.WeatherAnimationFrameInterval }; private readonly ScaleTransform _backgroundMotionScaleTransform = new(1, 1); private readonly TranslateTransform _backgroundMotionTranslateTransform = new(); private readonly AppSettingsService _settingsService = new(); diff --git a/LanMountainDesktop/Views/Components/HourlyWeatherWidget.axaml.cs b/LanMountainDesktop/Views/Components/HourlyWeatherWidget.axaml.cs index b21404b..307eb9e 100644 --- a/LanMountainDesktop/Views/Components/HourlyWeatherWidget.axaml.cs +++ b/LanMountainDesktop/Views/Components/HourlyWeatherWidget.axaml.cs @@ -90,7 +90,7 @@ public partial class HourlyWeatherWidget : UserControl, IDesktopComponentWidget, private readonly DispatcherTimer _backgroundAnimationTimer = new() { - Interval = UiMotionTokens.WeatherAnimationFrameInterval + Interval = FluttermotionToken.WeatherAnimationFrameInterval }; private readonly AppSettingsService _settingsService = new(); diff --git a/LanMountainDesktop/Views/Components/MultiDayWeatherWidget.axaml.cs b/LanMountainDesktop/Views/Components/MultiDayWeatherWidget.axaml.cs index 3c88f95..7b5e15d 100644 --- a/LanMountainDesktop/Views/Components/MultiDayWeatherWidget.axaml.cs +++ b/LanMountainDesktop/Views/Components/MultiDayWeatherWidget.axaml.cs @@ -88,7 +88,7 @@ public partial class MultiDayWeatherWidget : UserControl, IDesktopComponentWidge private readonly DispatcherTimer _backgroundAnimationTimer = new() { - Interval = UiMotionTokens.WeatherAnimationFrameInterval + Interval = FluttermotionToken.WeatherAnimationFrameInterval }; private readonly AppSettingsService _settingsService = new(); diff --git a/LanMountainDesktop/Views/Components/MusicControlWidget.axaml b/LanMountainDesktop/Views/Components/MusicControlWidget.axaml index 890f6a3..35cf009 100644 --- a/LanMountainDesktop/Views/Components/MusicControlWidget.axaml +++ b/LanMountainDesktop/Views/Components/MusicControlWidget.axaml @@ -86,7 +86,7 @@ Opacity="0.62" Stretch="UniformToFill"> - + diff --git a/LanMountainDesktop/Views/Components/WeatherWidget.axaml.cs b/LanMountainDesktop/Views/Components/WeatherWidget.axaml.cs index 9152fa2..b3c03ab 100644 --- a/LanMountainDesktop/Views/Components/WeatherWidget.axaml.cs +++ b/LanMountainDesktop/Views/Components/WeatherWidget.axaml.cs @@ -84,7 +84,7 @@ public partial class WeatherWidget : UserControl, IDesktopComponentWidget, IDesk private readonly DispatcherTimer _backgroundAnimationTimer = new() { - Interval = UiMotionTokens.WeatherAnimationFrameInterval + Interval = FluttermotionToken.WeatherAnimationFrameInterval }; private readonly AppSettingsService _settingsService = new(); diff --git a/LanMountainDesktop/Views/Components/WorldClockWidget.axaml b/LanMountainDesktop/Views/Components/WorldClockWidget.axaml new file mode 100644 index 0000000..8da8362 --- /dev/null +++ b/LanMountainDesktop/Views/Components/WorldClockWidget.axaml @@ -0,0 +1,21 @@ + + + + + + diff --git a/LanMountainDesktop/Views/Components/WorldClockWidget.axaml.cs b/LanMountainDesktop/Views/Components/WorldClockWidget.axaml.cs new file mode 100644 index 0000000..40762f1 --- /dev/null +++ b/LanMountainDesktop/Views/Components/WorldClockWidget.axaml.cs @@ -0,0 +1,671 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Shapes; +using Avalonia.Layout; +using Avalonia.Media; +using Avalonia.Threading; +using LanMountainDesktop.Services; + +namespace LanMountainDesktop.Views.Components; + +public partial class WorldClockWidget : UserControl, IDesktopComponentWidget, ITimeZoneAwareComponentWidget +{ + private const int BaseWidthCells = 4; + private const int BaseHeightCells = 2; + private const double BaseCellSize = 48; + private const double DialDesignSize = 100; + private const double DialCenter = DialDesignSize / 2d; + + private static readonly FontFamily MiSansFontFamily = + new("MiSans VF, avares://LanMountainDesktop/Assets/Fonts#MiSans"); + + private static readonly IReadOnlyDictionary ZhCityNames = + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["China Standard Time"] = "北京", + ["Asia/Shanghai"] = "北京", + ["GMT Standard Time"] = "伦敦", + ["Europe/London"] = "伦敦", + ["AUS Eastern Standard Time"] = "悉尼", + ["Australia/Sydney"] = "悉尼", + ["Eastern Standard Time"] = "纽约", + ["America/New_York"] = "纽约", + ["Tokyo Standard Time"] = "东京", + ["Asia/Tokyo"] = "东京", + ["UTC"] = "协调世界时", + ["Etc/UTC"] = "协调世界时" + }; + + private static readonly IReadOnlyDictionary EnCityNames = + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["China Standard Time"] = "Beijing", + ["Asia/Shanghai"] = "Beijing", + ["GMT Standard Time"] = "London", + ["Europe/London"] = "London", + ["AUS Eastern Standard Time"] = "Sydney", + ["Australia/Sydney"] = "Sydney", + ["Eastern Standard Time"] = "New York", + ["America/New_York"] = "New York", + ["Tokyo Standard Time"] = "Tokyo", + ["Asia/Tokyo"] = "Tokyo", + ["UTC"] = "UTC", + ["Etc/UTC"] = "UTC" + }; + + private sealed class ClockEntryVisual + { + public required StackPanel Host { get; init; } + + public required Border DialBorder { get; init; } + + public required Canvas TickCanvas { get; init; } + + public required Canvas NumberCanvas { get; init; } + + public required Line HourHand { get; init; } + + public required Line MinuteHand { get; init; } + + public required Line SecondHand { get; init; } + + public required Ellipse CenterOuter { get; init; } + + public required TextBlock CityTextBlock { get; init; } + + public required TextBlock DayTextBlock { get; init; } + + public required TextBlock OffsetTextBlock { get; init; } + + public bool? IsNightApplied { get; set; } + } + + private readonly DispatcherTimer _clockTimer = new() + { + Interval = TimeSpan.FromSeconds(1) + }; + + private readonly AppSettingsService _settingsService = new(); + private readonly LocalizationService _localizationService = new(); + private readonly ClockEntryVisual[] _entryVisuals = new ClockEntryVisual[WorldClockTimeZoneCatalog.ClockCount]; + private readonly TimeZoneInfo[] _entryTimeZones = new TimeZoneInfo[WorldClockTimeZoneCatalog.ClockCount]; + + private TimeZoneService? _timeZoneService; + private string _languageCode = "zh-CN"; + private double _currentCellSize = BaseCellSize; + private DateTime _nextLanguageProbeUtc = DateTime.MinValue; + private string _secondHandMode = ClockSecondHandMode.Tick; + + public WorldClockWidget() + { + InitializeComponent(); + + BuildClockEntryVisuals(); + LoadFromSettings(); + ApplySecondHandTimerInterval(); + ApplyCellSize(_currentCellSize); + UpdateClockVisuals(); + + _clockTimer.Tick += OnClockTimerTick; + AttachedToVisualTree += OnAttachedToVisualTree; + DetachedFromVisualTree += OnDetachedFromVisualTree; + SizeChanged += OnSizeChanged; + } + + public void SetTimeZoneService(TimeZoneService timeZoneService) + { + ClearTimeZoneService(); + _timeZoneService = timeZoneService; + _timeZoneService.TimeZoneChanged += OnTimeZoneChanged; + UpdateClockVisuals(); + } + + public void ClearTimeZoneService() + { + if (_timeZoneService is null) + { + return; + } + + _timeZoneService.TimeZoneChanged -= OnTimeZoneChanged; + _timeZoneService = null; + } + + public void RefreshFromSettings() + { + LoadFromSettings(); + ApplySecondHandTimerInterval(); + UpdateClockVisuals(); + } + + public void ApplyCellSize(double cellSize) + { + _currentCellSize = Math.Max(1, cellSize); + var scale = ResolveScale(); + + var totalWidth = Bounds.Width > 1 ? Bounds.Width : _currentCellSize * BaseWidthCells; + var totalHeight = Bounds.Height > 1 ? Bounds.Height : _currentCellSize * BaseHeightCells; + + var horizontalPadding = Math.Clamp(10 * scale, 4, 26); + var verticalPadding = Math.Clamp(8 * scale, 3, 22); + RootBorder.Padding = new Thickness(horizontalPadding, verticalPadding); + RootBorder.CornerRadius = new CornerRadius(Math.Clamp(24 * scale, 10, 46)); + + var usableWidth = Math.Max(48, totalWidth - horizontalPadding * 2); + var usableHeight = Math.Max(28, totalHeight - verticalPadding * 2); + + var columnSpacing = Math.Clamp(usableWidth * 0.015, 2, 14); + ClockHostGrid.ColumnSpacing = columnSpacing; + var widthPerClock = Math.Max(18, (usableWidth - columnSpacing * 3) / WorldClockTimeZoneCatalog.ClockCount); + + var secondaryFont = Math.Clamp(10.5 * scale * (widthPerClock / 46d), 7, 18); + var cityFont = Math.Clamp(secondaryFont * 1.42, 9, 24); + var textSpacing = Math.Clamp(2.8 * scale, 1, 7); + + var estimatedTextHeight = cityFont * 1.2 + secondaryFont * 2.35 + textSpacing * 3; + var dialSize = Math.Clamp(Math.Min(widthPerClock, usableHeight - estimatedTextHeight), 18, 108); + if (dialSize < 18) + { + dialSize = Math.Clamp(Math.Min(widthPerClock, usableHeight * 0.56), 16, 108); + } + + foreach (var entry in _entryVisuals) + { + entry.Host.Spacing = textSpacing; + entry.DialBorder.Width = dialSize; + entry.DialBorder.Height = dialSize; + entry.DialBorder.CornerRadius = new CornerRadius(dialSize / 2d); + + entry.CityTextBlock.FontSize = cityFont; + entry.DayTextBlock.FontSize = secondaryFont; + entry.OffsetTextBlock.FontSize = secondaryFont; + + var maxTextWidth = Math.Max(16, widthPerClock + 10); + entry.CityTextBlock.MaxWidth = maxTextWidth; + entry.DayTextBlock.MaxWidth = maxTextWidth; + entry.OffsetTextBlock.MaxWidth = maxTextWidth; + } + } + + private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e) + { + LoadFromSettings(); + ApplySecondHandTimerInterval(); + UpdateClockVisuals(); + _clockTimer.Start(); + } + + private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e) + { + _clockTimer.Stop(); + } + + private void OnSizeChanged(object? sender, SizeChangedEventArgs e) + { + _ = sender; + _ = e; + ApplyCellSize(_currentCellSize); + } + + private void OnTimeZoneChanged(object? sender, EventArgs e) + { + _ = sender; + _ = e; + UpdateClockVisuals(); + } + + private void OnClockTimerTick(object? sender, EventArgs e) + { + _ = sender; + _ = e; + UpdateClockVisuals(); + } + + private void BuildClockEntryVisuals() + { + ClockHostGrid.Children.Clear(); + for (var index = 0; index < WorldClockTimeZoneCatalog.ClockCount; index++) + { + var entry = CreateClockEntryVisual(); + _entryVisuals[index] = entry; + ClockHostGrid.Children.Add(entry.Host); + Grid.SetColumn(entry.Host, index); + Grid.SetRow(entry.Host, 0); + } + } + + private ClockEntryVisual CreateClockEntryVisual() + { + var tickCanvas = new Canvas + { + Width = DialDesignSize, + Height = DialDesignSize, + IsHitTestVisible = false + }; + var numberCanvas = new Canvas + { + Width = DialDesignSize, + Height = DialDesignSize, + IsHitTestVisible = false + }; + var handsCanvas = new Canvas + { + Width = DialDesignSize, + Height = DialDesignSize, + IsHitTestVisible = false + }; + + var hourHand = CreateHandLine("#2B3242", 5.0); + var minuteHand = CreateHandLine("#40495E", 3.2); + var secondHand = CreateHandLine("#1A74F2", 2.2); + handsCanvas.Children.Add(hourHand); + handsCanvas.Children.Add(minuteHand); + handsCanvas.Children.Add(secondHand); + + var centerOuter = new Ellipse + { + Width = 11, + Height = 11, + Fill = CreateBrush("#4F7BC0"), + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Center + }; + var centerInner = new Ellipse + { + Width = 4.5, + Height = 4.5, + Fill = CreateBrush("#1A74F2"), + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Center + }; + + var dialRoot = new Grid + { + Width = DialDesignSize, + Height = DialDesignSize + }; + dialRoot.Children.Add(tickCanvas); + dialRoot.Children.Add(numberCanvas); + dialRoot.Children.Add(handsCanvas); + dialRoot.Children.Add(centerOuter); + dialRoot.Children.Add(centerInner); + + var dialBorder = new Border + { + Width = 56, + Height = 56, + CornerRadius = new CornerRadius(28), + BorderThickness = new Thickness(1), + Background = CreateBrush("#FAFBFD"), + BorderBrush = CreateBrush("#DADFE8"), + ClipToBounds = true, + Child = new Viewbox + { + Stretch = Stretch.Uniform, + Child = dialRoot + } + }; + + var cityTextBlock = new TextBlock + { + Text = string.Empty, + FontFamily = MiSansFontFamily, + FontSize = 13, + FontWeight = FontWeight.SemiBold, + Foreground = CreateBrush("#20232A"), + TextAlignment = TextAlignment.Center, + TextTrimming = TextTrimming.CharacterEllipsis, + TextWrapping = TextWrapping.NoWrap, + HorizontalAlignment = HorizontalAlignment.Center + }; + + var dayTextBlock = new TextBlock + { + Text = string.Empty, + FontFamily = MiSansFontFamily, + FontSize = 10.5, + FontWeight = FontWeight.Medium, + Foreground = CreateBrush("#646C79"), + TextAlignment = TextAlignment.Center, + TextTrimming = TextTrimming.CharacterEllipsis, + TextWrapping = TextWrapping.NoWrap, + HorizontalAlignment = HorizontalAlignment.Center + }; + + var offsetTextBlock = new TextBlock + { + Text = string.Empty, + FontFamily = MiSansFontFamily, + FontSize = 10.5, + FontWeight = FontWeight.Medium, + Foreground = CreateBrush("#7A7F89"), + TextAlignment = TextAlignment.Center, + TextTrimming = TextTrimming.CharacterEllipsis, + TextWrapping = TextWrapping.NoWrap, + HorizontalAlignment = HorizontalAlignment.Center + }; + + var host = new StackPanel + { + Orientation = Orientation.Vertical, + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Center, + Spacing = 3, + Children = + { + dialBorder, + cityTextBlock, + dayTextBlock, + offsetTextBlock + } + }; + + var entry = new ClockEntryVisual + { + Host = host, + DialBorder = dialBorder, + TickCanvas = tickCanvas, + NumberCanvas = numberCanvas, + HourHand = hourHand, + MinuteHand = minuteHand, + SecondHand = secondHand, + CenterOuter = centerOuter, + CityTextBlock = cityTextBlock, + DayTextBlock = dayTextBlock, + OffsetTextBlock = offsetTextBlock + }; + + ApplyDialTheme(entry, isNight: false); + return entry; + } + + private static void BuildDialTicks(ClockEntryVisual entry, bool isNight) + { + entry.TickCanvas.Children.Clear(); + var majorColor = isNight ? "#E3E7F2" : "#2D3341"; + var minorColor = isNight ? "#9EA7B8" : "#9AA4B3"; + + for (var i = 0; i < 60; i++) + { + var isMajor = i % 5 == 0; + var angle = (i * 6 - 90) * Math.PI / 180d; + var outerRadius = DialCenter - 6.5; + var innerRadius = outerRadius - (isMajor ? 9 : 4.5); + + var x1 = DialCenter + Math.Cos(angle) * innerRadius; + var y1 = DialCenter + Math.Sin(angle) * innerRadius; + var x2 = DialCenter + Math.Cos(angle) * outerRadius; + var y2 = DialCenter + Math.Sin(angle) * outerRadius; + + entry.TickCanvas.Children.Add(new Line + { + StartPoint = new Point(x1, y1), + EndPoint = new Point(x2, y2), + Stroke = CreateBrush(isMajor ? majorColor : minorColor), + StrokeThickness = isMajor ? 1.9 : 0.8, + StrokeLineCap = PenLineCap.Round + }); + } + } + + private static void BuildDialNumbers(ClockEntryVisual entry, bool isNight) + { + entry.NumberCanvas.Children.Clear(); + var numberColor = isNight ? "#F2F5FB" : "#1B202A"; + var radius = 36; + for (var number = 1; number <= 12; number++) + { + var angle = (number * 30 - 90) * Math.PI / 180d; + var x = DialCenter + Math.Cos(angle) * radius; + var y = DialCenter + Math.Sin(angle) * radius; + var text = number.ToString(CultureInfo.InvariantCulture); + var isDoubleDigit = number >= 10; + var width = isDoubleDigit ? 14 : 10; + var height = 12; + var numberText = new TextBlock + { + Text = text, + Width = width, + Height = height, + FontFamily = MiSansFontFamily, + FontSize = 9, + FontWeight = FontWeight.SemiBold, + Foreground = CreateBrush(numberColor), + TextAlignment = TextAlignment.Center + }; + + Canvas.SetLeft(numberText, x - width / 2d); + Canvas.SetTop(numberText, y - height / 2d); + entry.NumberCanvas.Children.Add(numberText); + } + } + + private void LoadFromSettings() + { + var snapshot = _settingsService.Load(); + _languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode); + + var ids = WorldClockTimeZoneCatalog.NormalizeTimeZoneIds(snapshot.WorldClockTimeZoneIds); + for (var index = 0; index < WorldClockTimeZoneCatalog.ClockCount; index++) + { + var resolvedId = ids[index]; + _entryTimeZones[index] = WorldClockTimeZoneCatalog.ResolveTimeZoneOrLocal(resolvedId); + } + + _secondHandMode = ClockSecondHandMode.Normalize(snapshot.WorldClockSecondHandMode); + } + + private void ApplySecondHandTimerInterval() + { + _clockTimer.Interval = ClockSecondHandMode.IsSweep(_secondHandMode) + ? TimeSpan.FromMilliseconds(16) + : TimeSpan.FromSeconds(1); + } + + private void UpdateClockVisuals() + { + var utcNow = DateTime.UtcNow; + ProbeLanguageCodeIfNeeded(utcNow); + + var baseZone = _timeZoneService?.CurrentTimeZone ?? TimeZoneInfo.Local; + var baseNow = TimeZoneInfo.ConvertTimeFromUtc(utcNow, baseZone); + var baseOffset = baseZone.GetUtcOffset(utcNow); + + for (var index = 0; index < WorldClockTimeZoneCatalog.ClockCount; index++) + { + var entry = _entryVisuals[index]; + var zone = _entryTimeZones[index] ?? TimeZoneInfo.Local; + var zonedNow = TimeZoneInfo.ConvertTimeFromUtc(utcNow, zone); + var isNight = IsNightForLocalTime(zonedNow); + ApplyDialTheme(entry, isNight); + + var secondValue = ClockSecondHandMode.IsSweep(_secondHandMode) + ? zonedNow.Second + zonedNow.Millisecond / 1000d + : zonedNow.Second; + var minuteValue = zonedNow.Minute + secondValue / 60d; + var hourValue = (zonedNow.Hour % 12) + minuteValue / 60d; + + var hourAngle = hourValue * 30d; + var minuteAngle = minuteValue * 6d; + var secondAngle = secondValue * 6d; + + SetHandGeometry(entry.HourHand, hourAngle, forwardLength: 24, backwardLength: 4.8); + SetHandGeometry(entry.MinuteHand, minuteAngle, forwardLength: 33, backwardLength: 6); + SetHandGeometry(entry.SecondHand, secondAngle, forwardLength: 37, backwardLength: 8.5); + + entry.CityTextBlock.Text = ResolveCityName(zone); + entry.DayTextBlock.Text = ResolveRelativeDayLabel((zonedNow.Date - baseNow.Date).Days); + + var offsetDelta = zone.GetUtcOffset(utcNow) - baseOffset; + entry.OffsetTextBlock.Text = ResolveOffsetLabel(offsetDelta); + } + } + + private static void ApplyDialTheme(ClockEntryVisual entry, bool isNight) + { + if (entry.IsNightApplied.HasValue && entry.IsNightApplied.Value == isNight) + { + return; + } + + entry.IsNightApplied = isNight; + entry.DialBorder.Background = CreateBrush(isNight ? "#2D313A" : "#FAFBFD"); + entry.DialBorder.BorderBrush = CreateBrush(isNight ? "#262A33" : "#DADFE8"); + entry.HourHand.Stroke = CreateBrush(isNight ? "#F5F8FF" : "#2B3242"); + entry.MinuteHand.Stroke = CreateBrush(isNight ? "#DDE4F0" : "#40495E"); + entry.SecondHand.Stroke = CreateBrush("#1A74F2"); + entry.CenterOuter.Fill = CreateBrush(isNight ? "#97B4EA" : "#4F7BC0"); + + BuildDialTicks(entry, isNight); + BuildDialNumbers(entry, isNight); + } + + private void ProbeLanguageCodeIfNeeded(DateTime utcNow) + { + if (utcNow < _nextLanguageProbeUtc) + { + return; + } + + _nextLanguageProbeUtc = utcNow.AddSeconds(25); + try + { + var snapshot = _settingsService.Load(); + _languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode); + } + catch + { + _languageCode = "zh-CN"; + } + } + + private string ResolveCityName(TimeZoneInfo timeZone) + { + var cityNames = string.Equals(_languageCode, "zh-CN", StringComparison.OrdinalIgnoreCase) + ? ZhCityNames + : EnCityNames; + if (cityNames.TryGetValue(timeZone.Id, out var cityName)) + { + return cityName; + } + + var normalized = timeZone.Id; + var slashIndex = normalized.LastIndexOf('/'); + if (slashIndex >= 0 && slashIndex < normalized.Length - 1) + { + normalized = normalized[(slashIndex + 1)..]; + } + + normalized = normalized.Replace('_', ' ').Trim(); + normalized = normalized + .Replace("Standard Time", string.Empty, StringComparison.OrdinalIgnoreCase) + .Replace("Daylight Time", string.Empty, StringComparison.OrdinalIgnoreCase) + .Replace("Time", string.Empty, StringComparison.OrdinalIgnoreCase) + .Trim(); + + return string.IsNullOrWhiteSpace(normalized) ? timeZone.Id : normalized; + } + + private string ResolveRelativeDayLabel(int dayDelta) + { + if (dayDelta < 0) + { + return L("worldclock.widget.yesterday", "昨天"); + } + + if (dayDelta > 0) + { + return L("worldclock.widget.tomorrow", "明天"); + } + + return L("worldclock.widget.today", "今天"); + } + + private string ResolveOffsetLabel(TimeSpan delta) + { + var totalMinutes = (int)Math.Round(delta.TotalMinutes); + if (totalMinutes == 0) + { + return L("worldclock.widget.offset_same", "0 小时"); + } + + var absMinutes = Math.Abs(totalMinutes); + var hours = absMinutes / 60; + var minutes = absMinutes % 60; + var isAhead = totalMinutes > 0; + + if (minutes == 0) + { + return isAhead + ? Lf("worldclock.widget.offset_ahead_hours", "早 {0} 小时", hours) + : Lf("worldclock.widget.offset_behind_hours", "晚 {0} 小时", hours); + } + + return isAhead + ? Lf("worldclock.widget.offset_ahead_hm", "早 {0} 小时 {1} 分", hours, minutes) + : Lf("worldclock.widget.offset_behind_hm", "晚 {0} 小时 {1} 分", hours, minutes); + } + + private string L(string key, string fallback) + { + return _localizationService.GetString(_languageCode, key, fallback); + } + + private string Lf(string key, string fallback, params object[] args) + { + var template = L(key, fallback); + return string.Format(template, args); + } + + private double ResolveScale() + { + var cellScale = Math.Clamp(_currentCellSize / BaseCellSize, 0.56, 2.5); + var widthScale = Bounds.Width > 1 + ? Math.Clamp(Bounds.Width / Math.Max(1, _currentCellSize * BaseWidthCells), 0.52, 2.4) + : 1; + var heightScale = Bounds.Height > 1 + ? Math.Clamp(Bounds.Height / Math.Max(1, _currentCellSize * BaseHeightCells), 0.52, 2.4) + : 1; + return Math.Clamp(Math.Min(cellScale, Math.Min(widthScale, heightScale)), 0.50, 2.4); + } + + private static bool IsNightForLocalTime(DateTime localTime) + { + var hour = localTime.Hour + localTime.Minute / 60d; + return hour < 6 || hour >= 18; + } + + private static void SetHandGeometry(Line hand, double angleDeg, double forwardLength, double backwardLength) + { + var radians = (angleDeg - 90) * Math.PI / 180d; + var cos = Math.Cos(radians); + var sin = Math.Sin(radians); + + hand.StartPoint = new Point( + DialCenter - cos * backwardLength, + DialCenter - sin * backwardLength); + hand.EndPoint = new Point( + DialCenter + cos * forwardLength, + DialCenter + sin * forwardLength); + } + + private static Line CreateHandLine(string colorHex, double thickness) + { + return new Line + { + StartPoint = new Point(DialCenter, DialCenter), + EndPoint = new Point(DialCenter, DialCenter - 32), + Stroke = CreateBrush(colorHex), + StrokeThickness = thickness, + StrokeLineCap = PenLineCap.Round + }; + } + + private static IBrush CreateBrush(string colorHex) + { + return new SolidColorBrush(Color.Parse(colorHex)); + } +} diff --git a/LanMountainDesktop/Views/Components/WorldClockWidgetSettingsWindow.axaml b/LanMountainDesktop/Views/Components/WorldClockWidgetSettingsWindow.axaml new file mode 100644 index 0000000..9c0b6ff --- /dev/null +++ b/LanMountainDesktop/Views/Components/WorldClockWidgetSettingsWindow.axaml @@ -0,0 +1,121 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/LanMountainDesktop/Views/Components/WorldClockWidgetSettingsWindow.axaml.cs b/LanMountainDesktop/Views/Components/WorldClockWidgetSettingsWindow.axaml.cs new file mode 100644 index 0000000..1d2b8a6 --- /dev/null +++ b/LanMountainDesktop/Views/Components/WorldClockWidgetSettingsWindow.axaml.cs @@ -0,0 +1,244 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Interactivity; +using LanMountainDesktop.Services; + +namespace LanMountainDesktop.Views.Components; + +public partial class WorldClockWidgetSettingsWindow : UserControl +{ + private static readonly IReadOnlyDictionary ZhTimeZoneNames = + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["China Standard Time"] = "中国标准时间", + ["Asia/Shanghai"] = "中国标准时间", + ["GMT Standard Time"] = "格林威治标准时间", + ["Europe/London"] = "格林威治标准时间", + ["AUS Eastern Standard Time"] = "澳大利亚东部标准时间", + ["Australia/Sydney"] = "澳大利亚东部标准时间", + ["Eastern Standard Time"] = "美国东部标准时间", + ["America/New_York"] = "美国东部标准时间", + ["Tokyo Standard Time"] = "日本标准时间", + ["Asia/Tokyo"] = "日本标准时间", + ["UTC"] = "协调世界时", + ["Etc/UTC"] = "协调世界时" + }; + + private readonly AppSettingsService _appSettingsService = new(); + private readonly LocalizationService _localizationService = new(); + private readonly TimeZoneService _timeZoneService = new(); + private readonly ComboBox[] _timeZoneComboBoxes; + private bool _suppressEvents; + private string _languageCode = "zh-CN"; + private IReadOnlyList _allTimeZones = Array.Empty(); + private IReadOnlyList _selectedTimeZoneIds = Array.Empty(); + private string _secondHandMode = ClockSecondHandMode.Tick; + + public event EventHandler? SettingsChanged; + + public WorldClockWidgetSettingsWindow() + { + InitializeComponent(); + + _timeZoneComboBoxes = + [ + ClockOneTimeZoneComboBox, + ClockTwoTimeZoneComboBox, + ClockThreeTimeZoneComboBox, + ClockFourTimeZoneComboBox + ]; + + LoadState(); + ApplyLocalization(); + PopulateTimeZoneComboBoxes(); + } + + private void LoadState() + { + var snapshot = _appSettingsService.Load(); + _languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode); + + _allTimeZones = _timeZoneService + .GetAllTimeZones() + .OrderBy(zone => zone.GetUtcOffset(DateTime.UtcNow)) + .ThenBy(zone => zone.DisplayName, StringComparer.OrdinalIgnoreCase) + .ToList(); + + _selectedTimeZoneIds = WorldClockTimeZoneCatalog.NormalizeTimeZoneIds( + snapshot.WorldClockTimeZoneIds, + _allTimeZones); + _secondHandMode = ClockSecondHandMode.Normalize(snapshot.WorldClockSecondHandMode); + } + + private void ApplyLocalization() + { + TitleTextBlock.Text = L("worldclock.settings.title", "世界时钟设置"); + DescriptionTextBlock.Text = L("worldclock.settings.desc", "分别为四个时钟选择时区。"); + + ClockOneLabelTextBlock.Text = L("worldclock.settings.clock_1", "时钟 1"); + ClockTwoLabelTextBlock.Text = L("worldclock.settings.clock_2", "时钟 2"); + ClockThreeLabelTextBlock.Text = L("worldclock.settings.clock_3", "时钟 3"); + ClockFourLabelTextBlock.Text = L("worldclock.settings.clock_4", "时钟 4"); + SecondHandModeLabelTextBlock.Text = L("worldclock.settings.second_mode_label", "秒针方式"); + SecondHandTickRadioButton.Content = L("clock.second_mode.tick", "跳针"); + SecondHandSweepRadioButton.Content = L("clock.second_mode.sweep", "扫针"); + } + + private void PopulateTimeZoneComboBoxes() + { + _suppressEvents = true; + try + { + foreach (var comboBox in _timeZoneComboBoxes) + { + comboBox.Items.Clear(); + foreach (var timeZone in _allTimeZones) + { + comboBox.Items.Add(new ComboBoxItem + { + Tag = timeZone.Id, + Content = GetLocalizedTimeZoneDisplayName(timeZone) + }); + } + } + + for (var index = 0; index < _timeZoneComboBoxes.Length; index++) + { + var comboBox = _timeZoneComboBoxes[index]; + var targetId = index < _selectedTimeZoneIds.Count + ? _selectedTimeZoneIds[index] + : TimeZoneInfo.Local.Id; + + var selected = comboBox.Items + .OfType() + .FirstOrDefault(item => string.Equals(item.Tag as string, targetId, StringComparison.OrdinalIgnoreCase)); + + comboBox.SelectedItem = selected ?? comboBox.Items.OfType().FirstOrDefault(); + } + + var normalizedMode = ClockSecondHandMode.Normalize(_secondHandMode); + SecondHandTickRadioButton.IsChecked = string.Equals( + normalizedMode, + ClockSecondHandMode.Tick, + StringComparison.OrdinalIgnoreCase); + SecondHandSweepRadioButton.IsChecked = string.Equals( + normalizedMode, + ClockSecondHandMode.Sweep, + StringComparison.OrdinalIgnoreCase); + } + finally + { + _suppressEvents = false; + } + } + + private void OnTimeZoneSelectionChanged(object? sender, SelectionChangedEventArgs e) + { + _ = sender; + _ = e; + if (_suppressEvents) + { + return; + } + + SaveState(); + } + + private void OnSecondHandModeChanged(object? sender, RoutedEventArgs e) + { + _ = sender; + _ = e; + if (_suppressEvents) + { + return; + } + + SaveState(); + } + + private void SaveState() + { + var selectedIds = GetSelectedTimeZoneIds(); + var normalizedIds = WorldClockTimeZoneCatalog.NormalizeTimeZoneIds(selectedIds, _allTimeZones); + _secondHandMode = GetSelectedSecondHandMode(); + + var snapshot = _appSettingsService.Load(); + snapshot.WorldClockTimeZoneIds = normalizedIds.ToList(); + snapshot.WorldClockSecondHandMode = _secondHandMode; + _appSettingsService.Save(snapshot); + + _selectedTimeZoneIds = normalizedIds; + SettingsChanged?.Invoke(this, EventArgs.Empty); + } + + private string GetSelectedSecondHandMode() + { + return SecondHandSweepRadioButton.IsChecked == true + ? ClockSecondHandMode.Sweep + : ClockSecondHandMode.Tick; + } + + private List GetSelectedTimeZoneIds() + { + var selectedIds = new List(_timeZoneComboBoxes.Length); + foreach (var comboBox in _timeZoneComboBoxes) + { + if (comboBox.SelectedItem is ComboBoxItem item && + item.Tag is string timeZoneId && + !string.IsNullOrWhiteSpace(timeZoneId)) + { + selectedIds.Add(timeZoneId.Trim()); + continue; + } + + selectedIds.Add(TimeZoneInfo.Local.Id); + } + + return selectedIds; + } + + private string GetLocalizedTimeZoneDisplayName(TimeZoneInfo timeZone) + { + var offset = timeZone.GetUtcOffset(DateTime.UtcNow); + var sign = offset >= TimeSpan.Zero ? "+" : "-"; + var totalMinutes = Math.Abs((int)offset.TotalMinutes); + var hours = totalMinutes / 60; + var minutes = totalMinutes % 60; + + var displayName = string.Equals(_languageCode, "zh-CN", StringComparison.OrdinalIgnoreCase) + ? ResolveZhDisplayName(timeZone) + : ResolveEnDisplayName(timeZone); + + return $"(UTC{sign}{hours:D2}:{minutes:D2}) {displayName}"; + } + + private static string ResolveZhDisplayName(TimeZoneInfo timeZone) + { + if (ZhTimeZoneNames.TryGetValue(timeZone.Id, out var localizedName)) + { + return localizedName; + } + + return string.IsNullOrWhiteSpace(timeZone.StandardName) + ? timeZone.DisplayName + : timeZone.StandardName; + } + + private static string ResolveEnDisplayName(TimeZoneInfo timeZone) + { + if (!string.IsNullOrWhiteSpace(timeZone.StandardName)) + { + return timeZone.StandardName; + } + + return timeZone.DisplayName; + } + + private string L(string key, string fallback) + { + return _localizationService.GetString(_languageCode, key, fallback); + } +} diff --git a/LanMountainDesktop/Views/MainWindow.ComponentSystem.cs b/LanMountainDesktop/Views/MainWindow.ComponentSystem.cs index a6cff0b..1438ce4 100644 --- a/LanMountainDesktop/Views/MainWindow.ComponentSystem.cs +++ b/LanMountainDesktop/Views/MainWindow.ComponentSystem.cs @@ -409,7 +409,7 @@ public partial class MainWindow { OpenSettingsPage(); } - }, UiMotionTokens.Slow); + }, FluttermotionToken.Slow); } private void InitializeDesktopComponentDragHandlers() @@ -701,12 +701,24 @@ public partial class MainWindow return; } + if (placement.ComponentId == BuiltInComponentIds.DesktopClock) + { + OpenDesktopClockComponentSettings(); + return; + } + if (placement.ComponentId == BuiltInComponentIds.DesktopClassSchedule) { OpenClassScheduleComponentSettings(); return; } + if (placement.ComponentId == BuiltInComponentIds.DesktopWorldClock) + { + OpenWorldClockComponentSettings(); + return; + } + if (placement.ComponentId == BuiltInComponentIds.DesktopDailyArtwork) { OpenDailyArtworkComponentSettings(); @@ -751,6 +763,38 @@ public partial class MainWindow ComponentSettingsWindow.Opacity = 1; } + private void OpenDesktopClockComponentSettings() + { + if (ComponentSettingsWindow is null || ComponentSettingsContentHost is null) + { + return; + } + + var settingsContent = new AnalogClockWidgetSettingsWindow(); + settingsContent.SettingsChanged += OnDesktopClockSettingsChanged; + ComponentSettingsContentHost.Content = settingsContent; + + ComponentSettingsWindow.IsVisible = true; + ComponentSettingsWindow.Opacity = 0; + ComponentSettingsWindow.Opacity = 1; + } + + private void OpenWorldClockComponentSettings() + { + if (ComponentSettingsWindow is null || ComponentSettingsContentHost is null) + { + return; + } + + var settingsContent = new WorldClockWidgetSettingsWindow(); + settingsContent.SettingsChanged += OnWorldClockSettingsChanged; + ComponentSettingsContentHost.Content = settingsContent; + + ComponentSettingsWindow.IsVisible = true; + ComponentSettingsWindow.Opacity = 0; + ComponentSettingsWindow.Opacity = 1; + } + private void OpenStudyEnvironmentComponentSettings() { if (ComponentSettingsWindow is null || ComponentSettingsContentHost is null) @@ -796,6 +840,30 @@ public partial class MainWindow } } + private void OnDesktopClockSettingsChanged(object? sender, EventArgs e) + { + _ = sender; + _ = e; + + foreach (var pageGrid in _desktopPageComponentGrids.Values) + { + foreach (var host in pageGrid.Children.OfType()) + { + if (!host.Classes.Contains(DesktopComponentHostClass)) + { + continue; + } + + if (TryGetContentHost(host)?.Child is AnalogClockWidget widget) + { + widget.RefreshFromSettings(); + } + } + } + + PersistSettings(); + } + private void OnStudyEnvironmentSettingsChanged(object? sender, EventArgs e) { _ = sender; @@ -839,6 +907,30 @@ public partial class MainWindow PersistSettings(); } + private void OnWorldClockSettingsChanged(object? sender, EventArgs e) + { + _ = sender; + _ = e; + + foreach (var pageGrid in _desktopPageComponentGrids.Values) + { + foreach (var host in pageGrid.Children.OfType()) + { + if (!host.Classes.Contains(DesktopComponentHostClass)) + { + continue; + } + + if (TryGetContentHost(host)?.Child is WorldClockWidget widget) + { + widget.RefreshFromSettings(); + } + } + } + + PersistSettings(); + } + private void CloseComponentSettingsWindow() { if (ComponentSettingsWindow is null) @@ -851,6 +943,11 @@ public partial class MainWindow classScheduleSettingsWindow.SettingsChanged -= OnClassScheduleSettingsChanged; } + if (ComponentSettingsContentHost?.Content is AnalogClockWidgetSettingsWindow analogClockSettingsWindow) + { + analogClockSettingsWindow.SettingsChanged -= OnDesktopClockSettingsChanged; + } + if (ComponentSettingsContentHost?.Content is StudyEnvironmentWidgetSettingsWindow studyEnvironmentSettingsWindow) { studyEnvironmentSettingsWindow.SettingsChanged -= OnStudyEnvironmentSettingsChanged; @@ -861,6 +958,11 @@ public partial class MainWindow dailyArtworkSettingsWindow.SettingsChanged -= OnDailyArtworkSettingsChanged; } + if (ComponentSettingsContentHost?.Content is WorldClockWidgetSettingsWindow worldClockSettingsWindow) + { + worldClockSettingsWindow.SettingsChanged -= OnWorldClockSettingsChanged; + } + ComponentSettingsWindow.Opacity = 0; DispatcherTimer.RunOnce(() => @@ -873,7 +975,7 @@ public partial class MainWindow { ComponentSettingsContentHost.Content = null; } - }, UiMotionTokens.Slow); + }, FluttermotionToken.Slow); } private void AddDesktopPage() @@ -1272,6 +1374,14 @@ public partial class MainWindow new ComponentScaleRule(WidthUnit: 2, HeightUnit: 1, MinScale: 2)); } + if (string.Equals(componentId, BuiltInComponentIds.DesktopWorldClock, StringComparison.OrdinalIgnoreCase)) + { + // Keep world clock widget at 2:1 ratio: 4x2, 6x3, 8x4... + return SnapSpanToScaleRules( + span, + new ComponentScaleRule(WidthUnit: 2, HeightUnit: 1, MinScale: 2)); + } + if (string.Equals(componentId, BuiltInComponentIds.DesktopStudyScoreOverview, StringComparison.OrdinalIgnoreCase)) { // Keep score overview widget square: 4x4, 5x5, 6x6... diff --git a/LanMountainDesktop/Views/MainWindow.axaml b/LanMountainDesktop/Views/MainWindow.axaml index d37f7a3..be59788 100644 --- a/LanMountainDesktop/Views/MainWindow.axaml +++ b/LanMountainDesktop/Views/MainWindow.axaml @@ -67,7 +67,7 @@ VerticalAlignment="Stretch"> - + @@ -109,7 +109,7 @@ - + @@ -349,7 +349,7 @@ VerticalAlignment="Stretch"> - + @@ -365,7 +365,7 @@ - + @@ -1471,7 +1471,7 @@ PointerReleased="OnComponentLibraryWindowPointerReleased"> - + @@ -1582,7 +1582,7 @@ - + diff --git a/LanMountainDesktop/Views/MainWindow.axaml.cs b/LanMountainDesktop/Views/MainWindow.axaml.cs index ccba2e0..c1c7a6a 100644 --- a/LanMountainDesktop/Views/MainWindow.axaml.cs +++ b/LanMountainDesktop/Views/MainWindow.axaml.cs @@ -58,7 +58,7 @@ public partial class MainWindow : Window private const int MinEdgeInsetPercent = 0; private const int MaxEdgeInsetPercent = 30; private const int DefaultEdgeInsetPercent = 18; - private static readonly int SettingsTransitionDurationMs = (int)UiMotionTokens.Page.TotalMilliseconds; + private static readonly int SettingsTransitionDurationMs = (int)FluttermotionToken.Page.TotalMilliseconds; private const double WallpaperPreviewMaxWidth = 520; private const double LightBackgroundLuminanceThreshold = 0.57; private const string TaskbarLayoutBottomFullRowMacStyle = "BottomFullRowMacStyle";