using System; using System.Diagnostics; using Microsoft.Graphics.Canvas.Geometry; using Microsoft.Graphics.Canvas.UI.Xaml; using Windows.Foundation; using Windows.UI.Input; using Windows.UI.Xaml; using Windows.UI.Xaml.Controls; using Windows.UI.Xaml.Media; using Wino.Calendar.Args; using Wino.Core.Domain.Models.Calendar; namespace Wino.Calendar.Controls; public partial class WinoDayTimelineCanvas : Control, IDisposable { public event EventHandler TimelineCellSelected; public event EventHandler TimelineCellUnselected; private const string PART_InternalCanvas = nameof(PART_InternalCanvas); private CanvasControl Canvas; public static readonly DependencyProperty RenderOptionsProperty = DependencyProperty.Register(nameof(RenderOptions), typeof(CalendarRenderOptions), typeof(WinoDayTimelineCanvas), new PropertyMetadata(null, new PropertyChangedCallback(OnRenderingPropertiesChanged))); public static readonly DependencyProperty SeperatorColorProperty = DependencyProperty.Register(nameof(SeperatorColor), typeof(SolidColorBrush), typeof(WinoDayTimelineCanvas), new PropertyMetadata(null, new PropertyChangedCallback(OnRenderingPropertiesChanged))); public static readonly DependencyProperty HalfHourSeperatorColorProperty = DependencyProperty.Register(nameof(HalfHourSeperatorColor), typeof(SolidColorBrush), typeof(WinoDayTimelineCanvas), new PropertyMetadata(null, new PropertyChangedCallback(OnRenderingPropertiesChanged))); public static readonly DependencyProperty SelectedCellBackgroundBrushProperty = DependencyProperty.Register(nameof(SelectedCellBackgroundBrush), typeof(SolidColorBrush), typeof(WinoDayTimelineCanvas), new PropertyMetadata(null, new PropertyChangedCallback(OnRenderingPropertiesChanged))); public static readonly DependencyProperty WorkingHourCellBackgroundColorProperty = DependencyProperty.Register(nameof(WorkingHourCellBackgroundColor), typeof(SolidColorBrush), typeof(WinoDayTimelineCanvas), new PropertyMetadata(null, new PropertyChangedCallback(OnRenderingPropertiesChanged))); public static readonly DependencyProperty SelectedDateTimeProperty = DependencyProperty.Register(nameof(SelectedDateTime), typeof(DateTime?), typeof(WinoDayTimelineCanvas), new PropertyMetadata(null, new PropertyChangedCallback(OnSelectedDateTimeChanged))); public static readonly DependencyProperty PositionerUIElementProperty = DependencyProperty.Register(nameof(PositionerUIElement), typeof(UIElement), typeof(WinoDayTimelineCanvas), new PropertyMetadata(null)); public UIElement PositionerUIElement { get { return (UIElement)GetValue(PositionerUIElementProperty); } set { SetValue(PositionerUIElementProperty, value); } } public CalendarRenderOptions RenderOptions { get { return (CalendarRenderOptions)GetValue(RenderOptionsProperty); } set { SetValue(RenderOptionsProperty, value); } } public SolidColorBrush HalfHourSeperatorColor { get { return (SolidColorBrush)GetValue(HalfHourSeperatorColorProperty); } set { SetValue(HalfHourSeperatorColorProperty, value); } } public SolidColorBrush SeperatorColor { get { return (SolidColorBrush)GetValue(SeperatorColorProperty); } set { SetValue(SeperatorColorProperty, value); } } public SolidColorBrush WorkingHourCellBackgroundColor { get { return (SolidColorBrush)GetValue(WorkingHourCellBackgroundColorProperty); } set { SetValue(WorkingHourCellBackgroundColorProperty, value); } } public SolidColorBrush SelectedCellBackgroundBrush { get { return (SolidColorBrush)GetValue(SelectedCellBackgroundBrushProperty); } set { SetValue(SelectedCellBackgroundBrushProperty, value); } } public DateTime? SelectedDateTime { get { return (DateTime?)GetValue(SelectedDateTimeProperty); } set { SetValue(SelectedDateTimeProperty, value); } } protected override void OnApplyTemplate() { base.OnApplyTemplate(); Canvas = GetTemplateChild(PART_InternalCanvas) as CanvasControl; // TODO: These will leak. Dispose them properly when needed. Canvas.Draw += OnCanvasDraw; Canvas.PointerPressed += OnCanvasPointerPressed; ForceDraw(); } private static void OnSelectedDateTimeChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) { if (d is WinoDayTimelineCanvas control) { if (e.OldValue != null && e.NewValue == null) { control.RaiseCellUnselected(); } control.ForceDraw(); } } private void RaiseCellUnselected() { TimelineCellUnselected?.Invoke(this, new TimelineCellUnselectedArgs()); } private void OnCanvasPointerPressed(object sender, Windows.UI.Xaml.Input.PointerRoutedEventArgs e) { if (RenderOptions == null) return; var hourHeight = RenderOptions.CalendarSettings.HourHeight; // When users click to cell we need to find the day, hour and minutes (first 30 minutes or second 30 minutes) that it represents on the timeline. PointerPoint positionerRootPoint = e.GetCurrentPoint(PositionerUIElement); PointerPoint canvasPointerPoint = e.GetCurrentPoint(Canvas); Point touchPoint = canvasPointerPoint.Position; var singleDayWidth = (Canvas.ActualWidth / RenderOptions.TotalDayCount); int day = (int)(touchPoint.X / singleDayWidth); int hour = (int)(touchPoint.Y / hourHeight); bool isSecondHalf = touchPoint.Y % hourHeight > (hourHeight / 2); var diffX = positionerRootPoint.Position.X - touchPoint.X; var diffY = positionerRootPoint.Position.Y - touchPoint.Y; var cellStartRelativePositionX = diffX + (day * singleDayWidth); var cellEndRelativePositionX = cellStartRelativePositionX + singleDayWidth; var cellStartRelativePositionY = diffY + (hour * hourHeight) + (isSecondHalf ? hourHeight / 2 : 0); var cellEndRelativePositionY = cellStartRelativePositionY + (isSecondHalf ? (hourHeight / 2) : hourHeight); var cellSize = new Size(cellEndRelativePositionX - cellStartRelativePositionX, hourHeight / 2); var positionerPoint = new Point(cellStartRelativePositionX, cellStartRelativePositionY); var clickedDateTime = RenderOptions.DateRange.StartDate.AddDays(day).AddHours(hour).AddMinutes(isSecondHalf ? 30 : 0); // If there is already a selected date, in order to mimic the popup behavior, we need to dismiss the previous selection first. // Next click will be a new selection. // Raise the events directly here instead of DP to not lose pointer position. if (clickedDateTime == SelectedDateTime || SelectedDateTime != null) { SelectedDateTime = null; } else { SelectedDateTime = clickedDateTime; TimelineCellSelected?.Invoke(this, new TimelineCellSelectedArgs(clickedDateTime, touchPoint, positionerPoint, cellSize)); } Debug.WriteLine($"Clicked: {clickedDateTime}"); } public WinoDayTimelineCanvas() { DefaultStyleKey = typeof(WinoDayTimelineCanvas); } private static void OnRenderingPropertiesChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) { if (d is WinoDayTimelineCanvas control) { control.ForceDraw(); } } private void ForceDraw() => Canvas?.Invalidate(); private bool CanDrawTimeline() { return RenderOptions != null && Canvas != null && Canvas.ReadyToDraw && WorkingHourCellBackgroundColor != null && SeperatorColor != null && HalfHourSeperatorColor != null && SelectedCellBackgroundBrush != null; } private void OnCanvasDraw(CanvasControl sender, CanvasDrawEventArgs args) { if (!CanDrawTimeline()) return; int hours = 24; double canvasWidth = Canvas.ActualWidth; double canvasHeight = Canvas.ActualHeight; if (canvasWidth == 0 || canvasHeight == 0) return; // Calculate the width of each rectangle (1 day column) // Equal distribution of the whole width. double rectWidth = canvasWidth / RenderOptions.TotalDayCount; // Calculate the height of each rectangle (1 hour row) double rectHeight = RenderOptions.CalendarSettings.HourHeight; // Define stroke and fill colors var strokeColor = SeperatorColor.Color; float strokeThickness = 0.5f; for (int day = 0; day < RenderOptions.TotalDayCount; day++) { var currentDay = RenderOptions.DateRange.StartDate.AddDays(day); bool isWorkingDay = RenderOptions.CalendarSettings.WorkingDays.Contains(currentDay.DayOfWeek); // Loop through each hour (rows) for (int hour = 0; hour < hours; hour++) { var renderTime = TimeSpan.FromHours(hour); var representingDateTime = currentDay.AddHours(hour); // Calculate the position and size of the rectangle double x = day * rectWidth; double y = hour * rectHeight; var rectangle = new Rect(x, y, rectWidth, rectHeight); // Draw the rectangle border. // This is the main rectangle. args.DrawingSession.DrawRectangle(rectangle, strokeColor, strokeThickness); // Fill another rectangle with the working hour background color // This rectangle must be placed with -1 margin to prevent invisible borders of the main rectangle. if (isWorkingDay && renderTime >= RenderOptions.CalendarSettings.WorkingHourStart && renderTime <= RenderOptions.CalendarSettings.WorkingHourEnd) { var backgroundRectangle = new Rect(x + 1, y + 1, rectWidth - 1, rectHeight - 1); args.DrawingSession.DrawRectangle(backgroundRectangle, strokeColor, strokeThickness); args.DrawingSession.FillRectangle(backgroundRectangle, WorkingHourCellBackgroundColor.Color); } // Draw a line in the center of the rectangle for representing half hours. double lineY = y + rectHeight / 2; args.DrawingSession.DrawLine((float)x, (float)lineY, (float)(x + rectWidth), (float)lineY, HalfHourSeperatorColor.Color, strokeThickness, new CanvasStrokeStyle() { DashStyle = CanvasDashStyle.Dot }); } // Draw selected item background color for the date if possible. if (SelectedDateTime != null) { var selectedDateTime = SelectedDateTime.Value; if (selectedDateTime.Date == currentDay.Date) { var selectionRectHeight = rectHeight / 2; var selectedY = selectedDateTime.Hour * rectHeight + (selectedDateTime.Minute / 60) * rectHeight; // Second half of the hour is selected. if (selectedDateTime.TimeOfDay.Minutes == 30) { selectedY += rectHeight / 2; } var selectedRectangle = new Rect(day * rectWidth, selectedY, rectWidth, selectionRectHeight); args.DrawingSession.FillRectangle(selectedRectangle, SelectedCellBackgroundBrush.Color); } } } } public void Dispose() { if (Canvas == null) return; Canvas.Draw -= OnCanvasDraw; Canvas.PointerPressed -= OnCanvasPointerPressed; Canvas.RemoveFromVisualTree(); Canvas = null; } }