From 8fd09bcad4b0f3a5cb5dd44eaf10dc2210209302 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Burak=20Kaan=20K=C3=B6se?= Date: Mon, 30 Dec 2024 23:10:51 +0100 Subject: [PATCH] Proper handling of DateTimeOffset, support for Multi-Day events and reacting to adding/removing events for the days. --- Wino.Calendar.ViewModels/AppShellViewModel.cs | 9 -- .../CalendarPageViewModel.cs | 100 +++++++++-------- .../Data/CalendarItemViewModel.cs | 10 +- .../IAccountCalendarStateService.cs | 6 + .../Controls/AllDayItemsControl.xaml.cs | 10 +- Wino.Calendar/Controls/WinoCalendarControl.cs | 12 -- Wino.Calendar/Controls/WinoCalendarPanel.cs | 68 ++++++++++-- .../Services/AccountCalendarStateService.cs | 11 ++ .../Styles/CalendarThemeResources.xaml | 2 +- .../Styles/WinoCalendarResources.xaml | 8 +- Wino.Calendar/Views/AppShell.xaml.cs | 5 - Wino.Calendar/Views/CalendarPage.xaml.cs | 24 +++- .../Collections/CalendarEventCollection.cs | 104 ++++++++++-------- .../Entities/Calendar/CalendarItem.cs | 35 +++++- Wino.Core.Domain/Interfaces/ICalendarItem.cs | 7 +- .../Interfaces/ICalendarService.cs | 4 +- .../Models/Calendar/CalendarDayModel.cs | 5 +- .../Models/Calendar/DayRangeRenderModel.cs | 11 -- Wino.Core.ViewModels/CalendarBaseViewModel.cs | 4 +- .../Extensions/GoogleIntegratorExtensions.cs | 94 +++++++--------- .../Processors/GmailChangeProcessor.cs | 15 ++- .../Client/Calendar/CalendarEventAdded.cs | 4 +- Wino.Services/CalendarService.cs | 26 +++-- 23 files changed, 340 insertions(+), 234 deletions(-) diff --git a/Wino.Calendar.ViewModels/AppShellViewModel.cs b/Wino.Calendar.ViewModels/AppShellViewModel.cs index 48b0a88d..e778c37a 100644 --- a/Wino.Calendar.ViewModels/AppShellViewModel.cs +++ b/Wino.Calendar.ViewModels/AppShellViewModel.cs @@ -258,13 +258,6 @@ namespace Wino.Calendar.ViewModels private readonly IAccountService _accountService; private readonly ICalendarService _calendarService; - //public override void OnPageLoaded() - //{ - // base.OnPageLoaded(); - - // TodayClicked(); - //} - #region Commands [RelayCommand] @@ -341,7 +334,5 @@ namespace Wino.Calendar.ViewModels => await ExecuteUIThread(() => IsCalendarEnabled = message.IsEnabled); public void Receive(NavigateManageAccountsRequested message) => SelectedMenuItemIndex = 1; - - //public void Receive(GoToCalendarDayMessage message) => SelectedMenuItemIndex = -1; } } diff --git a/Wino.Calendar.ViewModels/CalendarPageViewModel.cs b/Wino.Calendar.ViewModels/CalendarPageViewModel.cs index e0c5942a..e25e5cd3 100644 --- a/Wino.Calendar.ViewModels/CalendarPageViewModel.cs +++ b/Wino.Calendar.ViewModels/CalendarPageViewModel.cs @@ -7,10 +7,12 @@ using System.Threading.Tasks; using CommunityToolkit.Diagnostics; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Messaging; +using MoreLinq; using Serilog; using Wino.Calendar.ViewModels.Data; using Wino.Calendar.ViewModels.Interfaces; using Wino.Core.Domain.Collections; +using Wino.Core.Domain.Entities.Calendar; using Wino.Core.Domain.Enums; using Wino.Core.Domain.Interfaces; using Wino.Core.Domain.Models.Calendar; @@ -70,20 +72,16 @@ namespace Wino.Calendar.ViewModels } private void AccountCalendarStateCollectivelyChanged(object sender, GroupedAccountCalendarViewModel e) - { - // For all date ranges, update the events. - foreach (var dayRange in DayRanges) - { - _ = InitializeCalendarEventsForDayRangeAsync(dayRange); - } - } + => FilterActiveCalendars(DayRanges); private void UpdateAccountCalendarRequested(object sender, AccountCalendarViewModel e) + => FilterActiveCalendars(DayRanges); + + private void FilterActiveCalendars(IEnumerable dayRangeRenderModels) { - foreach (var range in DayRanges) - { - _ = InitializeCalendarEventsForDayRangeAsync(range); - } + var days = dayRangeRenderModels.SelectMany(a => a.CalendarDays); + + days.ForEach(a => a.EventsCollection.FilterByCalendars(_accountCalendarStateService.ActiveCalendars.Select(a => a.Id))); } // TODO: Replace when calendar settings are updated. @@ -297,6 +295,9 @@ namespace Wino.Calendar.ViewModels await InitializeCalendarEventsForDayRangeAsync(renderModel).ConfigureAwait(false); } + // Filter by active calendars. This is a quick operation, and things are not on the UI yet. + FilterActiveCalendars(renderModels); + CalendarLoadDirection animationDirection = calendarLoadDirection; bool removeCurrent = calendarLoadDirection == CalendarLoadDirection.Replace; @@ -372,22 +373,39 @@ namespace Wino.Calendar.ViewModels } // TODO... - private async void EventsUpdatedInDayHeader(object sender, CalendarDayModel e) + private void EventsUpdatedInDayHeader(object sender, CalendarDayModel e) { - // Find the day range model that contains the day model. - // TODO: Maybe optimize by just updating the day? - if (sender is DayRangeRenderModel dayRangeRenderModel) - { - await InitializeCalendarEventsForDayRangeAsync(dayRangeRenderModel); - } + } + protected override async void OnCalendarEventAdded(CalendarItem calendarItem) + { + base.OnCalendarEventAdded(calendarItem); - //var dayRange = DayRanges.FirstOrDefault(a => a.CalendarDays.Contains(e)); + // test + var calendar = await _calendarService.GetAccountCalendarAsync(Guid.Parse("9ead7613-dacb-4163-8d33-2e32e65008a1")); - //if (dayRange == null) return; + calendarItem.AssignedCalendar = calendar; + // Check if event falls into the current date range. - //await InitializeCalendarEventsForDayRangeAsync(dayRange); + var loadedDateRange = GetLoadedDateRange(); + + if (loadedDateRange == null) return; + + // Check whether this event falls into any of the loaded date ranges. + + //if (calendarItem.Period.Start >= loadedDateRange.StartDate && calendarItem.Period.Start.Date <= loadedDateRange.EndDate) + //{ + // // Find the day representation for the event. + // var dayModel = DayRanges.SelectMany(a => a.CalendarDays).FirstOrDefault(a => a.RepresentingDate.Date == calendarItem.Period.Start.Date); + // if (dayModel == null) return; + + // var calendarItemViewModel = new CalendarItemViewModel(calendarItem); + // await ExecuteUIThread(() => + // { + // dayModel.EventsCollection.AddCalendarItem(calendarItemViewModel); + // }); + //} } private async Task InitializeCalendarEventsForDayRangeAsync(DayRangeRenderModel dayRangeRenderModel) @@ -401,38 +419,32 @@ namespace Wino.Calendar.ViewModels }); } - // Load for each selected calendar from the state. - var checkedCalendarViewModels = _accountCalendarStateService.GroupedAccountCalendars - .SelectMany(a => a.AccountCalendars) - .Where(b => b.IsChecked); + // Initialization is done for all calendars, regardless whether they are actively selected or not. + // This is because the filtering is cached internally of the calendar items in CalendarEventCollection. + var allCalendars = _accountCalendarStateService.GroupedAccountCalendars.SelectMany(a => a.AccountCalendars); - foreach (var calendarViewModel in checkedCalendarViewModels) + foreach (var calendarViewModel in allCalendars) { // Check all the events for the given date range and calendar. // Then find the day representation for all the events returned, and add to the collection. - var events = await _calendarService.GetCalendarEventsAsync(calendarViewModel, - dayRangeRenderModel.Period.Start, - dayRangeRenderModel.Period.End) - .ConfigureAwait(false); + var events = await _calendarService.GetCalendarEventsAsync(calendarViewModel, dayRangeRenderModel).ConfigureAwait(false); - var groupedEvents = events.GroupBy(a => a.StartTime.Date); - - foreach (var group in groupedEvents) + foreach (var @event in events) { - var startDate = group.Key; + // Find the days that the event falls into. + // TODO: Multi-day events are not fully supported yet. - var calendarDayModel = dayRangeRenderModel.CalendarDays.FirstOrDefault(a => a.RepresentingDate.Date == startDate); + var allDaysForEvent = dayRangeRenderModel.CalendarDays.Where(a => a.Period.OverlapsWith(@event.Period)); - if (calendarDayModel == null) continue; - - var calendarItemViewModels = group.Select(a => new CalendarItemViewModel(a)); - - await ExecuteUIThread(() => + foreach (var calendarDay in allDaysForEvent) { - // Use range-based add for performance. - calendarDayModel.EventsCollection.AddCalendarItemRange(calendarItemViewModels); - }); + var calendarItemViewModel = new CalendarItemViewModel(@event); + await ExecuteUIThread(() => + { + calendarDay.EventsCollection.AddCalendarItem(calendarItemViewModel); + }); + } } } } @@ -532,7 +544,7 @@ namespace Wino.Calendar.ViewModels // TODO: This might need throttling due to slider in the settings page for hour height. // or make sure the slider does not update on each tick but on focus lost. - Messenger.Send(new LoadCalendarMessage(DateTime.UtcNow.Date, CalendarInitInitiative.App, true)); + // Messenger.Send(new LoadCalendarMessage(DateTime.UtcNow.Date, CalendarInitInitiative.App, true)); } } } diff --git a/Wino.Calendar.ViewModels/Data/CalendarItemViewModel.cs b/Wino.Calendar.ViewModels/Data/CalendarItemViewModel.cs index fd150af7..ff6f17cf 100644 --- a/Wino.Calendar.ViewModels/Data/CalendarItemViewModel.cs +++ b/Wino.Calendar.ViewModels/Data/CalendarItemViewModel.cs @@ -14,13 +14,15 @@ namespace Wino.Calendar.ViewModels.Data public Guid Id => CalendarItem.Id; - public DateTimeOffset StartTime => CalendarItem.StartTime; + public IAccountCalendar AssignedCalendar => CalendarItem.AssignedCalendar; - public int DurationInMinutes => CalendarItem.DurationInMinutes; + public DateTime StartDate { get => CalendarItem.StartDate; set => CalendarItem.StartDate = value; } - public TimeRange Period => CalendarItem.Period; + public DateTime EndDate => CalendarItem.EndDate; - public IAccountCalendar AssignedCalendar => ((ICalendarItem)CalendarItem).AssignedCalendar; + public double DurationInSeconds { get => CalendarItem.DurationInSeconds; set => CalendarItem.DurationInSeconds = value; } + + public ITimePeriod Period => CalendarItem.Period; public CalendarItemViewModel(CalendarItem calendarItem) { diff --git a/Wino.Calendar.ViewModels/Interfaces/IAccountCalendarStateService.cs b/Wino.Calendar.ViewModels/Interfaces/IAccountCalendarStateService.cs index 43afa5c6..22d79e3e 100644 --- a/Wino.Calendar.ViewModels/Interfaces/IAccountCalendarStateService.cs +++ b/Wino.Calendar.ViewModels/Interfaces/IAccountCalendarStateService.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Collections.ObjectModel; using Wino.Calendar.ViewModels.Data; @@ -17,5 +18,10 @@ namespace Wino.Calendar.ViewModels.Interfaces public void AddAccountCalendar(AccountCalendarViewModel accountCalendar); public void RemoveAccountCalendar(AccountCalendarViewModel accountCalendar); + + /// + /// Enumeration of currently selected calendars. + /// + IEnumerable ActiveCalendars { get; } } } diff --git a/Wino.Calendar/Controls/AllDayItemsControl.xaml.cs b/Wino.Calendar/Controls/AllDayItemsControl.xaml.cs index f6b7924d..4937fd1f 100644 --- a/Wino.Calendar/Controls/AllDayItemsControl.xaml.cs +++ b/Wino.Calendar/Controls/AllDayItemsControl.xaml.cs @@ -1,5 +1,4 @@ -using System.Collections.Generic; -using Windows.UI.Xaml; +using Windows.UI.Xaml; using Windows.UI.Xaml.Controls; using Wino.Core.Domain.Collections; using Wino.Core.Domain.Interfaces; @@ -76,9 +75,6 @@ namespace Wino.Calendar.Controls collection.CalendarItemAdded += SingleEventUpdated; collection.CalendarItemRemoved += SingleEventUpdated; - collection.CalendarItemRangeAdded += CollectionOfEventsUpdated; - collection.CalendarItemRangeRemoved += CollectionOfEventsUpdated; - collection.CalendarItemsCleared += EventsCleared; } @@ -87,14 +83,10 @@ namespace Wino.Calendar.Controls collection.CalendarItemAdded -= SingleEventUpdated; collection.CalendarItemRemoved -= SingleEventUpdated; - collection.CalendarItemRangeAdded -= CollectionOfEventsUpdated; - collection.CalendarItemRangeRemoved -= CollectionOfEventsUpdated; - collection.CalendarItemsCleared -= EventsCleared; } private void SingleEventUpdated(object sender, ICalendarItem calendarItem) => UpdateCollectionVisuals(); - private void CollectionOfEventsUpdated(object sender, List calendarItems) => UpdateCollectionVisuals(); private void EventsCleared(object sender, System.EventArgs e) => UpdateCollectionVisuals(); private void UpdateCollectionVisuals() diff --git a/Wino.Calendar/Controls/WinoCalendarControl.cs b/Wino.Calendar/Controls/WinoCalendarControl.cs index c5818b4a..eb0018dd 100644 --- a/Wino.Calendar/Controls/WinoCalendarControl.cs +++ b/Wino.Calendar/Controls/WinoCalendarControl.cs @@ -1,6 +1,5 @@ using System; using System.Collections.ObjectModel; -using System.Diagnostics; using Windows.UI.Xaml; using Windows.UI.Xaml.Controls; using Wino.Calendar.Args; @@ -95,8 +94,6 @@ namespace Wino.Calendar.Controls { if (canvas == null) return; - Debug.WriteLine("Deregister active canvas."); - canvas.SelectedDateTime = null; canvas.TimelineCellSelected -= ActiveTimelineCellSelected; canvas.TimelineCellUnselected -= ActiveTimelineCellUnselected; @@ -106,8 +103,6 @@ namespace Wino.Calendar.Controls { if (canvas == null) return; - Debug.WriteLine("Register new canvas."); - canvas.SelectedDateTime = null; canvas.TimelineCellSelected += ActiveTimelineCellSelected; canvas.TimelineCellUnselected += ActiveTimelineCellUnselected; @@ -127,13 +122,6 @@ namespace Wino.Calendar.Controls InternalFlipView = GetTemplateChild(PART_WinoFlipView) as WinoCalendarFlipView; } - private void FlipViewsActiveTimelineCanvasChanged(object sender, WinoDayTimelineCanvas e) - { - ActiveCanvas = e; - - SelectedFlipViewDayRange = InternalFlipView.SelectedItem as DayRangeRenderModel; - } - private void ActiveTimelineCellUnselected(object sender, TimelineCellUnselectedArgs e) => TimelineCellUnselected?.Invoke(this, e); diff --git a/Wino.Calendar/Controls/WinoCalendarPanel.cs b/Wino.Calendar/Controls/WinoCalendarPanel.cs index ca9d3e21..cd934e6a 100644 --- a/Wino.Calendar/Controls/WinoCalendarPanel.cs +++ b/Wino.Calendar/Controls/WinoCalendarPanel.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; using System.Collections.Specialized; using System.Linq; +using Itenso.TimePeriod; using Windows.Foundation; using Windows.UI.Xaml; using Windows.UI.Xaml.Controls; @@ -21,6 +22,14 @@ namespace Wino.Calendar.Controls public static readonly DependencyProperty EventItemMarginProperty = DependencyProperty.Register(nameof(EventItemMargin), typeof(Thickness), typeof(WinoCalendarPanel), new PropertyMetadata(new Thickness(0, 0, 0, 0))); public static readonly DependencyProperty HourHeightProperty = DependencyProperty.Register(nameof(HourHeight), typeof(double), typeof(WinoCalendarPanel), new PropertyMetadata(0d)); + public static readonly DependencyProperty PeriodProperty = DependencyProperty.Register(nameof(Period), typeof(ITimePeriod), typeof(WinoCalendarPanel), new PropertyMetadata(null)); + + + public ITimePeriod Period + { + get { return (ITimePeriod)GetValue(PeriodProperty); } + set { SetValue(PeriodProperty, value); } + } public double HourHeight { @@ -41,11 +50,18 @@ namespace Wino.Calendar.Controls private double GetChildTopMargin(ICalendarItem calendarItemViewModel, double availableHeight) { - var childStart = calendarItemViewModel.StartTime; + var childStart = calendarItemViewModel.StartDate; - double totalMinutes = 1440; - double minutesFromStart = (childStart - childStart.DateTime.Date).TotalMinutes; - return (minutesFromStart / totalMinutes) * availableHeight; + if (childStart <= Period.Start) + { + // Event started before or exactly at the periods tart. This might be a multi-day event. + // We can simply consider event must not have a top margin. + + return 0d; + } + + double minutesFromStart = (childStart - Period.Start).TotalMinutes; + return (minutesFromStart / 1440) * availableHeight; } private double GetChildWidth(CalendarItemMeasurement calendarItemMeasurement, double availableWidth) @@ -56,16 +72,48 @@ namespace Wino.Calendar.Controls private double GetChildLeftMargin(CalendarItemMeasurement calendarItemMeasurement, double availableWidth) => availableWidth * calendarItemMeasurement.Left; - private double GetChildHeight(DateTimeOffset childStart, DateTimeOffset childEnd) + private double GetChildHeight(ICalendarItem child) { - double totalMinutes = 1440; + double childDurationInMinutes = 0d; + double availableHeight = HourHeight * 24; - double childDuration = (childEnd - childStart).TotalMinutes; - return (childDuration / totalMinutes) * availableHeight; + + var childStart = child.Period.Start; + var childEnd = child.Period.End; + + // Multi-day event. + + if (childStart < Period.Start) + { + if (childEnd >= Period.End) + { + // Event spans the whole period. + return availableHeight; + } + else + { + // Check how many of the event falls into the current period. + childDurationInMinutes = (childEnd - Period.Start).TotalMinutes; + } + } + else + { + childDurationInMinutes = (childEnd - childStart).TotalMinutes; + } + + return (childDurationInMinutes / 1440) * availableHeight; + } + + protected override Size MeasureOverride(Size availableSize) + { + ResetMeasurements(); + return base.MeasureOverride(availableSize); } protected override Size ArrangeOverride(Size finalSize) { + if (Period == null || HourHeight == 0d) return finalSize; + // Measure/arrange each child height and width. // This is a vertical calendar. Therefore the height of each child is the duration of the event. // Children weights for left and right will be saved if they don't exist. @@ -99,7 +147,7 @@ namespace Wino.Calendar.Controls var childMeasurement = _measurements[child.Id]; - double childHeight = Math.Max(0, GetChildHeight(child.StartTime, child.StartTime.AddMinutes(child.DurationInMinutes))); + double childHeight = Math.Max(0, GetChildHeight(child)); double childWidth = Math.Max(0, GetChildWidth(childMeasurement, finalSize.Width)); double childTop = Math.Max(0, GetChildTopMargin(child, availableHeight)); double childLeft = Math.Max(0, GetChildLeftMargin(childMeasurement, availableWidth)); @@ -142,7 +190,7 @@ namespace Wino.Calendar.Controls var columns = new List>(); DateTime? lastEventEnding = null; - foreach (var ev in events.OrderBy(ev => ev.Period.Start).ThenBy(ev => ev.Period.End)) + foreach (var ev in events.OrderBy(ev => ev.StartDate).ThenBy(ev => ev.EndDate)) { if (ev.Period.Start >= lastEventEnding) { diff --git a/Wino.Calendar/Services/AccountCalendarStateService.cs b/Wino.Calendar/Services/AccountCalendarStateService.cs index 10d211eb..516df311 100644 --- a/Wino.Calendar/Services/AccountCalendarStateService.cs +++ b/Wino.Calendar/Services/AccountCalendarStateService.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Collections.ObjectModel; using System.Linq; using CommunityToolkit.Mvvm.ComponentModel; @@ -21,6 +22,16 @@ namespace Wino.Calendar.Services private ObservableCollection _internalGroupedAccountCalendars = new ObservableCollection(); + public IEnumerable ActiveCalendars + { + get + { + return GroupedAccountCalendars + .SelectMany(a => a.AccountCalendars) + .Where(b => b.IsChecked); + } + } + public AccountCalendarStateService() { GroupedAccountCalendars = new ReadOnlyObservableCollection(_internalGroupedAccountCalendars); diff --git a/Wino.Calendar/Styles/CalendarThemeResources.xaml b/Wino.Calendar/Styles/CalendarThemeResources.xaml index 64aaae87..8c176f40 100644 --- a/Wino.Calendar/Styles/CalendarThemeResources.xaml +++ b/Wino.Calendar/Styles/CalendarThemeResources.xaml @@ -17,7 +17,7 @@ - #000000 + #525252 #32262626 #121212 diff --git a/Wino.Calendar/Styles/WinoCalendarResources.xaml b/Wino.Calendar/Styles/WinoCalendarResources.xaml index 9cdc0cb9..22c1795d 100644 --- a/Wino.Calendar/Styles/WinoCalendarResources.xaml +++ b/Wino.Calendar/Styles/WinoCalendarResources.xaml @@ -13,7 +13,11 @@ - + - + diff --git a/Wino.Calendar/Views/AppShell.xaml.cs b/Wino.Calendar/Views/AppShell.xaml.cs index 8bf21df9..c5c343f3 100644 --- a/Wino.Calendar/Views/AppShell.xaml.cs +++ b/Wino.Calendar/Views/AppShell.xaml.cs @@ -44,11 +44,6 @@ namespace Wino.Calendar.Views } - //public void Receive(GoToCalendarDayMessage message) - //{ - // CalendarView.GoToDay(message.DateTime); - //} - private void PreviousDateClicked(object sender, RoutedEventArgs e) => WeakReferenceMessenger.Default.Send(new GoPreviousDateRequestedMessage()); private void NextDateClicked(object sender, RoutedEventArgs e) => WeakReferenceMessenger.Default.Send(new GoNextDateRequestedMessage()); diff --git a/Wino.Calendar/Views/CalendarPage.xaml.cs b/Wino.Calendar/Views/CalendarPage.xaml.cs index 9724df55..08ae26d7 100644 --- a/Wino.Calendar/Views/CalendarPage.xaml.cs +++ b/Wino.Calendar/Views/CalendarPage.xaml.cs @@ -1,5 +1,6 @@ using System; using CommunityToolkit.Mvvm.Messaging; +using Itenso.TimePeriod; using Microsoft.UI.Xaml.Controls; using Windows.UI.Xaml.Controls; using Windows.UI.Xaml.Navigation; @@ -16,7 +17,7 @@ namespace Wino.Calendar.Views IRecipient, IRecipient { - private DateTime? selectedDateTime; + private DateTimeOffset? selectedDateTime; public CalendarPage() { InitializeComponent(); @@ -50,7 +51,13 @@ namespace Wino.Calendar.Views private void CellSelected(object sender, TimelineCellSelectedArgs e) { + // Selected date is in Local kind. selectedDateTime = e.ClickedDate; + var utc = DateTime.SpecifyKind(e.ClickedDate, DateTimeKind.Utc); + var unspecified = DateTime.SpecifyKind(e.ClickedDate, DateTimeKind.Unspecified); + + var putc = new TimeRange(utc, utc.AddMinutes(30)); + var punspecified = new TimeRange(unspecified, unspecified.AddMinutes(30)); // TODO: Popup is not positioned well on daily view. TeachingTipPositionerGrid.Width = e.CellSize.Width; @@ -59,8 +66,19 @@ namespace Wino.Calendar.Views Canvas.SetLeft(TeachingTipPositionerGrid, e.PositionerPoint.X); Canvas.SetTop(TeachingTipPositionerGrid, e.PositionerPoint.Y); - // TODO: End time can be from settings. - // WeakReferenceMessenger.Default.Send(new CalendarEventAdded(new CalendarItem(selectedDateTime.Value, selectedDateTime.Value.AddMinutes(30)))); + //var testCalendarItem = new CalendarItem + //{ + // CalendarId = Guid.Parse("9ead7613-dacb-4163-8d33-2e32e65008a1"), + // StartTime = selectedDateTime.Value, // All events are saved in UTC. + // DurationInMinutes = 30, + // CreatedAt = DateTime.UtcNow, + // Description = "Test Description", + // Location = "Poland", + // Title = "Test event", + // Id = Guid.NewGuid() + //}; + + //WeakReferenceMessenger.Default.Send(new CalendarEventAdded(testCalendarItem)); NewEventTip.IsOpen = true; } diff --git a/Wino.Core.Domain/Collections/CalendarEventCollection.cs b/Wino.Core.Domain/Collections/CalendarEventCollection.cs index 4084e850..1ee11064 100644 --- a/Wino.Core.Domain/Collections/CalendarEventCollection.cs +++ b/Wino.Core.Domain/Collections/CalendarEventCollection.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Collections.ObjectModel; using System.Linq; +using Itenso.TimePeriod; using Wino.Core.Domain.Entities.Calendar; using Wino.Core.Domain.Interfaces; @@ -12,9 +13,6 @@ namespace Wino.Core.Domain.Collections public event EventHandler CalendarItemAdded; public event EventHandler CalendarItemRemoved; - public event EventHandler> CalendarItemRangeAdded; - public event EventHandler> CalendarItemRangeRemoved; - public event EventHandler CalendarItemsCleared; private ObservableRangeCollection _internalRegularEvents = []; @@ -22,79 +20,97 @@ namespace Wino.Core.Domain.Collections public ReadOnlyObservableCollection RegularEvents { get; } public ReadOnlyObservableCollection AllDayEvents { get; } + public ITimePeriod Period { get; } - public CalendarEventCollection() + private readonly List _allItems = new List(); + + public CalendarEventCollection(ITimePeriod period) { RegularEvents = new ReadOnlyObservableCollection(_internalRegularEvents); AllDayEvents = new ReadOnlyObservableCollection(_internalAllDayEvents); + Period = period; } public bool HasCalendarEvent(AccountCalendar accountCalendar) + => _allItems.Any(x => x.AssignedCalendar.Id == accountCalendar.Id); + + public void FilterByCalendars(IEnumerable visibleCalendarIds) { - return _internalAllDayEvents.Any(x => x.AssignedCalendar.Id == accountCalendar.Id) || - _internalRegularEvents.Any(x => x.AssignedCalendar.Id == accountCalendar.Id); + foreach (var item in _allItems) + { + var collection = GetProperCollectionForCalendarItem(item); + + if (!visibleCalendarIds.Contains(item.AssignedCalendar.Id) && collection.Contains(item)) + { + RemoveCalendarItemInternal(collection, item, false); + } + else if (visibleCalendarIds.Contains(item.AssignedCalendar.Id) && !collection.Contains(item)) + { + AddCalendarItemInternal(collection, item, false); + } + } } - public void AddCalendarItemRange(IEnumerable calendarItems) + private ObservableRangeCollection GetProperCollectionForCalendarItem(ICalendarItem calendarItem) { - foreach (var calendarItem in calendarItems) - { - AddCalendarItem(calendarItem); - } + // Event duration is not simply enough to determine whether it's an all-day event or not. + // Event may start at 11:00 PM and end next day at 11:00 PM. It's not an all-day event. + // It's a multi-day event. - CalendarItemRangeAdded?.Invoke(this, new List(calendarItems)); - } + bool isAllDayEvent = calendarItem.Period.Duration.TotalDays == 1 && calendarItem.Period.Start.TimeOfDay == TimeSpan.Zero; - public void RemoveCalendarItemRange(IEnumerable calendarItems) - { - foreach (var calendarItem in calendarItems) - { - RemoveCalendarItem(calendarItem); - } - - CalendarItemRangeRemoved?.Invoke(this, new List(calendarItems)); + return isAllDayEvent ? _internalAllDayEvents : _internalRegularEvents; } public void AddCalendarItem(ICalendarItem calendarItem) + { + var collection = GetProperCollectionForCalendarItem(calendarItem); + AddCalendarItemInternal(collection, calendarItem); + } + + public void RemoveCalendarItem(ICalendarItem calendarItem) + { + var collection = GetProperCollectionForCalendarItem(calendarItem); + RemoveCalendarItemInternal(collection, calendarItem); + } + + private void AddCalendarItemInternal(ObservableRangeCollection collection, ICalendarItem calendarItem, bool create = true) { if (calendarItem is not ICalendarItemViewModel) throw new ArgumentException("CalendarItem must be of type ICalendarItemViewModel", nameof(calendarItem)); - if (calendarItem.Period.Duration.TotalMinutes == 1440) + collection.Add(calendarItem); + + if (create) { - _internalAllDayEvents.Add(calendarItem); - } - else - { - _internalRegularEvents.Add(calendarItem); + _allItems.Add(calendarItem); } CalendarItemAdded?.Invoke(this, calendarItem); } + private void RemoveCalendarItemInternal(ObservableRangeCollection collection, ICalendarItem calendarItem, bool destroy = true) + { + if (calendarItem is not ICalendarItemViewModel) + throw new ArgumentException("CalendarItem must be of type ICalendarItemViewModel", nameof(calendarItem)); + + collection.Remove(calendarItem); + + if (destroy) + { + _allItems.Remove(calendarItem); + } + + CalendarItemRemoved?.Invoke(this, calendarItem); + } + public void Clear() { _internalAllDayEvents.Clear(); _internalRegularEvents.Clear(); + _allItems.Clear(); CalendarItemsCleared?.Invoke(this, EventArgs.Empty); } - - public void RemoveCalendarItem(ICalendarItem calendarItem) - { - if (calendarItem is not ICalendarItemViewModel) - throw new ArgumentException("CalendarItem must be of type ICalendarItemViewModel", nameof(calendarItem)); - - if (calendarItem.Period.Duration.TotalMinutes == 1440) - { - _internalAllDayEvents.Remove(calendarItem); - } - else - { - _internalRegularEvents.Remove(calendarItem); - } - - CalendarItemRemoved?.Invoke(this, calendarItem); - } } } diff --git a/Wino.Core.Domain/Entities/Calendar/CalendarItem.cs b/Wino.Core.Domain/Entities/Calendar/CalendarItem.cs index c8168b57..2f7236e6 100644 --- a/Wino.Core.Domain/Entities/Calendar/CalendarItem.cs +++ b/Wino.Core.Domain/Entities/Calendar/CalendarItem.cs @@ -13,18 +13,43 @@ namespace Wino.Core.Domain.Entities.Calendar public string Title { get; set; } public string Description { get; set; } public string Location { get; set; } - public DateTimeOffset StartTime { get; set; } - public int DurationInMinutes { get; set; } + + public DateTime StartDate { get; set; } + + public DateTime EndDate + { + get + { + return StartDate.AddSeconds(DurationInSeconds); + } + } + + public TimeSpan StartDateOffset { get; set; } + public TimeSpan EndDateOffset { get; set; } + + private ITimePeriod _period; + public ITimePeriod Period + { + get + { + _period ??= new TimeRange(StartDate, EndDate); + + return _period; + } + } + + public double DurationInSeconds { get; set; } public string Recurrence { get; set; } + + // TODO + public string CustomEventColorHex { get; set; } + public string HtmlLink { get; set; } public CalendarItemStatus Status { get; set; } public CalendarItemVisibility Visibility { get; set; } public DateTimeOffset CreatedAt { get; set; } public DateTimeOffset UpdatedAt { get; set; } public Guid CalendarId { get; set; } - [Ignore] - public TimeRange Period => new TimeRange(StartTime.Date, StartTime.Date.AddMinutes(DurationInMinutes)); - [Ignore] public IAccountCalendar AssignedCalendar { get; set; } } diff --git a/Wino.Core.Domain/Interfaces/ICalendarItem.cs b/Wino.Core.Domain/Interfaces/ICalendarItem.cs index adb7b3ef..f548ac8d 100644 --- a/Wino.Core.Domain/Interfaces/ICalendarItem.cs +++ b/Wino.Core.Domain/Interfaces/ICalendarItem.cs @@ -7,9 +7,10 @@ namespace Wino.Core.Domain.Interfaces { string Title { get; } Guid Id { get; } - DateTimeOffset StartTime { get; } - int DurationInMinutes { get; } - TimeRange Period { get; } IAccountCalendar AssignedCalendar { get; } + DateTime StartDate { get; set; } + DateTime EndDate { get; } + double DurationInSeconds { get; set; } + ITimePeriod Period { get; } } } diff --git a/Wino.Core.Domain/Interfaces/ICalendarService.cs b/Wino.Core.Domain/Interfaces/ICalendarService.cs index ddcd9e7e..bc94a3c4 100644 --- a/Wino.Core.Domain/Interfaces/ICalendarService.cs +++ b/Wino.Core.Domain/Interfaces/ICalendarService.cs @@ -2,18 +2,20 @@ using System.Collections.Generic; using System.Threading.Tasks; using Wino.Core.Domain.Entities.Calendar; +using Wino.Core.Domain.Models.Calendar; namespace Wino.Core.Domain.Interfaces { public interface ICalendarService { Task> GetAccountCalendarsAsync(Guid accountId); + Task GetAccountCalendarAsync(Guid accountCalendarId); Task DeleteCalendarItemAsync(Guid calendarItemId); Task DeleteAccountCalendarAsync(AccountCalendar accountCalendar); Task InsertAccountCalendarAsync(AccountCalendar accountCalendar); Task UpdateAccountCalendarAsync(AccountCalendar accountCalendar); Task CreateNewCalendarItemAsync(CalendarItem calendarItem, List attendees); - Task> GetCalendarEventsAsync(IAccountCalendar calendar, DateTime rangeStart, DateTime rangeEnd); + Task> GetCalendarEventsAsync(IAccountCalendar calendar, DayRangeRenderModel dayRangeRenderModel); } } diff --git a/Wino.Core.Domain/Models/Calendar/CalendarDayModel.cs b/Wino.Core.Domain/Models/Calendar/CalendarDayModel.cs index 030aeb3d..085c1abf 100644 --- a/Wino.Core.Domain/Models/Calendar/CalendarDayModel.cs +++ b/Wino.Core.Domain/Models/Calendar/CalendarDayModel.cs @@ -10,14 +10,15 @@ namespace Wino.Core.Domain.Models.Calendar /// public class CalendarDayModel { - public TimeRange Period { get; } - public CalendarEventCollection EventsCollection { get; } = new CalendarEventCollection(); + public ITimePeriod Period { get; } + public CalendarEventCollection EventsCollection { get; } public CalendarDayModel(DateTime representingDate, CalendarRenderOptions calendarRenderOptions) { RepresentingDate = representingDate; Period = new TimeRange(representingDate, representingDate.AddDays(1)); CalendarRenderOptions = calendarRenderOptions; + EventsCollection = new CalendarEventCollection(Period); } public DateTime RepresentingDate { get; } diff --git a/Wino.Core.Domain/Models/Calendar/DayRangeRenderModel.cs b/Wino.Core.Domain/Models/Calendar/DayRangeRenderModel.cs index 75025205..c57ca583 100644 --- a/Wino.Core.Domain/Models/Calendar/DayRangeRenderModel.cs +++ b/Wino.Core.Domain/Models/Calendar/DayRangeRenderModel.cs @@ -56,25 +56,16 @@ namespace Wino.Core.Domain.Models.Calendar private void RegisterCalendarDayEvents(CalendarDayModel calendarDayModel) { calendarDayModel.EventsCollection.CalendarItemAdded += CalendarItemAdded; - calendarDayModel.EventsCollection.CalendarItemRangeRemoved += CalendarItemRangeRemoved; calendarDayModel.EventsCollection.CalendarItemRemoved += CalendarItemRemoved; - calendarDayModel.EventsCollection.CalendarItemRangeAdded += CalendarItemRangeAdded; } // TODO: These handlers have incorrect senders. They should be the CalendarDayModel. - - private void CalendarItemRangeAdded(object sender, List e) - => CalendarDayEventCollectionUpdated?.Invoke(this, sender as CalendarDayModel); - private void CalendarItemRemoved(object sender, ICalendarItem e) => CalendarDayEventCollectionUpdated?.Invoke(this, sender as CalendarDayModel); private void CalendarItemAdded(object sender, ICalendarItem e) => CalendarDayEventCollectionUpdated?.Invoke(this, sender as CalendarDayModel); - private void CalendarItemRangeRemoved(object sender, List e) - => CalendarDayEventCollectionUpdated?.Invoke(this, sender as CalendarDayModel); - /// /// Unregisters all calendar item change listeners to draw the UI for calendar events. /// @@ -82,9 +73,7 @@ namespace Wino.Core.Domain.Models.Calendar { foreach (var day in CalendarDays) { - day.EventsCollection.CalendarItemRangeRemoved -= CalendarItemRangeRemoved; day.EventsCollection.CalendarItemRemoved -= CalendarItemRemoved; - day.EventsCollection.CalendarItemRangeAdded -= CalendarItemRangeAdded; day.EventsCollection.CalendarItemAdded -= CalendarItemAdded; } } diff --git a/Wino.Core.ViewModels/CalendarBaseViewModel.cs b/Wino.Core.ViewModels/CalendarBaseViewModel.cs index aa1a3424..4ae8723d 100644 --- a/Wino.Core.ViewModels/CalendarBaseViewModel.cs +++ b/Wino.Core.ViewModels/CalendarBaseViewModel.cs @@ -1,5 +1,5 @@ using CommunityToolkit.Mvvm.Messaging; -using Wino.Core.Domain.Interfaces; +using Wino.Core.Domain.Entities.Calendar; using Wino.Messaging.Client.Calendar; namespace Wino.Core.ViewModels @@ -8,6 +8,6 @@ namespace Wino.Core.ViewModels { public void Receive(CalendarEventAdded message) => OnCalendarEventAdded(message.CalendarItem); - protected virtual void OnCalendarEventAdded(ICalendarItem calendarItem) { } + protected virtual void OnCalendarEventAdded(CalendarItem calendarItem) { } } } diff --git a/Wino.Core/Extensions/GoogleIntegratorExtensions.cs b/Wino.Core/Extensions/GoogleIntegratorExtensions.cs index 7128dc37..b02a7e0c 100644 --- a/Wino.Core/Extensions/GoogleIntegratorExtensions.cs +++ b/Wino.Core/Extensions/GoogleIntegratorExtensions.cs @@ -209,88 +209,74 @@ namespace Wino.Core.Extensions /// /// The Google Calendar Event object. /// The start DateTimeOffset of the event, or null if it cannot be determined. - public static DateTimeOffset? GetEventStartDateTimeOffset(this Event calendarEvent) - { - if (calendarEvent == null) - { - return null; - } + //public static DateTimeOffset GetEventStartDateTimeOffset(this Event calendarEvent) + //{ + // return GetEventDateTimeOffset(calendarEvent.Start); + //} - if (calendarEvent.Start != null) + public static DateTimeOffset GetEventDateTimeOffset(EventDateTime calendarEvent) + { + if (calendarEvent != null) { - if (calendarEvent.Start.DateTimeDateTimeOffset != null) + if (calendarEvent.DateTimeDateTimeOffset != null) { - return calendarEvent.Start.DateTimeDateTimeOffset; // Use the direct DateTimeOffset property! + return calendarEvent.DateTimeDateTimeOffset.Value; } - else if (calendarEvent.Start.Date != null) + else if (calendarEvent.Date != null) { - if (DateTime.TryParse(calendarEvent.Start.Date, out DateTime startDate)) + if (DateTime.TryParse(calendarEvent.Date, out DateTime eventDateTime)) { // Date-only events are treated as UTC midnight - return new DateTimeOffset(startDate, TimeSpan.Zero); + return new DateTimeOffset(eventDateTime, TimeSpan.Zero); } else { - return null; + throw new Exception("Invalid date format in Google Calendar event date."); } } } - return null; // Start time not found + throw new Exception("Google Calendar event has no date."); } /// - /// Calculates the duration of a Google Calendar Event in minutes. + /// Calculates the duration of a Google Calendar Event in seconds. /// Handles date-only and date-time events, but *does not* handle recurring events correctly. /// For recurring events, this method will return the duration of the *first* instance. /// /// The Google Calendar Event object. /// The duration of the event in minutes, or null if it cannot be determined. - public static int? GetEventDurationInMinutes(this Event calendarEvent) - { - if (calendarEvent == null) - { - return null; - } + //public static int GetEventDurationInSeconds(this Event calendarEvent) + //{ + // var start = calendarEvent.GetEventStartDateTimeOffset(); - DateTimeOffset? start = calendarEvent.GetEventStartDateTimeOffset(); - if (start == null) - { - return null; - } + // DateTimeOffset? end = null; + // if (calendarEvent.End != null) + // { + // if (calendarEvent.End.DateTimeDateTimeOffset != null) + // { + // end = calendarEvent.End.DateTimeDateTimeOffset; + // } + // else if (calendarEvent.End.Date != null) + // { + // if (DateTime.TryParse(calendarEvent.End.Date, out DateTime endDate)) + // { + // end = new DateTimeOffset(endDate, TimeSpan.Zero); + // } + // else + // { + // throw new Exception("Invalid date format in Google Calendar event end date."); + // } + // } + // } - DateTimeOffset? end = null; - if (calendarEvent.End != null) - { - if (calendarEvent.End.DateTimeDateTimeOffset != null) - { - end = calendarEvent.End.DateTimeDateTimeOffset; - } - else if (calendarEvent.End.Date != null) - { - if (DateTime.TryParse(calendarEvent.End.Date, out DateTime endDate)) - { - end = new DateTimeOffset(endDate, TimeSpan.Zero); - } - else - { - return null; - } - } - } - - if (end == null) - { - return null; - } - - return (int)(end.Value - start.Value).TotalMinutes; - } + // if (end == null) throw new Exception("Google Calendar event has no end date."); + // return (int)(end.Value - start).TotalSeconds; + //} /// /// RRULE, EXRULE, RDATE and EXDATE lines for a recurring event, as specified in RFC5545. - /// /// /// ___ separated lines. public static string GetRecurrenceString(this Event calendarEvent) diff --git a/Wino.Core/Integration/Processors/GmailChangeProcessor.cs b/Wino.Core/Integration/Processors/GmailChangeProcessor.cs index 2ff9605f..f00f0d9a 100644 --- a/Wino.Core/Integration/Processors/GmailChangeProcessor.cs +++ b/Wino.Core/Integration/Processors/GmailChangeProcessor.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.Threading.Tasks; using Google.Apis.Calendar.v3.Data; using Wino.Core.Domain.Entities.Calendar; @@ -33,22 +34,32 @@ namespace Wino.Core.Integration.Processors public async Task CreateCalendarItemAsync(Event calendarEvent, AccountCalendar assignedCalendar, MailAccount organizerAccount) { + var eventStartDateTimeOffset = GoogleIntegratorExtensions.GetEventDateTimeOffset(calendarEvent.Start); + var eventEndDateTimeOffset = GoogleIntegratorExtensions.GetEventDateTimeOffset(calendarEvent.End); + + var totalDurationInSeconds = (eventEndDateTimeOffset - eventStartDateTimeOffset).TotalSeconds; + var calendarItem = new CalendarItem() { CalendarId = assignedCalendar.Id, CreatedAt = DateTimeOffset.UtcNow, Description = calendarEvent.Description, - StartTime = GoogleIntegratorExtensions.GetEventStartDateTimeOffset(calendarEvent) ?? throw new Exception("Event without a start time."), - DurationInMinutes = GoogleIntegratorExtensions.GetEventDurationInMinutes(calendarEvent) ?? throw new Exception("Event without a duration."), Id = Guid.NewGuid(), + StartDate = eventStartDateTimeOffset.DateTime, + StartDateOffset = eventStartDateTimeOffset.Offset, + EndDateOffset = eventEndDateTimeOffset.Offset, + DurationInSeconds = totalDurationInSeconds, Location = calendarEvent.Location, Recurrence = GoogleIntegratorExtensions.GetRecurrenceString(calendarEvent), Status = GetStatus(calendarEvent.Status), Title = calendarEvent.Summary, UpdatedAt = DateTimeOffset.UtcNow, Visibility = GetVisibility(calendarEvent.Visibility), + HtmlLink = calendarEvent.HtmlLink }; + Debug.WriteLine($"({assignedCalendar.Name}) {calendarItem.Title}, Start: {calendarItem.StartDate.ToString("f")}, End: {calendarItem.EndDate.ToString("f")}"); + // TODO: There are some edge cases with cancellation here. CalendarItemStatus GetStatus(string status) { diff --git a/Wino.Messages/Client/Calendar/CalendarEventAdded.cs b/Wino.Messages/Client/Calendar/CalendarEventAdded.cs index 3a18e46c..8f86ff7f 100644 --- a/Wino.Messages/Client/Calendar/CalendarEventAdded.cs +++ b/Wino.Messages/Client/Calendar/CalendarEventAdded.cs @@ -1,9 +1,9 @@ -using Wino.Core.Domain.Interfaces; +using Wino.Core.Domain.Entities.Calendar; namespace Wino.Messaging.Client.Calendar { /// /// Raised when event is added to database. /// - public record CalendarEventAdded(ICalendarItem CalendarItem); + public record CalendarEventAdded(CalendarItem CalendarItem); } diff --git a/Wino.Services/CalendarService.cs b/Wino.Services/CalendarService.cs index 86ce4eb4..df8cf49a 100644 --- a/Wino.Services/CalendarService.cs +++ b/Wino.Services/CalendarService.cs @@ -3,11 +3,13 @@ using System.Collections.Generic; using System.Text.RegularExpressions; using System.Threading.Tasks; using CommunityToolkit.Mvvm.Messaging; +using Ical.Net.CalendarComponents; using Ical.Net.DataTypes; using SqlKata; using Wino.Core.Domain; using Wino.Core.Domain.Entities.Calendar; using Wino.Core.Domain.Interfaces; +using Wino.Core.Domain.Models.Calendar; using Wino.Messaging.Client.Calendar; using Wino.Services.Extensions; @@ -71,7 +73,7 @@ namespace Wino.Services }); } - public async Task> GetCalendarEventsAsync(IAccountCalendar calendar, DateTime rangeStart, DateTime rangeEnd) + public async Task> GetCalendarEventsAsync(IAccountCalendar calendar, DayRangeRenderModel dayRangeRenderModel) { // TODO: We might need to implement caching here. // I don't know how much of the events we'll have in total, but this logic scans all events every time. @@ -84,19 +86,22 @@ namespace Wino.Services ev.AssignedCalendar = calendar; // Parse recurrence rules - var calendarEvent = new Ical.Net.CalendarComponents.CalendarEvent + var calendarEvent = new CalendarEvent { - Start = new CalDateTime(ev.StartTime.UtcDateTime), - Duration = TimeSpan.FromMinutes(ev.DurationInMinutes), + Start = new CalDateTime(ev.StartDate), + End = new CalDateTime(ev.EndDate), }; if (string.IsNullOrEmpty(ev.Recurrence)) { - // No recurrence, only check if we fall into the date range. - // All events are saved in UTC, so we need to convert the range to UTC as well. - if (ev.StartTime.UtcDateTime < rangeEnd - && ev.StartTime.UtcDateTime.AddMinutes(ev.DurationInMinutes) > rangeStart) + // No recurrence, only check if we fall into the given period. + + if (ev.Period.OverlapsWith(dayRangeRenderModel.Period)) { + // TODO: We overlap, but this might be a multi-day event. + // Should we split the events here or in panel? + // For now just continue. + result.Add(ev); } } @@ -110,7 +115,7 @@ namespace Wino.Services } // Calculate occurrences in the range. - var occurrences = calendarEvent.GetOccurrences(rangeStart, rangeEnd); + var occurrences = calendarEvent.GetOccurrences(dayRangeRenderModel.Period.Start, dayRangeRenderModel.Period.End); foreach (var occurrence in occurrences) { @@ -121,5 +126,8 @@ namespace Wino.Services return result; } + + public Task GetAccountCalendarAsync(Guid accountCalendarId) + => Connection.GetAsync(accountCalendarId); } }