mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-20 23:54:26 +08:00
0.4.0
This commit is contained in:
@@ -19,7 +19,7 @@
|
||||
<Application.Styles>
|
||||
<sty:FluentAvaloniaTheme />
|
||||
<mi:MaterialIconStyles />
|
||||
<StyleInclude Source="avares://LanMountainDesktop/Styles/MotionTokens.axaml" />
|
||||
<StyleInclude Source="avares://LanMountainDesktop/Styles/FluttermotionToken.axaml" />
|
||||
<StyleInclude Source="avares://LanMountainDesktop/Styles/GlassModule.axaml" />
|
||||
<StyleInclude Source="avares://LanMountainDesktop/Styles/SettingsAnimations.axaml" />
|
||||
|
||||
|
||||
@@ -110,7 +110,7 @@ public class PanelIntroAnimationBehavior
|
||||
var index = 0;
|
||||
var timer = new DispatcherTimer(DispatcherPriority.Background)
|
||||
{
|
||||
Interval = UiMotionTokens.StaggerStepInterval
|
||||
Interval = FluttermotionToken.StaggerStepInterval
|
||||
};
|
||||
timer.Tick += (_, _) =>
|
||||
{
|
||||
|
||||
@@ -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<bool> IsEnabledProperty =
|
||||
AvaloniaProperty.RegisterAttached<PopupIntroAnimationBehavior, Control, bool>("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);
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "诗词获取失败",
|
||||
|
||||
@@ -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<string> 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<string>(WorldClockTimeZoneIds)
|
||||
: [];
|
||||
|
||||
return clone;
|
||||
}
|
||||
}
|
||||
|
||||
21
LanMountainDesktop/Services/ClockSecondHandMode.cs
Normal file
21
LanMountainDesktop/Services/ClockSecondHandMode.cs
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
187
LanMountainDesktop/Services/WorldClockTimeZoneCatalog.cs
Normal file
187
LanMountainDesktop/Services/WorldClockTimeZoneCatalog.cs
Normal file
@@ -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<string, string[]> 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<string> NormalizeTimeZoneIds(IEnumerable<string>? configuredIds)
|
||||
{
|
||||
var available = TimeZoneInfo.GetSystemTimeZones();
|
||||
return NormalizeTimeZoneIds(configuredIds, available);
|
||||
}
|
||||
|
||||
public static IReadOnlyList<string> NormalizeTimeZoneIds(
|
||||
IEnumerable<string>? configuredIds,
|
||||
IReadOnlyList<TimeZoneInfo> availableTimeZones)
|
||||
{
|
||||
var availableById = BuildAvailableTimeZoneLookup(availableTimeZones);
|
||||
var requested = configuredIds?
|
||||
.Where(id => !string.IsNullOrWhiteSpace(id))
|
||||
.Select(id => id.Trim())
|
||||
.ToList() ?? [];
|
||||
|
||||
var normalized = new List<string>(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<string, TimeZoneInfo> BuildAvailableTimeZoneLookup(
|
||||
IReadOnlyList<TimeZoneInfo> 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<string, TimeZoneInfo> 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<string, TimeZoneInfo> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
12
LanMountainDesktop/Styles/FluttermotionToken.axaml
Normal file
12
LanMountainDesktop/Styles/FluttermotionToken.axaml
Normal file
@@ -0,0 +1,12 @@
|
||||
<Styles xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||
<Styles.Resources>
|
||||
<x:TimeSpan x:Key="FluttermotionToken.Duration.Fast">0:0:0.12</x:TimeSpan>
|
||||
<x:TimeSpan x:Key="FluttermotionToken.Duration.Standard">0:0:0.16</x:TimeSpan>
|
||||
<x:TimeSpan x:Key="FluttermotionToken.Duration.Slow">0:0:0.20</x:TimeSpan>
|
||||
<x:TimeSpan x:Key="FluttermotionToken.Duration.Page">0:0:0.24</x:TimeSpan>
|
||||
<x:TimeSpan x:Key="FluttermotionToken.Duration.Intro">0:0:0.32</x:TimeSpan>
|
||||
|
||||
<x:Double x:Key="FluttermotionToken.BackdropBlurRadiusStrong">30</x:Double>
|
||||
</Styles.Resources>
|
||||
</Styles>
|
||||
@@ -25,9 +25,9 @@
|
||||
<Setter Property="Padding" Value="16,10" />
|
||||
<Setter Property="Transitions">
|
||||
<Transitions>
|
||||
<TransformOperationsTransition Property="RenderTransform" Duration="0:0:0.12" />
|
||||
<DoubleTransition Property="Opacity" Duration="0:0:0.12" />
|
||||
<BrushTransition Property="Background" Duration="0:0:0.12" />
|
||||
<TransformOperationsTransition Property="RenderTransform" Duration="{StaticResource FluttermotionToken.Duration.Fast}" />
|
||||
<DoubleTransition Property="Opacity" Duration="{StaticResource FluttermotionToken.Duration.Fast}" />
|
||||
<BrushTransition Property="Background" Duration="{StaticResource FluttermotionToken.Duration.Fast}" />
|
||||
</Transitions>
|
||||
</Setter>
|
||||
</Style>
|
||||
@@ -150,7 +150,7 @@
|
||||
<Setter Property="BoxShadow" Value="0 12 32 #33000000" />
|
||||
<Setter Property="Transitions">
|
||||
<Transitions>
|
||||
<ThicknessTransition Property="Padding" Duration="0:0:0.2" Easing="QuarticEaseOut" />
|
||||
<ThicknessTransition Property="Padding" Duration="{StaticResource FluttermotionToken.Duration.Slow}" Easing="QuarticEaseOut" />
|
||||
</Transitions>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
<Styles xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||
<Styles.Resources>
|
||||
<x:String x:Key="MotionEasingStandard">0.22,1,0.36,1</x:String>
|
||||
|
||||
<x:String x:Key="MotionDurationFast">0:0:0.12</x:String>
|
||||
<x:String x:Key="MotionDurationStandard">0:0:0.16</x:String>
|
||||
<x:String x:Key="MotionDurationSlow">0:0:0.20</x:String>
|
||||
<x:String x:Key="MotionDurationPage">0:0:0.24</x:String>
|
||||
<x:String x:Key="MotionDurationIntro">0:0:0.32</x:String>
|
||||
|
||||
<x:Double x:Key="MotionBackdropBlurRadiusStrong">30</x:Double>
|
||||
</Styles.Resources>
|
||||
</Styles>
|
||||
@@ -21,7 +21,7 @@
|
||||
</Setter>
|
||||
<Style Selector="^[(behaviors|PanelIntroAnimationBehavior.IsAnimationPlayed)=True]">
|
||||
<Style.Animations>
|
||||
<Animation Duration="0:0:0.32"
|
||||
<Animation Duration="{StaticResource FluttermotionToken.Duration.Intro}"
|
||||
FillMode="Both"
|
||||
Easing="0.22,1,0.36,1">
|
||||
<KeyFrame Cue="0%">
|
||||
@@ -53,9 +53,9 @@
|
||||
<Setter Property="MinHeight" Value="34" />
|
||||
<Setter Property="Transitions">
|
||||
<Transitions>
|
||||
<BrushTransition Property="Background" Duration="0:0:0.16" Easing="0.22,1,0.36,1" />
|
||||
<BrushTransition Property="BorderBrush" Duration="0:0:0.16" Easing="0.22,1,0.36,1" />
|
||||
<TransformOperationsTransition Property="RenderTransform" Duration="0:0:0.16" Easing="0.22,1,0.36,1" />
|
||||
<BrushTransition Property="Background" Duration="{StaticResource FluttermotionToken.Duration.Standard}" Easing="0.22,1,0.36,1" />
|
||||
<BrushTransition Property="BorderBrush" Duration="{StaticResource FluttermotionToken.Duration.Standard}" Easing="0.22,1,0.36,1" />
|
||||
<TransformOperationsTransition Property="RenderTransform" Duration="{StaticResource FluttermotionToken.Duration.Standard}" Easing="0.22,1,0.36,1" />
|
||||
</Transitions>
|
||||
</Setter>
|
||||
</Style>
|
||||
@@ -74,8 +74,8 @@
|
||||
<Style Selector="Grid.settings-scope ComboBox">
|
||||
<Setter Property="Transitions">
|
||||
<Transitions>
|
||||
<BrushTransition Property="Background" Duration="0:0:0.12" Easing="0.22,1,0.36,1" />
|
||||
<TransformOperationsTransition Property="RenderTransform" Duration="0:0:0.12" Easing="0.22,1,0.36,1" />
|
||||
<BrushTransition Property="Background" Duration="{StaticResource FluttermotionToken.Duration.Fast}" Easing="0.22,1,0.36,1" />
|
||||
<TransformOperationsTransition Property="RenderTransform" Duration="{StaticResource FluttermotionToken.Duration.Fast}" Easing="0.22,1,0.36,1" />
|
||||
</Transitions>
|
||||
</Setter>
|
||||
</Style>
|
||||
@@ -87,8 +87,8 @@
|
||||
<Style Selector="Grid.settings-scope ToggleSwitch">
|
||||
<Setter Property="Transitions">
|
||||
<Transitions>
|
||||
<DoubleTransition Property="Opacity" Duration="0:0:0.16" Easing="0.22,1,0.36,1" />
|
||||
<TransformOperationsTransition Property="RenderTransform" Duration="0:0:0.16" Easing="0.22,1,0.36,1" />
|
||||
<DoubleTransition Property="Opacity" Duration="{StaticResource FluttermotionToken.Duration.Standard}" Easing="0.22,1,0.36,1" />
|
||||
<TransformOperationsTransition Property="RenderTransform" Duration="{StaticResource FluttermotionToken.Duration.Standard}" Easing="0.22,1,0.36,1" />
|
||||
</Transitions>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
@@ -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);
|
||||
@@ -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<string, string> ZhCityNames =
|
||||
new Dictionary<string, string>(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<string, string> EnCityNames =
|
||||
new Dictionary<string, string>(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)
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
mc:Ignorable="d"
|
||||
d:DesignWidth="560"
|
||||
d:DesignHeight="300"
|
||||
x:Class="LanMountainDesktop.Views.Components.AnalogClockWidgetSettingsWindow">
|
||||
<Border Background="{DynamicResource AdaptiveBackgroundBrush}"
|
||||
Padding="16">
|
||||
<Grid RowDefinitions="Auto,Auto,*"
|
||||
RowSpacing="10">
|
||||
<TextBlock x:Name="TitleTextBlock"
|
||||
Text="时钟设置"
|
||||
FontSize="18"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
|
||||
|
||||
<TextBlock x:Name="DescriptionTextBlock"
|
||||
Grid.Row="1"
|
||||
Text="为单时钟选择时区。"
|
||||
FontSize="12"
|
||||
TextWrapping="Wrap"
|
||||
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}" />
|
||||
|
||||
<ScrollViewer Grid.Row="2"
|
||||
HorizontalScrollBarVisibility="Disabled"
|
||||
VerticalScrollBarVisibility="Auto">
|
||||
<StackPanel Spacing="10"
|
||||
Margin="0,0,6,0">
|
||||
<Border Background="{DynamicResource AdaptiveSurfaceRaisedBrush}"
|
||||
BorderBrush="{DynamicResource AdaptiveButtonBorderBrush}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="12"
|
||||
Padding="12">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock x:Name="TimeZoneLabelTextBlock"
|
||||
Text="时区"
|
||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
|
||||
<ComboBox x:Name="TimeZoneComboBox"
|
||||
HorizontalAlignment="Stretch"
|
||||
MinWidth="0"
|
||||
SelectionChanged="OnTimeZoneSelectionChanged" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Background="{DynamicResource AdaptiveSurfaceRaisedBrush}"
|
||||
BorderBrush="{DynamicResource AdaptiveButtonBorderBrush}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="12"
|
||||
Padding="12">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock x:Name="SecondHandModeLabelTextBlock"
|
||||
Text="秒针方式"
|
||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
|
||||
<StackPanel Orientation="Horizontal"
|
||||
Spacing="12">
|
||||
<RadioButton x:Name="SecondHandTickRadioButton"
|
||||
GroupName="desktop_clock_second_mode"
|
||||
Content="跳针"
|
||||
Checked="OnSecondHandModeChanged" />
|
||||
<RadioButton x:Name="SecondHandSweepRadioButton"
|
||||
GroupName="desktop_clock_second_mode"
|
||||
Content="扫针"
|
||||
Checked="OnSecondHandModeChanged" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</Grid>
|
||||
</Border>
|
||||
</UserControl>
|
||||
@@ -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<string, string> ZhTimeZoneNames =
|
||||
new Dictionary<string, string>(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<TimeZoneInfo> _allTimeZones = Array.Empty<TimeZoneInfo>();
|
||||
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<ComboBoxItem>()
|
||||
.FirstOrDefault(item => string.Equals(item.Tag as string, normalizedId, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
TimeZoneComboBox.SelectedItem = selected ?? TimeZoneComboBox.Items.OfType<ComboBoxItem>().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);
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -86,7 +86,7 @@
|
||||
Opacity="0.62"
|
||||
Stretch="UniformToFill">
|
||||
<Image.Effect>
|
||||
<BlurEffect Radius="{DynamicResource MotionBackdropBlurRadiusStrong}" />
|
||||
<BlurEffect Radius="{DynamicResource FluttermotionToken.BackdropBlurRadiusStrong}" />
|
||||
</Image.Effect>
|
||||
</Image>
|
||||
</Border>
|
||||
|
||||
@@ -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();
|
||||
|
||||
21
LanMountainDesktop/Views/Components/WorldClockWidget.axaml
Normal file
21
LanMountainDesktop/Views/Components/WorldClockWidget.axaml
Normal file
@@ -0,0 +1,21 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
mc:Ignorable="d"
|
||||
d:DesignWidth="420"
|
||||
d:DesignHeight="210"
|
||||
x:Class="LanMountainDesktop.Views.Components.WorldClockWidget">
|
||||
|
||||
<Border x:Name="RootBorder"
|
||||
Background="#F4F5F7"
|
||||
BorderBrush="#16000000"
|
||||
BorderThickness="1"
|
||||
CornerRadius="26"
|
||||
ClipToBounds="True"
|
||||
Padding="10,8">
|
||||
<Grid x:Name="ClockHostGrid"
|
||||
ColumnDefinitions="*,*,*,*"
|
||||
ColumnSpacing="8" />
|
||||
</Border>
|
||||
</UserControl>
|
||||
671
LanMountainDesktop/Views/Components/WorldClockWidget.axaml.cs
Normal file
671
LanMountainDesktop/Views/Components/WorldClockWidget.axaml.cs
Normal file
@@ -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<string, string> ZhCityNames =
|
||||
new Dictionary<string, string>(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<string, string> EnCityNames =
|
||||
new Dictionary<string, string>(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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
mc:Ignorable="d"
|
||||
d:DesignWidth="560"
|
||||
d:DesignHeight="380"
|
||||
x:Class="LanMountainDesktop.Views.Components.WorldClockWidgetSettingsWindow">
|
||||
<Border Background="{DynamicResource AdaptiveBackgroundBrush}"
|
||||
Padding="16">
|
||||
<Grid RowDefinitions="Auto,Auto,*"
|
||||
RowSpacing="10">
|
||||
<TextBlock x:Name="TitleTextBlock"
|
||||
Text="世界时钟设置"
|
||||
FontSize="18"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
|
||||
|
||||
<TextBlock x:Name="DescriptionTextBlock"
|
||||
Grid.Row="1"
|
||||
Text="分别为四个时钟选择时区。"
|
||||
FontSize="12"
|
||||
TextWrapping="Wrap"
|
||||
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}" />
|
||||
|
||||
<ScrollViewer Grid.Row="2"
|
||||
HorizontalScrollBarVisibility="Disabled"
|
||||
VerticalScrollBarVisibility="Auto">
|
||||
<StackPanel Spacing="10"
|
||||
Margin="0,0,6,0">
|
||||
<Border Background="{DynamicResource AdaptiveSurfaceRaisedBrush}"
|
||||
BorderBrush="{DynamicResource AdaptiveButtonBorderBrush}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="12"
|
||||
Padding="12">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock x:Name="SecondHandModeLabelTextBlock"
|
||||
Text="秒针方式"
|
||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
|
||||
<StackPanel Orientation="Horizontal"
|
||||
Spacing="12">
|
||||
<RadioButton x:Name="SecondHandTickRadioButton"
|
||||
GroupName="world_clock_second_mode"
|
||||
Content="跳针"
|
||||
Checked="OnSecondHandModeChanged" />
|
||||
<RadioButton x:Name="SecondHandSweepRadioButton"
|
||||
GroupName="world_clock_second_mode"
|
||||
Content="扫针"
|
||||
Checked="OnSecondHandModeChanged" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Background="{DynamicResource AdaptiveSurfaceRaisedBrush}"
|
||||
BorderBrush="{DynamicResource AdaptiveButtonBorderBrush}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="12"
|
||||
Padding="12">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock x:Name="ClockOneLabelTextBlock"
|
||||
Text="时钟 1"
|
||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
|
||||
<ComboBox x:Name="ClockOneTimeZoneComboBox"
|
||||
HorizontalAlignment="Stretch"
|
||||
MinWidth="0"
|
||||
SelectionChanged="OnTimeZoneSelectionChanged" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Background="{DynamicResource AdaptiveSurfaceRaisedBrush}"
|
||||
BorderBrush="{DynamicResource AdaptiveButtonBorderBrush}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="12"
|
||||
Padding="12">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock x:Name="ClockTwoLabelTextBlock"
|
||||
Text="时钟 2"
|
||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
|
||||
<ComboBox x:Name="ClockTwoTimeZoneComboBox"
|
||||
HorizontalAlignment="Stretch"
|
||||
MinWidth="0"
|
||||
SelectionChanged="OnTimeZoneSelectionChanged" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Background="{DynamicResource AdaptiveSurfaceRaisedBrush}"
|
||||
BorderBrush="{DynamicResource AdaptiveButtonBorderBrush}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="12"
|
||||
Padding="12">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock x:Name="ClockThreeLabelTextBlock"
|
||||
Text="时钟 3"
|
||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
|
||||
<ComboBox x:Name="ClockThreeTimeZoneComboBox"
|
||||
HorizontalAlignment="Stretch"
|
||||
MinWidth="0"
|
||||
SelectionChanged="OnTimeZoneSelectionChanged" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Background="{DynamicResource AdaptiveSurfaceRaisedBrush}"
|
||||
BorderBrush="{DynamicResource AdaptiveButtonBorderBrush}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="12"
|
||||
Padding="12">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock x:Name="ClockFourLabelTextBlock"
|
||||
Text="时钟 4"
|
||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
|
||||
<ComboBox x:Name="ClockFourTimeZoneComboBox"
|
||||
HorizontalAlignment="Stretch"
|
||||
MinWidth="0"
|
||||
SelectionChanged="OnTimeZoneSelectionChanged" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</Grid>
|
||||
</Border>
|
||||
</UserControl>
|
||||
@@ -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<string, string> ZhTimeZoneNames =
|
||||
new Dictionary<string, string>(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<TimeZoneInfo> _allTimeZones = Array.Empty<TimeZoneInfo>();
|
||||
private IReadOnlyList<string> _selectedTimeZoneIds = Array.Empty<string>();
|
||||
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<ComboBoxItem>()
|
||||
.FirstOrDefault(item => string.Equals(item.Tag as string, targetId, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
comboBox.SelectedItem = selected ?? comboBox.Items.OfType<ComboBoxItem>().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<string> GetSelectedTimeZoneIds()
|
||||
{
|
||||
var selectedIds = new List<string>(_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);
|
||||
}
|
||||
}
|
||||
@@ -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<Border>())
|
||||
{
|
||||
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<Border>())
|
||||
{
|
||||
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...
|
||||
|
||||
@@ -67,7 +67,7 @@
|
||||
VerticalAlignment="Stretch">
|
||||
<Grid.Transitions>
|
||||
<Transitions>
|
||||
<DoubleTransition Property="Opacity" Duration="0:0:0.24" />
|
||||
<DoubleTransition Property="Opacity" Duration="{StaticResource FluttermotionToken.Duration.Page}" />
|
||||
</Transitions>
|
||||
</Grid.Transitions>
|
||||
|
||||
@@ -109,7 +109,7 @@
|
||||
<TranslateTransform>
|
||||
<TranslateTransform.Transitions>
|
||||
<Transitions>
|
||||
<DoubleTransition Property="X" Duration="0:0:0.24" />
|
||||
<DoubleTransition Property="X" Duration="{StaticResource FluttermotionToken.Duration.Page}" />
|
||||
</Transitions>
|
||||
</TranslateTransform.Transitions>
|
||||
</TranslateTransform>
|
||||
@@ -349,7 +349,7 @@
|
||||
VerticalAlignment="Stretch">
|
||||
<Grid.Transitions>
|
||||
<Transitions>
|
||||
<DoubleTransition Property="Opacity" Duration="0:0:0.24" />
|
||||
<DoubleTransition Property="Opacity" Duration="{StaticResource FluttermotionToken.Duration.Page}" />
|
||||
</Transitions>
|
||||
</Grid.Transitions>
|
||||
|
||||
@@ -365,7 +365,7 @@
|
||||
<TranslateTransform Y="30">
|
||||
<TranslateTransform.Transitions>
|
||||
<Transitions>
|
||||
<DoubleTransition Property="Y" Duration="0:0:0.24" />
|
||||
<DoubleTransition Property="Y" Duration="{StaticResource FluttermotionToken.Duration.Page}" />
|
||||
</Transitions>
|
||||
</TranslateTransform.Transitions>
|
||||
</TranslateTransform>
|
||||
@@ -1471,7 +1471,7 @@
|
||||
PointerReleased="OnComponentLibraryWindowPointerReleased">
|
||||
<Border.Transitions>
|
||||
<Transitions>
|
||||
<DoubleTransition Property="Opacity" Duration="0:0:0.2" />
|
||||
<DoubleTransition Property="Opacity" Duration="{StaticResource FluttermotionToken.Duration.Slow}" />
|
||||
</Transitions>
|
||||
</Border.Transitions>
|
||||
|
||||
@@ -1582,7 +1582,7 @@
|
||||
<TranslateTransform>
|
||||
<TranslateTransform.Transitions>
|
||||
<Transitions>
|
||||
<DoubleTransition Property="X" Duration="0:0:0.22" />
|
||||
<DoubleTransition Property="X" Duration="{StaticResource FluttermotionToken.Duration.Page}" />
|
||||
</Transitions>
|
||||
</TranslateTransform.Transitions>
|
||||
</TranslateTransform>
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user