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 SelectedDateProperty = AvaloniaProperty.Register( nameof(SelectedDate), defaultBindingMode: BindingMode.TwoWay); public static readonly StyledProperty ShowTimeProperty = AvaloniaProperty.Register(nameof(ShowTime), false); public static readonly StyledProperty IsRangeProperty = AvaloniaProperty.Register(nameof(IsRange), false); public static readonly StyledProperty StartDateProperty = AvaloniaProperty.Register( nameof(StartDate), defaultBindingMode: BindingMode.TwoWay); public static readonly StyledProperty EndDateProperty = AvaloniaProperty.Register( nameof(EndDate), defaultBindingMode: BindingMode.TwoWay); public static readonly StyledProperty WatermarkProperty = AvaloniaProperty.Register(nameof(Watermark), "Pick a date"); public static readonly StyledProperty DisplayTextProperty = AvaloniaProperty.Register(nameof(DisplayText)); public static readonly StyledProperty DisplayForegroundProperty = AvaloniaProperty.Register(nameof(DisplayForeground)); public static readonly StyledProperty TimeTextProperty = AvaloniaProperty.Register( 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; } }