2025-12-26 20:46:48 +01:00
|
|
|
using System;
|
|
|
|
|
using System.Diagnostics;
|
2025-12-29 14:10:09 +01:00
|
|
|
using System.Linq;
|
|
|
|
|
using CommunityToolkit.WinUI;
|
2025-12-26 20:46:48 +01:00
|
|
|
using Microsoft.UI.Input;
|
|
|
|
|
using Microsoft.UI.Xaml;
|
|
|
|
|
using Microsoft.UI.Xaml.Controls;
|
|
|
|
|
using Microsoft.UI.Xaml.Input;
|
|
|
|
|
using Microsoft.UI.Xaml.Media;
|
2025-12-30 10:02:24 +01:00
|
|
|
using SkiaSharp;
|
|
|
|
|
using SkiaSharp.Views.Windows;
|
2025-12-26 20:46:48 +01:00
|
|
|
using Windows.Foundation;
|
|
|
|
|
using Wino.Calendar.Args;
|
|
|
|
|
using Wino.Core.Domain.Models.Calendar;
|
|
|
|
|
|
|
|
|
|
namespace Wino.Calendar.Controls;
|
|
|
|
|
|
|
|
|
|
public partial class WinoDayTimelineCanvas : Control, IDisposable
|
|
|
|
|
{
|
2026-02-27 20:12:43 +01:00
|
|
|
public event EventHandler<TimelineCellSelectedArgs>? TimelineCellSelected;
|
|
|
|
|
public event EventHandler<TimelineCellUnselectedArgs>? TimelineCellUnselected;
|
2025-12-26 20:46:48 +01:00
|
|
|
|
|
|
|
|
private const string PART_InternalCanvas = nameof(PART_InternalCanvas);
|
2026-02-27 20:12:43 +01:00
|
|
|
private SKXamlCanvas? Canvas;
|
2025-12-26 20:46:48 +01:00
|
|
|
|
|
|
|
|
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));
|
|
|
|
|
|
2026-02-27 20:12:43 +01:00
|
|
|
public UIElement? PositionerUIElement
|
2025-12-26 20:46:48 +01:00
|
|
|
{
|
2026-02-27 20:12:43 +01:00
|
|
|
get { return (UIElement?)GetValue(PositionerUIElementProperty); }
|
2025-12-26 20:46:48 +01:00
|
|
|
set { SetValue(PositionerUIElementProperty, value); }
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-27 20:12:43 +01:00
|
|
|
public CalendarRenderOptions? RenderOptions
|
2025-12-26 20:46:48 +01:00
|
|
|
{
|
2026-02-27 20:12:43 +01:00
|
|
|
get { return (CalendarRenderOptions?)GetValue(RenderOptionsProperty); }
|
2025-12-26 20:46:48 +01:00
|
|
|
set { SetValue(RenderOptionsProperty, value); }
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-27 20:12:43 +01:00
|
|
|
public SolidColorBrush? HalfHourSeperatorColor
|
2025-12-26 20:46:48 +01:00
|
|
|
{
|
2026-02-27 20:12:43 +01:00
|
|
|
get { return (SolidColorBrush?)GetValue(HalfHourSeperatorColorProperty); }
|
2025-12-26 20:46:48 +01:00
|
|
|
set { SetValue(HalfHourSeperatorColorProperty, value); }
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-27 20:12:43 +01:00
|
|
|
public SolidColorBrush? SeperatorColor
|
2025-12-26 20:46:48 +01:00
|
|
|
{
|
2026-02-27 20:12:43 +01:00
|
|
|
get { return (SolidColorBrush?)GetValue(SeperatorColorProperty); }
|
2025-12-26 20:46:48 +01:00
|
|
|
set { SetValue(SeperatorColorProperty, value); }
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-27 20:12:43 +01:00
|
|
|
public SolidColorBrush? WorkingHourCellBackgroundColor
|
2025-12-26 20:46:48 +01:00
|
|
|
{
|
2026-02-27 20:12:43 +01:00
|
|
|
get { return (SolidColorBrush?)GetValue(WorkingHourCellBackgroundColorProperty); }
|
2025-12-26 20:46:48 +01:00
|
|
|
set { SetValue(WorkingHourCellBackgroundColorProperty, value); }
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-27 20:12:43 +01:00
|
|
|
public SolidColorBrush? SelectedCellBackgroundBrush
|
2025-12-26 20:46:48 +01:00
|
|
|
{
|
2026-02-27 20:12:43 +01:00
|
|
|
get { return (SolidColorBrush?)GetValue(SelectedCellBackgroundBrushProperty); }
|
2025-12-26 20:46:48 +01:00
|
|
|
set { SetValue(SelectedCellBackgroundBrushProperty, value); }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public DateTime? SelectedDateTime
|
|
|
|
|
{
|
|
|
|
|
get { return (DateTime?)GetValue(SelectedDateTimeProperty); }
|
|
|
|
|
set { SetValue(SelectedDateTimeProperty, value); }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
protected override void OnApplyTemplate()
|
|
|
|
|
{
|
|
|
|
|
base.OnApplyTemplate();
|
|
|
|
|
|
2025-12-30 10:02:24 +01:00
|
|
|
Canvas = GetTemplateChild(PART_InternalCanvas) as SKXamlCanvas;
|
2025-12-26 20:46:48 +01:00
|
|
|
|
2025-12-30 10:02:24 +01:00
|
|
|
if (Canvas != null)
|
|
|
|
|
{
|
|
|
|
|
Canvas.PaintSurface += OnCanvasPaintSurface;
|
|
|
|
|
Canvas.PointerPressed += OnCanvasPointerPressed;
|
|
|
|
|
}
|
2025-12-26 20:46:48 +01:00
|
|
|
|
|
|
|
|
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());
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-27 20:12:43 +01:00
|
|
|
private void OnCanvasPointerPressed(object? sender, PointerRoutedEventArgs e)
|
2025-12-26 20:46:48 +01:00
|
|
|
{
|
|
|
|
|
if (RenderOptions == null) return;
|
2026-02-27 20:12:43 +01:00
|
|
|
var canvas = Canvas;
|
|
|
|
|
if (canvas == null) return;
|
2025-12-26 20:46:48 +01:00
|
|
|
|
|
|
|
|
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.
|
|
|
|
|
|
2025-12-29 14:10:09 +01:00
|
|
|
if (PositionerUIElement == null)
|
|
|
|
|
{
|
|
|
|
|
PositionerUIElement = this.FindParents().LastOrDefault(a => a is Grid);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-27 20:12:43 +01:00
|
|
|
if (PositionerUIElement == null)
|
|
|
|
|
return;
|
|
|
|
|
|
2025-12-26 20:46:48 +01:00
|
|
|
PointerPoint positionerRootPoint = e.GetCurrentPoint(PositionerUIElement);
|
2026-02-27 20:12:43 +01:00
|
|
|
PointerPoint canvasPointerPoint = e.GetCurrentPoint(canvas);
|
2025-12-26 20:46:48 +01:00
|
|
|
|
|
|
|
|
Point touchPoint = canvasPointerPoint.Position;
|
|
|
|
|
|
2026-02-27 20:12:43 +01:00
|
|
|
var singleDayWidth = (canvas.ActualWidth / RenderOptions.TotalDayCount);
|
2025-12-26 20:46:48 +01:00
|
|
|
|
|
|
|
|
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
|
|
|
|
|
&& WorkingHourCellBackgroundColor != null
|
|
|
|
|
&& SeperatorColor != null
|
|
|
|
|
&& HalfHourSeperatorColor != null
|
|
|
|
|
&& SelectedCellBackgroundBrush != null;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-27 20:12:43 +01:00
|
|
|
private void OnCanvasPaintSurface(object? sender, SKPaintSurfaceEventArgs e)
|
2025-12-26 20:46:48 +01:00
|
|
|
{
|
|
|
|
|
if (!CanDrawTimeline()) return;
|
2026-02-27 20:12:43 +01:00
|
|
|
var renderOptions = RenderOptions!;
|
|
|
|
|
var workingHourCellBackgroundColor = WorkingHourCellBackgroundColor!;
|
|
|
|
|
var seperatorColor = SeperatorColor!;
|
|
|
|
|
var halfHourSeperatorColor = HalfHourSeperatorColor!;
|
|
|
|
|
var selectedCellBackgroundBrush = SelectedCellBackgroundBrush!;
|
2025-12-26 20:46:48 +01:00
|
|
|
|
2025-12-30 10:02:24 +01:00
|
|
|
var canvas = e.Surface.Canvas;
|
|
|
|
|
canvas.Clear(SKColors.Transparent);
|
|
|
|
|
|
2025-12-26 20:46:48 +01:00
|
|
|
int hours = 24;
|
|
|
|
|
|
2025-12-30 10:02:24 +01:00
|
|
|
double canvasWidth = e.Info.Width;
|
|
|
|
|
double canvasHeight = e.Info.Height;
|
2025-12-26 20:46:48 +01:00
|
|
|
|
|
|
|
|
if (canvasWidth == 0 || canvasHeight == 0) return;
|
|
|
|
|
|
|
|
|
|
// Calculate the width of each rectangle (1 day column)
|
|
|
|
|
// Equal distribution of the whole width.
|
2026-02-27 20:12:43 +01:00
|
|
|
double rectWidth = canvasWidth / renderOptions.TotalDayCount;
|
2025-12-26 20:46:48 +01:00
|
|
|
|
|
|
|
|
// Calculate the height of each rectangle (1 hour row)
|
2026-02-27 20:12:43 +01:00
|
|
|
double rectHeight = renderOptions.CalendarSettings.HourHeight;
|
2025-12-26 20:46:48 +01:00
|
|
|
|
|
|
|
|
// Define stroke and fill colors
|
2026-02-27 20:12:43 +01:00
|
|
|
var strokeColor = ToSKColor(seperatorColor.Color);
|
2025-12-26 20:46:48 +01:00
|
|
|
float strokeThickness = 0.5f;
|
|
|
|
|
|
2025-12-30 10:02:24 +01:00
|
|
|
// Create paints for drawing
|
|
|
|
|
using var strokePaint = new SKPaint
|
|
|
|
|
{
|
|
|
|
|
Color = strokeColor,
|
|
|
|
|
StrokeWidth = strokeThickness,
|
|
|
|
|
Style = SKPaintStyle.Stroke,
|
|
|
|
|
IsAntialias = true
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
using var fillPaint = new SKPaint
|
|
|
|
|
{
|
|
|
|
|
Style = SKPaintStyle.Fill,
|
|
|
|
|
IsAntialias = true
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
using var dashedPaint = new SKPaint
|
|
|
|
|
{
|
2026-02-27 20:12:43 +01:00
|
|
|
Color = ToSKColor(halfHourSeperatorColor.Color),
|
2025-12-30 10:02:24 +01:00
|
|
|
StrokeWidth = strokeThickness,
|
|
|
|
|
Style = SKPaintStyle.Stroke,
|
|
|
|
|
PathEffect = SKPathEffect.CreateDash([2f, 2f], 0),
|
|
|
|
|
IsAntialias = true
|
|
|
|
|
};
|
|
|
|
|
|
2026-02-27 20:12:43 +01:00
|
|
|
for (int day = 0; day < renderOptions.TotalDayCount; day++)
|
2025-12-26 20:46:48 +01:00
|
|
|
{
|
2026-02-27 20:12:43 +01:00
|
|
|
var currentDay = renderOptions.DateRange.StartDate.AddDays(day);
|
2025-12-26 20:46:48 +01:00
|
|
|
|
2026-02-27 20:12:43 +01:00
|
|
|
bool isWorkingDay = renderOptions.CalendarSettings.WorkingDays.Contains(currentDay.DayOfWeek);
|
2025-12-26 20:46:48 +01:00
|
|
|
|
|
|
|
|
// 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
|
2025-12-30 10:02:24 +01:00
|
|
|
float x = (float)(day * rectWidth);
|
|
|
|
|
float y = (float)(hour * rectHeight);
|
|
|
|
|
float width = (float)rectWidth;
|
|
|
|
|
float height = (float)rectHeight;
|
2025-12-26 20:46:48 +01:00
|
|
|
|
2025-12-30 10:02:24 +01:00
|
|
|
var rectangle = new SKRect(x, y, x + width, y + height);
|
2025-12-26 20:46:48 +01:00
|
|
|
|
|
|
|
|
// Draw the rectangle border.
|
|
|
|
|
// This is the main rectangle.
|
2025-12-30 10:02:24 +01:00
|
|
|
canvas.DrawRect(rectangle, strokePaint);
|
2025-12-26 20:46:48 +01:00
|
|
|
|
|
|
|
|
// 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.
|
2026-02-27 20:12:43 +01:00
|
|
|
if (isWorkingDay && renderTime >= renderOptions.CalendarSettings.WorkingHourStart && renderTime <= renderOptions.CalendarSettings.WorkingHourEnd)
|
2025-12-26 20:46:48 +01:00
|
|
|
{
|
2025-12-30 10:02:24 +01:00
|
|
|
var backgroundRectangle = new SKRect(x + 1, y + 1, x + width - 1, y + height - 1);
|
2025-12-26 20:46:48 +01:00
|
|
|
|
2025-12-30 10:02:24 +01:00
|
|
|
canvas.DrawRect(backgroundRectangle, strokePaint);
|
2026-02-27 20:12:43 +01:00
|
|
|
fillPaint.Color = ToSKColor(workingHourCellBackgroundColor.Color);
|
2025-12-30 10:02:24 +01:00
|
|
|
canvas.DrawRect(backgroundRectangle, fillPaint);
|
2025-12-26 20:46:48 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Draw a line in the center of the rectangle for representing half hours.
|
2025-12-30 10:02:24 +01:00
|
|
|
float lineY = y + height / 2;
|
|
|
|
|
canvas.DrawLine(x, lineY, x + width, lineY, dashedPaint);
|
2025-12-26 20:46:48 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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;
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-30 10:02:24 +01:00
|
|
|
var selectedRectangle = new SKRect(
|
|
|
|
|
(float)(day * rectWidth),
|
|
|
|
|
(float)selectedY,
|
|
|
|
|
(float)(day * rectWidth + rectWidth),
|
|
|
|
|
(float)(selectedY + selectionRectHeight));
|
|
|
|
|
|
2026-02-27 20:12:43 +01:00
|
|
|
fillPaint.Color = ToSKColor(selectedCellBackgroundBrush.Color);
|
2025-12-30 10:02:24 +01:00
|
|
|
canvas.DrawRect(selectedRectangle, fillPaint);
|
2025-12-26 20:46:48 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-30 10:02:24 +01:00
|
|
|
private static SKColor ToSKColor(Windows.UI.Color color)
|
|
|
|
|
{
|
|
|
|
|
return new SKColor(color.R, color.G, color.B, color.A);
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-26 20:46:48 +01:00
|
|
|
public void Dispose()
|
|
|
|
|
{
|
|
|
|
|
if (Canvas == null) return;
|
|
|
|
|
|
2025-12-30 10:02:24 +01:00
|
|
|
Canvas.PaintSurface -= OnCanvasPaintSurface;
|
2025-12-26 20:46:48 +01:00
|
|
|
Canvas.PointerPressed -= OnCanvasPointerPressed;
|
|
|
|
|
|
|
|
|
|
Canvas = null;
|
|
|
|
|
}
|
|
|
|
|
}
|