This commit is contained in:
lincube
2026-03-05 16:34:22 +08:00
parent 3cdb4bbd98
commit 24f1b896e1
31 changed files with 1882 additions and 52 deletions

View File

@@ -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" />

View File

@@ -110,7 +110,7 @@ public class PanelIntroAnimationBehavior
var index = 0;
var timer = new DispatcherTimer(DispatcherPriority.Background)
{
Interval = UiMotionTokens.StaggerStepInterval
Interval = FluttermotionToken.StaggerStepInterval
};
timer.Tick += (_, _) =>
{

View File

@@ -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);

View File

@@ -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";

View File

@@ -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",

View File

@@ -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",

View File

@@ -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": "诗词获取失败",

View File

@@ -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;
}
}

View 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);
}
}

View 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;
}
}
}

View 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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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);

View File

@@ -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)

View File

@@ -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>

View File

@@ -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);
}
}

View File

@@ -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",

View File

@@ -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();

View File

@@ -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();

View File

@@ -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();

View File

@@ -86,7 +86,7 @@
Opacity="0.62"
Stretch="UniformToFill">
<Image.Effect>
<BlurEffect Radius="{DynamicResource MotionBackdropBlurRadiusStrong}" />
<BlurEffect Radius="{DynamicResource FluttermotionToken.BackdropBlurRadiusStrong}" />
</Image.Effect>
</Image>
</Border>

View File

@@ -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();

View 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>

View 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));
}
}

View File

@@ -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>

View File

@@ -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);
}
}

View File

@@ -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...

View File

@@ -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>

View File

@@ -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";