Files
Wino-Mail/Wino.Calendar.ViewModels/CalendarPageViewModel.cs
T

1216 lines
45 KiB
C#
Raw Normal View History

2024-11-10 23:28:25 +01:00
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using CommunityToolkit.Diagnostics;
using CommunityToolkit.Mvvm.ComponentModel;
2025-01-01 17:28:29 +01:00
using CommunityToolkit.Mvvm.Input;
2024-11-10 23:28:25 +01:00
using CommunityToolkit.Mvvm.Messaging;
using Itenso.TimePeriod;
using MoreLinq;
using Serilog;
using Wino.Calendar.ViewModels.Data;
using Wino.Calendar.ViewModels.Interfaces;
using Wino.Calendar.ViewModels.Messages;
using Wino.Core.Domain.Collections;
using Wino.Core.Domain.Entities.Calendar;
2024-11-10 23:28:25 +01:00
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Calendar;
2024-11-26 20:03:10 +01:00
using Wino.Core.Domain.Models.Calendar.CalendarTypeStrategies;
using Wino.Core.Domain.Models.Navigation;
2024-11-10 23:28:25 +01:00
using Wino.Core.ViewModels;
using Wino.Messaging.Client.Calendar;
using Wino.Messaging.UI;
2024-11-10 23:28:25 +01:00
2025-05-18 14:06:25 +02:00
namespace Wino.Calendar.ViewModels;
public partial class CalendarPageViewModel : CalendarBaseViewModel,
IRecipient<LoadCalendarMessage>,
IRecipient<CalendarItemDeleted>,
IRecipient<CalendarSettingsUpdatedMessage>,
IRecipient<CalendarItemTappedMessage>,
IRecipient<CalendarItemDoubleTappedMessage>,
IRecipient<CalendarItemRightTappedMessage>,
IRecipient<AccountRemovedMessage>
2024-11-10 23:28:25 +01:00
{
2025-05-18 14:06:25 +02:00
#region Quick Event Creation
2024-11-10 23:28:25 +01:00
2025-05-18 14:06:25 +02:00
[ObservableProperty]
private bool _isQuickEventDialogOpen;
2024-11-10 23:28:25 +01:00
2025-05-18 14:06:25 +02:00
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(SelectedQuickEventAccountCalendarName))]
[NotifyCanExecuteChangedFor(nameof(SaveQuickEventCommand))]
private AccountCalendarViewModel _selectedQuickEventAccountCalendar;
2024-12-31 22:22:19 +01:00
2025-05-18 14:06:25 +02:00
public string SelectedQuickEventAccountCalendarName
{
get
2024-12-31 22:22:19 +01:00
{
2025-05-18 14:06:25 +02:00
return SelectedQuickEventAccountCalendar == null ? "Pick a calendar" : SelectedQuickEventAccountCalendar.Name;
2024-12-31 22:22:19 +01:00
}
2025-05-18 14:06:25 +02:00
}
2024-12-31 22:22:19 +01:00
2025-05-18 14:06:25 +02:00
[ObservableProperty]
private List<string> _hourSelectionStrings;
2025-01-01 17:28:29 +01:00
2025-05-18 14:06:25 +02:00
// To be able to revert the values when the user enters an invalid time.
private string _previousSelectedStartTimeString;
private string _previousSelectedEndTimeString;
2025-01-01 17:28:29 +01:00
2025-05-18 14:06:25 +02:00
[ObservableProperty]
private DateTime? _selectedQuickEventDate;
2025-01-01 17:28:29 +01:00
2025-05-18 14:06:25 +02:00
[ObservableProperty]
private bool _isAllDay;
2025-01-01 17:28:29 +01:00
2025-05-18 14:06:25 +02:00
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(SaveQuickEventCommand))]
private string _selectedStartTimeString;
2025-01-01 17:28:29 +01:00
2025-05-18 14:06:25 +02:00
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(SaveQuickEventCommand))]
private string _selectedEndTimeString;
2025-01-01 17:28:29 +01:00
2025-05-18 14:06:25 +02:00
[ObservableProperty]
private string _location;
2025-01-01 17:28:29 +01:00
2025-05-18 14:06:25 +02:00
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(SaveQuickEventCommand))]
private string _eventName;
2025-01-01 17:28:29 +01:00
2025-05-18 14:06:25 +02:00
public DateTime QuickEventStartTime => SelectedQuickEventDate.Value.Date.Add(CurrentSettings.GetTimeSpan(SelectedStartTimeString).Value);
public DateTime QuickEventEndTime => SelectedQuickEventDate.Value.Date.Add(CurrentSettings.GetTimeSpan(SelectedEndTimeString).Value);
2025-01-01 17:28:29 +01:00
2025-05-18 14:06:25 +02:00
public bool CanSaveQuickEvent => SelectedQuickEventAccountCalendar != null &&
!string.IsNullOrWhiteSpace(EventName) &&
!string.IsNullOrWhiteSpace(SelectedStartTimeString) &&
!string.IsNullOrWhiteSpace(SelectedEndTimeString) &&
QuickEventEndTime > QuickEventStartTime;
2025-01-01 17:28:29 +01:00
2025-05-18 14:06:25 +02:00
#endregion
2025-01-01 17:28:29 +01:00
2025-05-18 14:06:25 +02:00
#region Data Initialization
2025-05-18 14:06:25 +02:00
[ObservableProperty]
2026-01-06 12:07:22 +01:00
public partial CalendarOrientation CalendarOrientation { get; set; } = CalendarOrientation.Horizontal;
2025-05-18 14:06:25 +02:00
[ObservableProperty]
2026-01-06 12:07:22 +01:00
public partial DayRangeCollection DayRanges { get; set; } = [];
2025-05-18 14:06:25 +02:00
[ObservableProperty]
2026-01-06 12:07:22 +01:00
public partial int SelectedDateRangeIndex { get; set; }
2025-05-18 14:06:25 +02:00
[ObservableProperty]
2026-01-06 12:07:22 +01:00
public partial DayRangeRenderModel SelectedDayRange { get; set; }
2025-05-18 14:06:25 +02:00
[ObservableProperty]
2026-01-06 12:07:22 +01:00
public partial bool IsCalendarEnabled { get; set; } = true;
2024-11-10 23:28:25 +01:00
2025-05-18 14:06:25 +02:00
#endregion
2025-05-18 14:06:25 +02:00
#region Event Details
2025-05-18 14:06:25 +02:00
public event EventHandler DetailsShowCalendarItemChanged;
2026-01-06 12:07:22 +01:00
public bool CanJoinOnline => DisplayDetailsCalendarItemViewModel != null &&
!string.IsNullOrEmpty(DisplayDetailsCalendarItemViewModel.CalendarItem.HtmlLink);
2025-05-18 14:06:25 +02:00
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsEventDetailsVisible))]
2026-01-06 12:07:22 +01:00
[NotifyCanExecuteChangedFor(nameof(JoinOnlineCommand))]
[NotifyPropertyChangedFor(nameof(CanJoinOnline))]
2026-01-06 12:07:22 +01:00
public partial CalendarItemViewModel DisplayDetailsCalendarItemViewModel { get; set; }
2025-05-18 14:06:25 +02:00
public bool IsEventDetailsVisible => DisplayDetailsCalendarItemViewModel != null;
2025-05-18 14:06:25 +02:00
#endregion
2025-05-18 14:06:25 +02:00
// TODO: Get rid of some of the items if we have too many.
private const int maxDayRangeSize = 10;
2025-05-18 14:06:25 +02:00
private readonly ICalendarService _calendarService;
private readonly INavigationService _navigationService;
private readonly IKeyPressService _keyPressService;
2026-01-06 12:07:22 +01:00
private readonly INativeAppService _nativeAppService;
2025-05-18 14:06:25 +02:00
private readonly IPreferencesService _preferencesService;
2025-12-30 11:59:54 +01:00
private readonly IWinoRequestDelegator _winoRequestDelegator;
2024-11-10 23:28:25 +01:00
2025-05-18 14:06:25 +02:00
// Store latest rendered options.
private CalendarDisplayType _currentDisplayType;
private int _displayDayCount;
2024-11-10 23:28:25 +01:00
2025-05-18 14:06:25 +02:00
private SemaphoreSlim _calendarLoadingSemaphore = new(1);
private bool isLoadMoreBlocked = false;
2025-05-18 14:06:25 +02:00
[ObservableProperty]
private CalendarSettings _currentSettings;
2024-11-10 23:28:25 +01:00
2025-05-18 14:06:25 +02:00
public IStatePersistanceService StatePersistanceService { get; }
public IAccountCalendarStateService AccountCalendarStateService { get; }
2024-11-10 23:28:25 +01:00
2025-05-18 14:06:25 +02:00
public CalendarPageViewModel(IStatePersistanceService statePersistanceService,
ICalendarService calendarService,
INavigationService navigationService,
IKeyPressService keyPressService,
2026-01-06 12:07:22 +01:00
INativeAppService nativeAppService,
2025-05-18 14:06:25 +02:00
IAccountCalendarStateService accountCalendarStateService,
2025-12-30 11:59:54 +01:00
IPreferencesService preferencesService,
IWinoRequestDelegator winoRequestDelegator)
2025-05-18 14:06:25 +02:00
{
StatePersistanceService = statePersistanceService;
AccountCalendarStateService = accountCalendarStateService;
2025-05-18 14:06:25 +02:00
_calendarService = calendarService;
_navigationService = navigationService;
_keyPressService = keyPressService;
2026-01-06 12:07:22 +01:00
_nativeAppService = nativeAppService;
2025-05-18 14:06:25 +02:00
_preferencesService = preferencesService;
2025-12-30 11:59:54 +01:00
_winoRequestDelegator = winoRequestDelegator;
2025-05-18 14:06:25 +02:00
AccountCalendarStateService.AccountCalendarSelectionStateChanged += UpdateAccountCalendarRequested;
AccountCalendarStateService.CollectiveAccountGroupSelectionStateChanged += AccountCalendarStateCollectivelyChanged;
2026-02-19 01:37:43 +01:00
// We don't register on navigation here. This page is cached.
RegisterRecipients();
2025-05-18 14:06:25 +02:00
}
2024-11-10 23:28:25 +01:00
2025-12-27 19:16:24 +01:00
protected override void RegisterRecipients()
{
base.RegisterRecipients();
Messenger.Unregister<LoadCalendarMessage>(this);
Messenger.Unregister<CalendarSettingsUpdatedMessage>(this);
Messenger.Unregister<CalendarItemTappedMessage>(this);
Messenger.Unregister<CalendarItemDoubleTappedMessage>(this);
Messenger.Unregister<CalendarItemRightTappedMessage>(this);
Messenger.Unregister<AccountRemovedMessage>(this);
2026-02-19 01:37:43 +01:00
Messenger.Register<LoadCalendarMessage>(this);
Messenger.Register<CalendarSettingsUpdatedMessage>(this);
Messenger.Register<CalendarItemTappedMessage>(this);
Messenger.Register<CalendarItemDoubleTappedMessage>(this);
Messenger.Register<CalendarItemRightTappedMessage>(this);
Messenger.Register<AccountRemovedMessage>(this);
2025-12-27 19:16:24 +01:00
}
2025-05-18 14:06:25 +02:00
private void AccountCalendarStateCollectivelyChanged(object sender, GroupedAccountCalendarViewModel e)
=> FilterActiveCalendars(DayRanges);
2025-05-18 14:06:25 +02:00
private void UpdateAccountCalendarRequested(object sender, AccountCalendarViewModel e)
=> FilterActiveCalendars(DayRanges);
2025-05-18 14:06:25 +02:00
private async void FilterActiveCalendars(IEnumerable<DayRangeRenderModel> dayRangeRenderModels)
{
await ExecuteUIThread(() =>
{
2025-05-18 14:06:25 +02:00
var days = dayRangeRenderModels.SelectMany(a => a.CalendarDays);
2025-05-18 14:06:25 +02:00
days.ForEach(a => a.EventsCollection.FilterByCalendars(AccountCalendarStateService.ActiveCalendars.Select(a => a.Id)));
2025-05-18 14:06:25 +02:00
DisplayDetailsCalendarItemViewModel = null;
});
}
2026-01-06 12:07:22 +01:00
[RelayCommand(CanExecute = nameof(CanJoinOnline))]
private async Task JoinOnlineAsync()
{
if (DisplayDetailsCalendarItemViewModel == null || string.IsNullOrEmpty(DisplayDetailsCalendarItemViewModel.CalendarItem.HtmlLink)) return;
await _nativeAppService.LaunchUriAsync(new Uri(DisplayDetailsCalendarItemViewModel.CalendarItem.HtmlLink));
}
2025-05-18 14:06:25 +02:00
// TODO: Replace when calendar settings are updated.
// Should be a field ideally.
private BaseCalendarTypeDrawingStrategy GetDrawingStrategy(CalendarDisplayType displayType)
{
return displayType switch
2024-11-10 23:28:25 +01:00
{
2025-05-18 14:06:25 +02:00
CalendarDisplayType.Day => new DayCalendarDrawingStrategy(CurrentSettings),
CalendarDisplayType.Week => new WeekCalendarDrawingStrategy(CurrentSettings),
CalendarDisplayType.Month => new MonthCalendarDrawingStrategy(CurrentSettings),
_ => throw new NotImplementedException(),
};
}
2024-11-10 23:28:25 +01:00
2025-05-18 14:06:25 +02:00
public override void OnNavigatedTo(NavigationMode mode, object parameters)
{
RefreshSettings();
2025-01-01 17:28:29 +01:00
2026-02-19 01:37:43 +01:00
if (mode == NavigationMode.Back) return;
2025-05-18 14:06:25 +02:00
// Automatically select the first primary calendar for quick event dialog.
SelectedQuickEventAccountCalendar = AccountCalendarStateService.ActiveCalendars.FirstOrDefault(a => a.IsPrimary);
}
2025-01-01 17:28:29 +01:00
2025-05-18 14:06:25 +02:00
[RelayCommand]
private void NavigateSeries()
{
if (DisplayDetailsCalendarItemViewModel == null) return;
2025-05-18 14:06:25 +02:00
NavigateEvent(DisplayDetailsCalendarItemViewModel, CalendarEventTargetType.Series);
}
2025-05-18 14:06:25 +02:00
[RelayCommand]
private void NavigateEventDetails()
{
if (DisplayDetailsCalendarItemViewModel == null) return;
2025-05-18 14:06:25 +02:00
NavigateEvent(DisplayDetailsCalendarItemViewModel, CalendarEventTargetType.Single);
}
2025-05-18 14:06:25 +02:00
private void NavigateEvent(CalendarItemViewModel calendarItemViewModel, CalendarEventTargetType calendarEventTargetType)
{
var target = new CalendarItemTarget(calendarItemViewModel.CalendarItem, calendarEventTargetType);
_navigationService.Navigate(WinoPage.EventDetailsPage, target);
}
[RelayCommand(AllowConcurrentExecutions = false, CanExecute = nameof(CanSaveQuickEvent))]
private async Task SaveQuickEventAsync()
{
2025-12-30 11:59:54 +01:00
try
2025-01-01 17:28:29 +01:00
{
2025-12-30 11:59:54 +01:00
var startDate = IsAllDay ? SelectedQuickEventDate.Value.Date : QuickEventStartTime;
var endDate = IsAllDay ? SelectedQuickEventDate.Value.Date.AddDays(1) : QuickEventEndTime;
var durationSeconds = (endDate - startDate).TotalSeconds;
// Get the user's current timezone from the system
var currentTimeZone = TimeZoneInfo.Local.Id;
var calendarItem = new CalendarItem
{
Id = Guid.NewGuid(),
CalendarId = SelectedQuickEventAccountCalendar.Id,
StartDate = startDate,
DurationInSeconds = durationSeconds,
StartTimeZone = currentTimeZone,
EndTimeZone = currentTimeZone,
CreatedAt = DateTime.UtcNow,
Description = string.Empty,
Location = Location ?? string.Empty,
Title = EventName,
ShowAs = SelectedQuickEventAccountCalendar.DefaultShowAs,
2025-12-30 11:59:54 +01:00
IsHidden = false,
AssignedCalendar = SelectedQuickEventAccountCalendar
};
// Close dialog first
IsQuickEventDialogOpen = false;
// Save to local database first
// await _calendarService.CreateNewCalendarItemAsync(calendarItem, null);
// Queue the request via delegator
var preparationRequest = new CalendarOperationPreparationRequest(CalendarSynchronizerOperation.CreateEvent, calendarItem, null);
await _winoRequestDelegator.ExecuteAsync(preparationRequest);
}
catch (Exception ex)
{
Log.Error(ex, "Error creating quick event");
// Re-open dialog if there was an error
IsQuickEventDialogOpen = true;
}
2025-05-18 14:06:25 +02:00
}
2025-01-01 17:28:29 +01:00
2025-05-18 14:06:25 +02:00
[RelayCommand]
private void MoreDetails()
{
// TODO: Navigate to advanced event creation page with existing parameters.
}
2025-01-01 17:28:29 +01:00
2025-05-18 14:06:25 +02:00
public void SelectQuickEventTimeRange(TimeSpan startTime, TimeSpan endTime)
{
IsAllDay = false;
2025-01-01 17:28:29 +01:00
2025-05-18 14:06:25 +02:00
SelectedStartTimeString = CurrentSettings.GetTimeString(startTime);
SelectedEndTimeString = CurrentSettings.GetTimeString(endTime);
}
2025-01-01 17:28:29 +01:00
2025-05-18 14:06:25 +02:00
// Manage event detail popup context and select-unselect the proper items.
// Item selection rules are defined in the selection method.
partial void OnDisplayDetailsCalendarItemViewModelChanging(CalendarItemViewModel oldValue, CalendarItemViewModel newValue)
{
if (oldValue != null)
{
UnselectCalendarItem(oldValue);
2025-01-01 17:28:29 +01:00
}
2025-05-18 14:06:25 +02:00
if (newValue != null)
{
2025-05-18 14:06:25 +02:00
SelectCalendarItem(newValue);
}
2025-05-18 14:06:25 +02:00
}
2025-05-18 14:06:25 +02:00
// Notify view that the detail context changed.
// This will align the event detail popup to the selected event.
partial void OnDisplayDetailsCalendarItemViewModelChanged(CalendarItemViewModel value)
=> DetailsShowCalendarItemChanged?.Invoke(this, EventArgs.Empty);
2025-05-18 14:06:25 +02:00
private void RefreshSettings()
{
CurrentSettings = _preferencesService.GetCurrentCalendarSettings();
2025-01-01 17:28:29 +01:00
2025-05-18 14:06:25 +02:00
// Populate the hour selection strings.
var timeStrings = new List<string>();
2025-01-01 17:28:29 +01:00
2025-05-18 14:06:25 +02:00
for (int hour = 0; hour < 24; hour++)
{
for (int minute = 0; minute < 60; minute += 30)
2025-01-01 17:28:29 +01:00
{
2025-05-18 14:06:25 +02:00
var time = new DateTime(1, 1, 1, hour, minute, 0);
2025-01-01 17:28:29 +01:00
2025-05-18 14:06:25 +02:00
if (CurrentSettings.DayHeaderDisplayType == DayHeaderDisplayType.TwentyFourHour)
{
timeStrings.Add(time.ToString("HH:mm"));
}
else
{
timeStrings.Add(time.ToString("h:mm tt"));
2025-01-01 17:28:29 +01:00
}
}
}
2025-05-18 14:06:25 +02:00
HourSelectionStrings = timeStrings;
}
2024-11-10 23:28:25 +01:00
2025-05-18 14:06:25 +02:00
partial void OnIsCalendarEnabledChanging(bool oldValue, bool newValue) => Messenger.Send(new CalendarEnableStatusChangedMessage(newValue));
2025-05-18 14:06:25 +02:00
private bool ShouldResetDayRanges(LoadCalendarMessage message)
{
if (message.ForceRedraw) return true;
2024-11-10 23:28:25 +01:00
2025-05-18 14:06:25 +02:00
// Never reset if the initiative is from the app.
if (message.CalendarInitInitiative == CalendarInitInitiative.App) return false;
2024-11-10 23:28:25 +01:00
2025-05-18 14:06:25 +02:00
// 1. Display type is different.
// 2. Day display count is different.
// 3. Display date is not in the visible range.
2025-05-18 14:06:25 +02:00
if (DayRanges.DisplayRange == null) return false;
2024-11-10 23:28:25 +01:00
2025-05-18 14:06:25 +02:00
return
(_currentDisplayType != StatePersistanceService.CalendarDisplayType ||
_displayDayCount != StatePersistanceService.DayDisplayCount ||
!(message.DisplayDate >= DayRanges.DisplayRange.StartDate && message.DisplayDate <= DayRanges.DisplayRange.EndDate));
}
2025-05-18 14:06:25 +02:00
private void AdjustCalendarOrientation()
{
// Orientation only changes when we should reset.
// Handle the FlipView orientation here.
// We don't want to change the orientation while the item manipulation is going on.
// That causes a glitch in the UI.
2025-05-18 14:06:25 +02:00
bool isRequestedVerticalCalendar = StatePersistanceService.CalendarDisplayType == CalendarDisplayType.Month;
bool isLastRenderedVerticalCalendar = _currentDisplayType == CalendarDisplayType.Month;
2025-05-18 14:06:25 +02:00
if (isRequestedVerticalCalendar && !isLastRenderedVerticalCalendar)
2024-11-10 23:28:25 +01:00
{
2025-05-18 14:06:25 +02:00
CalendarOrientation = CalendarOrientation.Vertical;
}
else
{
CalendarOrientation = CalendarOrientation.Horizontal;
}
}
2024-11-10 23:28:25 +01:00
2025-05-18 14:06:25 +02:00
public async void Receive(LoadCalendarMessage message)
{
await _calendarLoadingSemaphore.WaitAsync();
2025-05-18 14:06:25 +02:00
try
{
await ExecuteUIThread(() => IsCalendarEnabled = false);
2025-05-18 14:06:25 +02:00
if (ShouldResetDayRanges(message))
2024-11-10 23:28:25 +01:00
{
2025-05-18 14:06:25 +02:00
Debug.WriteLine("Will reset day ranges.");
await ClearDayRangeModelsAsync();
2024-11-10 23:28:25 +01:00
}
2025-05-18 14:06:25 +02:00
else if (ShouldScrollToItem(message))
2024-11-10 23:28:25 +01:00
{
2025-05-18 14:06:25 +02:00
// Scroll to the selected date.
Messenger.Send(new ScrollToDateMessage(message.DisplayDate));
Debug.WriteLine("Scrolling to selected date.");
return;
2024-11-10 23:28:25 +01:00
}
2025-05-18 14:06:25 +02:00
AdjustCalendarOrientation();
// This will replace the whole collection because the user initiated a new render.
await RenderDatesAsync(message.CalendarInitInitiative,
message.DisplayDate,
CalendarLoadDirection.Replace);
2025-05-18 14:06:25 +02:00
// Scroll to the current hour.
Messenger.Send(new ScrollToHourMessage(TimeSpan.FromHours(DateTime.Now.Hour)));
}
catch (Exception ex)
{
Log.Error(ex, "Error while loading calendar.");
Debugger.Break();
}
finally
{
2025-05-18 14:06:25 +02:00
_calendarLoadingSemaphore.Release();
2025-05-18 14:06:25 +02:00
await ExecuteUIThread(() => IsCalendarEnabled = true);
}
2025-05-18 14:06:25 +02:00
}
private async Task AddDayRangeModelAsync(DayRangeRenderModel dayRangeRenderModel)
{
if (dayRangeRenderModel == null) return;
2025-05-18 14:06:25 +02:00
await ExecuteUIThread(() =>
{
2025-05-18 14:06:25 +02:00
DayRanges.Add(dayRangeRenderModel);
});
}
2024-12-31 15:32:03 +01:00
2025-05-18 14:06:25 +02:00
private async Task InsertDayRangeModelAsync(DayRangeRenderModel dayRangeRenderModel, int index)
{
if (dayRangeRenderModel == null) return;
2025-05-18 14:06:25 +02:00
await ExecuteUIThread(() =>
{
2025-05-18 14:06:25 +02:00
DayRanges.Insert(index, dayRangeRenderModel);
});
}
2024-12-31 15:32:03 +01:00
2025-05-18 14:06:25 +02:00
private async Task RemoveDayRangeModelAsync(DayRangeRenderModel dayRangeRenderModel)
{
if (dayRangeRenderModel == null) return;
2025-05-18 14:06:25 +02:00
await ExecuteUIThread(() =>
{
2025-05-18 14:06:25 +02:00
DayRanges.Remove(dayRangeRenderModel);
});
}
2025-05-18 14:06:25 +02:00
private async Task ClearDayRangeModelsAsync()
{
await ExecuteUIThread(() =>
2024-11-10 23:28:25 +01:00
{
2025-05-18 14:06:25 +02:00
DayRanges.Clear();
});
}
2025-05-18 14:06:25 +02:00
private async Task RenderDatesAsync(CalendarInitInitiative calendarInitInitiative,
DateTime? loadingDisplayDate = null,
CalendarLoadDirection calendarLoadDirection = CalendarLoadDirection.Replace)
{
isLoadMoreBlocked = calendarLoadDirection == CalendarLoadDirection.Replace;
// This is the part we arrange the flip view calendar logic.
2024-11-10 23:28:25 +01:00
2025-05-18 14:06:25 +02:00
/* Loading for a month of the selected date is fine.
* If the selected date is in the loaded range, we'll just change the selected flip index to scroll.
* If the selected date is not in the loaded range:
* 1. Detect the direction of the scroll.
* 2. Load the next month.
* 3. Replace existing month with the new month.
*/
2024-11-10 23:28:25 +01:00
2025-05-18 14:06:25 +02:00
// 2 things are important: How many items should 1 flip have, and, where we should start loading?
2024-11-10 23:28:25 +01:00
2025-05-18 14:06:25 +02:00
// User initiated renders must always have a date to start with.
if (calendarInitInitiative == CalendarInitInitiative.User) Guard.IsNotNull(loadingDisplayDate, nameof(loadingDisplayDate));
2024-11-10 23:28:25 +01:00
2025-05-18 14:06:25 +02:00
var strategy = GetDrawingStrategy(StatePersistanceService.CalendarDisplayType);
var displayDate = loadingDisplayDate.GetValueOrDefault();
2024-11-10 23:28:25 +01:00
2025-05-18 14:06:25 +02:00
// How many days should be placed in 1 flip view item?
int eachFlipItemCount = strategy.GetRenderDayCount(displayDate, StatePersistanceService.DayDisplayCount);
2024-11-10 23:28:25 +01:00
2025-05-18 14:06:25 +02:00
DateRange flipLoadRange = null;
2024-11-10 23:28:25 +01:00
2025-05-18 14:06:25 +02:00
if (calendarInitInitiative == CalendarInitInitiative.User || DayRanges.DisplayRange == null)
{
flipLoadRange = strategy.GetRenderDateRange(displayDate, StatePersistanceService.DayDisplayCount);
}
else
{
// App is trying to load.
// This should be based on direction. We'll load the next or previous range.
// DisplayDate is either the start or end date of the current visible range.
if (calendarLoadDirection == CalendarLoadDirection.Previous)
2024-11-10 23:28:25 +01:00
{
2025-05-18 14:06:25 +02:00
flipLoadRange = strategy.GetPreviousDateRange(DayRanges.DisplayRange, StatePersistanceService.DayDisplayCount);
2024-11-10 23:28:25 +01:00
}
else
{
2025-05-18 14:06:25 +02:00
flipLoadRange = strategy.GetNextDateRange(DayRanges.DisplayRange, StatePersistanceService.DayDisplayCount);
2024-11-10 23:28:25 +01:00
}
2025-05-18 14:06:25 +02:00
}
2024-11-10 23:28:25 +01:00
2025-05-18 14:06:25 +02:00
// Create day ranges for each flip item until we reach the total days to load.
int totalFlipItemCount = (int)Math.Ceiling((double)flipLoadRange.TotalDays / eachFlipItemCount);
2024-11-10 23:28:25 +01:00
2025-05-18 14:06:25 +02:00
List<DayRangeRenderModel> renderModels = new();
2024-11-10 23:28:25 +01:00
2025-05-18 14:06:25 +02:00
for (int i = 0; i < totalFlipItemCount; i++)
{
var startDate = flipLoadRange.StartDate.AddDays(i * eachFlipItemCount);
var endDate = startDate.AddDays(eachFlipItemCount);
2024-11-10 23:28:25 +01:00
2025-05-18 14:06:25 +02:00
var range = new DateRange(startDate, endDate);
var renderOptions = new CalendarRenderOptions(range, CurrentSettings);
2024-11-10 23:28:25 +01:00
2025-05-18 14:06:25 +02:00
var dayRangeHeaderModel = new DayRangeRenderModel(renderOptions);
renderModels.Add(dayRangeHeaderModel);
}
2024-12-28 23:17:16 +01:00
2025-05-18 14:06:25 +02:00
// Dates are loaded. Now load the events for them.
foreach (var renderModel in renderModels)
{
await InitializeCalendarEventsForDayRangeAsync(renderModel).ConfigureAwait(false);
}
2025-05-18 14:06:25 +02:00
// Filter by active calendars. This is a quick operation, and things are not on the UI yet.
FilterActiveCalendars(renderModels);
2024-11-10 23:28:25 +01:00
2025-05-18 14:06:25 +02:00
CalendarLoadDirection animationDirection = calendarLoadDirection;
2024-11-10 23:28:25 +01:00
2025-05-18 14:06:25 +02:00
//bool removeCurrent = calendarLoadDirection == CalendarLoadDirection.Replace;
2024-11-10 23:28:25 +01:00
2025-05-18 14:06:25 +02:00
if (calendarLoadDirection == CalendarLoadDirection.Replace)
{
// New date ranges are being replaced.
// We must preserve existing selection if any, add the items before/after the current one, remove the current one.
// This will make sure the new dates are animated in the correct direction.
2024-11-10 23:28:25 +01:00
2025-05-18 14:06:25 +02:00
isLoadMoreBlocked = true;
2024-11-10 23:28:25 +01:00
2025-05-18 14:06:25 +02:00
// Remove all other dates except this one.
var rangesToRemove = DayRanges.Where(a => a != SelectedDayRange).ToList();
2024-11-10 23:28:25 +01:00
2025-05-18 14:06:25 +02:00
foreach (var range in rangesToRemove)
2024-11-10 23:28:25 +01:00
{
2025-05-18 14:06:25 +02:00
await RemoveDayRangeModelAsync(range);
2024-11-10 23:28:25 +01:00
}
2025-05-18 14:06:25 +02:00
animationDirection = displayDate <= SelectedDayRange?.CalendarRenderOptions.DateRange.StartDate ?
CalendarLoadDirection.Previous : CalendarLoadDirection.Next;
}
2024-11-10 23:28:25 +01:00
2025-05-18 14:06:25 +02:00
if (animationDirection == CalendarLoadDirection.Next)
{
foreach (var item in renderModels)
{
await AddDayRangeModelAsync(item);
2024-11-10 23:28:25 +01:00
}
2025-05-18 14:06:25 +02:00
}
else if (animationDirection == CalendarLoadDirection.Previous)
{
// Wait for the animation to finish.
// Otherwise it somehow shutters a little, which is annoying.
2024-11-10 23:28:25 +01:00
2025-05-18 14:06:25 +02:00
// if (!removeCurrent) await Task.Delay(350);
2024-11-10 23:28:25 +01:00
2025-05-18 14:06:25 +02:00
// Insert each render model in reverse order.
for (int i = renderModels.Count - 1; i >= 0; i--)
2024-11-10 23:28:25 +01:00
{
2025-05-18 14:06:25 +02:00
await InsertDayRangeModelAsync(renderModels[i], 0);
2024-11-10 23:28:25 +01:00
}
2025-05-18 14:06:25 +02:00
}
2024-11-10 23:28:25 +01:00
2025-05-18 14:06:25 +02:00
Debug.WriteLine($"Flip count: ({DayRanges.Count})");
2024-11-10 23:28:25 +01:00
2025-05-18 14:06:25 +02:00
foreach (var item in DayRanges)
{
Debug.WriteLine($"- {item.CalendarRenderOptions.DateRange.ToString()}");
}
2024-11-10 23:28:25 +01:00
2025-05-18 14:06:25 +02:00
//if (removeCurrent)
//{
// await RemoveDayRangeModelAsync(SelectedDayRange);
//}
2024-11-10 23:28:25 +01:00
2025-05-18 14:06:25 +02:00
// TODO...
// await TryConsolidateItemsAsync();
2024-11-10 23:28:25 +01:00
2025-05-18 14:06:25 +02:00
isLoadMoreBlocked = false;
2024-11-10 23:28:25 +01:00
2025-05-18 14:06:25 +02:00
// Only scroll if the render is initiated by user.
// Otherwise we'll scroll to the app rendered invisible date range.
if (calendarInitInitiative == CalendarInitInitiative.User)
{
2025-05-18 14:06:25 +02:00
// Save the current settings for the page for later comparison.
_currentDisplayType = StatePersistanceService.CalendarDisplayType;
_displayDayCount = StatePersistanceService.DayDisplayCount;
2025-05-18 14:06:25 +02:00
Messenger.Send(new ScrollToDateMessage(displayDate));
}
}
2025-05-18 14:06:25 +02:00
private async Task InitializeCalendarEventsForDayRangeAsync(DayRangeRenderModel dayRangeRenderModel)
{
// Clear all events first for all days.
foreach (var day in dayRangeRenderModel.CalendarDays)
{
2025-05-18 14:06:25 +02:00
await ExecuteUIThread(() =>
{
2025-05-18 14:06:25 +02:00
day.EventsCollection.Clear();
});
}
2025-05-18 14:06:25 +02:00
// 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.
foreach (var calendarViewModel in AccountCalendarStateService.AllCalendars)
2025-05-18 14:06:25 +02:00
{
// 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).ConfigureAwait(false);
2025-05-18 14:06:25 +02:00
foreach (var @event in events)
{
// Find the days that the event falls into.
var allDaysForEvent = dayRangeRenderModel.CalendarDays.Where(a => a.Period.OverlapsWith(@event.Period));
2025-05-18 14:06:25 +02:00
foreach (var calendarDay in allDaysForEvent)
{
var calendarItemViewModel = new CalendarItemViewModel(@event);
await ExecuteUIThread(() =>
{
2025-05-18 14:06:25 +02:00
calendarDay.EventsCollection.AddCalendarItem(calendarItemViewModel);
});
}
}
}
2025-05-18 14:06:25 +02:00
}
2026-02-13 03:09:13 +01:00
private async Task RefreshVisibleRangesAsync()
{
try
{
await _calendarLoadingSemaphore.WaitAsync().ConfigureAwait(false);
if (DayRanges == null || DayRanges.Count == 0)
return;
RefreshSettings();
foreach (var dayRange in DayRanges)
{
await InitializeCalendarEventsForDayRangeAsync(dayRange).ConfigureAwait(false);
}
FilterActiveCalendars(DayRanges);
}
catch (Exception ex)
{
Log.Error(ex, "Failed to refresh calendar ranges after navigation back.");
}
finally
{
_calendarLoadingSemaphore.Release();
}
}
2025-05-18 14:06:25 +02:00
private async Task TryConsolidateItemsAsync()
{
// Check if trimming is necessary
if (DayRanges.Count > maxDayRangeSize)
2024-11-10 23:28:25 +01:00
{
2025-05-18 14:06:25 +02:00
Debug.WriteLine("Trimming items.");
2024-11-10 23:28:25 +01:00
2025-05-18 14:06:25 +02:00
isLoadMoreBlocked = true;
2024-11-10 23:28:25 +01:00
2025-05-18 14:06:25 +02:00
var removeCount = DayRanges.Count - maxDayRangeSize;
2024-11-10 23:28:25 +01:00
2025-05-18 14:06:25 +02:00
await Task.Delay(500);
2024-11-10 23:28:25 +01:00
2025-05-18 14:06:25 +02:00
// Right shifted, remove from the start.
if (SelectedDateRangeIndex > DayRanges.Count / 2)
{
DayRanges.RemoveRange(DayRanges.Take(removeCount).ToList());
}
else
{
// Left shifted, remove from the end.
DayRanges.RemoveRange(DayRanges.Skip(DayRanges.Count - removeCount).Take(removeCount));
2024-11-10 23:28:25 +01:00
}
2025-05-18 14:06:25 +02:00
SelectedDateRangeIndex = DayRanges.IndexOf(SelectedDayRange);
2024-11-10 23:28:25 +01:00
}
2025-05-18 14:06:25 +02:00
}
2024-11-10 23:28:25 +01:00
2025-05-18 14:06:25 +02:00
private bool ShouldScrollToItem(LoadCalendarMessage message)
{
// Never scroll if the initiative is from the app.
if (message.CalendarInitInitiative == CalendarInitInitiative.App) return false;
2024-11-10 23:28:25 +01:00
2025-05-18 14:06:25 +02:00
// Nothing to scroll.
if (DayRanges.Count == 0) return false;
2024-11-10 23:28:25 +01:00
2025-05-18 14:06:25 +02:00
if (DayRanges.DisplayRange == null) return false;
2025-05-18 14:06:25 +02:00
var selectedDate = message.DisplayDate;
2024-11-10 23:28:25 +01:00
2025-05-18 14:06:25 +02:00
return selectedDate >= DayRanges.DisplayRange.StartDate && selectedDate <= DayRanges.DisplayRange.EndDate;
}
2024-11-10 23:28:25 +01:00
2025-05-18 14:06:25 +02:00
partial void OnIsAllDayChanged(bool value)
{
if (value)
2025-01-01 17:28:29 +01:00
{
2025-05-18 14:06:25 +02:00
SelectedStartTimeString = HourSelectionStrings.FirstOrDefault();
SelectedEndTimeString = HourSelectionStrings.FirstOrDefault();
2025-01-01 17:28:29 +01:00
}
2025-05-18 14:06:25 +02:00
else
2025-01-01 17:28:29 +01:00
{
2025-05-18 14:06:25 +02:00
SelectedStartTimeString = _previousSelectedStartTimeString;
SelectedEndTimeString = _previousSelectedEndTimeString;
2025-01-01 17:28:29 +01:00
}
2025-05-18 14:06:25 +02:00
}
2025-01-01 17:28:29 +01:00
2025-12-26 20:46:48 +01:00
partial void OnSelectedStartTimeStringChanged(string oldValue, string newValue)
2025-05-18 14:06:25 +02:00
{
var parsedTime = CurrentSettings.GetTimeSpan(newValue);
2025-01-01 17:28:29 +01:00
2025-05-18 14:06:25 +02:00
if (parsedTime == null)
{
SelectedStartTimeString = _previousSelectedStartTimeString;
2025-01-01 17:28:29 +01:00
}
2025-05-18 14:06:25 +02:00
else if (IsAllDay)
{
_previousSelectedStartTimeString = newValue;
}
}
2025-01-01 17:28:29 +01:00
2025-12-26 20:46:48 +01:00
partial void OnSelectedEndTimeStringChanged(string oldValue, string newValue)
2025-05-18 14:06:25 +02:00
{
var parsedTime = CurrentSettings.GetTimeSpan(newValue);
if (parsedTime == null)
2024-11-10 23:28:25 +01:00
{
2025-05-18 14:06:25 +02:00
SelectedEndTimeString = _previousSelectedStartTimeString;
}
else if (IsAllDay)
{
_previousSelectedEndTimeString = newValue;
}
}
2025-05-18 14:06:25 +02:00
partial void OnSelectedDayRangeChanged(DayRangeRenderModel value)
{
DisplayDetailsCalendarItemViewModel = null;
2024-11-10 23:28:25 +01:00
2025-05-18 14:06:25 +02:00
if (DayRanges.Count == 0 || SelectedDateRangeIndex < 0) return;
2024-11-10 23:28:25 +01:00
2025-05-18 14:06:25 +02:00
var selectedRange = DayRanges[SelectedDateRangeIndex];
2025-05-18 14:06:25 +02:00
Messenger.Send(new VisibleDateRangeChangedMessage(new DateRange(selectedRange.Period.Start, selectedRange.Period.End)));
2025-05-18 14:06:25 +02:00
if (isLoadMoreBlocked) return;
2024-11-10 23:28:25 +01:00
2025-05-18 14:06:25 +02:00
_ = LoadMoreAsync();
}
private async Task LoadMoreAsync()
{
try
2024-11-10 23:28:25 +01:00
{
2025-05-18 14:06:25 +02:00
await _calendarLoadingSemaphore.WaitAsync();
2025-05-18 14:06:25 +02:00
// Depending on the selected index, we'll load more dates.
// Day ranges may change while the async update is in progress.
// Therefore we wait for semaphore to be released before we continue.
// There is no need to load more if the current index is not in ideal position.
2025-05-18 14:06:25 +02:00
if (SelectedDateRangeIndex == 0)
2024-11-10 23:28:25 +01:00
{
2025-05-18 14:06:25 +02:00
await RenderDatesAsync(CalendarInitInitiative.App, calendarLoadDirection: CalendarLoadDirection.Previous);
2024-11-10 23:28:25 +01:00
}
2025-05-18 14:06:25 +02:00
else if (SelectedDateRangeIndex == DayRanges.Count - 1)
2024-11-10 23:28:25 +01:00
{
2025-05-18 14:06:25 +02:00
await RenderDatesAsync(CalendarInitInitiative.App, calendarLoadDirection: CalendarLoadDirection.Next);
2024-11-10 23:28:25 +01:00
}
}
2025-05-18 14:06:25 +02:00
catch (Exception)
{
Debugger.Break();
}
finally
2024-11-10 23:28:25 +01:00
{
2025-05-18 14:06:25 +02:00
_calendarLoadingSemaphore.Release();
}
}
2025-05-18 14:06:25 +02:00
public void Receive(CalendarSettingsUpdatedMessage message)
{
RefreshSettings();
2025-05-18 14:06:25 +02:00
// 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.
2025-05-18 14:06:25 +02:00
// Messenger.Send(new LoadCalendarMessage(DateTime.UtcNow.Date, CalendarInitInitiative.App, true));
}
2025-05-18 14:06:25 +02:00
private IEnumerable<CalendarItemViewModel> GetCalendarItems(CalendarItemViewModel calendarItemViewModel, CalendarDayModel selectedDay)
{
2026-01-01 10:07:56 +01:00
// Multi-day events, all-day events, and recurring events are rendered across multiple days.
// We need to find all instances with the same ID across all visible date ranges.
2026-01-01 10:07:56 +01:00
if (calendarItemViewModel.IsRecurringEvent || calendarItemViewModel.IsMultiDayEvent)
2025-05-18 14:06:25 +02:00
{
return DayRanges
.SelectMany(a => a.CalendarDays)
.Select(b => b.EventsCollection.GetCalendarItem(calendarItemViewModel.Id))
.Where(c => c != null)
.Cast<CalendarItemViewModel>()
.Distinct();
}
2026-01-01 10:07:56 +01:00
else
{
// Single-day, non-recurring events only appear once
return [calendarItemViewModel];
}
2025-05-18 14:06:25 +02:00
}
2025-05-18 14:06:25 +02:00
private void UnselectCalendarItem(CalendarItemViewModel calendarItemViewModel, CalendarDayModel calendarDay = null)
{
if (calendarItemViewModel == null) return;
2025-05-18 14:06:25 +02:00
var itemsToUnselect = GetCalendarItems(calendarItemViewModel, calendarDay);
2025-05-18 14:06:25 +02:00
foreach (var item in itemsToUnselect)
{
2025-05-18 14:06:25 +02:00
item.IsSelected = false;
}
}
2025-05-18 14:06:25 +02:00
private void SelectCalendarItem(CalendarItemViewModel calendarItemViewModel, CalendarDayModel calendarDay = null)
{
if (calendarItemViewModel == null) return;
2025-05-18 14:06:25 +02:00
var itemsToSelect = GetCalendarItems(calendarItemViewModel, calendarDay);
2025-05-18 14:06:25 +02:00
foreach (var item in itemsToSelect)
{
2025-05-18 14:06:25 +02:00
item.IsSelected = true;
}
2025-05-18 14:06:25 +02:00
}
private void UpdateCalendarItemBusyState(Guid calendarItemId, bool isBusy)
{
var calendarItems = DayRanges
.SelectMany(a => a.CalendarDays)
.Select(b => b.EventsCollection.GetCalendarItem(calendarItemId))
.Where(c => c != null)
.OfType<CalendarItemViewModel>()
.Distinct();
foreach (var item in calendarItems)
{
item.IsBusy = isBusy;
}
}
2026-02-18 20:43:10 +01:00
private CalendarItemViewModel FindPendingBusyMatchByRemoteEventId(CalendarItem syncedItem)
{
if (syncedItem == null ||
string.IsNullOrWhiteSpace(syncedItem.RemoteEventId) ||
!TryExtractClientItemIdFromRemoteEventId(syncedItem.RemoteEventId, out var clientItemId))
{
return null;
}
return DayRanges
.SelectMany(a => a.CalendarDays)
.SelectMany(b => b.EventsCollection.RegularEvents.Concat(b.EventsCollection.AllDayEvents))
.OfType<CalendarItemViewModel>()
.FirstOrDefault(vm => vm.IsBusy &&
vm.Id == clientItemId &&
vm.AssignedCalendar?.Id == syncedItem.CalendarId);
}
private static bool TryExtractClientItemIdFromRemoteEventId(string remoteEventId, out Guid clientItemId)
{
clientItemId = Guid.Empty;
if (string.IsNullOrWhiteSpace(remoteEventId))
return false;
var uid = remoteEventId.Split(new[] { "::" }, StringSplitOptions.None)[0];
const string calDavPrefix = "caldav-";
if (!uid.StartsWith(calDavPrefix, StringComparison.OrdinalIgnoreCase))
return false;
var guidPart = uid[calDavPrefix.Length..];
return Guid.TryParseExact(guidPart, "N", out clientItemId) || Guid.TryParse(guidPart, out clientItemId);
}
private void RemoveCalendarItemEverywhere(Guid calendarItemId)
{
foreach (var dayRange in DayRanges)
{
foreach (var calendarDay in dayRange.CalendarDays)
{
var existingItem = calendarDay.EventsCollection.GetCalendarItem(calendarItemId);
if (existingItem != null)
{
calendarDay.EventsCollection.RemoveCalendarItem(existingItem);
}
}
}
}
2025-05-18 14:06:25 +02:00
public void Receive(CalendarItemTappedMessage message)
{
if (message.CalendarItemViewModel == null) return;
2025-05-18 14:06:25 +02:00
DisplayDetailsCalendarItemViewModel = message.CalendarItemViewModel;
}
2025-05-18 14:06:25 +02:00
public void Receive(CalendarItemDoubleTappedMessage message) => NavigateEvent(message.CalendarItemViewModel, CalendarEventTargetType.Single);
public void Receive(CalendarItemRightTappedMessage message) { }
public async void Receive(AccountRemovedMessage message)
{
var removedAccountId = message.Account.Id;
await ExecuteUIThread(() =>
{
foreach (var dayRange in DayRanges)
{
foreach (var calendarDay in dayRange.CalendarDays)
{
calendarDay.EventsCollection.RemoveCalendarItems(item => item.AssignedCalendar?.AccountId == removedAccountId);
}
}
if (DisplayDetailsCalendarItemViewModel?.AssignedCalendar?.AccountId == removedAccountId)
{
DisplayDetailsCalendarItemViewModel = null;
}
SelectedQuickEventAccountCalendar = AccountCalendarStateService.ActiveCalendars.FirstOrDefault(a => a.IsPrimary);
});
}
2025-12-31 15:33:13 +01:00
protected override async void OnCalendarItemDeleted(CalendarItem calendarItem)
2025-05-18 14:06:25 +02:00
{
base.OnCalendarItemDeleted(calendarItem);
Debug.WriteLine($"Calendar item deleted: {calendarItem.Id}");
2025-12-31 15:33:13 +01:00
// Check if the deleted item (or its series master) is currently displayed in details view.
var isDeletedDetailsItem = DisplayDetailsCalendarItemViewModel?.Id == calendarItem.Id;
var isDeletedSeriesMasterOfDetailsItem = DisplayDetailsCalendarItemViewModel?.CalendarItem?.RecurringCalendarItemId == calendarItem.Id;
if (isDeletedDetailsItem || isDeletedSeriesMasterOfDetailsItem)
2026-01-01 10:07:56 +01:00
{
// Clear the details view since this item was deleted
DisplayDetailsCalendarItemViewModel = null;
}
2025-12-31 15:33:13 +01:00
// Remove the event and its occurrences from all visible date ranges.
await ExecuteUIThread(() =>
{
foreach (var dayRange in DayRanges)
{
foreach (var calendarDay in dayRange.CalendarDays)
{
calendarDay.EventsCollection.RemoveCalendarItems(item =>
item.Id == calendarItem.Id ||
(item is CalendarItemViewModel vm && vm.CalendarItem.RecurringCalendarItemId == calendarItem.Id));
2025-12-31 15:33:13 +01:00
}
}
});
}
protected override async void OnCalendarItemUpdated(CalendarItem calendarItem, CalendarItemUpdateSource source)
2025-12-31 15:33:13 +01:00
{
base.OnCalendarItemUpdated(calendarItem, source);
2025-12-31 15:33:13 +01:00
Debug.WriteLine($"Calendar item updated: {calendarItem.Id}");
// Local-only calendar operations are persisted immediately without real network I/O.
// Ignore optimistic client updates to prevent applying the same mutation twice.
var isLocalCalendarUpdate = string.IsNullOrWhiteSpace(calendarItem.RemoteEventId) ||
calendarItem.RemoteEventId.StartsWith("local-", StringComparison.OrdinalIgnoreCase);
if (isLocalCalendarUpdate && source == CalendarItemUpdateSource.ClientUpdated)
{
return;
}
2026-01-06 11:11:37 +01:00
// Series master events should not be visible on the UI.
if (calendarItem.IsRecurringParent)
{
Debug.WriteLine($"Skipping series master event update: {calendarItem.Title}");
return;
}
if (DayRanges.DisplayRange == null) return;
// Find all days that currently have this item and days that should have it after update
var currentDaysWithItem = DayRanges
.SelectMany(a => a.CalendarDays)
.Where(day => day.EventsCollection.GetCalendarItem(calendarItem.Id) != null)
.ToList();
var targetDaysForItem = DayRanges
.SelectMany(a => a.CalendarDays)
.Where(a => a.Period.OverlapsWith(calendarItem.Period))
.ToList();
2025-12-31 15:33:13 +01:00
await ExecuteUIThread(() =>
{
if (source == CalendarItemUpdateSource.ClientUpdated)
{
UpdateCalendarItemBusyState(calendarItem.Id, true);
}
else if (source == CalendarItemUpdateSource.ClientReverted || source == CalendarItemUpdateSource.Server)
{
UpdateCalendarItemBusyState(calendarItem.Id, false);
}
2026-01-06 11:11:37 +01:00
// Update existing items in-place where the item should remain
foreach (var calendarDay in currentDaysWithItem)
2025-12-31 15:33:13 +01:00
{
2026-01-06 11:11:37 +01:00
if (targetDaysForItem.Contains(calendarDay))
2025-12-31 15:33:13 +01:00
{
2026-01-06 11:11:37 +01:00
// Item should stay in this day - update in-place
calendarDay.EventsCollection.UpdateCalendarItem(calendarItem);
if (source == CalendarItemUpdateSource.Server)
{
var existingViewModel = calendarDay.EventsCollection.GetCalendarItem(calendarItem.Id) as CalendarItemViewModel;
if (existingViewModel != null)
{
existingViewModel.IsBusy = false;
}
}
2026-01-06 11:11:37 +01:00
}
else
{
// Item should no longer be in this day (time changed) - remove it
2025-12-31 15:33:13 +01:00
var existingItem = calendarDay.EventsCollection.GetCalendarItem(calendarItem.Id);
if (existingItem != null)
{
calendarDay.EventsCollection.RemoveCalendarItem(existingItem);
}
}
}
2026-01-06 11:11:37 +01:00
// Add to new days where the item wasn't present before
foreach (var calendarDay in targetDaysForItem)
2025-12-31 15:33:13 +01:00
{
2026-01-06 11:11:37 +01:00
if (!currentDaysWithItem.Contains(calendarDay))
2025-12-31 15:33:13 +01:00
{
2026-01-06 11:11:37 +01:00
var calendarItemViewModel = new CalendarItemViewModel(calendarItem);
2025-12-31 15:33:13 +01:00
calendarDay.EventsCollection.AddCalendarItem(calendarItemViewModel);
2026-01-06 11:11:37 +01:00
}
2025-12-31 15:33:13 +01:00
}
2026-01-06 11:11:37 +01:00
});
2025-12-31 15:33:13 +01:00
FilterActiveCalendars(DayRanges);
2025-05-18 14:06:25 +02:00
}
protected override async void OnCalendarItemAdded(CalendarItem calendarItem)
2025-05-18 14:06:25 +02:00
{
base.OnCalendarItemAdded(calendarItem);
Debug.WriteLine($"Calendar item added: {calendarItem.Id}");
2026-01-06 11:11:37 +01:00
// Series master events should not be visible on the UI.
// Their instances are already expanded and synced individually.
// For revert scenarios, restore visible child instances from local storage.
2026-01-06 11:11:37 +01:00
if (calendarItem.IsRecurringParent)
{
Debug.WriteLine($"Skipping series master event: {calendarItem.Title}");
await RestoreVisibleRecurringSeriesInstancesAsync(calendarItem);
2026-01-06 11:11:37 +01:00
return;
}
// Check if event falls into the current date range.
if (DayRanges.DisplayRange == null) return;
2026-02-18 20:43:10 +01:00
// If this is server data, reconcile against optimistic client-side items first.
// This prevents duplicate rendering when a pending busy item is replaced by the synced one.
2026-01-01 10:07:56 +01:00
if (!string.IsNullOrEmpty(calendarItem.RemoteEventId))
{
2026-02-18 20:43:10 +01:00
var pendingMatch = FindPendingBusyMatchByRemoteEventId(calendarItem);
if (pendingMatch != null)
2026-01-01 10:07:56 +01:00
{
2026-02-18 20:43:10 +01:00
Debug.WriteLine($"Mapped pending busy item {pendingMatch.Id} with synced server event {calendarItem.Id}.");
2026-01-01 10:07:56 +01:00
await ExecuteUIThread(() =>
{
2026-02-18 20:43:10 +01:00
RemoveCalendarItemEverywhere(pendingMatch.Id);
2026-01-01 10:07:56 +01:00
});
}
}
// Get all periods from the visible day ranges
2026-01-06 11:11:37 +01:00
// Note: Recurring event occurrences are now synced from server as individual instances
// No local expansion needed - just check if this item overlaps with visible periods
var allDaysForEvent = DayRanges
.SelectMany(a => a.CalendarDays)
.Where(a => a.Period.OverlapsWith(calendarItem.Period));
2026-01-06 11:11:37 +01:00
foreach (var calendarDay in allDaysForEvent)
2025-05-18 14:06:25 +02:00
{
var calendarItemViewModel = new CalendarItemViewModel(calendarItem)
{
IsBusy = string.IsNullOrEmpty(calendarItem.RemoteEventId)
};
2025-05-18 14:06:25 +02:00
2026-01-06 11:11:37 +01:00
await ExecuteUIThread(() =>
{
2026-01-06 11:11:37 +01:00
calendarDay.EventsCollection.AddCalendarItem(calendarItemViewModel);
});
}
2025-05-18 14:06:25 +02:00
FilterActiveCalendars(DayRanges);
2024-11-10 23:28:25 +01:00
}
private async Task RestoreVisibleRecurringSeriesInstancesAsync(CalendarItem recurringParent)
{
if (DayRanges.DisplayRange == null || recurringParent?.AssignedCalendar == null)
return;
var visibleRange = new TimeRange(DayRanges.DisplayRange.StartDate, DayRanges.DisplayRange.EndDate);
var visibleItems = await _calendarService.GetCalendarEventsAsync(recurringParent.AssignedCalendar, visibleRange).ConfigureAwait(false);
var recurringChildren = visibleItems
.Where(item => item.RecurringCalendarItemId == recurringParent.Id && !item.IsHidden && !item.IsRecurringParent)
.ToList();
if (!recurringChildren.Any())
return;
await ExecuteUIThread(() =>
{
foreach (var child in recurringChildren)
{
child.AssignedCalendar ??= recurringParent.AssignedCalendar;
var targetDays = DayRanges
.SelectMany(a => a.CalendarDays)
.Where(day => day.Period.OverlapsWith(child.Period));
foreach (var day in targetDays)
{
if (day.EventsCollection.GetCalendarItem(child.Id) != null)
continue;
day.EventsCollection.AddCalendarItem(new CalendarItemViewModel(child)
{
IsBusy = string.IsNullOrEmpty(child.RemoteEventId)
});
}
}
});
FilterActiveCalendars(DayRanges);
}
2024-11-10 23:28:25 +01:00
}