Files
LanMountainDesktop/LanMountainDesktop.AirAppHost/ClockAirAppView.axaml.cs

666 lines
24 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Globalization;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Primitives;
using Avalonia.Interactivity;
using Avalonia.Layout;
using Avalonia.Media;
using Avalonia.Threading;
using LanMountainDesktop.Services;
using LanMountainDesktop.Services.ClockAirApp;
namespace LanMountainDesktop.AirAppHost;
public sealed partial class ClockAirAppView : UserControl
{
private sealed class WorldClockRowVisual
{
public required TimeZoneInfo TimeZone { get; init; }
public required TextBlock TimeTextBlock { get; init; }
public required TextBlock DateTextBlock { get; init; }
public required TextBlock OffsetTextBlock { get; init; }
}
private readonly DispatcherTimer _clockTimer = new()
{
Interval = TimeSpan.FromMilliseconds(250)
};
private readonly AirAppLaunchOptions _options;
private readonly ClockAirAppSettingsStore _settingsStore = new();
private readonly LocalizationService _localizationService = new();
private readonly ClockAirAppStopwatchState _stopwatchState = new();
private readonly ClockAirAppTimerState _timerState = new();
private readonly List<TimeZoneInfo> _allTimeZones;
private readonly List<WorldClockRowVisual> _worldClockRows = [];
private ClockAirAppSettingsSnapshot _settings = ClockAirAppSettingsSnapshot.Normalize(null);
private CultureInfo _culture = CultureInfo.CurrentCulture;
private string _languageCode = "zh-CN";
private string _selectedTab = ClockAirAppTabIds.WorldClock;
private bool _suppressSettingsEvents;
public ClockAirAppView()
: this(AirAppLaunchOptions.Parse([]))
{
}
public ClockAirAppView(AirAppLaunchOptions options)
{
_options = options;
_allTimeZones = TimeZoneInfo.GetSystemTimeZones()
.OrderBy(static zone => zone.GetUtcOffset(DateTime.UtcNow))
.ThenBy(static zone => zone.DisplayName, StringComparer.OrdinalIgnoreCase)
.ToList();
InitializeComponent();
LoadLanguage();
LoadSettings();
ApplyLocalizedText();
PopulateSettingsControls();
PopulateTimeZoneCombo();
RebuildWorldClockRows();
SelectStartupTab();
UpdateAll();
_clockTimer.Tick += OnClockTimerTick;
AttachedToVisualTree += (_, _) =>
{
UpdateAll();
_clockTimer.Start();
};
DetachedFromVisualTree += (_, _) => _clockTimer.Stop();
}
private void LoadLanguage()
{
try
{
var appSettings = new AppSettingsService().Load();
_languageCode = _localizationService.NormalizeLanguageCode(appSettings.LanguageCode);
_culture = CultureInfo.GetCultureInfo(_languageCode);
}
catch
{
_languageCode = "zh-CN";
_culture = CultureInfo.GetCultureInfo("zh-CN");
}
}
private void LoadSettings()
{
_settings = _settingsStore.Load();
_timerState.SetDuration(TimeSpan.FromMinutes(5));
}
private void ApplyLocalizedText()
{
HeaderTitleTextBlock.Text = L("clockairapp.title", "Clock");
HeaderSubtitleTextBlock.Text = L("clockairapp.subtitle", "World clock, stopwatch and timer");
WorldTabButton.Content = L("clockairapp.tab.world", "World");
StopwatchTabButton.Content = L("clockairapp.tab.stopwatch", "Stopwatch");
TimerTabButton.Content = L("clockairapp.tab.timer", "Timer");
SettingsTabButton.Content = L("clockairapp.tab.settings", "Settings");
LocalLabelTextBlock.Text = L("clockairapp.world.local", "Local time");
AddCityButton.Content = L("clockairapp.world.add", "Add");
TimeZoneSearchTextBox.PlaceholderText = L("clockairapp.world.search", "Search city or time zone");
StopwatchHintTextBlock.Text = L("clockairapp.stopwatch.hint", "Lap timing stays in this window session.");
StopwatchStartPauseButton.Content = L("clockairapp.action.start", "Start");
StopwatchLapButton.Content = L("clockairapp.stopwatch.lap", "Lap");
StopwatchResetButton.Content = L("clockairapp.action.reset", "Reset");
TimerHintTextBlock.Text = L("clockairapp.timer.hint", "Choose a preset or enter custom minutes.");
TimerApplyButton.Content = L("clockairapp.timer.apply", "Apply");
TimerStartPauseButton.Content = L("clockairapp.action.start", "Start");
TimerResetButton.Content = L("clockairapp.action.reset", "Reset");
TimerMinutesTextBox.PlaceholderText = L("clockairapp.timer.minutes", "Minutes");
SettingsHeaderTextBlock.Text = L("clockairapp.settings.title", "Clock settings");
TimeFormatLabelTextBlock.Text = L("clockairapp.settings.time_format", "Time format");
StartupTabLabelTextBlock.Text = L("clockairapp.settings.startup_tab", "Startup page");
ShowSecondsCheckBox.Content = L("clockairapp.settings.show_seconds", "Show seconds");
ActivateOnTimerFinishedCheckBox.Content = L("clockairapp.settings.activate_timer", "Activate window when timer finishes");
}
private void PopulateSettingsControls()
{
_suppressSettingsEvents = true;
try
{
SetComboItems(
TimeFormatComboBox,
[
(ClockAirAppTimeFormatMode.System, L("clockairapp.settings.time_format.system", "Follow system")),
(ClockAirAppTimeFormatMode.TwentyFourHour, L("clockairapp.settings.time_format.24h", "24-hour")),
(ClockAirAppTimeFormatMode.TwelveHour, L("clockairapp.settings.time_format.12h", "12-hour"))
],
_settings.TimeFormatMode);
SetComboItems(
StartupTabComboBox,
[
(ClockAirAppTabIds.Last, L("clockairapp.settings.startup.last", "Last used")),
(ClockAirAppTabIds.WorldClock, L("clockairapp.tab.world", "World")),
(ClockAirAppTabIds.Stopwatch, L("clockairapp.tab.stopwatch", "Stopwatch")),
(ClockAirAppTabIds.Timer, L("clockairapp.tab.timer", "Timer"))
],
_settings.StartupTab);
ShowSecondsCheckBox.IsChecked = _settings.ShowSeconds;
ActivateOnTimerFinishedCheckBox.IsChecked = _settings.ActivateOnTimerFinished;
}
finally
{
_suppressSettingsEvents = false;
}
}
private static void SetComboItems(ComboBox comboBox, IEnumerable<(string Id, string Text)> items, string selectedId)
{
comboBox.Items.Clear();
foreach (var item in items)
{
comboBox.Items.Add(new ComboBoxItem
{
Tag = item.Id,
Content = item.Text
});
}
comboBox.SelectedItem = comboBox.Items
.OfType<ComboBoxItem>()
.FirstOrDefault(item => string.Equals(item.Tag as string, selectedId, StringComparison.OrdinalIgnoreCase))
?? comboBox.Items.OfType<ComboBoxItem>().FirstOrDefault();
}
private void SelectStartupTab()
{
var startupTab = ClockAirAppTabIds.Normalize(_settings.StartupTab, ClockAirAppTabIds.Last);
var tab = string.Equals(startupTab, ClockAirAppTabIds.Last, StringComparison.OrdinalIgnoreCase)
? ClockAirAppTabIds.Normalize(_settings.LastSelectedTab)
: ClockAirAppTabIds.Normalize(startupTab);
SelectTab(tab, save: false);
}
private void OnClockTimerTick(object? sender, EventArgs e)
{
_ = sender;
_ = e;
UpdateAll();
}
private void UpdateAll()
{
var now = DateTimeOffset.Now;
UpdateWorldClock(now);
UpdateStopwatch(now);
UpdateTimer(now);
}
private void UpdateWorldClock(DateTimeOffset now)
{
var localNow = now.LocalDateTime;
LocalTimeTextBlock.Text = ClockAirAppTimeFormatter.FormatTime(localNow, _settings, _culture);
LocalDateTextBlock.Text = localNow.ToString("yyyy-MM-dd dddd", _culture);
LocalTimeZoneTextBlock.Text = TimeZoneInfo.Local.DisplayName;
WorldSummaryTextBlock.Text = Lf("clockairapp.world.count", "{0} cities", _settings.WorldClockTimeZoneIds.Count);
var utcNow = now.UtcDateTime;
foreach (var row in _worldClockRows)
{
var zonedTime = TimeZoneInfo.ConvertTimeFromUtc(utcNow, row.TimeZone);
row.TimeTextBlock.Text = ClockAirAppTimeFormatter.FormatTime(zonedTime, _settings, _culture);
row.DateTextBlock.Text = $"{ResolveRelativeDayLabel((zonedTime.Date - localNow.Date).Days)} - {zonedTime.ToString("yyyy-MM-dd", _culture)}";
row.OffsetTextBlock.Text = ClockAirAppTimeFormatter.FormatUtcOffset(row.TimeZone.GetUtcOffset(utcNow));
}
}
private void UpdateStopwatch(DateTimeOffset now)
{
StopwatchElapsedTextBlock.Text = ClockAirAppTimeFormatter.FormatDuration(_stopwatchState.GetElapsed(now), includeMilliseconds: true);
StopwatchStartPauseButton.Content = _stopwatchState.IsRunning
? L("clockairapp.action.pause", "Pause")
: L("clockairapp.action.start", "Start");
StopwatchLapButton.IsEnabled = _stopwatchState.GetElapsed(now) > TimeSpan.Zero;
StopwatchResetButton.IsEnabled = _stopwatchState.GetElapsed(now) > TimeSpan.Zero || _stopwatchState.Laps.Count > 0;
}
private void UpdateTimer(DateTimeOffset now)
{
if (_timerState.Update(now))
{
TimerStatusTextBlock.Text = L("clockairapp.timer.finished", "Timer finished");
if (_settings.ActivateOnTimerFinished && VisualRoot is Window window)
{
window.Activate();
}
}
TimerRemainingTextBlock.Text = ClockAirAppTimeFormatter.FormatDuration(_timerState.GetRemaining(now));
TimerStartPauseButton.Content = _timerState.IsRunning
? L("clockairapp.action.pause", "Pause")
: L("clockairapp.action.start", "Start");
TimerResetButton.IsEnabled = _timerState.GetRemaining(now) < _timerState.Duration || _timerState.IsCompleted;
if (!_timerState.IsCompleted && string.IsNullOrWhiteSpace(TimerStatusTextBlock.Text))
{
TimerStatusTextBlock.Text = Lf("clockairapp.timer.duration_status", "Duration {0}", ClockAirAppTimeFormatter.FormatDuration(_timerState.Duration));
}
}
private void OnTabButtonClick(object? sender, RoutedEventArgs e)
{
if (sender is ToggleButton button && button.Tag is string tab)
{
SelectTab(tab, save: true);
}
}
private void SelectTab(string tab, bool save)
{
_selectedTab = ClockAirAppTabIds.Normalize(tab);
WorldPage.IsVisible = string.Equals(_selectedTab, ClockAirAppTabIds.WorldClock, StringComparison.OrdinalIgnoreCase);
StopwatchPage.IsVisible = string.Equals(_selectedTab, ClockAirAppTabIds.Stopwatch, StringComparison.OrdinalIgnoreCase);
TimerPage.IsVisible = string.Equals(_selectedTab, ClockAirAppTabIds.Timer, StringComparison.OrdinalIgnoreCase);
SettingsPage.IsVisible = string.Equals(_selectedTab, ClockAirAppTabIds.Settings, StringComparison.OrdinalIgnoreCase);
WorldTabButton.IsChecked = WorldPage.IsVisible;
StopwatchTabButton.IsChecked = StopwatchPage.IsVisible;
TimerTabButton.IsChecked = TimerPage.IsVisible;
SettingsTabButton.IsChecked = SettingsPage.IsVisible;
if (save)
{
_settings.LastSelectedTab = _selectedTab;
_settingsStore.Save(_settings);
}
}
private void OnTimeZoneSearchChanged(object? sender, TextChangedEventArgs e)
{
_ = sender;
_ = e;
PopulateTimeZoneCombo();
}
private void PopulateTimeZoneCombo()
{
var query = TimeZoneSearchTextBox.Text?.Trim() ?? string.Empty;
var zones = _allTimeZones
.Where(zone => MatchesTimeZoneQuery(zone, query))
.Take(80)
.ToList();
TimeZoneComboBox.Items.Clear();
foreach (var zone in zones)
{
TimeZoneComboBox.Items.Add(new ComboBoxItem
{
Tag = zone.Id,
Content = FormatTimeZoneOption(zone)
});
}
TimeZoneComboBox.SelectedItem = TimeZoneComboBox.Items.OfType<ComboBoxItem>().FirstOrDefault();
}
private bool MatchesTimeZoneQuery(TimeZoneInfo zone, string query)
{
if (string.IsNullOrWhiteSpace(query))
{
return true;
}
var cityName = ClockAirAppTimeFormatter.ResolveCityName(zone, _languageCode);
return zone.Id.Contains(query, StringComparison.OrdinalIgnoreCase) ||
zone.DisplayName.Contains(query, StringComparison.OrdinalIgnoreCase) ||
zone.StandardName.Contains(query, StringComparison.OrdinalIgnoreCase) ||
cityName.Contains(query, StringComparison.OrdinalIgnoreCase);
}
private string FormatTimeZoneOption(TimeZoneInfo zone)
{
return $"{ClockAirAppTimeFormatter.FormatUtcOffset(zone.GetUtcOffset(DateTime.UtcNow))} | {ClockAirAppTimeFormatter.ResolveCityName(zone, _languageCode)} | {zone.StandardName}";
}
private void OnAddCityClick(object? sender, RoutedEventArgs e)
{
_ = sender;
_ = e;
if (TimeZoneComboBox.SelectedItem is not ComboBoxItem item || item.Tag is not string zoneId)
{
return;
}
if (_settings.WorldClockTimeZoneIds.Any(existing => string.Equals(existing, zoneId, StringComparison.OrdinalIgnoreCase)))
{
return;
}
_settings.WorldClockTimeZoneIds.Add(zoneId);
SaveWorldClockSettings();
}
private void RebuildWorldClockRows()
{
_worldClockRows.Clear();
WorldClockRowsPanel.Children.Clear();
for (var index = 0; index < _settings.WorldClockTimeZoneIds.Count; index++)
{
var timeZone = WorldClockTimeZoneCatalog.ResolveTimeZoneOrLocal(_settings.WorldClockTimeZoneIds[index]);
AddWorldClockRow(timeZone, index);
}
}
private void AddWorldClockRow(TimeZoneInfo timeZone, int index)
{
var cityText = new TextBlock
{
Text = ClockAirAppTimeFormatter.ResolveCityName(timeZone, _languageCode),
FontSize = 15,
FontWeight = FontWeight.SemiBold,
Foreground = TryGetBrush("AirAppTitleTextBrush", "#FF171A20")
};
var timeText = new TextBlock
{
FontSize = 24,
FontWeight = FontWeight.SemiBold,
LetterSpacing = 0,
Foreground = TryGetBrush("AirAppTitleTextBrush", "#FF171A20"),
HorizontalAlignment = HorizontalAlignment.Right
};
var dateText = new TextBlock
{
FontSize = 12,
Foreground = TryGetBrush("AirAppSecondaryTextBrush", "#FF657080")
};
var offsetText = new TextBlock
{
FontSize = 12,
Foreground = TryGetBrush("AirAppSecondaryTextBrush", "#FF657080"),
HorizontalAlignment = HorizontalAlignment.Right
};
var upButton = CreateIconButton("↑", L("clockairapp.action.move_up", "Move up"));
upButton.IsEnabled = index > 0;
upButton.Click += (_, _) => MoveWorldClock(index, -1);
var downButton = CreateIconButton("↓", L("clockairapp.action.move_down", "Move down"));
downButton.IsEnabled = index < _settings.WorldClockTimeZoneIds.Count - 1;
downButton.Click += (_, _) => MoveWorldClock(index, 1);
var removeButton = CreateIconButton("×", L("clockairapp.action.remove", "Remove"));
removeButton.IsEnabled = _settings.WorldClockTimeZoneIds.Count > 1;
removeButton.Click += (_, _) => RemoveWorldClock(index);
var row = new Grid
{
ColumnDefinitions = new ColumnDefinitions("*,Auto,Auto,Auto,Auto"),
ColumnSpacing = 8
};
var leftStack = new StackPanel
{
Spacing = 3,
VerticalAlignment = VerticalAlignment.Center,
Children =
{
cityText,
dateText
}
};
var timeStack = new StackPanel
{
Spacing = 2,
VerticalAlignment = VerticalAlignment.Center,
Children =
{
timeText,
offsetText
}
};
row.Children.Add(leftStack);
row.Children.Add(timeStack);
row.Children.Add(upButton);
row.Children.Add(downButton);
row.Children.Add(removeButton);
Grid.SetColumn(timeStack, 1);
Grid.SetColumn(upButton, 2);
Grid.SetColumn(downButton, 3);
Grid.SetColumn(removeButton, 4);
WorldClockRowsPanel.Children.Add(new Border
{
Background = new SolidColorBrush(Color.Parse("#0A000000")),
CornerRadius = new CornerRadius(14),
Padding = new Thickness(12, 10),
Child = row
});
_worldClockRows.Add(new WorldClockRowVisual
{
TimeZone = timeZone,
TimeTextBlock = timeText,
DateTextBlock = dateText,
OffsetTextBlock = offsetText
});
}
private Button CreateIconButton(string text, string tooltip)
{
var button = new Button
{
Content = text,
Classes = { "clock-icon-command" }
};
ToolTip.SetTip(button, tooltip);
return button;
}
private void MoveWorldClock(int index, int delta)
{
var nextIndex = index + delta;
if (index < 0 || nextIndex < 0 || index >= _settings.WorldClockTimeZoneIds.Count || nextIndex >= _settings.WorldClockTimeZoneIds.Count)
{
return;
}
(_settings.WorldClockTimeZoneIds[index], _settings.WorldClockTimeZoneIds[nextIndex]) =
(_settings.WorldClockTimeZoneIds[nextIndex], _settings.WorldClockTimeZoneIds[index]);
SaveWorldClockSettings();
}
private void RemoveWorldClock(int index)
{
if (_settings.WorldClockTimeZoneIds.Count <= 1 || index < 0 || index >= _settings.WorldClockTimeZoneIds.Count)
{
return;
}
_settings.WorldClockTimeZoneIds.RemoveAt(index);
SaveWorldClockSettings();
}
private void SaveWorldClockSettings()
{
_settings = ClockAirAppSettingsSnapshot.Normalize(_settings);
_settingsStore.Save(_settings);
RebuildWorldClockRows();
UpdateWorldClock(DateTimeOffset.Now);
}
private void OnStopwatchStartPauseClick(object? sender, RoutedEventArgs e)
{
_ = sender;
_ = e;
var now = DateTimeOffset.Now;
if (_stopwatchState.IsRunning)
{
_stopwatchState.Pause(now);
}
else
{
_stopwatchState.StartOrResume(now);
}
UpdateStopwatch(now);
}
private void OnStopwatchLapClick(object? sender, RoutedEventArgs e)
{
_ = sender;
_ = e;
_ = _stopwatchState.AddLap(DateTimeOffset.Now);
RebuildStopwatchLaps();
}
private void OnStopwatchResetClick(object? sender, RoutedEventArgs e)
{
_ = sender;
_ = e;
_stopwatchState.Reset();
RebuildStopwatchLaps();
UpdateStopwatch(DateTimeOffset.Now);
}
private void RebuildStopwatchLaps()
{
StopwatchLapsPanel.Children.Clear();
for (var index = 0; index < _stopwatchState.Laps.Count; index++)
{
var lap = _stopwatchState.Laps[index];
StopwatchLapsPanel.Children.Add(new TextBlock
{
Text = Lf("clockairapp.stopwatch.lap_format", "Lap {0} {1}", _stopwatchState.Laps.Count - index, ClockAirAppTimeFormatter.FormatDuration(lap, includeMilliseconds: true)),
Foreground = TryGetBrush("AirAppSecondaryTextBrush", "#FF657080"),
FontSize = 13
});
}
}
private void OnTimerPresetClick(object? sender, RoutedEventArgs e)
{
if (sender is Button button &&
button.Tag is string minutesText &&
int.TryParse(minutesText, NumberStyles.Integer, CultureInfo.InvariantCulture, out var minutes))
{
SetTimerDuration(minutes);
}
}
private void OnTimerApplyClick(object? sender, RoutedEventArgs e)
{
_ = sender;
_ = e;
if (!int.TryParse(TimerMinutesTextBox.Text, NumberStyles.Integer, CultureInfo.CurrentCulture, out var minutes))
{
TimerStatusTextBlock.Text = L("clockairapp.timer.invalid", "Enter a valid minute value.");
return;
}
SetTimerDuration(minutes);
}
private void SetTimerDuration(int minutes)
{
minutes = Math.Clamp(minutes, 1, 24 * 60);
TimerMinutesTextBox.Text = minutes.ToString(CultureInfo.CurrentCulture);
_timerState.SetDuration(TimeSpan.FromMinutes(minutes));
TimerStatusTextBlock.Text = Lf("clockairapp.timer.duration_status", "Duration {0}", ClockAirAppTimeFormatter.FormatDuration(_timerState.Duration));
UpdateTimer(DateTimeOffset.Now);
}
private void OnTimerStartPauseClick(object? sender, RoutedEventArgs e)
{
_ = sender;
_ = e;
var now = DateTimeOffset.Now;
if (_timerState.IsRunning)
{
_timerState.Pause(now);
}
else
{
_timerState.StartOrResume(now);
TimerStatusTextBlock.Text = string.Empty;
}
UpdateTimer(now);
}
private void OnTimerResetClick(object? sender, RoutedEventArgs e)
{
_ = sender;
_ = e;
_timerState.Reset();
TimerStatusTextBlock.Text = Lf("clockairapp.timer.duration_status", "Duration {0}", ClockAirAppTimeFormatter.FormatDuration(_timerState.Duration));
UpdateTimer(DateTimeOffset.Now);
}
private void OnSettingsChanged(object? sender, SelectionChangedEventArgs e)
{
_ = e;
SaveSettingsFromControls(sender);
}
private void OnSettingsChanged(object? sender, RoutedEventArgs e)
{
_ = e;
SaveSettingsFromControls(sender);
}
private void SaveSettingsFromControls(object? sender)
{
_ = sender;
if (_suppressSettingsEvents)
{
return;
}
_settings.TimeFormatMode = TimeFormatComboBox.SelectedItem is ComboBoxItem timeFormatItem && timeFormatItem.Tag is string timeFormat
? timeFormat
: ClockAirAppTimeFormatMode.System;
_settings.StartupTab = StartupTabComboBox.SelectedItem is ComboBoxItem startupItem && startupItem.Tag is string startupTab
? startupTab
: ClockAirAppTabIds.Last;
_settings.ShowSeconds = ShowSecondsCheckBox.IsChecked == true;
_settings.ActivateOnTimerFinished = ActivateOnTimerFinishedCheckBox.IsChecked == true;
_settingsStore.Save(_settings);
UpdateAll();
}
private string ResolveRelativeDayLabel(int dayDelta)
{
if (dayDelta < 0)
{
return L("worldclock.widget.yesterday", "Yesterday");
}
if (dayDelta > 0)
{
return L("worldclock.widget.tomorrow", "Tomorrow");
}
return L("worldclock.widget.today", "Today");
}
private IBrush TryGetBrush(string resourceKey, string fallbackColor)
{
return this.TryFindResource(resourceKey, out var value) && value is IBrush brush
? brush
: new SolidColorBrush(Color.Parse(fallbackColor));
}
private string L(string key, string fallback)
{
return _localizationService.GetString(_languageCode, key, fallback);
}
private string Lf(string key, string fallback, params object[] args)
{
return string.Format(_culture, L(key, fallback), args);
}
}