Encapsulation of grouped account selection events and collective events.

This commit is contained in:
Burak Kaan Köse
2024-12-29 19:37:36 +01:00
parent eef2ee1baa
commit f7bfbd5080
16 changed files with 242 additions and 49 deletions

View File

@@ -1,10 +1,12 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Threading;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using CommunityToolkit.Mvvm.Messaging;
using Serilog;
using Wino.Calendar.ViewModels.Data;
using Wino.Calendar.ViewModels.Interfaces;
using Wino.Core.Domain.Collections;
@@ -65,6 +67,9 @@ namespace Wino.Calendar.ViewModels
public bool IsVerticalCalendar => StatePersistenceService.CalendarDisplayType == CalendarDisplayType.Month;
// For updating account calendars asynchronously.
private SemaphoreSlim _accountCalendarUpdateSemaphoreSlim = new(1);
public AppShellViewModel(IPreferencesService preferencesService,
IStatePersistanceService statePersistanceService,
IAccountService accountService,
@@ -77,6 +82,9 @@ namespace Wino.Calendar.ViewModels
_calendarService = calendarService;
AccountCalendarStateService = accountCalendarStateService;
AccountCalendarStateService.AccountCalendarSelectionStateChanged += UpdateAccountCalendarRequested;
AccountCalendarStateService.CollectiveAccountGroupSelectionStateChanged += AccountCalendarStateCollectivelyChanged;
NavigationService = navigationService;
ServerConnectionManager = serverConnectionManager;
PreferencesService = preferencesService;
@@ -104,16 +112,33 @@ namespace Wino.Calendar.ViewModels
UpdateDateNavigationHeaderItems();
await InitializeAccountCalendarsAsync();
TodayClicked();
}
private void AddGroupedAccountCalendarViewModel(GroupedAccountCalendarViewModel groupedAccountCalendarViewModel)
private async void AccountCalendarStateCollectivelyChanged(object sender, GroupedAccountCalendarViewModel e)
{
foreach (var calendarViewModel in groupedAccountCalendarViewModel.AccountCalendars)
{
calendarViewModel.CalendarSelectionStateChanged += UpdateAccountCalendarRequested;
}
// When using three-state checkbox, multiple accounts will be selected/unselected at the same time.
// Reporting all these changes one by one to the UI is not efficient and may cause problems in the future.
AccountCalendarStateService.GroupedAccountCalendars.Add(groupedAccountCalendarViewModel);
// Update all calendar states at once.
try
{
await _accountCalendarUpdateSemaphoreSlim.WaitAsync();
foreach (var calendar in e.AccountCalendars)
{
await _calendarService.UpdateAccountCalendarAsync(calendar.AccountCalendar).ConfigureAwait(false);
}
}
catch (Exception ex)
{
Log.Error(ex, "Error while waiting for account calendar update semaphore.");
}
finally
{
_accountCalendarUpdateSemaphoreSlim.Release();
}
}
private async void UpdateAccountCalendarRequested(object sender, AccountCalendarViewModel e)
@@ -121,7 +146,7 @@ namespace Wino.Calendar.ViewModels
private async Task InitializeAccountCalendarsAsync()
{
await Dispatcher.ExecuteOnUIThread(() => AccountCalendarStateService.GroupedAccountCalendars.Clear());
await Dispatcher.ExecuteOnUIThread(() => AccountCalendarStateService.ClearGroupedAccountCalendar());
var accounts = await _accountService.GetAccountsAsync().ConfigureAwait(false);
@@ -141,7 +166,7 @@ namespace Wino.Calendar.ViewModels
await Dispatcher.ExecuteOnUIThread(() =>
{
AddGroupedAccountCalendarViewModel(groupedAccountCalendarViewModel);
AccountCalendarStateService.AddGroupedAccountCalendar(groupedAccountCalendarViewModel);
});
}
}
@@ -233,12 +258,12 @@ namespace Wino.Calendar.ViewModels
private readonly IAccountService _accountService;
private readonly ICalendarService _calendarService;
public override void OnPageLoaded()
{
base.OnPageLoaded();
//public override void OnPageLoaded()
//{
// base.OnPageLoaded();
TodayClicked();
}
// TodayClicked();
//}
#region Commands

View File

@@ -7,6 +7,7 @@ using System.Threading.Tasks;
using CommunityToolkit.Diagnostics;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Messaging;
using Wino.Calendar.ViewModels.Data;
using Wino.Calendar.ViewModels.Interfaces;
using Wino.Core.Domain.Collections;
using Wino.Core.Domain.Enums;
@@ -211,8 +212,6 @@ namespace Wino.Calendar.ViewModels
}
}
// Create day ranges for each flip item until we reach the total days to load.
int totalFlipItemCount = (int)Math.Ceiling((double)flipLoadRange.TotalDays / eachFlipItemCount);
@@ -233,15 +232,7 @@ namespace Wino.Calendar.ViewModels
foreach (var renderModel in renderModels)
{
foreach (var day in renderModel.CalendarDays)
{
var events = await _calendarService.GetCalendarEventsAsync(Guid.Parse("9ead7613-dacb-4163-8d33-2e32e65008a1"), day.Period.Start, day.Period.End).ConfigureAwait(false);
foreach (var calendarItem in events)
{
day.EventsCollection.Add(calendarItem);
}
}
await InitializeCalendarEventsAsync(renderModel).ConfigureAwait(false);
}
CalendarLoadDirection animationDirection = calendarLoadDirection;
@@ -326,6 +317,42 @@ namespace Wino.Calendar.ViewModels
}
}
private async Task InitializeCalendarEventsAsync(DayRangeRenderModel dayRangeRenderModel)
{
// Load for each selected calendar from the state.
var checkedCalendarViewModels = _accountCalendarStateService.GroupedAccountCalendars
.SelectMany(a => a.AccountCalendars)
.Where(b => b.IsChecked);
foreach (var calendarViewModel in checkedCalendarViewModels)
{
// 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);
foreach (var calendarItem in events)
{
var calendarDayModel = dayRangeRenderModel.CalendarDays.FirstOrDefault(a => a.RepresentingDate.Date == calendarItem.StartTime.Date);
if (calendarDayModel == null) continue;
var calendarItemViewModel = new CalendarItemViewModel(calendarItem);
await ExecuteUIThread(() =>
{
// TODO: EventsCollection should not take CalendarItem, but CalendarItemViewModel.
// Enforce it later on.
calendarDayModel.EventsCollection.Add(calendarItemViewModel);
});
}
}
}
private async Task TryConsolidateItemsAsync()
{
// Check if trimming is necessary

View File

@@ -8,8 +8,6 @@ namespace Wino.Calendar.ViewModels.Data
{
public partial class AccountCalendarViewModel : ObservableObject, IAccountCalendar
{
public event EventHandler<AccountCalendarViewModel> CalendarSelectionStateChanged;
public MailAccount Account { get; }
public AccountCalendar AccountCalendar { get; }
@@ -24,11 +22,7 @@ namespace Wino.Calendar.ViewModels.Data
[ObservableProperty]
private bool _isChecked;
partial void OnIsCheckedChanged(bool value)
{
IsExtended = value;
CalendarSelectionStateChanged?.Invoke(this, this);
}
partial void OnIsCheckedChanged(bool value) => IsExtended = value;
public string Name
{
@@ -71,5 +65,6 @@ namespace Wino.Calendar.ViewModels.Data
get => AccountCalendar.RemoteCalendarId;
set => SetProperty(AccountCalendar.RemoteCalendarId, value, AccountCalendar, (u, r) => u.RemoteCalendarId = r);
}
public Guid Id { get => ((IAccountCalendar)AccountCalendar).Id; set => ((IAccountCalendar)AccountCalendar).Id = value; }
}
}

View File

@@ -1,13 +1,14 @@
using System;
using CommunityToolkit.Mvvm.ComponentModel;
using Itenso.TimePeriod;
using Wino.Core.Domain.Entities.Calendar;
using Wino.Core.Domain.Interfaces;
namespace Wino.Calendar.ViewModels.Data
{
public partial class CalendarItemViewModel : ObservableObject, ICalendarItem
public partial class CalendarItemViewModel : ObservableObject, ICalendarItem, ICalendarItemViewModel
{
public ICalendarItem CalendarItem { get; }
public CalendarItem CalendarItem { get; }
public string Title => CalendarItem.Title;
@@ -19,7 +20,9 @@ namespace Wino.Calendar.ViewModels.Data
public TimeRange Period => CalendarItem.Period;
public CalendarItemViewModel(ICalendarItem calendarItem)
public IAccountCalendar AssignedCalendar => ((ICalendarItem)CalendarItem).AssignedCalendar;
public CalendarItemViewModel(CalendarItem calendarItem)
{
CalendarItem = calendarItem;
}

View File

@@ -1,4 +1,5 @@
using System.Collections.Generic;
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Collections.Specialized;
using System.Linq;
@@ -9,6 +10,9 @@ namespace Wino.Calendar.ViewModels.Data
{
public partial class GroupedAccountCalendarViewModel : ObservableObject
{
public event EventHandler CollectiveSelectionStateChanged;
public event EventHandler<AccountCalendarViewModel> CalendarSelectionStateChanged;
public MailAccount Account { get; }
public ObservableCollection<AccountCalendarViewModel> AccountCalendars { get; }
@@ -59,6 +63,7 @@ namespace Wino.Calendar.ViewModels.Data
if (e.PropertyName == nameof(AccountCalendarViewModel.IsChecked))
{
ManageIsCheckedState();
UpdateCalendarCheckedState(viewModel, viewModel.IsChecked, true);
}
}
}
@@ -73,6 +78,8 @@ namespace Wino.Calendar.ViewModels.Data
private void ManageIsCheckedState()
{
if (_isExternalPropChangeBlocked) return;
_isExternalPropChangeBlocked = true;
if (AccountCalendars.All(c => c.IsChecked))
@@ -95,22 +102,45 @@ namespace Wino.Calendar.ViewModels.Data
{
if (_isExternalPropChangeBlocked) return;
// Update is triggered by user on the three-state checkbox.
// We should not report all changes one by one.
_isExternalPropChangeBlocked = true;
if (newValue == null)
{
// Only primary calendars must be checked.
foreach (var calendar in AccountCalendars)
{
calendar.IsChecked = calendar.IsPrimary;
UpdateCalendarCheckedState(calendar, calendar.IsPrimary);
}
}
else
{
foreach (var calendar in AccountCalendars)
{
calendar.IsChecked = newValue.GetValueOrDefault();
UpdateCalendarCheckedState(calendar, newValue.GetValueOrDefault());
}
}
_isExternalPropChangeBlocked = false;
CollectiveSelectionStateChanged?.Invoke(this, EventArgs.Empty);
}
private void UpdateCalendarCheckedState(AccountCalendarViewModel accountCalendarViewModel, bool newValue, bool ignoreValueCheck = false)
{
var currentValue = accountCalendarViewModel.IsChecked;
if (currentValue == newValue && !ignoreValueCheck) return;
accountCalendarViewModel.IsChecked = newValue;
// No need to report.
if (_isExternalPropChangeBlocked == true) return;
CalendarSelectionStateChanged?.Invoke(this, accountCalendarViewModel);
}
}
}

View File

@@ -1,10 +1,21 @@
using System.Collections.ObjectModel;
using System;
using System.Collections.ObjectModel;
using Wino.Calendar.ViewModels.Data;
namespace Wino.Calendar.ViewModels.Interfaces
{
public interface IAccountCalendarStateService
{
ObservableCollection<GroupedAccountCalendarViewModel> GroupedAccountCalendars { get; }
ReadOnlyObservableCollection<GroupedAccountCalendarViewModel> GroupedAccountCalendars { get; }
event EventHandler<GroupedAccountCalendarViewModel> CollectiveAccountGroupSelectionStateChanged;
event EventHandler<AccountCalendarViewModel> AccountCalendarSelectionStateChanged;
public void AddGroupedAccountCalendar(GroupedAccountCalendarViewModel groupedAccountCalendar);
public void RemoveGroupedAccountCalendar(GroupedAccountCalendarViewModel groupedAccountCalendar);
public void ClearGroupedAccountCalendar();
public void AddAccountCalendar(AccountCalendarViewModel accountCalendar);
public void RemoveAccountCalendar(AccountCalendarViewModel accountCalendar);
}
}

View File

@@ -1,13 +1,91 @@
using System.Collections.ObjectModel;
using System;
using System.Collections.ObjectModel;
using System.Linq;
using CommunityToolkit.Mvvm.ComponentModel;
using Wino.Calendar.ViewModels.Data;
using Wino.Calendar.ViewModels.Interfaces;
namespace Wino.Calendar.Services
{
/// <summary>
/// Encapsulated state manager for collectively managing the state of account calendars.
/// Callers must react to the events to update their state only from this service.
/// </summary>
public partial class AccountCalendarStateService : ObservableObject, IAccountCalendarStateService
{
public event EventHandler<GroupedAccountCalendarViewModel> CollectiveAccountGroupSelectionStateChanged;
public event EventHandler<AccountCalendarViewModel> AccountCalendarSelectionStateChanged;
[ObservableProperty]
private ObservableCollection<GroupedAccountCalendarViewModel> _groupedAccountCalendars = new ObservableCollection<GroupedAccountCalendarViewModel>();
private ReadOnlyObservableCollection<GroupedAccountCalendarViewModel> groupedAccountCalendars;
private ObservableCollection<GroupedAccountCalendarViewModel> _internalGroupedAccountCalendars = new ObservableCollection<GroupedAccountCalendarViewModel>();
public AccountCalendarStateService()
{
GroupedAccountCalendars = new ReadOnlyObservableCollection<GroupedAccountCalendarViewModel>(_internalGroupedAccountCalendars);
}
private void SingleGroupCalendarCollectiveStateChanged(object sender, EventArgs e)
=> CollectiveAccountGroupSelectionStateChanged?.Invoke(this, sender as GroupedAccountCalendarViewModel);
private void SingleCalendarSelectionStateChanged(object sender, AccountCalendarViewModel e)
=> AccountCalendarSelectionStateChanged?.Invoke(this, e);
public void AddGroupedAccountCalendar(GroupedAccountCalendarViewModel groupedAccountCalendar)
{
groupedAccountCalendar.CalendarSelectionStateChanged += SingleCalendarSelectionStateChanged;
groupedAccountCalendar.CollectiveSelectionStateChanged += SingleGroupCalendarCollectiveStateChanged;
_internalGroupedAccountCalendars.Add(groupedAccountCalendar);
}
public void RemoveGroupedAccountCalendar(GroupedAccountCalendarViewModel groupedAccountCalendar)
{
groupedAccountCalendar.CalendarSelectionStateChanged -= SingleCalendarSelectionStateChanged;
groupedAccountCalendar.CollectiveSelectionStateChanged -= SingleGroupCalendarCollectiveStateChanged;
_internalGroupedAccountCalendars.Remove(groupedAccountCalendar);
}
public void ClearGroupedAccountCalendar()
{
foreach (var groupedAccountCalendar in _internalGroupedAccountCalendars)
{
RemoveGroupedAccountCalendar(groupedAccountCalendar);
}
}
public void AddAccountCalendar(AccountCalendarViewModel accountCalendar)
{
// Find the group that this calendar belongs to.
var group = _internalGroupedAccountCalendars.FirstOrDefault(g => g.Account.Id == accountCalendar.Account.Id);
if (group == null)
{
// If the group doesn't exist, create it.
group = new GroupedAccountCalendarViewModel(accountCalendar.Account, new[] { accountCalendar });
AddGroupedAccountCalendar(group);
}
else
{
group.AccountCalendars.Add(accountCalendar);
}
}
public void RemoveAccountCalendar(AccountCalendarViewModel accountCalendar)
{
var group = _internalGroupedAccountCalendars.FirstOrDefault(g => g.Account.Id == accountCalendar.Account.Id);
// We don't expect but just in case.
if (group == null) return;
group.AccountCalendars.Remove(accountCalendar);
if (group.AccountCalendars.Count == 0)
{
RemoveGroupedAccountCalendar(group);
}
}
}
}

View File

@@ -1,13 +1,18 @@
using System.Collections.ObjectModel;
using System;
using System.Collections.ObjectModel;
using Wino.Core.Domain.Interfaces;
namespace Wino.Core.Domain.Collections
{
// TODO: Could be read-only collection in the MVVM package.
public class CalendarEventCollection : ObservableRangeCollection<ICalendarItem>
{
public ObservableCollection<ICalendarItem> AllDayEvents { get; } = new ObservableCollection<ICalendarItem>();
public new void Add(ICalendarItem calendarItem)
{
if (calendarItem is not ICalendarItemViewModel)
throw new ArgumentException("CalendarItem must be of type ICalendarItemViewModel", nameof(calendarItem));
base.Add(calendarItem);
if (calendarItem.Period.Duration.TotalMinutes == 1440)
@@ -18,6 +23,9 @@ namespace Wino.Core.Domain.Collections
public new void Remove(ICalendarItem calendarItem)
{
if (calendarItem is not ICalendarItemViewModel)
throw new ArgumentException("CalendarItem must be of type ICalendarItemViewModel", nameof(calendarItem));
base.Remove(calendarItem);
if (calendarItem.Period.Duration.TotalMinutes == 1440)

View File

@@ -24,5 +24,8 @@ namespace Wino.Core.Domain.Entities.Calendar
[Ignore]
public TimeRange Period => new TimeRange(StartTime.Date, StartTime.Date.AddMinutes(DurationInMinutes));
[Ignore]
public IAccountCalendar AssignedCalendar { get; set; }
}
}

View File

@@ -11,5 +11,6 @@ namespace Wino.Core.Domain.Interfaces
Guid AccountId { get; set; }
string RemoteCalendarId { get; set; }
bool IsExtended { get; set; }
Guid Id { get; set; }
}
}

View File

@@ -10,5 +10,6 @@ namespace Wino.Core.Domain.Interfaces
DateTimeOffset StartTime { get; }
int DurationInMinutes { get; }
TimeRange Period { get; }
IAccountCalendar AssignedCalendar { get; }
}
}

View File

@@ -0,0 +1,7 @@
namespace Wino.Core.Domain.Interfaces
{
/// <summary>
/// Temporarily to enforce CalendarItemViewModel. Used in CalendarEventCollection.
/// </summary>
public interface ICalendarItemViewModel { }
}

View File

@@ -14,6 +14,6 @@ namespace Wino.Core.Domain.Interfaces
Task InsertAccountCalendarAsync(AccountCalendar accountCalendar);
Task UpdateAccountCalendarAsync(AccountCalendar accountCalendar);
Task CreateNewCalendarItemAsync(CalendarItem calendarItem, List<CalendarEventAttendee> attendees);
Task<List<ICalendarItem>> GetCalendarEventsAsync(Guid calendarId, DateTime rangeStart, DateTime rangeEnd);
Task<List<CalendarItem>> GetCalendarEventsAsync(IAccountCalendar calendar, DateTime rangeStart, DateTime rangeEnd);
}
}

View File

@@ -12,6 +12,7 @@ namespace Wino.Core.Domain.Models.Calendar
{
public TimeRange Period { get; }
public CalendarEventCollection EventsCollection { get; } = new CalendarEventCollection();
public CalendarDayModel(DateTime representingDate, CalendarRenderOptions calendarRenderOptions)
{
RepresentingDate = representingDate;

View File

@@ -71,16 +71,18 @@ namespace Wino.Services
});
}
public async Task<List<ICalendarItem>> GetCalendarEventsAsync(Guid calendarId, DateTime rangeStart, DateTime rangeEnd)
public async Task<List<CalendarItem>> GetCalendarEventsAsync(IAccountCalendar calendar, DateTime rangeStart, DateTime rangeEnd)
{
// 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.
var accountEvents = await Connection.Table<CalendarItem>().Where(x => x.CalendarId == calendarId).ToListAsync();
var result = new List<ICalendarItem>();
var accountEvents = await Connection.Table<CalendarItem>().Where(x => x.CalendarId == calendar.Id).ToListAsync();
var result = new List<CalendarItem>();
foreach (var ev in accountEvents)
{
ev.AssignedCalendar = calendar;
// Parse recurrence rules
var calendarEvent = new Ical.Net.CalendarComponents.CalendarEvent
{

View File

@@ -41,16 +41,17 @@ namespace Wino.Core.SourceGeneration.Translator
predicate: static (node, _) => node is ClassDeclarationSyntax,
transform: static (context, _) => (ClassDeclarationSyntax)context.TargetNode);
// Get the JSON schema
// Get the JSON schema and track changes
var jsonSchema = context.AdditionalTextsProvider
.Where(static file => file.Path.EndsWith("en_US\\resources.json"))
.Select((text, _) => (text, text.GetText()))
.Collect();
.Collect()
.WithTrackingName("JsonSchema");
// Combine the JSON schema with the marked classes
var combined = classDeclarations.Combine(jsonSchema);
// Generate the source
// Generate the source only when the JSON schema changes
context.RegisterSourceOutput(combined,
static (spc, source) => Execute(source.Left, source.Right, spc));
}