From 8586d0ef546fa64938b46a28b127d953b54cc9ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Burak=20Kaan=20K=C3=B6se?= Date: Mon, 23 Mar 2026 10:22:47 +0100 Subject: [PATCH] Calendar rendering. --- .../CalendarPageViewModel.cs | 5 + .../Controls/Calendar/CalendarItemAccessor.cs | 14 + .../Calendar/CalendarPeriodControl.xaml | 114 +++ .../Calendar/CalendarPeriodControl.xaml.cs | 835 ++++++++++++++++++ .../Controls/Calendar/HeaderTextLayout.cs | 13 + .../Controls/Calendar/LayoutRect.cs | 3 + .../Controls/Calendar/MonthCalendarLayout.cs | 146 +++ .../Controls/Calendar/TimedCalendarLayout.cs | 165 ++++ .../Styles/CalendarViewStyles.xaml | 34 +- Wino.Mail.WinUI/Styles/DataTemplates.xaml | 32 + .../Views/Calendar/CalendarPage.xaml | 11 +- Wino.Mail.WinUI/Wino.Mail.WinUI.csproj | 7 + 12 files changed, 1347 insertions(+), 32 deletions(-) create mode 100644 Wino.Mail.WinUI/Controls/Calendar/CalendarItemAccessor.cs create mode 100644 Wino.Mail.WinUI/Controls/Calendar/CalendarPeriodControl.xaml create mode 100644 Wino.Mail.WinUI/Controls/Calendar/CalendarPeriodControl.xaml.cs create mode 100644 Wino.Mail.WinUI/Controls/Calendar/HeaderTextLayout.cs create mode 100644 Wino.Mail.WinUI/Controls/Calendar/LayoutRect.cs create mode 100644 Wino.Mail.WinUI/Controls/Calendar/MonthCalendarLayout.cs create mode 100644 Wino.Mail.WinUI/Controls/Calendar/TimedCalendarLayout.cs diff --git a/Wino.Calendar.ViewModels/CalendarPageViewModel.cs b/Wino.Calendar.ViewModels/CalendarPageViewModel.cs index 33ec8d93..230570f4 100644 --- a/Wino.Calendar.ViewModels/CalendarPageViewModel.cs +++ b/Wino.Calendar.ViewModels/CalendarPageViewModel.cs @@ -121,6 +121,9 @@ public partial class CalendarPageViewModel : CalendarBaseViewModel, [ObservableProperty] public partial bool IsCalendarEnabled { get; set; } = true; + [ObservableProperty] + public partial IReadOnlyList CalendarItems { get; set; } = []; + #endregion #region Event Details @@ -321,6 +324,7 @@ public partial class CalendarPageViewModel : CalendarBaseViewModel, VisibleDateRangeText = string.Empty; LoadedDateWindow = null; _loadedCalendarItems = []; + CalendarItems = []; } public void Dispose() @@ -586,6 +590,7 @@ public partial class CalendarPageViewModel : CalendarBaseViewModel, await ExecuteUIThreadIfActiveAsync(lifetimeVersion, () => { _loadedCalendarItems = loadedItems; + CalendarItems = loadedItems; }).ConfigureAwait(false); } diff --git a/Wino.Mail.WinUI/Controls/Calendar/CalendarItemAccessor.cs b/Wino.Mail.WinUI/Controls/Calendar/CalendarItemAccessor.cs new file mode 100644 index 00000000..6153e094 --- /dev/null +++ b/Wino.Mail.WinUI/Controls/Calendar/CalendarItemAccessor.cs @@ -0,0 +1,14 @@ +using System; +using Wino.Calendar.ViewModels.Data; + +namespace Wino.Calendar.Controls; + +internal static class CalendarItemAccessor +{ + public static bool TryGetTimeRange(CalendarItemViewModel item, out DateTimeOffset start, out DateTimeOffset end) + { + start = new DateTimeOffset(item.StartDate); + end = new DateTimeOffset(item.EndDate); + return end > start; + } +} diff --git a/Wino.Mail.WinUI/Controls/Calendar/CalendarPeriodControl.xaml b/Wino.Mail.WinUI/Controls/Calendar/CalendarPeriodControl.xaml new file mode 100644 index 00000000..533b2115 --- /dev/null +++ b/Wino.Mail.WinUI/Controls/Calendar/CalendarPeriodControl.xaml @@ -0,0 +1,114 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Wino.Mail.WinUI/Controls/Calendar/CalendarPeriodControl.xaml.cs b/Wino.Mail.WinUI/Controls/Calendar/CalendarPeriodControl.xaml.cs new file mode 100644 index 00000000..436e7471 --- /dev/null +++ b/Wino.Mail.WinUI/Controls/Calendar/CalendarPeriodControl.xaml.cs @@ -0,0 +1,835 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Linq; +using System.Numerics; +using System.Runtime.CompilerServices; +using CommunityToolkit.WinUI; +using Itenso.TimePeriod; +using Microsoft.UI.Composition; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Hosting; +using Microsoft.UI.Xaml.Media; +using SkiaSharp; +using SkiaSharp.Views.Windows; +using Windows.UI; +using Wino.Calendar.ViewModels.Data; +using Wino.Core.Domain.Enums; +using Wino.Core.Domain.Interfaces; +using Wino.Core.Domain.Models.Calendar; + +namespace Wino.Calendar.Controls; + +public sealed partial class CalendarPeriodControl : UserControl, INotifyPropertyChanged +{ + private VisibleDateRange _currentRange = new( + CalendarDisplayType.Month, + DateOnly.FromDateTime(DateTime.Today), + DateOnly.FromDateTime(DateTime.Today), + DateOnly.FromDateTime(DateTime.Today), + DateOnly.FromDateTime(DateTime.Today), + 1, + true, + true, + [DateOnly.FromDateTime(DateTime.Today)]); + + private TimedCalendarLayoutResult _timedLayout = new([], 0, []); + private MonthCalendarLayoutResult _monthLayout = new(0, 0, [], []); + private INotifyCollectionChanged? _observableItemsSource; + private double _timedDayWidth; + private double _monthCellWidth; + private double _monthCellHeight; + private bool _hasPresentedState; + private CalendarDisplayType _lastDisplayMode = CalendarDisplayType.Month; + private DateOnly _lastDisplayDate = DateOnly.FromDateTime(DateTime.Today); + private DayOfWeek _lastFirstDayOfWeek = DayOfWeek.Monday; + + [GeneratedDependencyProperty] + public partial VisibleDateRange? VisibleRange { get; set; } + + [GeneratedDependencyProperty] + public partial CalendarSettings? CalendarSettings { get; set; } + + [GeneratedDependencyProperty] + public partial IReadOnlyList? CalendarItems { get; set; } + + [GeneratedDependencyProperty] + public partial string? TimedHeaderDateFormat { get; set; } + + public CalendarPeriodControl() => InitializeComponent(); + + public event PropertyChangedEventHandler? PropertyChanged; + + private ObservableCollection TimedHeaderTextsCollection { get; } = []; + private ObservableCollection MonthHeaderTextsCollection { get; } = []; + private ObservableCollection TimedItemsCollection { get; } = []; + private ObservableCollection MonthCellLabelsCollection { get; } = []; + private ObservableCollection MonthItemsCollection { get; } = []; + + public IEnumerable TimedHeaderTexts => TimedHeaderTextsCollection; + public IEnumerable MonthHeaderTexts => MonthHeaderTextsCollection; + public IEnumerable TimedItems => TimedItemsCollection; + public IEnumerable MonthCellLabels => MonthCellLabelsCollection; + public IEnumerable MonthItems => MonthItemsCollection; + + public double TimedDayWidth + { + get => _timedDayWidth; + private set + { + if (_timedDayWidth == value) + { + return; + } + + _timedDayWidth = value; + OnPropertyChanged(); + } + } + + public double MonthCellWidth + { + get => _monthCellWidth; + private set + { + if (_monthCellWidth == value) + { + return; + } + + _monthCellWidth = value; + OnPropertyChanged(); + } + } + + public double MonthCellHeight + { + get => _monthCellHeight; + private set + { + if (_monthCellHeight == value) + { + return; + } + + _monthCellHeight = value; + OnPropertyChanged(); + } + } + + public double TimelineHeight => TimedCalendarLayoutCalculator.GetTimelineHeight(GetHourHeight()); + + partial void OnVisibleRangeChanged(VisibleDateRange? newValue) => Refresh(); + partial void OnCalendarSettingsChanged(CalendarSettings? newValue) => Refresh(); + partial void OnTimedHeaderDateFormatChanged(string? newValue) => Refresh(); + + partial void OnCalendarItemsChanged(IReadOnlyList? newValue) + { + DetachCurrentItemsSource(); + AttachItemsSource(newValue); + Refresh(); + } + + private void ControlLoaded(object sender, RoutedEventArgs e) + { + AttachItemsSource(CalendarItems); + Refresh(); + } + + private void ControlSizeChanged(object sender, SizeChangedEventArgs e) => Refresh(); + + private IEnumerable CurrentItems => CalendarItems ?? []; + + private void AttachItemsSource(IReadOnlyList? itemsSource) + { + _observableItemsSource = itemsSource as INotifyCollectionChanged; + + if (_observableItemsSource is not null) + { + _observableItemsSource.CollectionChanged += ItemsSourceCollectionChanged; + } + } + + private void DetachItemsSource(IReadOnlyList? itemsSource) + { + var observableItemsSource = itemsSource as INotifyCollectionChanged; + + if (observableItemsSource is not null) + { + observableItemsSource.CollectionChanged -= ItemsSourceCollectionChanged; + } + + if (ReferenceEquals(_observableItemsSource, observableItemsSource)) + { + _observableItemsSource = null; + } + } + + private void DetachCurrentItemsSource() + { + if (_observableItemsSource is not null) + { + _observableItemsSource.CollectionChanged -= ItemsSourceCollectionChanged; + _observableItemsSource = null; + } + } + + private void ItemsSourceCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) => Refresh(); + + private void Refresh() + { + if (!IsLoaded || ActualWidth <= 0 || VisibleRange is null || CalendarSettings is null) + { + return; + } + + var transition = GetTransitionInfo(); + _currentRange = CreateLayoutRange(VisibleRange, CalendarSettings); + + if (VisibleRange.DisplayType == CalendarDisplayType.Month) + { + RefreshMonthView(); + } + else + { + RefreshTimedView(); + } + + RunTransition(transition); + _hasPresentedState = true; + _lastDisplayMode = VisibleRange.DisplayType; + _lastDisplayDate = VisibleRange.AnchorDate; + _lastFirstDayOfWeek = CalendarSettings.FirstDayOfWeek; + } + + private void RefreshTimedView() + { + TimedRoot.Visibility = Visibility.Visible; + MonthRoot.Visibility = Visibility.Collapsed; + + TimedDayWidth = _currentRange.Dates.Count == 0 ? 0d : ActualWidth / _currentRange.Dates.Count; + TimedViewport.Width = ActualWidth; + TimedViewport.Height = TimelineHeight; + + _timedLayout = TimedCalendarLayoutCalculator.Calculate(_currentRange, CurrentItems, ActualWidth, GetHourHeight()); + + ReplaceCollection( + TimedHeaderTextsCollection, + _timedLayout.VisibleDates.Select(date => + new HeaderTextLayout( + date.ToDateTime(TimeOnly.MinValue).ToString( + string.IsNullOrWhiteSpace(TimedHeaderDateFormat) ? "ddd dd" : TimedHeaderDateFormat, + CalendarSettings!.CultureInfo), + TimedDayWidth))); + + var eventTemplate = (DataTemplate)Resources["CalendarEventTemplate"]; + ReplaceCollection(TimedItemsCollection, _timedLayout.Items.Select(item => + { + PrepareDisplayMetadata(item.Item, item.Date); + item.Template = eventTemplate; + return item; + })); + RenderTimedItems(); + + TimedHeaderCanvas.Invalidate(); + TimedStructureCanvas.Invalidate(); + } + + private void RefreshMonthView() + { + TimedRoot.Visibility = Visibility.Collapsed; + MonthRoot.Visibility = Visibility.Visible; + + var availableMonthHeight = Math.Max(0d, ActualHeight - MonthHeadersItemsControl.ActualHeight); + _monthLayout = MonthCalendarLayoutCalculator.Calculate(_currentRange, CurrentItems, ActualWidth, availableMonthHeight); + + MonthCellWidth = _monthLayout.CellWidth; + MonthCellHeight = _monthLayout.CellHeight; + + MonthViewport.Width = ActualWidth; + MonthViewport.Height = availableMonthHeight; + + ReplaceCollection( + MonthHeaderTextsCollection, + Enumerable.Range(0, MonthCalendarLayoutCalculator.ColumnCount) + .Select(index => + { + var day = (DayOfWeek)(((int)CalendarSettings!.FirstDayOfWeek + index) % 7); + return new HeaderTextLayout( + CalendarSettings.CultureInfo.DateTimeFormat.AbbreviatedDayNames[(int)day], + MonthCellWidth); + })); + + ReplaceCollection( + MonthCellLabelsCollection, + _monthLayout.Cells.Select(cell => + new MonthCellLabelLayout( + cell.Date.Day.ToString(CalendarSettings!.CultureInfo), + cell.Date.Month == VisibleRange!.AnchorDate.Month && cell.Date.Year == VisibleRange.AnchorDate.Year ? 1d : 0.55d, + new LayoutRect(cell.Bounds.X + 4, cell.Bounds.Y + 2, cell.Bounds.Width, cell.Bounds.Height)))); + + var monthEventTemplate = (DataTemplate)Resources["MonthEventTemplate"]; + ReplaceCollection(MonthItemsCollection, _monthLayout.Items.Select(item => + { + PrepareDisplayMetadata(item.Item, item.Date); + item.Template = monthEventTemplate; + return item; + })); + RenderMonthCellLabels(); + RenderMonthItems(); + + MonthStructureCanvas.Invalidate(); + } + + private void PrepareDisplayMetadata(CalendarItemViewModel item, DateOnly date) + { + if (CalendarSettings is null || item is not ICalendarItemViewModel calendarItemViewModel) + { + return; + } + + calendarItemViewModel.DisplayingPeriod = new TimeRange( + date.ToDateTime(TimeOnly.MinValue), + date.AddDays(1).ToDateTime(TimeOnly.MinValue)); + calendarItemViewModel.CalendarSettings = CalendarSettings; + } + + private static VisibleDateRange CreateLayoutRange(VisibleDateRange visibleRange, CalendarSettings calendarSettings) + { + if (visibleRange.DisplayType != CalendarDisplayType.Month) + { + return visibleRange; + } + + var start = AlignToWeekStart(visibleRange.StartDate, calendarSettings.FirstDayOfWeek); + var end = AlignToWeekEnd(visibleRange.EndDate, calendarSettings.FirstDayOfWeek); + var totalDays = end.DayNumber - start.DayNumber + 1; + + if (totalDays <= 35) + { + end = start.AddDays(34); + } + else if (totalDays < 42) + { + end = start.AddDays(41); + } + + var dates = Enumerable.Range(0, end.DayNumber - start.DayNumber + 1) + .Select(offset => start.AddDays(offset)) + .ToArray(); + + return new VisibleDateRange( + visibleRange.DisplayType, + visibleRange.AnchorDate, + start, + end, + visibleRange.PrimaryDate, + dates.Length, + dates.Contains(DateOnly.FromDateTime(DateTime.Today)), + start.Year == end.Year && start.Month == end.Month, + dates); + } + + private static DateOnly AlignToWeekStart(DateOnly date, DayOfWeek firstDayOfWeek) + { + var offset = ((int)date.DayOfWeek - (int)firstDayOfWeek + 7) % 7; + return date.AddDays(-offset); + } + + private static DateOnly AlignToWeekEnd(DateOnly date, DayOfWeek firstDayOfWeek) + { + var lastDayOfWeek = (DayOfWeek)(((int)firstDayOfWeek + 6) % 7); + var offset = ((int)lastDayOfWeek - (int)date.DayOfWeek + 7) % 7; + return date.AddDays(offset); + } + + private void TimedHeaderCanvasPaintSurface(object? sender, SKPaintSurfaceEventArgs e) + { + using var borderPaint = CreateLinePaint(); + var canvas = e.Surface.Canvas; + canvas.Clear(SKColors.Transparent); + + if (_timedLayout.VisibleDates.Count == 0 || ActualWidth <= 0) + { + return; + } + + var scaleX = (float)(e.Info.Width / ActualWidth); + var height = e.Info.Height; + var dayWidth = (float)(_timedLayout.DayWidth * scaleX); + + for (var index = 1; index < _timedLayout.VisibleDates.Count; index++) + { + var x = dayWidth * index; + canvas.DrawLine(x, 0, x, height, borderPaint); + } + + canvas.DrawLine(0, height - 1, e.Info.Width, height - 1, borderPaint); + } + + private void TimedStructureCanvasPaintSurface(object? sender, SKPaintSurfaceEventArgs e) + { + using var linePaint = CreateLinePaint(); + using var defaultFillPaint = CreateFillPaint(GetDefaultHourBackground()); + using var workFillPaint = CreateFillPaint(GetWorkHourBackground()); + var canvas = e.Surface.Canvas; + canvas.Clear(SKColors.Transparent); + + if (_timedLayout.VisibleDates.Count == 0 || ActualWidth <= 0) + { + return; + } + + var hourHeight = GetHourHeight(); + var timelineHeight = TimedCalendarLayoutCalculator.GetTimelineHeight(hourHeight); + var scaleX = (float)(e.Info.Width / ActualWidth); + var scaleY = (float)(e.Info.Height / timelineHeight); + var dayWidth = (float)(_timedLayout.DayWidth * scaleX); + var workDayStartHour = CalendarSettings?.WorkingHourStart.TotalHours ?? 9d; + var workDayEndHour = CalendarSettings?.WorkingHourEnd.TotalHours ?? 17d; + + for (var dayIndex = 0; dayIndex < _timedLayout.VisibleDates.Count; dayIndex++) + { + var x = dayIndex * dayWidth; + + for (var hour = 0; hour < 24; hour++) + { + var y = (float)(hour * hourHeight * scaleY); + var scaledHourHeight = (float)(hourHeight * scaleY); + var fillPaint = hour >= workDayStartHour && hour < workDayEndHour ? workFillPaint : defaultFillPaint; + canvas.DrawRect(x, y, dayWidth, scaledHourHeight, fillPaint); + } + } + + for (var hour = 0; hour <= 24; hour++) + { + var y = (float)(hour * hourHeight * scaleY); + canvas.DrawLine(0, y, e.Info.Width, y, linePaint); + } + + for (var index = 0; index <= _timedLayout.VisibleDates.Count; index++) + { + var x = dayWidth * index; + canvas.DrawLine(x, 0, x, e.Info.Height, linePaint); + } + } + + private void MonthStructureCanvasPaintSurface(object? sender, SKPaintSurfaceEventArgs e) + { + using var linePaint = CreateLinePaint(); + using var todayPaint = new SKPaint + { + Style = SKPaintStyle.Fill, + Color = new SKColor(0, 120, 215, 26), + IsAntialias = true + }; + + var canvas = e.Surface.Canvas; + canvas.Clear(SKColors.Transparent); + + if (_monthLayout.CellWidth <= 0 || _monthLayout.CellHeight <= 0 || MonthViewport.ActualWidth <= 0 || MonthViewport.ActualHeight <= 0) + { + return; + } + + var cellWidth = (float)(e.Info.Width / MonthCalendarLayoutCalculator.ColumnCount); + var cellHeight = (float)(e.Info.Height / MonthCalendarLayoutCalculator.RowCount); + var today = DateOnly.FromDateTime(DateTime.Now.Date); + + foreach (var cell in _monthLayout.Cells) + { + if (cell.Date != today) + { + continue; + } + + canvas.DrawRect((float)cell.Bounds.X, (float)cell.Bounds.Y, (float)cell.Bounds.Width, (float)cell.Bounds.Height, todayPaint); + } + + for (var row = 0; row <= MonthCalendarLayoutCalculator.RowCount; row++) + { + var y = row * cellHeight; + canvas.DrawLine(0, y, e.Info.Width, y, linePaint); + } + + for (var column = 0; column <= MonthCalendarLayoutCalculator.ColumnCount; column++) + { + var x = column * cellWidth; + canvas.DrawLine(x, 0, x, e.Info.Height, linePaint); + } + } + + private static void ReplaceCollection(ObservableCollection target, IEnumerable items) + { + target.Clear(); + + foreach (var item in items) + { + target.Add(item); + } + } + + private void RenderTimedItems() + { + TimedItemsCanvas.Children.Clear(); + + foreach (var item in TimedItemsCollection) + { + var presenter = new ContentPresenter + { + Width = item.Bounds.Width, + Height = item.Bounds.Height, + Content = item.Item, + ContentTemplate = item.Template + }; + + Canvas.SetLeft(presenter, item.Bounds.X); + Canvas.SetTop(presenter, item.Bounds.Y); + TimedItemsCanvas.Children.Add(presenter); + } + } + + private void RenderMonthCellLabels() + { + MonthCellLabelsCanvas.Children.Clear(); + + foreach (var label in MonthCellLabelsCollection) + { + var textBlock = new TextBlock + { + Width = label.Bounds.Width, + Height = label.Bounds.Height, + Opacity = label.LabelOpacity, + Text = label.DayText + }; + + Canvas.SetLeft(textBlock, label.Bounds.X); + Canvas.SetTop(textBlock, label.Bounds.Y); + MonthCellLabelsCanvas.Children.Add(textBlock); + } + } + + private void RenderMonthItems() + { + MonthItemsCanvas.Children.Clear(); + + foreach (var item in MonthItemsCollection) + { + var presenter = new ContentPresenter + { + Width = item.Bounds.Width, + Height = item.Bounds.Height, + Content = item.Item, + ContentTemplate = item.Template + }; + + Canvas.SetLeft(presenter, item.Bounds.X); + Canvas.SetTop(presenter, item.Bounds.Y); + MonthItemsCanvas.Children.Add(presenter); + } + } + + private CalendarTransitionInfo GetTransitionInfo() + { + if (!_hasPresentedState || VisibleRange is null || CalendarSettings is null) + { + return new CalendarTransitionInfo(CalendarTransitionKind.None, 0); + } + + if (_lastDisplayMode != VisibleRange.DisplayType) + { + return new CalendarTransitionInfo(CalendarTransitionKind.ModeChange, 0); + } + + if (_lastDisplayDate != VisibleRange.AnchorDate) + { + return new CalendarTransitionInfo( + CalendarTransitionKind.Navigation, + VisibleRange.AnchorDate.CompareTo(_lastDisplayDate)); + } + + if (_lastFirstDayOfWeek != CalendarSettings.FirstDayOfWeek) + { + return new CalendarTransitionInfo(CalendarTransitionKind.Refresh, 0); + } + + return new CalendarTransitionInfo(CalendarTransitionKind.None, 0); + } + + private void RunTransition(CalendarTransitionInfo transition) + { + if (transition.Kind == CalendarTransitionKind.None || VisibleRange is null) + { + return; + } + + if (VisibleRange.DisplayType != CalendarDisplayType.Month) + { + RunTimedTransition(transition); + return; + } + + var target = (UIElement)MonthRoot; + var visual = ElementCompositionPreview.GetElementVisual(target); + var compositor = visual.Compositor; + + visual.CenterPoint = new Vector3((float)(target.RenderSize.Width * 0.5), (float)(target.RenderSize.Height * 0.5), 0f); + visual.StopAnimation(nameof(visual.Offset)); + visual.StopAnimation(nameof(visual.Opacity)); + visual.StopAnimation(nameof(visual.Scale)); + + switch (transition.Kind) + { + case CalendarTransitionKind.Navigation: + StartNavigationTransition(compositor, visual, transition.Direction, target.RenderSize.Width); + break; + case CalendarTransitionKind.ModeChange: + StartModeTransition(compositor, visual); + break; + case CalendarTransitionKind.Refresh: + StartRefreshTransition(compositor, visual); + break; + } + } + + private void RunTimedTransition(CalendarTransitionInfo transition) + { + var headerVisual = ElementCompositionPreview.GetElementVisual(TimedHeaderHost); + var contentVisual = ElementCompositionPreview.GetElementVisual(TimedScrollViewer); + var compositor = headerVisual.Compositor; + + PrepareAnimatedVisual(headerVisual, TimedHeaderHost); + PrepareAnimatedVisual(contentVisual, TimedScrollViewer); + + switch (transition.Kind) + { + case CalendarTransitionKind.Navigation: + StartTimedNavigationTransition(compositor, transition.Direction); + break; + case CalendarTransitionKind.ModeChange: + StartTimedModeTransition(compositor); + break; + case CalendarTransitionKind.Refresh: + StartTimedRefreshTransition(compositor); + break; + } + } + + private static void StartNavigationTransition(Compositor compositor, Visual visual, int direction, double width) + { + var travel = (float)Math.Max(48d, Math.Min(160d, width * 0.08d)); + var startX = direction >= 0 ? travel : -travel; + + var offsetAnimation = compositor.CreateVector3KeyFrameAnimation(); + offsetAnimation.InsertKeyFrame(0f, new Vector3(startX, 0f, 0f)); + offsetAnimation.InsertKeyFrame(1f, Vector3.Zero); + offsetAnimation.Duration = TimeSpan.FromMilliseconds(220); + + var opacityAnimation = compositor.CreateScalarKeyFrameAnimation(); + opacityAnimation.InsertKeyFrame(0f, 0.72f); + opacityAnimation.InsertKeyFrame(1f, 1f); + opacityAnimation.Duration = offsetAnimation.Duration; + + visual.StartAnimation(nameof(visual.Offset), offsetAnimation); + visual.StartAnimation(nameof(visual.Opacity), opacityAnimation); + } + + private void StartTimedNavigationTransition(Compositor compositor, int direction) + { + var width = Math.Max(TimedRoot.RenderSize.Width, ActualWidth); + var travel = (float)Math.Max(56d, Math.Min(184d, width * 0.09d)); + var signedTravel = direction >= 0 ? travel : -travel; + var clipInset = (float)Math.Max(18d, Math.Min(64d, width * 0.05d)); + + StartTimedElementTransition(compositor, TimedHeaderHost, signedTravel * 0.45f, 0f, 0.78f, TimeSpan.FromMilliseconds(180), direction >= 0 ? 0f : clipInset, direction >= 0 ? clipInset : 0f); + StartTimedElementTransition(compositor, TimedScrollViewer, signedTravel, 0f, 0.68f, TimeSpan.FromMilliseconds(240), direction >= 0 ? 0f : clipInset, direction >= 0 ? clipInset : 0f); + } + + private static void StartModeTransition(Compositor compositor, Visual visual) + { + var offsetAnimation = compositor.CreateVector3KeyFrameAnimation(); + offsetAnimation.InsertKeyFrame(0f, new Vector3(0f, 18f, 0f)); + offsetAnimation.InsertKeyFrame(1f, Vector3.Zero); + offsetAnimation.Duration = TimeSpan.FromMilliseconds(260); + + var opacityAnimation = compositor.CreateScalarKeyFrameAnimation(); + opacityAnimation.InsertKeyFrame(0f, 0f); + opacityAnimation.InsertKeyFrame(1f, 1f); + opacityAnimation.Duration = offsetAnimation.Duration; + + var scaleAnimation = compositor.CreateVector3KeyFrameAnimation(); + scaleAnimation.InsertKeyFrame(0f, new Vector3(0.985f, 0.985f, 1f)); + scaleAnimation.InsertKeyFrame(1f, new Vector3(1f, 1f, 1f)); + scaleAnimation.Duration = offsetAnimation.Duration; + + visual.StartAnimation(nameof(visual.Offset), offsetAnimation); + visual.StartAnimation(nameof(visual.Opacity), opacityAnimation); + visual.StartAnimation(nameof(visual.Scale), scaleAnimation); + } + + private void StartTimedModeTransition(Compositor compositor) + { + StartTimedElementTransition(compositor, TimedHeaderHost, 0f, 10f, 0f, TimeSpan.FromMilliseconds(180), 0f, 0f); + StartTimedElementTransition(compositor, TimedScrollViewer, 0f, 18f, 0f, TimeSpan.FromMilliseconds(240), 0f, 0f); + } + + private static void StartRefreshTransition(Compositor compositor, Visual visual) + { + var opacityAnimation = compositor.CreateScalarKeyFrameAnimation(); + opacityAnimation.InsertKeyFrame(0f, 0.82f); + opacityAnimation.InsertKeyFrame(1f, 1f); + opacityAnimation.Duration = TimeSpan.FromMilliseconds(160); + + visual.StartAnimation(nameof(visual.Opacity), opacityAnimation); + } + + private void StartTimedRefreshTransition(Compositor compositor) + { + StartOpacityTransition(compositor, ElementCompositionPreview.GetElementVisual(TimedHeaderHost), 0.86f, TimeSpan.FromMilliseconds(140)); + StartOpacityTransition(compositor, ElementCompositionPreview.GetElementVisual(TimedScrollViewer), 0.8f, TimeSpan.FromMilliseconds(160)); + } + + private static void PrepareAnimatedVisual(Visual visual, UIElement target) + { + visual.CenterPoint = new Vector3((float)(target.RenderSize.Width * 0.5), (float)(target.RenderSize.Height * 0.5), 0f); + visual.StopAnimation(nameof(visual.Offset)); + visual.StopAnimation(nameof(visual.Opacity)); + visual.StopAnimation(nameof(visual.Scale)); + } + + private static void StartTimedElementTransition(Compositor compositor, UIElement target, float offsetX, float offsetY, float startingOpacity, TimeSpan duration, float leftInset, float rightInset) + { + var visual = ElementCompositionPreview.GetElementVisual(target); + PrepareAnimatedVisual(visual, target); + + var easing = compositor.CreateCubicBezierEasingFunction(new Vector2(0.16f, 1f), new Vector2(0.3f, 1f)); + var fadeEasing = compositor.CreateCubicBezierEasingFunction(new Vector2(0.2f, 0f), new Vector2(0f, 1f)); + var clip = visual.Clip as InsetClip ?? compositor.CreateInsetClip(); + + clip.LeftInset = leftInset; + clip.RightInset = rightInset; + visual.Clip = clip; + + var offsetAnimation = compositor.CreateVector3KeyFrameAnimation(); + offsetAnimation.InsertKeyFrame(0f, new Vector3(offsetX, offsetY, 0f)); + offsetAnimation.InsertKeyFrame(1f, Vector3.Zero, easing); + offsetAnimation.Duration = duration; + + var opacityAnimation = compositor.CreateScalarKeyFrameAnimation(); + opacityAnimation.InsertKeyFrame(0f, startingOpacity); + opacityAnimation.InsertKeyFrame(1f, 1f, fadeEasing); + opacityAnimation.Duration = duration; + + var scaleAnimation = compositor.CreateVector3KeyFrameAnimation(); + scaleAnimation.InsertKeyFrame(0f, new Vector3(0.996f, 0.996f, 1f)); + scaleAnimation.InsertKeyFrame(1f, new Vector3(1f, 1f, 1f), easing); + scaleAnimation.Duration = duration; + + var leftInsetAnimation = compositor.CreateScalarKeyFrameAnimation(); + leftInsetAnimation.InsertKeyFrame(1f, 0f, easing); + leftInsetAnimation.Duration = duration; + + var rightInsetAnimation = compositor.CreateScalarKeyFrameAnimation(); + rightInsetAnimation.InsertKeyFrame(1f, 0f, easing); + rightInsetAnimation.Duration = duration; + + visual.StartAnimation(nameof(visual.Offset), offsetAnimation); + visual.StartAnimation(nameof(visual.Opacity), opacityAnimation); + visual.StartAnimation(nameof(visual.Scale), scaleAnimation); + clip.StartAnimation(nameof(clip.LeftInset), leftInsetAnimation); + clip.StartAnimation(nameof(clip.RightInset), rightInsetAnimation); + } + + private static void StartOpacityTransition(Compositor compositor, Visual visual, float startingOpacity, TimeSpan duration) + { + visual.StopAnimation(nameof(visual.Opacity)); + + var easing = compositor.CreateCubicBezierEasingFunction(new Vector2(0.2f, 0f), new Vector2(0f, 1f)); + var opacityAnimation = compositor.CreateScalarKeyFrameAnimation(); + opacityAnimation.InsertKeyFrame(0f, startingOpacity); + opacityAnimation.InsertKeyFrame(1f, 1f, easing); + opacityAnimation.Duration = duration; + + visual.StartAnimation(nameof(visual.Opacity), opacityAnimation); + } + + private static SKPaint CreateLinePaint() + { + var strokeColor = GetStrokeColor(); + + return new SKPaint + { + Color = new SKColor(strokeColor.R, strokeColor.G, strokeColor.B, (byte)Math.Max(40, strokeColor.A / 2)), + IsAntialias = false, + StrokeWidth = 1 + }; + } + + private static SKPaint CreateFillPaint(Brush brush) + { + return new SKPaint + { + Color = ToSkColor(brush), + Style = SKPaintStyle.Fill, + IsAntialias = false + }; + } + + private static SKColor ToSkColor(Brush brush) + { + return brush is SolidColorBrush solidColorBrush + ? new SKColor(solidColorBrush.Color.R, solidColorBrush.Color.G, solidColorBrush.Color.B, solidColorBrush.Color.A) + : SKColors.Transparent; + } + + private Brush GetDefaultHourBackground() + { + if (Application.Current.Resources.TryGetValue("LayerFillColorDefaultBrush", out var resource) && resource is Brush brush) + { + return brush; + } + + return new SolidColorBrush(Color.FromArgb(255, 28, 34, 42)); + } + + private Brush GetWorkHourBackground() + { + if (Application.Current.Resources.TryGetValue("SolidBackgroundFillColorBaseBrush", out var resource) && resource is SolidColorBrush solidBrush) + { + return new SolidColorBrush(Color.FromArgb(64, solidBrush.Color.R, solidBrush.Color.G, solidBrush.Color.B)); + } + + return new SolidColorBrush(Color.FromArgb(255, 34, 40, 52)); + } + + private double GetHourHeight() => CalendarSettings?.HourHeight ?? 60d; + + private static Color GetStrokeColor() + { + if (Application.Current.Resources.TryGetValue("CardStrokeColorDefaultBrush", out var resource) && + resource is SolidColorBrush solidBrush) + { + return solidBrush.Color; + } + + return Color.FromArgb(96, 210, 210, 210); + } + + private void OnPropertyChanged([CallerMemberName] string? propertyName = null) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } + + private readonly record struct CalendarTransitionInfo(CalendarTransitionKind Kind, int Direction); + + private enum CalendarTransitionKind + { + None, + Navigation, + ModeChange, + Refresh + } +} diff --git a/Wino.Mail.WinUI/Controls/Calendar/HeaderTextLayout.cs b/Wino.Mail.WinUI/Controls/Calendar/HeaderTextLayout.cs new file mode 100644 index 00000000..21cafb22 --- /dev/null +++ b/Wino.Mail.WinUI/Controls/Calendar/HeaderTextLayout.cs @@ -0,0 +1,13 @@ +namespace Wino.Calendar.Controls; + +public sealed class HeaderTextLayout +{ + public HeaderTextLayout(string text, double width) + { + Text = text; + Width = width; + } + + public string Text { get; set; } + public double Width { get; set; } +} diff --git a/Wino.Mail.WinUI/Controls/Calendar/LayoutRect.cs b/Wino.Mail.WinUI/Controls/Calendar/LayoutRect.cs new file mode 100644 index 00000000..ef2043f2 --- /dev/null +++ b/Wino.Mail.WinUI/Controls/Calendar/LayoutRect.cs @@ -0,0 +1,3 @@ +namespace Wino.Calendar.Controls; + +public readonly record struct LayoutRect(double X, double Y, double Width, double Height); diff --git a/Wino.Mail.WinUI/Controls/Calendar/MonthCalendarLayout.cs b/Wino.Mail.WinUI/Controls/Calendar/MonthCalendarLayout.cs new file mode 100644 index 00000000..ff348651 --- /dev/null +++ b/Wino.Mail.WinUI/Controls/Calendar/MonthCalendarLayout.cs @@ -0,0 +1,146 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.UI.Xaml; +using Wino.Calendar.ViewModels.Data; +using Wino.Core.Domain.Models.Calendar; + +namespace Wino.Calendar.Controls; + +public sealed class MonthCellLayout +{ + public MonthCellLayout(DateOnly date, int index, int row, int column, LayoutRect bounds) + { + Date = date; + Index = index; + Row = row; + Column = column; + Bounds = bounds; + } + + public DateOnly Date { get; set; } + public int Index { get; set; } + public int Row { get; set; } + public int Column { get; set; } + public LayoutRect Bounds { get; set; } +} + +public sealed class MonthItemLayout +{ + public MonthItemLayout(CalendarItemViewModel item, int cellIndex, DateOnly date, LayoutRect bounds, DataTemplate? template = null) + { + Item = item; + CellIndex = cellIndex; + Date = date; + Bounds = bounds; + Template = template; + } + + public CalendarItemViewModel Item { get; set; } + public int CellIndex { get; set; } + public DateOnly Date { get; set; } + public LayoutRect Bounds { get; set; } + public DataTemplate? Template { get; set; } +} + +public sealed class MonthCellLabelLayout +{ + public MonthCellLabelLayout(string dayText, double labelOpacity, LayoutRect bounds) + { + DayText = dayText; + LabelOpacity = labelOpacity; + Bounds = bounds; + } + + public string DayText { get; set; } + public double LabelOpacity { get; set; } + public LayoutRect Bounds { get; set; } +} + +internal sealed record MonthCalendarLayoutResult(double CellWidth, double CellHeight, IReadOnlyList Cells, IReadOnlyList Items); + +internal static class MonthCalendarLayoutCalculator +{ + public const int ColumnCount = 7; + public const int RowCount = 6; + + private const double CellPadding = 4d; + private const double DayLabelHeight = 20d; + private const double ItemHeight = 18d; + private const double ItemGap = 2d; + + public static MonthCalendarLayoutResult Calculate(VisibleDateRange range, IEnumerable items, double availableWidth, double availableHeight) + { + var cellWidth = availableWidth <= 0 ? 0d : availableWidth / ColumnCount; + var cellHeight = availableHeight <= 0 ? 0d : availableHeight / RowCount; + var cells = range.Dates + .Select((date, index) => + { + var row = index / ColumnCount; + var column = index % ColumnCount; + return new MonthCellLayout( + date, + index, + row, + column, + new LayoutRect(column * cellWidth, row * cellHeight, cellWidth, cellHeight)); + }) + .ToArray(); + + var itemLayouts = new List(); + + foreach (var cell in cells) + { + var cellItems = GetCellItems(items, cell.Date).ToList(); + + for (var index = 0; index < cellItems.Count; index++) + { + var y = cell.Bounds.Y + DayLabelHeight + CellPadding + (index * (ItemHeight + ItemGap)); + + if (y + ItemHeight > cell.Bounds.Y + cell.Bounds.Height - CellPadding) + { + break; + } + + itemLayouts.Add(new MonthItemLayout( + cellItems[index], + cell.Index, + cell.Date, + new LayoutRect( + cell.Bounds.X + CellPadding, + y, + Math.Max(0, cell.Bounds.Width - (CellPadding * 2)), + ItemHeight))); + } + } + + return new MonthCalendarLayoutResult(cellWidth, cellHeight, cells, itemLayouts); + } + + private static IEnumerable GetCellItems(IEnumerable items, DateOnly date) + { + var dayStart = date.ToDateTime(TimeOnly.MinValue); + var dayEnd = dayStart.AddDays(1); + + foreach (var item in items) + { + if (!CalendarItemAccessor.TryGetTimeRange(item, out var start, out var end)) + { + continue; + } + + var localStart = start.LocalDateTime; + var localEnd = end.LocalDateTime; + + if (localEnd <= localStart) + { + continue; + } + + if (localStart < dayEnd && localEnd > dayStart) + { + yield return item; + } + } + } +} diff --git a/Wino.Mail.WinUI/Controls/Calendar/TimedCalendarLayout.cs b/Wino.Mail.WinUI/Controls/Calendar/TimedCalendarLayout.cs new file mode 100644 index 00000000..b0bdfd23 --- /dev/null +++ b/Wino.Mail.WinUI/Controls/Calendar/TimedCalendarLayout.cs @@ -0,0 +1,165 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.UI.Xaml; +using Wino.Calendar.ViewModels.Data; +using Wino.Core.Domain.Models.Calendar; + +namespace Wino.Calendar.Controls; + +public sealed class TimedItemLayout +{ + public TimedItemLayout(CalendarItemViewModel item, int dayIndex, DateOnly date, LayoutRect bounds, DataTemplate? template = null) + { + Item = item; + DayIndex = dayIndex; + Date = date; + Bounds = bounds; + Template = template; + } + + public CalendarItemViewModel Item { get; set; } + public int DayIndex { get; set; } + public DateOnly Date { get; set; } + public LayoutRect Bounds { get; set; } + public DataTemplate? Template { get; set; } +} + +internal sealed record TimedCalendarLayoutResult(IReadOnlyList VisibleDates, double DayWidth, IReadOnlyList Items); + +internal static class TimedCalendarLayoutCalculator +{ + public static double GetTimelineHeight(double hourHeight) => hourHeight * 24d; + + public static TimedCalendarLayoutResult Calculate(VisibleDateRange range, IEnumerable items, double availableWidth, double hourHeight) + { + var visibleDates = range.Dates; + var dayWidth = visibleDates.Count == 0 ? 0d : availableWidth / visibleDates.Count; + var layouts = new List(); + + for (var dayIndex = 0; dayIndex < visibleDates.Count; dayIndex++) + { + var date = visibleDates[dayIndex]; + var daySegments = BuildDaySegments(items, date) + .OrderBy(segment => segment.StartMinute) + .ThenBy(segment => segment.EndMinute) + .ToList(); + + foreach (var cluster in BuildClusters(daySegments)) + { + AssignColumns(cluster); + var columnCount = cluster.Max(segment => segment.ColumnIndex) + 1; + var subColumnWidth = columnCount == 0 ? dayWidth : dayWidth / columnCount; + + foreach (var segment in cluster) + { + var x = (dayIndex * dayWidth) + (segment.ColumnIndex * subColumnWidth) + 2; + var width = Math.Max(0, subColumnWidth - 4); + var y = (segment.StartMinute / 60d) * hourHeight; + var height = Math.Max(1, ((segment.EndMinute - segment.StartMinute) / 60d) * hourHeight); + + layouts.Add(new TimedItemLayout(segment.Item, dayIndex, date, new LayoutRect(x, y, width, height))); + } + } + } + + return new TimedCalendarLayoutResult(visibleDates, dayWidth, layouts); + } + + private static List BuildDaySegments(IEnumerable items, DateOnly date) + { + var dayStart = date.ToDateTime(TimeOnly.MinValue); + var dayEnd = dayStart.AddDays(1); + var segments = new List(); + + foreach (var item in items) + { + if (!CalendarItemAccessor.TryGetTimeRange(item, out var start, out var end)) + { + continue; + } + + var localStart = start.LocalDateTime; + var localEnd = end.LocalDateTime; + + if (localEnd <= localStart) + { + continue; + } + + var segmentStart = localStart > dayStart ? localStart : dayStart; + var segmentEnd = localEnd < dayEnd ? localEnd : dayEnd; + + if (segmentEnd <= segmentStart) + { + continue; + } + + segments.Add(new Segment(item, (segmentStart - dayStart).TotalMinutes, (segmentEnd - dayStart).TotalMinutes)); + } + + return segments; + } + + private static IEnumerable> BuildClusters(List segments) + { + if (segments.Count == 0) + { + yield break; + } + + var cluster = new List { segments[0] }; + var clusterEnd = segments[0].EndMinute; + + for (var index = 1; index < segments.Count; index++) + { + var segment = segments[index]; + + if (segment.StartMinute < clusterEnd) + { + cluster.Add(segment); + clusterEnd = Math.Max(clusterEnd, segment.EndMinute); + continue; + } + + yield return cluster; + cluster = [segment]; + clusterEnd = segment.EndMinute; + } + + yield return cluster; + } + + private static void AssignColumns(List segments) + { + var columnEnds = new List(); + + foreach (var segment in segments) + { + var assignedColumn = -1; + + for (var columnIndex = 0; columnIndex < columnEnds.Count; columnIndex++) + { + if (columnEnds[columnIndex] <= segment.StartMinute) + { + assignedColumn = columnIndex; + columnEnds[columnIndex] = segment.EndMinute; + break; + } + } + + if (assignedColumn < 0) + { + assignedColumn = columnEnds.Count; + columnEnds.Add(segment.EndMinute); + } + + segment.ColumnIndex = assignedColumn; + } + } + + private sealed record Segment(CalendarItemViewModel Item, double StartMinute, double EndMinute) + { + public int ColumnIndex { get; set; } + } +} diff --git a/Wino.Mail.WinUI/Styles/CalendarViewStyles.xaml b/Wino.Mail.WinUI/Styles/CalendarViewStyles.xaml index 45b06774..b1649546 100644 --- a/Wino.Mail.WinUI/Styles/CalendarViewStyles.xaml +++ b/Wino.Mail.WinUI/Styles/CalendarViewStyles.xaml @@ -2,7 +2,9 @@ + xmlns:data="using:Wino.Calendar.ViewModels.Data" + xmlns:local="using:Wino.Mail.WinUI.Styles" + xmlns:selectors1="using:Wino.Selectors"> - - - - - - - + diff --git a/Wino.Mail.WinUI/Styles/DataTemplates.xaml b/Wino.Mail.WinUI/Styles/DataTemplates.xaml index 59911da1..b40c74d8 100644 --- a/Wino.Mail.WinUI/Styles/DataTemplates.xaml +++ b/Wino.Mail.WinUI/Styles/DataTemplates.xaml @@ -8,12 +8,14 @@ xmlns:coreControls="using:Wino.Mail.WinUI.Controls" xmlns:coreSelectors="using:Wino.Mail.WinUI.Selectors" xmlns:coreViewModelData="using:Wino.Core.ViewModels.Data" + xmlns:data="using:Wino.Calendar.ViewModels.Data" xmlns:domain="using:Wino.Core.Domain" xmlns:helpers="using:Wino.Helpers" xmlns:local="using:Wino.Mail.WinUI.Styles" xmlns:menu="using:Wino.Core.Domain.MenuItems" xmlns:muxc="using:Microsoft.UI.Xaml.Controls" xmlns:personalization="using:Wino.Mail.WinUI.Models.Personalization" + xmlns:selectors1="using:Wino.Selectors" xmlns:shared="using:Wino.Core.Domain.Entities.Shared" xmlns:viewModelData="using:Wino.Mail.ViewModels.Data" xmlns:winuiControls="using:CommunityToolkit.WinUI.Controls"> @@ -326,6 +328,36 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Wino.Mail.WinUI/Views/Calendar/CalendarPage.xaml b/Wino.Mail.WinUI/Views/Calendar/CalendarPage.xaml index 6bfe9cc9..76bb0119 100644 --- a/Wino.Mail.WinUI/Views/Calendar/CalendarPage.xaml +++ b/Wino.Mail.WinUI/Views/Calendar/CalendarPage.xaml @@ -3,10 +3,17 @@ xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:abstract="using:Wino.Calendar.Views.Abstract" + xmlns:calendarControls="using:Wino.Calendar.Controls" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" - xmlns:domain="using:Wino.Core.Domain" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" mc:Ignorable="d"> - + + + diff --git a/Wino.Mail.WinUI/Wino.Mail.WinUI.csproj b/Wino.Mail.WinUI/Wino.Mail.WinUI.csproj index b64c1d87..712ad1e9 100644 --- a/Wino.Mail.WinUI/Wino.Mail.WinUI.csproj +++ b/Wino.Mail.WinUI/Wino.Mail.WinUI.csproj @@ -115,6 +115,7 @@ + @@ -218,6 +219,7 @@ + @@ -341,6 +343,11 @@ + + + MSBuild:Compile + + MSBuild:Compile