mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-20 23:54:26 +08:00
414 lines
14 KiB
C#
414 lines
14 KiB
C#
using System;
|
|
using System.Globalization;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using Avalonia;
|
|
using Avalonia.Controls;
|
|
using Avalonia.Media;
|
|
using Avalonia.Threading;
|
|
using LanMontainDesktop.Services;
|
|
|
|
namespace LanMontainDesktop.Views.Components;
|
|
|
|
public partial class HolidayCalendarWidget : UserControl, IDesktopComponentWidget, ITimeZoneAwareComponentWidget
|
|
{
|
|
private readonly DispatcherTimer _timer = new()
|
|
{
|
|
Interval = TimeSpan.FromMinutes(15)
|
|
};
|
|
|
|
private static readonly HolidayCalendarService HolidayService = new();
|
|
|
|
private TimeZoneService? _timeZoneService;
|
|
private double _currentCellSize = 48;
|
|
private CancellationTokenSource? _refreshCts;
|
|
private long _refreshVersion;
|
|
|
|
public HolidayCalendarWidget()
|
|
{
|
|
InitializeComponent();
|
|
|
|
_timer.Tick += OnTimerTick;
|
|
AttachedToVisualTree += OnAttachedToVisualTree;
|
|
DetachedFromVisualTree += OnDetachedFromVisualTree;
|
|
SizeChanged += OnSizeChanged;
|
|
|
|
ApplyCellSize(_currentCellSize);
|
|
TriggerContentRefresh();
|
|
}
|
|
|
|
public void SetTimeZoneService(TimeZoneService timeZoneService)
|
|
{
|
|
ClearTimeZoneService();
|
|
_timeZoneService = timeZoneService;
|
|
_timeZoneService.TimeZoneChanged += OnTimeZoneChanged;
|
|
TriggerContentRefresh();
|
|
}
|
|
|
|
public void ClearTimeZoneService()
|
|
{
|
|
if (_timeZoneService is null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
_timeZoneService.TimeZoneChanged -= OnTimeZoneChanged;
|
|
_timeZoneService = null;
|
|
}
|
|
|
|
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
|
{
|
|
TriggerContentRefresh();
|
|
_timer.Start();
|
|
}
|
|
|
|
private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
|
{
|
|
_timer.Stop();
|
|
_refreshCts?.Cancel();
|
|
_refreshCts?.Dispose();
|
|
_refreshCts = null;
|
|
}
|
|
|
|
private void OnSizeChanged(object? sender, SizeChangedEventArgs e)
|
|
{
|
|
ApplyCellSize(_currentCellSize);
|
|
}
|
|
|
|
private void OnTimerTick(object? sender, EventArgs e)
|
|
{
|
|
TriggerContentRefresh();
|
|
}
|
|
|
|
private void OnTimeZoneChanged(object? sender, EventArgs e)
|
|
{
|
|
TriggerContentRefresh();
|
|
}
|
|
|
|
private void TriggerContentRefresh()
|
|
{
|
|
_refreshCts?.Cancel();
|
|
_refreshCts?.Dispose();
|
|
_refreshCts = new CancellationTokenSource();
|
|
|
|
var now = _timeZoneService?.GetCurrentTime() ?? DateTime.Now;
|
|
var isZh = CultureInfo.CurrentCulture.TwoLetterISOLanguageName.Equals("zh", StringComparison.OrdinalIgnoreCase);
|
|
var version = Interlocked.Increment(ref _refreshVersion);
|
|
_ = UpdateContentAsync(now, isZh, version, _refreshCts.Token);
|
|
}
|
|
|
|
private async Task UpdateContentAsync(DateTime now, bool isZh, long refreshVersion, CancellationToken cancellationToken)
|
|
{
|
|
HolidayDisplayInfo displayInfo;
|
|
try
|
|
{
|
|
displayInfo = await HolidayService.GetDisplayInfoAsync(now, cancellationToken);
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
return;
|
|
}
|
|
catch
|
|
{
|
|
var fallbackDayType = now.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday
|
|
? HolidayDayType.Weekend
|
|
: HolidayDayType.Workday;
|
|
|
|
displayInfo = new HolidayDisplayInfo(
|
|
NextHoliday: HolidayService.GetNextHoliday(now),
|
|
TodayStatus: new HolidayDayStatus(
|
|
Date: DateOnly.FromDateTime(now.Date),
|
|
DayType: fallbackDayType,
|
|
TypeNameZh: fallbackDayType == HolidayDayType.Weekend ? "\u5468\u672b" : "\u5de5\u4f5c\u65e5",
|
|
IsHoliday: false,
|
|
IsAdjustedWorkday: false,
|
|
NameZh: null,
|
|
NameEn: null,
|
|
TargetHolidayZh: null),
|
|
UsesOnlineData: false);
|
|
}
|
|
|
|
if (cancellationToken.IsCancellationRequested ||
|
|
refreshVersion != Volatile.Read(ref _refreshVersion))
|
|
{
|
|
return;
|
|
}
|
|
|
|
var holiday = displayInfo.NextHoliday;
|
|
|
|
if (holiday is null)
|
|
{
|
|
TitleTextBlock.Text = isZh
|
|
? "\u6682\u65e0\u8282\u5047\u65e5\u6570\u636e"
|
|
: "No holiday data";
|
|
CountTextBlock.Text = "--";
|
|
DayUnitTextBlock.Text = isZh ? "\u5929" : "Days";
|
|
DateTextBlock.Text = "--";
|
|
ApplyCellSize(_currentCellSize);
|
|
return;
|
|
}
|
|
|
|
var today = DateOnly.FromDateTime(now.Date);
|
|
var remainDays = Math.Max(0, holiday.Date.DayNumber - today.DayNumber);
|
|
CountTextBlock.Text = remainDays.ToString(CultureInfo.InvariantCulture);
|
|
|
|
if (isZh)
|
|
{
|
|
if (remainDays == 0)
|
|
{
|
|
TitleTextBlock.Text = $"{holiday.NameZh}\u4eca\u5929";
|
|
}
|
|
else
|
|
{
|
|
var adjustPrefix = displayInfo.TodayStatus.IsAdjustedWorkday
|
|
? string.IsNullOrWhiteSpace(displayInfo.TodayStatus.NameZh)
|
|
? "\u4eca\u65e5\u8c03\u4f11\u8865\u73ed\uff0c"
|
|
: string.Create(CultureInfo.InvariantCulture, $"\u4eca\u65e5{displayInfo.TodayStatus.NameZh}\uff0c")
|
|
: string.Empty;
|
|
TitleTextBlock.Text = string.Create(
|
|
CultureInfo.InvariantCulture,
|
|
$"{adjustPrefix}\u8ddd{holiday.NameZh}\u8fd8\u6709");
|
|
}
|
|
|
|
DayUnitTextBlock.Text = "\u5929";
|
|
|
|
var holidayDateText = HolidayCalendarService.FormatDate(holiday.Date, isZh: true);
|
|
DateTextBlock.Text = displayInfo.TodayStatus.IsAdjustedWorkday && remainDays > 0
|
|
? string.Create(CultureInfo.InvariantCulture, $"{holidayDateText} \u00b7 \u4eca\u65e5\u8865\u73ed")
|
|
: holidayDateText;
|
|
}
|
|
else
|
|
{
|
|
if (remainDays == 0)
|
|
{
|
|
TitleTextBlock.Text = $"{holiday.NameEn} is today";
|
|
}
|
|
else
|
|
{
|
|
var adjustPrefix = displayInfo.TodayStatus.IsAdjustedWorkday
|
|
? "Make-up workday today, "
|
|
: string.Empty;
|
|
TitleTextBlock.Text = $"{adjustPrefix}Days to {holiday.NameEn}";
|
|
}
|
|
|
|
DayUnitTextBlock.Text = "Days";
|
|
|
|
var holidayDateText = HolidayCalendarService.FormatDate(holiday.Date, isZh: false);
|
|
DateTextBlock.Text = displayInfo.TodayStatus.IsAdjustedWorkday && remainDays > 0
|
|
? $"{holidayDateText} - make-up workday"
|
|
: holidayDateText;
|
|
}
|
|
|
|
ApplyCellSize(_currentCellSize);
|
|
}
|
|
|
|
public void ApplyCellSize(double cellSize)
|
|
{
|
|
_currentCellSize = Math.Max(1, cellSize);
|
|
var width = Bounds.Width > 1 ? Bounds.Width : 220;
|
|
var height = Bounds.Height > 1 ? Bounds.Height : 220;
|
|
var shortSide = Math.Min(width, height);
|
|
var scale = ResolveScale(width, height);
|
|
var isCompact = width < 170 || height < 170;
|
|
var isUltraCompact = width < 130 || height < 130;
|
|
var titleUnits = GetDisplayUnits(TitleTextBlock.Text);
|
|
var dateUnits = GetDisplayUnits(DateTextBlock.Text);
|
|
var titleNeedsTwoLines = isUltraCompact || titleUnits >= (isCompact ? 13 : 17);
|
|
var dateNeedsTwoLines = isUltraCompact || dateUnits >= (isCompact ? 15 : 20);
|
|
|
|
RootBorder.CornerRadius = new CornerRadius(Math.Clamp(shortSide * 0.13, 10, 46));
|
|
var padding = Math.Clamp(shortSide * 0.05, 4.5, 21);
|
|
RootBorder.Padding = new Thickness(padding);
|
|
LayoutRoot.RowSpacing = Math.Clamp(shortSide * 0.028, 2.2, 12);
|
|
var rowWeights = ApplyAdaptiveRowHeights(isCompact, isUltraCompact, titleNeedsTwoLines, dateNeedsTwoLines);
|
|
|
|
var innerWidth = Math.Max(1, width - padding * 2);
|
|
var innerHeight = Math.Max(1, height - padding * 2);
|
|
var totalWeight = Math.Max(0.001, rowWeights[0] + rowWeights[1] + rowWeights[2] + rowWeights[3] + rowWeights[4]);
|
|
var row0Height = innerHeight * (rowWeights[0] / totalWeight);
|
|
var row1Height = innerHeight * (rowWeights[1] / totalWeight);
|
|
var row3Height = innerHeight * (rowWeights[3] / totalWeight);
|
|
var row4Height = innerHeight * (rowWeights[4] / totalWeight);
|
|
var horizontalMargin = Math.Clamp(8 * scale, 4, 14);
|
|
var titleMaxWidth = Math.Max(24, innerWidth - horizontalMargin * 2);
|
|
var dateMaxWidth = titleMaxWidth;
|
|
|
|
var titlePreferred = Math.Clamp(24 * scale, 8.8, 34);
|
|
var titleHeightCap = Math.Max(10, row0Height * 0.94);
|
|
var titleLineCount = titleNeedsTwoLines ? 2 : 1;
|
|
TitleTextBlock.MaxLines = titleLineCount;
|
|
TitleTextBlock.TextWrapping = titleLineCount > 1 ? TextWrapping.Wrap : TextWrapping.NoWrap;
|
|
TitleTextBlock.Margin = new Thickness(horizontalMargin, 0, horizontalMargin, 0);
|
|
TitleTextBlock.FontSize = FitTextSize(
|
|
TitleTextBlock.Text,
|
|
TitleTextBlock.FontWeight,
|
|
Math.Min(titlePreferred, Math.Max(8.8, row0Height * 0.62)),
|
|
8.6,
|
|
titleMaxWidth,
|
|
titleHeightCap,
|
|
titleLineCount,
|
|
lineHeightFactor: 1.10);
|
|
TitleTextBlock.LineHeight = TitleTextBlock.FontSize * 1.10;
|
|
|
|
var digitCount = Math.Max(1, CountTextBlock.Text?.Trim().Length ?? 1);
|
|
var digitCompression = digitCount switch
|
|
{
|
|
>= 5 => 0.68,
|
|
4 => 0.8,
|
|
3 => 0.9,
|
|
_ => 1.0
|
|
};
|
|
var countCompactFactor = isUltraCompact ? 0.86 : isCompact ? 0.93 : 1.0;
|
|
var countPreferred = Math.Clamp(132 * scale * digitCompression * countCompactFactor, 28, 170);
|
|
var countHeightCap = Math.Max(30, row1Height * 0.96);
|
|
CountTextBlock.FontSize = FitTextSize(
|
|
CountTextBlock.Text,
|
|
CountTextBlock.FontWeight,
|
|
Math.Min(countPreferred, Math.Max(28, row1Height * 0.9)),
|
|
24,
|
|
titleMaxWidth,
|
|
countHeightCap,
|
|
maxLines: 1,
|
|
lineHeightFactor: 1.08);
|
|
CountTextBlock.LineHeight = CountTextBlock.FontSize * 1.08;
|
|
|
|
var unitCompactFactor = isUltraCompact ? 0.8 : isCompact ? 0.9 : 1.0;
|
|
DayUnitTextBlock.FontSize = Math.Clamp(52 * scale * unitCompactFactor, 10, 72);
|
|
DayUnitTextBlock.FontSize = Math.Min(DayUnitTextBlock.FontSize, Math.Max(10, row3Height * 0.64));
|
|
DayUnitTextBlock.LineHeight = DayUnitTextBlock.FontSize * 1.02;
|
|
|
|
var dateCompactFactor = isUltraCompact ? 0.84 : isCompact ? 0.92 : 1.0;
|
|
var datePreferred = Math.Clamp(32 * scale * dateCompactFactor, 9, 46);
|
|
var dateHeightCap = Math.Max(10, row4Height * 0.96);
|
|
var dateLineCount = dateNeedsTwoLines ? 2 : 1;
|
|
DateTextBlock.MaxLines = dateLineCount;
|
|
DateTextBlock.TextWrapping = dateLineCount > 1 ? TextWrapping.Wrap : TextWrapping.NoWrap;
|
|
DateTextBlock.Margin = new Thickness(horizontalMargin, 0, horizontalMargin, 0);
|
|
DateTextBlock.FontSize = FitTextSize(
|
|
DateTextBlock.Text,
|
|
DateTextBlock.FontWeight,
|
|
Math.Min(datePreferred, Math.Max(9, row4Height * 0.58)),
|
|
8.5,
|
|
dateMaxWidth,
|
|
dateHeightCap,
|
|
dateLineCount,
|
|
lineHeightFactor: 1.12);
|
|
DateTextBlock.LineHeight = DateTextBlock.FontSize * 1.12;
|
|
}
|
|
|
|
private double[] ApplyAdaptiveRowHeights(
|
|
bool isCompact,
|
|
bool isUltraCompact,
|
|
bool titleNeedsTwoLines,
|
|
bool dateNeedsTwoLines)
|
|
{
|
|
var weights = isUltraCompact
|
|
? new[] { 1.35, 2.55, 0.48, 0.6, 0.82 }
|
|
: isCompact
|
|
? new[] { 1.2, 2.45, 0.56, 0.7, 0.9 }
|
|
: new[] { 1.1, 2.3, 0.62, 0.78, 0.95 };
|
|
|
|
if (titleNeedsTwoLines)
|
|
{
|
|
weights[0] += 0.36;
|
|
weights[1] -= 0.21;
|
|
weights[2] -= 0.08;
|
|
weights[3] -= 0.07;
|
|
}
|
|
|
|
if (dateNeedsTwoLines)
|
|
{
|
|
weights[4] += 0.42;
|
|
weights[1] -= 0.23;
|
|
weights[2] -= 0.10;
|
|
weights[3] -= 0.09;
|
|
}
|
|
|
|
weights[0] = Math.Max(0.92, weights[0]);
|
|
weights[1] = Math.Max(1.45, weights[1]);
|
|
weights[2] = Math.Max(0.34, weights[2]);
|
|
weights[3] = Math.Max(0.44, weights[3]);
|
|
weights[4] = Math.Max(0.72, weights[4]);
|
|
|
|
if (LayoutRoot.RowDefinitions.Count < 5)
|
|
{
|
|
return weights;
|
|
}
|
|
|
|
for (var i = 0; i < 5; i++)
|
|
{
|
|
LayoutRoot.RowDefinitions[i].Height = new GridLength(weights[i], GridUnitType.Star);
|
|
}
|
|
|
|
return weights;
|
|
}
|
|
|
|
private static int GetDisplayUnits(string? text)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(text))
|
|
{
|
|
return 0;
|
|
}
|
|
|
|
var units = 0;
|
|
foreach (var ch in text.Trim())
|
|
{
|
|
if (char.IsWhiteSpace(ch))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
units += ch > 0x7F ? 2 : 1;
|
|
}
|
|
|
|
return units;
|
|
}
|
|
|
|
private static double FitTextSize(
|
|
string? text,
|
|
FontWeight fontWeight,
|
|
double preferredSize,
|
|
double minSize,
|
|
double maxWidth,
|
|
double maxHeight,
|
|
int maxLines,
|
|
double lineHeightFactor)
|
|
{
|
|
var safeText = string.IsNullOrWhiteSpace(text) ? " " : text.Trim();
|
|
var safeMaxWidth = Math.Max(1, maxWidth);
|
|
var safeMaxHeight = Math.Max(1, maxHeight);
|
|
var safeMaxLines = Math.Max(1, maxLines);
|
|
|
|
var probe = new TextBlock
|
|
{
|
|
Text = safeText,
|
|
FontWeight = fontWeight,
|
|
MaxLines = safeMaxLines,
|
|
TextWrapping = safeMaxLines > 1 ? TextWrapping.Wrap : TextWrapping.NoWrap
|
|
};
|
|
|
|
for (var size = preferredSize; size >= minSize; size -= 0.5)
|
|
{
|
|
probe.FontSize = size;
|
|
probe.LineHeight = size * lineHeightFactor;
|
|
probe.Measure(new Size(safeMaxWidth, double.PositiveInfinity));
|
|
var desired = probe.DesiredSize;
|
|
if (desired.Width <= safeMaxWidth + 0.6 &&
|
|
desired.Height <= safeMaxHeight + 0.6)
|
|
{
|
|
return size;
|
|
}
|
|
}
|
|
|
|
return minSize;
|
|
}
|
|
|
|
private double ResolveScale(double width, double height)
|
|
{
|
|
var cellScale = Math.Clamp(_currentCellSize / 44d, 0.56, 2.0);
|
|
var widthScale = Math.Clamp(width / 220d, 0.5, 2.0);
|
|
var heightScale = Math.Clamp(height / 220d, 0.5, 2.0);
|
|
return Math.Clamp(Math.Min(cellScale, Math.Min(widthScale, heightScale) * 1.02), 0.5, 2.0);
|
|
}
|
|
}
|