Files
ClaudeDo/src/ClaudeDo.Ui/Views/Controls/ThemedDatePicker.axaml.cs
mika kuns 47b07373af feat(ui): add ThemedDatePicker control and adopt in Prime settings
New themed picker supports single-date, date+time, and range modes
(replaces inconsistent CalendarDatePicker / DatePicker / TimePicker
visuals). Used in the Prime schedules row to combine StartDate /
EndDate into a single range picker.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 10:39:53 +02:00

424 lines
13 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;
using System.Globalization;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Data;
using Avalonia.Interactivity;
using Avalonia.Layout;
using Avalonia.Media;
namespace ClaudeDo.Ui.Views.Controls;
public partial class ThemedDatePicker : UserControl
{
public static readonly StyledProperty<DateTime?> SelectedDateProperty =
AvaloniaProperty.Register<ThemedDatePicker, DateTime?>(
nameof(SelectedDate), defaultBindingMode: BindingMode.TwoWay);
public static readonly StyledProperty<bool> ShowTimeProperty =
AvaloniaProperty.Register<ThemedDatePicker, bool>(nameof(ShowTime), false);
public static readonly StyledProperty<bool> IsRangeProperty =
AvaloniaProperty.Register<ThemedDatePicker, bool>(nameof(IsRange), false);
public static readonly StyledProperty<DateTime?> StartDateProperty =
AvaloniaProperty.Register<ThemedDatePicker, DateTime?>(
nameof(StartDate), defaultBindingMode: BindingMode.TwoWay);
public static readonly StyledProperty<DateTime?> EndDateProperty =
AvaloniaProperty.Register<ThemedDatePicker, DateTime?>(
nameof(EndDate), defaultBindingMode: BindingMode.TwoWay);
public static readonly StyledProperty<string?> WatermarkProperty =
AvaloniaProperty.Register<ThemedDatePicker, string?>(nameof(Watermark), "Pick a date");
public static readonly StyledProperty<string?> DisplayTextProperty =
AvaloniaProperty.Register<ThemedDatePicker, string?>(nameof(DisplayText));
public static readonly StyledProperty<IBrush?> DisplayForegroundProperty =
AvaloniaProperty.Register<ThemedDatePicker, IBrush?>(nameof(DisplayForeground));
public static readonly StyledProperty<string?> TimeTextProperty =
AvaloniaProperty.Register<ThemedDatePicker, string?>(
nameof(TimeText), "09:00",
defaultBindingMode: BindingMode.TwoWay);
public DateTime? SelectedDate
{
get => GetValue(SelectedDateProperty);
set => SetValue(SelectedDateProperty, value);
}
public bool ShowTime
{
get => GetValue(ShowTimeProperty);
set => SetValue(ShowTimeProperty, value);
}
public bool IsRange
{
get => GetValue(IsRangeProperty);
set => SetValue(IsRangeProperty, value);
}
public DateTime? StartDate
{
get => GetValue(StartDateProperty);
set => SetValue(StartDateProperty, value);
}
public DateTime? EndDate
{
get => GetValue(EndDateProperty);
set => SetValue(EndDateProperty, value);
}
public string? Watermark
{
get => GetValue(WatermarkProperty);
set => SetValue(WatermarkProperty, value);
}
public string? DisplayText
{
get => GetValue(DisplayTextProperty);
set => SetValue(DisplayTextProperty, value);
}
public IBrush? DisplayForeground
{
get => GetValue(DisplayForegroundProperty);
set => SetValue(DisplayForegroundProperty, value);
}
public string? TimeText
{
get => GetValue(TimeTextProperty);
set => SetValue(TimeTextProperty, value);
}
private static readonly string[] TimeFormats = { @"h\:mm", @"hh\:mm" };
private DateTime _displayMonth;
private bool _suppressTimeSync;
public ThemedDatePicker()
{
InitializeComponent();
_displayMonth = new DateTime(DateTime.Today.Year, DateTime.Today.Month, 1);
BuildWeekdayHeaders();
BuildDayGrid();
UpdateDisplayText();
UpdateTimeRowVisibility();
PickerPopup.Opened += OnPopupOpened;
}
private void UpdateTimeRowVisibility()
{
if (TimeRow is null) return;
TimeRow.IsVisible = ShowTime && !IsRange;
}
private void OnPopupOpened(object? sender, EventArgs e)
{
var seed = AnchorDate() ?? DateTime.Today;
_displayMonth = new DateTime(seed.Year, seed.Month, 1);
BuildDayGrid();
}
private DateTime? AnchorDate() =>
IsRange ? (StartDate ?? EndDate) : SelectedDate;
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
{
base.OnPropertyChanged(change);
if (change.Property == SelectedDateProperty)
{
UpdateDisplayText();
SyncTimeFromSelected();
BuildDayGrid();
}
else if (change.Property == StartDateProperty || change.Property == EndDateProperty)
{
UpdateDisplayText();
BuildDayGrid();
}
else if (change.Property == ShowTimeProperty || change.Property == WatermarkProperty
|| change.Property == IsRangeProperty)
{
UpdateDisplayText();
BuildDayGrid();
UpdateTimeRowVisibility();
}
else if (change.Property == TimeTextProperty)
{
ApplyTimeTextToSelected();
}
}
private void UpdateDisplayText()
{
if (IsRange)
{
var (s, end) = NormalizeRange(StartDate?.Date, EndDate?.Date);
if (s is null && end is null)
{
DisplayText = Watermark ?? "Pick a range";
DisplayForeground = TryGetBrush("TextDimBrush");
return;
}
if (s is not null && end is null)
{
DisplayText = $"{s.Value:MMM d} select end";
DisplayForeground = TryGetBrush("TextBrush");
return;
}
// both set
var sd = s!.Value;
var ed = end!.Value;
DisplayText = sd.Year == ed.Year
? $"{sd:MMM d} {ed:MMM d, yyyy}"
: $"{sd:MMM d, yyyy} {ed:MMM d, yyyy}";
DisplayForeground = TryGetBrush("TextBrush");
return;
}
if (SelectedDate is null)
{
DisplayText = Watermark ?? "Pick a date";
DisplayForeground = TryGetBrush("TextDimBrush");
return;
}
var d = SelectedDate.Value;
DisplayText = ShowTime
? d.ToString("MMM d, yyyy · HH:mm", CultureInfo.CurrentCulture)
: d.ToString("MMM d, yyyy", CultureInfo.CurrentCulture);
DisplayForeground = TryGetBrush("TextBrush");
}
private static (DateTime? Start, DateTime? End) NormalizeRange(DateTime? a, DateTime? b)
{
if (a is null && b is null) return (null, null);
if (a is null) return (b, b);
if (b is null) return (a, null);
return a.Value <= b.Value ? (a, b) : (b, a);
}
private IBrush? TryGetBrush(string key)
{
if (this.TryFindResource(key, out var v) && v is IBrush b) return b;
return null;
}
private void SyncTimeFromSelected()
{
if (SelectedDate is null) return;
_suppressTimeSync = true;
TimeText = SelectedDate.Value.ToString("HH:mm");
_suppressTimeSync = false;
}
private void ApplyTimeTextToSelected()
{
if (_suppressTimeSync || !ShowTime || IsRange || SelectedDate is null) return;
if (TryParseTime(TimeText, out var t))
{
var d = SelectedDate.Value.Date + t;
if (d != SelectedDate) SelectedDate = d;
}
}
private static bool TryParseTime(string? text, out TimeSpan ts)
{
ts = default;
if (string.IsNullOrWhiteSpace(text)) return false;
return TimeSpan.TryParseExact(text, TimeFormats, CultureInfo.InvariantCulture, out ts);
}
private void BuildWeekdayHeaders()
{
if (WeekdayHeaders is null) return;
WeekdayHeaders.Children.Clear();
var firstDow = CultureInfo.CurrentCulture.DateTimeFormat.FirstDayOfWeek;
var names = CultureInfo.CurrentCulture.DateTimeFormat.AbbreviatedDayNames;
for (int i = 0; i < 7; i++)
{
var dow = (DayOfWeek)(((int)firstDow + i) % 7);
var name = names[(int)dow];
if (name.Length > 3) name = name.Substring(0, 3);
WeekdayHeaders.Children.Add(new TextBlock { Text = name, Classes = { "weekday" } });
}
}
private void BuildDayGrid()
{
if (DayGrid is null || MonthHeader is null) return;
DayGrid.Children.Clear();
MonthHeader.Text = _displayMonth.ToString("MMMM yyyy", CultureInfo.CurrentCulture);
var firstDow = CultureInfo.CurrentCulture.DateTimeFormat.FirstDayOfWeek;
var offset = ((int)_displayMonth.DayOfWeek - (int)firstDow + 7) % 7;
var start = _displayMonth.AddDays(-offset);
var today = DateTime.Today;
var sel = SelectedDate?.Date;
var (rs, re) = NormalizeRange(StartDate?.Date, EndDate?.Date);
var rangeFill = TryGetBrush("AccentSoftBrush");
for (int i = 0; i < 42; i++)
{
var day = start.AddDays(i);
var cell = new Grid();
if (IsRange && rs.HasValue && re.HasValue && rs.Value != re.Value
&& day >= rs.Value && day <= re.Value)
{
Thickness margin;
if (day == rs.Value)
margin = new Thickness(19, 0, 0, 0);
else if (day == re.Value)
margin = new Thickness(0, 0, 19, 0);
else
margin = default;
cell.Children.Add(new Border
{
Background = rangeFill,
Margin = margin,
HorizontalAlignment = HorizontalAlignment.Stretch,
VerticalAlignment = VerticalAlignment.Stretch
});
}
var btn = new Button
{
Content = day.Day.ToString(CultureInfo.CurrentCulture),
Classes = { "day" },
Tag = day
};
if (day.Month != _displayMonth.Month) btn.Classes.Add("outside");
if (day == today) btn.Classes.Add("today");
var isSelected = IsRange
? (rs.HasValue && day == rs.Value) || (re.HasValue && day == re.Value)
: sel.HasValue && day == sel.Value;
if (isSelected) btn.Classes.Add("selected");
btn.Click += OnDayClick;
cell.Children.Add(btn);
DayGrid.Children.Add(cell);
}
}
private void OnDayClick(object? sender, RoutedEventArgs e)
{
if (sender is not Button { Tag: DateTime day }) return;
if (IsRange)
{
// State A: nothing or both set → start a fresh range
// State B: only start set → complete (or restart) range
if (StartDate is null || EndDate is not null)
{
StartDate = day.Date;
EndDate = null;
}
else
{
var s = StartDate.Value.Date;
if (day.Date < s)
{
StartDate = day.Date;
EndDate = s;
}
else
{
EndDate = day.Date;
}
}
BuildDayGrid();
return;
}
var time = TimeSpan.Zero;
if (ShowTime)
{
if (TryParseTime(TimeText, out var parsed)) time = parsed;
else if (SelectedDate is { } cur) time = cur.TimeOfDay;
}
else if (SelectedDate is { } cur) time = cur.TimeOfDay;
SelectedDate = day.Date + time;
if (!ShowTime) PickerPopup.IsOpen = false;
}
private void OnPrevMonthClick(object? sender, RoutedEventArgs e)
{
_displayMonth = _displayMonth.AddMonths(-1);
BuildDayGrid();
}
private void OnNextMonthClick(object? sender, RoutedEventArgs e)
{
_displayMonth = _displayMonth.AddMonths(1);
BuildDayGrid();
}
private void OnTodayClick(object? sender, RoutedEventArgs e) => SetQuickDate(DateTime.Today);
private void OnTomorrowClick(object? sender, RoutedEventArgs e) => SetQuickDate(DateTime.Today.AddDays(1));
private void OnNextMondayClick(object? sender, RoutedEventArgs e)
{
var today = DateTime.Today;
int delta = ((int)DayOfWeek.Monday - (int)today.DayOfWeek + 7) % 7;
if (delta == 0) delta = 7;
SetQuickDate(today.AddDays(delta));
}
private void OnClearClick(object? sender, RoutedEventArgs e)
{
if (IsRange)
{
StartDate = null;
EndDate = null;
}
else
{
SelectedDate = null;
}
PickerPopup.IsOpen = false;
}
private void SetQuickDate(DateTime date)
{
_displayMonth = new DateTime(date.Year, date.Month, 1);
if (IsRange)
{
StartDate = date.Date;
EndDate = date.Date;
BuildDayGrid();
PickerPopup.IsOpen = false;
return;
}
var time = TimeSpan.Zero;
if (ShowTime)
{
if (!TryParseTime(TimeText, out time))
time = SelectedDate?.TimeOfDay ?? new TimeSpan(9, 0, 0);
}
SelectedDate = date.Date + time;
BuildDayGrid();
if (!ShowTime) PickerPopup.IsOpen = false;
}
private void OnDoneClick(object? sender, RoutedEventArgs e)
{
ApplyTimeTextToSelected();
PickerPopup.IsOpen = false;
}
}