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>
This commit is contained in:
423
src/ClaudeDo.Ui/Views/Controls/ThemedDatePicker.axaml.cs
Normal file
423
src/ClaudeDo.Ui/Views/Controls/ThemedDatePicker.axaml.cs
Normal file
@@ -0,0 +1,423 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user