Proper handling of DateTimeOffset, support for Multi-Day events and reacting to adding/removing events for the days.

This commit is contained in:
Burak Kaan Köse
2024-12-30 23:10:51 +01:00
parent 8cc7d46d7b
commit 8fd09bcad4
23 changed files with 340 additions and 234 deletions

View File

@@ -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;
}
}

View File

@@ -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<DayRangeRenderModel> 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));
}
}
}

View File

@@ -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)
{

View File

@@ -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);
/// <summary>
/// Enumeration of currently selected calendars.
/// </summary>
IEnumerable<AccountCalendarViewModel> ActiveCalendars { get; }
}
}

View File

@@ -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<ICalendarItem> calendarItems) => UpdateCollectionVisuals();
private void EventsCleared(object sender, System.EventArgs e) => UpdateCollectionVisuals();
private void UpdateCollectionVisuals()

View File

@@ -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);

View File

@@ -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<List<ICalendarItem>>();
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)
{

View File

@@ -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<GroupedAccountCalendarViewModel> _internalGroupedAccountCalendars = new ObservableCollection<GroupedAccountCalendarViewModel>();
public IEnumerable<AccountCalendarViewModel> ActiveCalendars
{
get
{
return GroupedAccountCalendars
.SelectMany(a => a.AccountCalendars)
.Where(b => b.IsChecked);
}
}
public AccountCalendarStateService()
{
GroupedAccountCalendars = new ReadOnlyObservableCollection<GroupedAccountCalendarViewModel>(_internalGroupedAccountCalendars);

View File

@@ -17,7 +17,7 @@
<ResourceDictionary x:Key="Dark">
<!-- CalendarControl -->
<SolidColorBrush x:Key="CalendarSeperatorBrush">#000000</SolidColorBrush>
<SolidColorBrush x:Key="CalendarSeperatorBrush">#525252</SolidColorBrush>
<SolidColorBrush x:Key="CalendarFieldWorkingHoursBackgroundBrush">#32262626</SolidColorBrush>
<SolidColorBrush x:Key="CalendarFieldSelectedBackgroundBrush">#121212</SolidColorBrush>

View File

@@ -13,7 +13,11 @@
<!-- Default Calendar Item View Model Template -->
<DataTemplate x:Key="CalendarItemViewModelItemTemplate" x:DataType="data:CalendarItemViewModel">
<Grid Background="{x:Bind helpers:XamlHelpers.GetSolidColorBrushFromHex(AssignedCalendar.BackgroundColorHex), Mode=OneWay}" CornerRadius="4">
<Grid
Background="{x:Bind helpers:XamlHelpers.GetSolidColorBrushFromHex(AssignedCalendar.BackgroundColorHex), Mode=OneWay}"
BorderBrush="{ThemeResource CalendarSeperatorBrush}"
BorderThickness="1"
CornerRadius="6">
<TextBlock
Margin="2,0"
HorizontalAlignment="Center"
@@ -44,7 +48,7 @@
<ItemsControl ItemTemplate="{StaticResource CalendarItemViewModelItemTemplate}" ItemsSource="{x:Bind EventsCollection.RegularEvents}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<controls:WinoCalendarPanel HourHeight="{Binding Path=CalendarRenderOptions.CalendarSettings.HourHeight}" />
<controls:WinoCalendarPanel HourHeight="{Binding Path=CalendarRenderOptions.CalendarSettings.HourHeight}" Period="{Binding Path=Period}" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
</ItemsControl>

View File

@@ -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());

View File

@@ -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<GoNextDateRequestedMessage>,
IRecipient<GoPreviousDateRequestedMessage>
{
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;
}

View File

@@ -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<ICalendarItem> CalendarItemAdded;
public event EventHandler<ICalendarItem> CalendarItemRemoved;
public event EventHandler<List<ICalendarItem>> CalendarItemRangeAdded;
public event EventHandler<List<ICalendarItem>> CalendarItemRangeRemoved;
public event EventHandler CalendarItemsCleared;
private ObservableRangeCollection<ICalendarItem> _internalRegularEvents = [];
@@ -22,79 +20,97 @@ namespace Wino.Core.Domain.Collections
public ReadOnlyObservableCollection<ICalendarItem> RegularEvents { get; }
public ReadOnlyObservableCollection<ICalendarItem> AllDayEvents { get; }
public ITimePeriod Period { get; }
public CalendarEventCollection()
private readonly List<ICalendarItem> _allItems = new List<ICalendarItem>();
public CalendarEventCollection(ITimePeriod period)
{
RegularEvents = new ReadOnlyObservableCollection<ICalendarItem>(_internalRegularEvents);
AllDayEvents = new ReadOnlyObservableCollection<ICalendarItem>(_internalAllDayEvents);
Period = period;
}
public bool HasCalendarEvent(AccountCalendar accountCalendar)
=> _allItems.Any(x => x.AssignedCalendar.Id == accountCalendar.Id);
public void FilterByCalendars(IEnumerable<Guid> 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<ICalendarItem> calendarItems)
private ObservableRangeCollection<ICalendarItem> 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<ICalendarItem>(calendarItems));
}
bool isAllDayEvent = calendarItem.Period.Duration.TotalDays == 1 && calendarItem.Period.Start.TimeOfDay == TimeSpan.Zero;
public void RemoveCalendarItemRange(IEnumerable<ICalendarItem> calendarItems)
{
foreach (var calendarItem in calendarItems)
{
RemoveCalendarItem(calendarItem);
}
CalendarItemRangeRemoved?.Invoke(this, new List<ICalendarItem>(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<ICalendarItem> 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<ICalendarItem> 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);
}
}
}

View File

@@ -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; }
}

View File

@@ -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; }
}
}

View File

@@ -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<List<AccountCalendar>> GetAccountCalendarsAsync(Guid accountId);
Task<AccountCalendar> GetAccountCalendarAsync(Guid accountCalendarId);
Task DeleteCalendarItemAsync(Guid calendarItemId);
Task DeleteAccountCalendarAsync(AccountCalendar accountCalendar);
Task InsertAccountCalendarAsync(AccountCalendar accountCalendar);
Task UpdateAccountCalendarAsync(AccountCalendar accountCalendar);
Task CreateNewCalendarItemAsync(CalendarItem calendarItem, List<CalendarEventAttendee> attendees);
Task<List<CalendarItem>> GetCalendarEventsAsync(IAccountCalendar calendar, DateTime rangeStart, DateTime rangeEnd);
Task<List<CalendarItem>> GetCalendarEventsAsync(IAccountCalendar calendar, DayRangeRenderModel dayRangeRenderModel);
}
}

View File

@@ -10,14 +10,15 @@ namespace Wino.Core.Domain.Models.Calendar
/// </summary>
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; }

View File

@@ -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<ICalendarItem> 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<ICalendarItem> e)
=> CalendarDayEventCollectionUpdated?.Invoke(this, sender as CalendarDayModel);
/// <summary>
/// Unregisters all calendar item change listeners to draw the UI for calendar events.
/// </summary>
@@ -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;
}
}

View File

@@ -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) { }
}
}

View File

@@ -209,88 +209,74 @@ namespace Wino.Core.Extensions
/// </summary>
/// <param name="calendarEvent">The Google Calendar Event object.</param>
/// <returns>The start DateTimeOffset of the event, or null if it cannot be determined.</returns>
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.");
}
/// <summary>
/// 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.
/// </summary>
/// <param name="calendarEvent">The Google Calendar Event object.</param>
/// <returns>The duration of the event in minutes, or null if it cannot be determined.</returns>
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;
//}
/// <summary>
/// RRULE, EXRULE, RDATE and EXDATE lines for a recurring event, as specified in RFC5545.
///
/// </summary>
/// <returns>___ separated lines.</returns>
public static string GetRecurrenceString(this Event calendarEvent)

View File

@@ -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<CalendarItem> 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)
{

View File

@@ -1,9 +1,9 @@
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Entities.Calendar;
namespace Wino.Messaging.Client.Calendar
{
/// <summary>
/// Raised when event is added to database.
/// </summary>
public record CalendarEventAdded(ICalendarItem CalendarItem);
public record CalendarEventAdded(CalendarItem CalendarItem);
}

View File

@@ -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<List<CalendarItem>> GetCalendarEventsAsync(IAccountCalendar calendar, DateTime rangeStart, DateTime rangeEnd)
public async Task<List<CalendarItem>> 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<AccountCalendar> GetAccountCalendarAsync(Guid accountCalendarId)
=> Connection.GetAsync<AccountCalendar>(accountCalendarId);
}
}