diff --git a/Wino.Calendar.ViewModels/CalendarAppShellViewModel.cs b/Wino.Calendar.ViewModels/CalendarAppShellViewModel.cs index c23ab658..43aec751 100644 --- a/Wino.Calendar.ViewModels/CalendarAppShellViewModel.cs +++ b/Wino.Calendar.ViewModels/CalendarAppShellViewModel.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.ComponentModel; using System.Globalization; using System.Linq; using System.Threading; @@ -9,16 +10,15 @@ using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Messaging; using Serilog; using Wino.Calendar.ViewModels.Data; -using Wino.Core.Domain.Entities.Calendar; using Wino.Calendar.ViewModels.Interfaces; using Wino.Core.Domain; using Wino.Core.Domain.Collections; +using Wino.Core.Domain.Entities.Calendar; using Wino.Core.Domain.Enums; -using Wino.Core.Domain.Extensions; using Wino.Core.Domain.Interfaces; using Wino.Core.Domain.MenuItems; -using Wino.Core.Domain.Models.Calendar; using Wino.Core.Domain.Models; +using Wino.Core.Domain.Models.Calendar; using Wino.Core.Domain.Models.Navigation; using Wino.Core.Domain.Models.Synchronization; using Wino.Core.ViewModels; @@ -30,8 +30,6 @@ namespace Wino.Calendar.ViewModels; public partial class CalendarAppShellViewModel : CalendarBaseViewModel, ICalendarShellClient, - IRecipient, - IRecipient, IRecipient, IRecipient { @@ -41,6 +39,8 @@ public partial class CalendarAppShellViewModel : CalendarBaseViewModel, public INavigationService NavigationService { get; } public WinoApplicationMode Mode => WinoApplicationMode.Calendar; public bool HandlesNavigationSelection => false; + public VisibleDateRange CurrentVisibleRange => _calendarPageViewModel.CurrentVisibleRange; + public string VisibleDateRangeText => _calendarPageViewModel.VisibleDateRangeText; System.Collections.IEnumerable ICalendarShellClient.GroupedAccountCalendars => AccountCalendarStateService.GroupedAccountCalendars; System.Collections.IEnumerable ICalendarShellClient.DateNavigationHeaderItems => DateNavigationHeaderItems; object IShellClient.SelectedMenuItem @@ -50,6 +50,8 @@ public partial class CalendarAppShellViewModel : CalendarBaseViewModel, } System.Windows.Input.ICommand ICalendarShellClient.TodayClickedCommand => TodayClickedCommand; System.Windows.Input.ICommand ICalendarShellClient.DateClickedCommand => DateClickedCommand; + System.Windows.Input.ICommand ICalendarShellClient.PreviousDateRangeCommand => PreviousDateRangeCommand; + System.Windows.Input.ICommand ICalendarShellClient.NextDateRangeCommand => NextDateRangeCommand; public MenuItemCollection MenuItems { get; private set; } public MenuItemCollection FooterItems { get; private set; } @@ -57,23 +59,6 @@ public partial class CalendarAppShellViewModel : CalendarBaseViewModel, [ObservableProperty] private int _selectedMenuItemIndex = -1; - [ObservableProperty] - private bool isCalendarEnabled; - - - - /// - /// Gets or sets the display date of the calendar. - /// - [ObservableProperty] - private DateTimeOffset _displayDate; - - /// - /// Gets or sets the highlighted range in the CalendarView and displayed date range in FlipView. - /// - [ObservableProperty] - private DateRange highlightedDateRange; - [ObservableProperty] private ObservableRangeCollection dateNavigationHeaderItems = []; @@ -87,36 +72,44 @@ public partial class CalendarAppShellViewModel : CalendarBaseViewModel, private readonly SettingsItem _settingsItem = new(); private readonly StoreUpdateMenuItem _storeUpdateMenuItem = new(); - - // For updating account calendars asynchronously. - private SemaphoreSlim _accountCalendarUpdateSemaphoreSlim = new(1); + private readonly SemaphoreSlim _accountCalendarUpdateSemaphoreSlim = new(1); + private readonly CalendarPageViewModel _calendarPageViewModel; + private readonly IMailDialogService _dialogService; + private readonly IUpdateManager _updateManager; + private readonly IStoreUpdateService _storeUpdateService; + private readonly IAccountService _accountService; + private readonly ICalendarService _calendarService; + private readonly IDateContextProvider _dateContextProvider; private bool _runtimeSubscriptionsAttached; private bool _hasRegisteredPersistentRecipients; + private DateTime? _navigationDate; - public CalendarAppShellViewModel(IPreferencesService preferencesService, - IStatePersistanceService statePersistanceService, - IAccountService accountService, - ICalendarService calendarService, - IAccountCalendarStateService accountCalendarStateService, - INavigationService navigationService, - CalendarPageViewModel calendarPageViewModel, - IMailDialogService dialogService, - IUpdateManager updateManager, - IStoreUpdateService storeUpdateService) + public CalendarAppShellViewModel( + IPreferencesService preferencesService, + IStatePersistanceService statePersistanceService, + IAccountService accountService, + ICalendarService calendarService, + IAccountCalendarStateService accountCalendarStateService, + INavigationService navigationService, + CalendarPageViewModel calendarPageViewModel, + IMailDialogService dialogService, + IUpdateManager updateManager, + IStoreUpdateService storeUpdateService, + IDateContextProvider dateContextProvider) { + PreferencesService = preferencesService; + StatePersistenceService = statePersistanceService; + AccountCalendarStateService = accountCalendarStateService; + NavigationService = navigationService; _accountService = accountService; _calendarService = calendarService; _calendarPageViewModel = calendarPageViewModel; _dialogService = dialogService; _updateManager = updateManager; _storeUpdateService = storeUpdateService; + _dateContextProvider = dateContextProvider; - AccountCalendarStateService = accountCalendarStateService; - - NavigationService = navigationService; - PreferencesService = preferencesService; - - StatePersistenceService = statePersistanceService; + _calendarPageViewModel.PropertyChanged += CalendarPageViewModelPropertyChanged; } protected override void OnDispatcherAssigned() @@ -129,17 +122,30 @@ public partial class CalendarAppShellViewModel : CalendarBaseViewModel, _ = RefreshFooterItemsAsync(false); } + private void CalendarPageViewModelPropertyChanged(object sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(CalendarPageViewModel.CurrentVisibleRange)) + { + OnPropertyChanged(nameof(CurrentVisibleRange)); + } + + if (e.PropertyName == nameof(CalendarPageViewModel.CurrentVisibleRange) || + e.PropertyName == nameof(CalendarPageViewModel.VisibleDateRangeText)) + { + OnPropertyChanged(nameof(VisibleDateRangeText)); + UpdateDateNavigationHeaderItems(); + } + } + private void PrefefencesChanged(object sender, string e) { - if (e == nameof(StatePersistenceService.CalendarDisplayType)) - { - Messenger.Send(new CalendarDisplayTypeChangedMessage(StatePersistenceService.CalendarDisplayType)); + if (e != nameof(StatePersistenceService.CalendarDisplayType)) + return; - UpdateDateNavigationHeaderItems(); - - // Change the calendar. - DateClicked(new CalendarViewDayClickedEventArgs(GetDisplayTypeSwitchDate())); - } + Messenger.Send(new CalendarDisplayTypeChangedMessage(StatePersistenceService.CalendarDisplayType)); + OnPropertyChanged(nameof(IsVerticalCalendar)); + UpdateDateNavigationHeaderItems(); + NavigateCalendarDate(GetDisplayTypeSwitchDate()); } private async void PreferencesServiceChanged(object sender, string e) @@ -167,9 +173,7 @@ public partial class CalendarAppShellViewModel : CalendarBaseViewModel, PreferencesService.PreferenceChanged += PreferencesServiceChanged; await RefreshFooterItemsAsync(mode == NavigationMode.New); - UpdateDateNavigationHeaderItems(); - await InitializeAccountCalendarsAsync(); ValidateConfiguredNewEventCalendar(); @@ -189,7 +193,6 @@ public partial class CalendarAppShellViewModel : CalendarBaseViewModel, { DateNavigationHeaderItems.Clear(); AccountCalendarStateService.ClearGroupedAccountCalendars(); - HighlightedDateRange = null; SelectedDateNavigationHeaderIndex = -1; }); _calendarPageViewModel.CleanupForShellDeactivation(); @@ -223,7 +226,6 @@ public partial class CalendarAppShellViewModel : CalendarBaseViewModel, return; var notes = await _updateManager.GetLatestUpdateNotesAsync(); - if (notes.Sections.Count == 0) return; @@ -246,10 +248,6 @@ public partial class CalendarAppShellViewModel : CalendarBaseViewModel, private async void AccountCalendarStateCollectivelyChanged(object sender, GroupedAccountCalendarViewModel e) { - // 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. - - // Update all calendar states at once. try { await _accountCalendarUpdateSemaphoreSlim.WaitAsync(); @@ -281,15 +279,7 @@ public partial class CalendarAppShellViewModel : CalendarBaseViewModel, foreach (var account in accounts) { var accountCalendars = await _calendarService.GetAccountCalendarsAsync(account.Id).ConfigureAwait(false); - var calendarViewModels = new List(); - - foreach (var calendar in accountCalendars) - { - var calendarViewModel = new AccountCalendarViewModel(account, calendar); - - calendarViewModels.Add(calendarViewModel); - } - + var calendarViewModels = accountCalendars.Select(calendar => new AccountCalendarViewModel(account, calendar)).ToList(); var groupedAccountCalendarViewModel = new GroupedAccountCalendarViewModel(account, calendarViewModels); await Dispatcher.ExecuteOnUIThread(() => @@ -299,9 +289,15 @@ public partial class CalendarAppShellViewModel : CalendarBaseViewModel, } } + private void NavigateCalendarDate(DateTime date) + { + _navigationDate = date.Date; + ForceNavigateCalendarDate(); + } + private void ForceNavigateCalendarDate() { - var args = new CalendarPageNavigationArgs() + var args = new CalendarPageNavigationArgs { NavigationDate = _navigationDate ?? DateTime.Now.Date }; @@ -310,73 +306,56 @@ public partial class CalendarAppShellViewModel : CalendarBaseViewModel, _navigationDate = null; } - partial void OnSelectedMenuItemIndexChanged(int oldValue, int newValue) { } - [RelayCommand] private async Task Sync() { - // Sync all calendars. var accounts = await _accountService.GetAccountsAsync().ConfigureAwait(false); - foreach (var account in accounts) { - var t = new NewCalendarSynchronizationRequested(new CalendarSynchronizationOptions() + Messenger.Send(new NewCalendarSynchronizationRequested(new CalendarSynchronizationOptions { AccountId = account.Id, Type = CalendarSynchronizationType.CalendarEvents - }); - - Messenger.Send(t); + })); } } - /// - /// When calendar type switches, we need to navigate to the most ideal date. - /// This method returns that date. - /// private DateTime GetDisplayTypeSwitchDate() { + var today = _dateContextProvider.GetToday(); var settings = PreferencesService.GetCurrentCalendarSettings(); - switch (StatePersistenceService.CalendarDisplayType) - { - case CalendarDisplayType.Day: - if (HighlightedDateRange.IsInRange(DateTime.Now)) return DateTime.Now.Date; - - return HighlightedDateRange.StartDate; - case CalendarDisplayType.Week: - if (HighlightedDateRange == null || HighlightedDateRange.IsInRange(DateTime.Now)) - { - return DateTime.Now.Date.GetWeekStartDateForDate(settings.FirstDayOfWeek); - } - - return HighlightedDateRange.StartDate.GetWeekStartDateForDate(settings.FirstDayOfWeek); - case CalendarDisplayType.WorkWeek: - break; - case CalendarDisplayType.Month: - break; - default: - break; - } - - return DateTime.Today.Date; + var referenceRange = CurrentVisibleRange + ?? CalendarRangeResolver.Resolve(new CalendarDisplayRequest(StatePersistenceService.CalendarDisplayType, today), settings, today); + var targetRange = CalendarRangeResolver.ChangeDisplayType(referenceRange, StatePersistenceService.CalendarDisplayType, settings, today); + return targetRange.AnchorDate.ToDateTime(TimeOnly.MinValue); } - private DateTime? _navigationDate; - private readonly IAccountService _accountService; - private readonly ICalendarService _calendarService; - private readonly CalendarPageViewModel _calendarPageViewModel; - private readonly IMailDialogService _dialogService; - private readonly IUpdateManager _updateManager; - private readonly IStoreUpdateService _storeUpdateService; - - #region Commands - [RelayCommand] private void TodayClicked() { - _navigationDate = DateTime.Now.Date; + NavigateCalendarDate(_dateContextProvider.GetToday().ToDateTime(TimeOnly.MinValue)); + } - ForceNavigateCalendarDate(); + [RelayCommand] + private void PreviousDateRange() + { + NavigateRelativePeriod(-1); + } + + [RelayCommand] + private void NextDateRange() + { + NavigateRelativePeriod(1); + } + + private void NavigateRelativePeriod(int direction) + { + var today = _dateContextProvider.GetToday(); + var settings = PreferencesService.GetCurrentCalendarSettings(); + var referenceRange = CurrentVisibleRange + ?? CalendarRangeResolver.Resolve(new CalendarDisplayRequest(StatePersistenceService.CalendarDisplayType, today), settings, today); + var targetRange = CalendarRangeResolver.Navigate(referenceRange, direction, settings, today); + NavigateCalendarDate(targetRange.AnchorDate.ToDateTime(TimeOnly.MinValue)); } public async Task HandleNavigationItemInvokedAsync(IMenuItem menuItem) @@ -421,7 +400,6 @@ public partial class CalendarAppShellViewModel : CalendarBaseViewModel, } var pickingResult = await _dialogService.ShowSingleCalendarPickerDialogAsync(availableGroups); - if (pickingResult.ShouldNavigateToCalendarSettings) { NavigationService.Navigate(WinoPage.CalendarSettingsPage); @@ -456,17 +434,9 @@ public partial class CalendarAppShellViewModel : CalendarBaseViewModel, } } - - [RelayCommand] private void DateClicked(CalendarViewDayClickedEventArgs clickedDateArgs) - { - _navigationDate = clickedDateArgs.ClickedDate; - - ForceNavigateCalendarDate(); - } - - #endregion + => NavigateCalendarDate(clickedDateArgs.ClickedDate); protected override void RegisterRecipients() { @@ -474,8 +444,6 @@ public partial class CalendarAppShellViewModel : CalendarBaseViewModel, UnregisterRecipients(); - Messenger.Register(this); - Messenger.Register(this); Messenger.Register(this); Messenger.Register(this); } @@ -484,99 +452,17 @@ public partial class CalendarAppShellViewModel : CalendarBaseViewModel, { base.UnregisterRecipients(); - Messenger.Unregister(this); - Messenger.Unregister(this); Messenger.Unregister(this); Messenger.Unregister(this); } - public void Receive(VisibleDateRangeChangedMessage message) => HighlightedDateRange = message.DateRange; - - /// - /// Sets the header navigation items based on visible date range and calendar type. - /// private void UpdateDateNavigationHeaderItems() { - var settings = PreferencesService.GetCurrentCalendarSettings(); - var cultureInfo = settings.CultureInfo ?? CultureInfo.CurrentUICulture; - - var visibleRange = HighlightedDateRange ?? new DateRange(DateTime.Today, DateTime.Today.AddDays(1)); - var headerText = GetHeaderText(visibleRange, cultureInfo); - - DateNavigationHeaderItems.ReplaceRange([headerText]); + var headerText = VisibleDateRangeText; + DateNavigationHeaderItems.ReplaceRange(string.IsNullOrWhiteSpace(headerText) ? [] : [headerText]); SelectedDateNavigationHeaderIndex = DateNavigationHeaderItems.Count > 0 ? 0 : -1; } - private string GetHeaderText(DateRange visibleRange, CultureInfo cultureInfo) - { - var startDate = visibleRange.StartDate.Date; - var endDate = visibleRange.EndDate.Date > startDate ? visibleRange.EndDate.Date.AddDays(-1) : startDate; - - switch (StatePersistenceService.CalendarDisplayType) - { - case CalendarDisplayType.Day: - return startDate.ToString("MMMM d, dddd", cultureInfo); - case CalendarDisplayType.Week: - case CalendarDisplayType.WorkWeek: - if (startDate.Month == endDate.Month && startDate.Year == endDate.Year) - { - return $"{startDate.ToString("MMMM d", cultureInfo)} - {endDate.ToString("%d", cultureInfo)}"; - } - - return $"{startDate.ToString("MMMM d", cultureInfo)} - {endDate.ToString("MMMM d", cultureInfo)}"; - case CalendarDisplayType.Month: - return GetDominantMonthHeaderText(startDate, endDate, cultureInfo); - default: - return startDate.ToString("d", cultureInfo); - } - } - - private static string GetDominantMonthHeaderText(DateTime startDate, DateTime endDate, CultureInfo cultureInfo) - { - if (endDate < startDate) - { - endDate = startDate; - } - - var monthDayCounts = new Dictionary<(int Year, int Month), int>(); - - for (var day = startDate; day <= endDate; day = day.AddDays(1)) - { - var key = (day.Year, day.Month); - - if (monthDayCounts.TryGetValue(key, out var count)) - { - monthDayCounts[key] = count + 1; - } - else - { - monthDayCounts[key] = 1; - } - } - - var dominantKey = (Year: startDate.Year, Month: startDate.Month); - var dominantCount = -1; - - foreach (var pair in monthDayCounts) - { - if (pair.Value > dominantCount) - { - dominantCount = pair.Value; - dominantKey = pair.Key; - } - } - - return new DateTime(dominantKey.Year, dominantKey.Month, 1).ToString("Y", cultureInfo); - } - - partial void OnHighlightedDateRangeChanged(DateRange value) - { - UpdateDateNavigationHeaderItems(); - } - - public async void Receive(CalendarEnableStatusChangedMessage message) - => await ExecuteUIThread(() => IsCalendarEnabled = message.IsEnabled); - public void Receive(CalendarDisplayTypeChangedMessage message) { OnPropertyChanged(nameof(IsVerticalCalendar)); @@ -615,11 +501,11 @@ public partial class CalendarAppShellViewModel : CalendarBaseViewModel, var exists = AccountCalendarStateService.AllCalendars .Any(calendar => calendar.Id == PreferencesService.DefaultNewEventCalendarId.Value); - if (exists) - return; - - PreferencesService.NewEventButtonBehavior = NewEventButtonBehavior.AskEachTime; - PreferencesService.DefaultNewEventCalendarId = null; + if (!exists) + { + PreferencesService.NewEventButtonBehavior = NewEventButtonBehavior.AskEachTime; + PreferencesService.DefaultNewEventCalendarId = null; + } } private static (DateTime StartDate, DateTime EndDate) GetDefaultComposeDateRange() @@ -650,12 +536,3 @@ public partial class CalendarAppShellViewModel : CalendarBaseViewModel, Task IShellClient.HandleNavigationSelectionChangedAsync(IMenuItem menuItem) => Task.CompletedTask; } - - - - - - - - - diff --git a/Wino.Calendar.ViewModels/CalendarPageViewModel.cs b/Wino.Calendar.ViewModels/CalendarPageViewModel.cs index 10e4b1ee..33ec8d93 100644 --- a/Wino.Calendar.ViewModels/CalendarPageViewModel.cs +++ b/Wino.Calendar.ViewModels/CalendarPageViewModel.cs @@ -1,29 +1,23 @@ -using System; +using System; using System.Collections.Generic; -using System.Diagnostics; using System.Linq; using System.Runtime.InteropServices; using System.Threading; using System.Threading.Tasks; -using CommunityToolkit.Diagnostics; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; 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; -using Wino.Core.Domain.Collections; using Wino.Core.Domain.Entities.Calendar; using Wino.Core.Domain.Enums; -using Wino.Core.Domain.Extensions; using Wino.Core.Domain.Interfaces; using Wino.Core.Domain.Models; using Wino.Core.Domain.Models.Calendar; -using Wino.Core.Domain.Models.Calendar.CalendarTypeStrategies; using Wino.Core.Domain.Models.Navigation; using Wino.Core.ViewModels; using Wino.Messaging.Client.Calendar; @@ -52,19 +46,13 @@ public partial class CalendarPageViewModel : CalendarBaseViewModel, public partial AccountCalendarViewModel SelectedQuickEventAccountCalendar { get; set; } public string SelectedQuickEventAccountCalendarName - { - get - { - return SelectedQuickEventAccountCalendar == null ? "Pick a calendar" : SelectedQuickEventAccountCalendar.Name; - } - } + => SelectedQuickEventAccountCalendar == null ? "Pick a calendar" : SelectedQuickEventAccountCalendar.Name; [ObservableProperty] - public partial List HourSelectionStrings { get; set; } + public partial List HourSelectionStrings { get; set; } = []; - // To be able to revert the values when the user enters an invalid time. - private string _previousSelectedStartTimeString; - private string _previousSelectedEndTimeString; + private string _previousSelectedStartTimeString = string.Empty; + private string _previousSelectedEndTimeString = string.Empty; [ObservableProperty] [NotifyCanExecuteChangedFor(nameof(SaveQuickEventCommand))] @@ -76,18 +64,18 @@ public partial class CalendarPageViewModel : CalendarBaseViewModel, [ObservableProperty] [NotifyCanExecuteChangedFor(nameof(SaveQuickEventCommand))] - public partial string SelectedStartTimeString { get; set; } + public partial string SelectedStartTimeString { get; set; } = string.Empty; [ObservableProperty] [NotifyCanExecuteChangedFor(nameof(SaveQuickEventCommand))] - public partial string SelectedEndTimeString { get; set; } + public partial string SelectedEndTimeString { get; set; } = string.Empty; [ObservableProperty] - public partial string Location { get; set; } + public partial string Location { get; set; } = string.Empty; [ObservableProperty] [NotifyCanExecuteChangedFor(nameof(SaveQuickEventCommand))] - public partial string EventName { get; set; } + public partial string EventName { get; set; } = string.Empty; public DateTime QuickEventStartTime => SelectedQuickEventDate.Value.Date.Add(CurrentSettings.GetTimeSpan(SelectedStartTimeString).Value); public DateTime QuickEventEndTime => SelectedQuickEventDate.Value.Date.Add(CurrentSettings.GetTimeSpan(SelectedEndTimeString).Value); @@ -119,16 +107,17 @@ public partial class CalendarPageViewModel : CalendarBaseViewModel, #endregion - #region Data Initialization + #region Visible Range [ObservableProperty] - public partial CalendarOrientation CalendarOrientation { get; set; } = CalendarOrientation.Horizontal; + public partial VisibleDateRange CurrentVisibleRange { get; set; } + [ObservableProperty] - public partial DayRangeCollection DayRanges { get; set; } = []; + public partial string VisibleDateRangeText { get; set; } = string.Empty; + [ObservableProperty] - public partial int SelectedDateRangeIndex { get; set; } - [ObservableProperty] - public partial DayRangeRenderModel SelectedDayRange { get; set; } + public partial DateRange LoadedDateWindow { get; set; } + [ObservableProperty] public partial bool IsCalendarEnabled { get; set; } = true; @@ -139,7 +128,7 @@ public partial class CalendarPageViewModel : CalendarBaseViewModel, public event EventHandler DetailsShowCalendarItemChanged; public bool CanJoinOnline => DisplayDetailsCalendarItemViewModel != null && - !string.IsNullOrEmpty(DisplayDetailsCalendarItemViewModel.CalendarItem.HtmlLink); + !string.IsNullOrEmpty(DisplayDetailsCalendarItemViewModel.CalendarItem.HtmlLink); [ObservableProperty] [NotifyPropertyChangedFor(nameof(IsEventDetailsVisible))] @@ -151,26 +140,20 @@ public partial class CalendarPageViewModel : CalendarBaseViewModel, #endregion - // TODO: Get rid of some of the items if we have too many. - private const int maxDayRangeSize = 10; - private readonly ICalendarService _calendarService; private readonly INavigationService _navigationService; - private readonly IKeyPressService _keyPressService; private readonly INativeAppService _nativeAppService; private readonly IPreferencesService _preferencesService; private readonly IWinoRequestDelegator _winoRequestDelegator; private readonly IMailDialogService _dialogService; + private readonly IDateContextProvider _dateContextProvider; + private readonly ICalendarRangeTextFormatter _calendarRangeTextFormatter; - // Store latest rendered options. - private CalendarDisplayType _currentDisplayType; - private int _displayDayCount; - - private SemaphoreSlim _calendarLoadingSemaphore = new(1); - private bool isLoadMoreBlocked = false; + private readonly SemaphoreSlim _calendarLoadingSemaphore = new(1); private bool _subscriptionsAttached; private CancellationTokenSource _pageLifetimeCts = new(); private long _pageLifetimeVersion; + private List _loadedCalendarItems = []; [ObservableProperty] public partial CalendarSettings CurrentSettings { get; set; } @@ -178,27 +161,31 @@ public partial class CalendarPageViewModel : CalendarBaseViewModel, public IStatePersistanceService StatePersistanceService { get; } public IAccountCalendarStateService AccountCalendarStateService { get; } - public CalendarPageViewModel(IStatePersistanceService statePersistanceService, - ICalendarService calendarService, - INavigationService navigationService, - IKeyPressService keyPressService, - INativeAppService nativeAppService, - IAccountCalendarStateService accountCalendarStateService, - IPreferencesService preferencesService, - IWinoRequestDelegator winoRequestDelegator, - IMailDialogService dialogService) + public CalendarPageViewModel( + IStatePersistanceService statePersistanceService, + ICalendarService calendarService, + INavigationService navigationService, + IKeyPressService keyPressService, + INativeAppService nativeAppService, + IAccountCalendarStateService accountCalendarStateService, + IPreferencesService preferencesService, + IWinoRequestDelegator winoRequestDelegator, + IMailDialogService dialogService, + IDateContextProvider dateContextProvider, + ICalendarRangeTextFormatter calendarRangeTextFormatter) { StatePersistanceService = statePersistanceService; AccountCalendarStateService = accountCalendarStateService; - _calendarService = calendarService; _navigationService = navigationService; - _keyPressService = keyPressService; _nativeAppService = nativeAppService; _preferencesService = preferencesService; _winoRequestDelegator = winoRequestDelegator; _dialogService = dialogService; + _dateContextProvider = dateContextProvider; + _calendarRangeTextFormatter = calendarRangeTextFormatter; + RefreshSettings(); } public override async Task KeyboardShortcutHook(KeyboardShortcutTriggerDetails args) @@ -262,52 +249,20 @@ public partial class CalendarPageViewModel : CalendarBaseViewModel, } private void AccountCalendarStateCollectivelyChanged(object sender, GroupedAccountCalendarViewModel e) - => _ = FilterActiveCalendarsAsync(DayRanges); + => _ = ReloadCurrentVisibleRangeAsync(); private void UpdateAccountCalendarRequested(object sender, AccountCalendarViewModel e) - => _ = FilterActiveCalendarsAsync(DayRanges); - - private async Task FilterActiveCalendarsAsync(IEnumerable dayRangeRenderModels) - { - await FilterActiveCalendarsAsync(dayRangeRenderModels, CurrentPageLifetimeVersion).ConfigureAwait(false); - } - - private async Task FilterActiveCalendarsAsync(IEnumerable dayRangeRenderModels, long lifetimeVersion) - { - if (dayRangeRenderModels == null || !IsPageActive(lifetimeVersion)) - return; - - await ExecuteUIThreadIfActiveAsync(lifetimeVersion, () => - { - var days = dayRangeRenderModels.SelectMany(a => a.CalendarDays); - - days.ForEach(a => a.EventsCollection.FilterByCalendars(AccountCalendarStateService.ActiveCalendars.Select(a => a.Id))); - - DisplayDetailsCalendarItemViewModel = null; - }); - } + => _ = ReloadCurrentVisibleRangeAsync(); [RelayCommand(CanExecute = nameof(CanJoinOnline))] private async Task JoinOnlineAsync() { - if (DisplayDetailsCalendarItemViewModel == null || string.IsNullOrEmpty(DisplayDetailsCalendarItemViewModel.CalendarItem.HtmlLink)) return; + if (DisplayDetailsCalendarItemViewModel == null || string.IsNullOrEmpty(DisplayDetailsCalendarItemViewModel.CalendarItem.HtmlLink)) + return; await _nativeAppService.LaunchUriAsync(new Uri(DisplayDetailsCalendarItemViewModel.CalendarItem.HtmlLink)); } - // TODO: Replace when calendar settings are updated. - // Should be a field ideally. - private BaseCalendarTypeDrawingStrategy GetDrawingStrategy(CalendarDisplayType displayType) - { - return displayType switch - { - CalendarDisplayType.Day => new DayCalendarDrawingStrategy(CurrentSettings), - CalendarDisplayType.Week => new WeekCalendarDrawingStrategy(CurrentSettings), - CalendarDisplayType.Month => new MonthCalendarDrawingStrategy(CurrentSettings), - _ => throw new NotImplementedException(), - }; - } - public override void OnNavigatedTo(NavigationMode mode, object parameters) { ResetPageLifetime(); @@ -316,15 +271,8 @@ public partial class CalendarPageViewModel : CalendarBaseViewModel, RefreshSettings(); IsCalendarEnabled = true; - if (mode == NavigationMode.Back && DayRanges.Count > 0) - { - RestoreVisibleState(); - _ = RefreshVisibleRangesAsync(); - return; - } - - // Automatically select the first primary calendar for quick event dialog. - SelectedQuickEventAccountCalendar = AccountCalendarStateService.ActiveCalendars.FirstOrDefault(a => a.IsPrimary); + SelectedQuickEventAccountCalendar = AccountCalendarStateService.ActiveCalendars.FirstOrDefault(a => a.IsPrimary) + ?? AccountCalendarStateService.ActiveCalendars.FirstOrDefault(); } public override void OnNavigatedFrom(NavigationMode mode, object parameters) @@ -364,15 +312,15 @@ public partial class CalendarPageViewModel : CalendarBaseViewModel, private void ReleasePageState() { DetachSubscriptions(); - DisplayDetailsCalendarItemViewModel = null; SelectedQuickEventAccountCalendar = null; SelectedQuickEventDate = null; - SelectedDayRange = null; - SelectedDateRangeIndex = 0; IsQuickEventDialogOpen = false; - DayRanges = []; HourSelectionStrings = []; + CurrentVisibleRange = null; + VisibleDateRangeText = string.Empty; + LoadedDateWindow = null; + _loadedCalendarItems = []; } public void Dispose() @@ -387,67 +335,16 @@ public partial class CalendarPageViewModel : CalendarBaseViewModel, GC.SuppressFinalize(this); } - public bool RestoreVisibleState() - { - IsCalendarEnabled = true; - - if (DayRanges.Count == 0) - { - SelectedDayRange = null; - SelectedDateRangeIndex = -1; - return false; - } - - var targetIndex = SelectedDateRangeIndex; - - if (SelectedDayRange != null) - { - var existingSelectedRangeIndex = DayRanges.IndexOf(SelectedDayRange); - if (existingSelectedRangeIndex >= 0) - { - targetIndex = existingSelectedRangeIndex; - } - } - - if (targetIndex < 0 || targetIndex >= DayRanges.Count) - { - targetIndex = 0; - } - - SelectedDateRangeIndex = targetIndex; - SelectedDayRange = DayRanges[targetIndex]; - - return true; - } + public bool RestoreVisibleState() => CurrentVisibleRange != null; public DateTime GetRestoreDate() - { - if (SelectedDayRange != null) - { - return SelectedDayRange.CalendarRenderOptions.DateRange.StartDate; - } - - if (DayRanges.Count == 0) - { - return DateTime.Now.Date; - } - - var targetIndex = SelectedDateRangeIndex; - if (targetIndex < 0 || targetIndex >= DayRanges.Count) - { - targetIndex = 0; - } - - return DayRanges[targetIndex].CalendarRenderOptions.DateRange.StartDate; - } + => CurrentVisibleRange?.AnchorDate.ToDateTime(TimeOnly.MinValue) ?? DateTime.Now.Date; private long CurrentPageLifetimeVersion => Interlocked.Read(ref _pageLifetimeVersion); private bool IsPageActive(long lifetimeVersion) => lifetimeVersion == CurrentPageLifetimeVersion && !_pageLifetimeCts.IsCancellationRequested; - private bool IsCurrentPageActive => !_pageLifetimeCts.IsCancellationRequested; - private void ResetPageLifetime() { CancelPendingOperations(); @@ -468,11 +365,9 @@ public partial class CalendarPageViewModel : CalendarBaseViewModel, if (!IsPageActive(lifetimeVersion)) return false; - var cancellationToken = _pageLifetimeCts.Token; - try { - await _calendarLoadingSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false); + await _calendarLoadingSemaphore.WaitAsync(_pageLifetimeCts.Token).ConfigureAwait(false); return IsPageActive(lifetimeVersion); } catch (OperationCanceledException) @@ -525,7 +420,8 @@ public partial class CalendarPageViewModel : CalendarBaseViewModel, [RelayCommand] private void NavigateSeries() { - if (DisplayDetailsCalendarItemViewModel == null) return; + if (DisplayDetailsCalendarItemViewModel == null) + return; NavigateEvent(DisplayDetailsCalendarItemViewModel, CalendarEventTargetType.Series); } @@ -533,7 +429,8 @@ public partial class CalendarPageViewModel : CalendarBaseViewModel, [RelayCommand] private void NavigateEventDetails() { - if (DisplayDetailsCalendarItemViewModel == null) return; + if (DisplayDetailsCalendarItemViewModel == null) + return; NavigateEvent(DisplayDetailsCalendarItemViewModel, CalendarEventTargetType.Single); } @@ -547,53 +444,43 @@ public partial class CalendarPageViewModel : CalendarBaseViewModel, [RelayCommand(AllowConcurrentExecutions = false, CanExecute = nameof(CanSaveQuickEvent))] private async Task SaveQuickEventAsync() { - try + var startDate = IsAllDay ? SelectedQuickEventDate.Value.Date : QuickEventStartTime; + var endDate = IsAllDay ? SelectedQuickEventDate.Value.Date.AddDays(1) : QuickEventEndTime; + var composeResult = new CalendarEventComposeResult { - var startDate = IsAllDay ? SelectedQuickEventDate.Value.Date : QuickEventStartTime; - var endDate = IsAllDay ? SelectedQuickEventDate.Value.Date.AddDays(1) : QuickEventEndTime; - var composeResult = new CalendarEventComposeResult - { - CalendarId = SelectedQuickEventAccountCalendar.Id, - AccountId = SelectedQuickEventAccountCalendar.Account.Id, - Title = EventName, - Location = Location ?? string.Empty, - HtmlNotes = string.Empty, - StartDate = startDate, - EndDate = endDate, - IsAllDay = IsAllDay, - TimeZoneId = TimeZoneInfo.Local.Id, - ShowAs = SelectedQuickEventAccountCalendar.DefaultShowAs, - SelectedReminders = [], - Attendees = [], - Attachments = [], - Recurrence = string.Empty, - RecurrenceSummary = string.Empty - }; + CalendarId = SelectedQuickEventAccountCalendar.Id, + AccountId = SelectedQuickEventAccountCalendar.Account.Id, + Title = EventName, + Location = Location ?? string.Empty, + HtmlNotes = string.Empty, + StartDate = startDate, + EndDate = endDate, + IsAllDay = IsAllDay, + TimeZoneId = TimeZoneInfo.Local.Id, + ShowAs = SelectedQuickEventAccountCalendar.DefaultShowAs, + SelectedReminders = [], + Attendees = [], + Attachments = [], + Recurrence = string.Empty, + RecurrenceSummary = string.Empty + }; - // Close dialog first - IsQuickEventDialogOpen = false; + IsQuickEventDialogOpen = false; - var preparationRequest = new CalendarOperationPreparationRequest( - CalendarSynchronizerOperation.CreateEvent, - ComposeResult: composeResult); - await _winoRequestDelegator.ExecuteAsync(preparationRequest); - } - catch (Exception ex) - { - Log.Error(ex, "Error creating quick event"); - // Re-open dialog if there was an error - IsQuickEventDialogOpen = true; - } + var preparationRequest = new CalendarOperationPreparationRequest( + CalendarSynchronizerOperation.CreateEvent, + ComposeResult: composeResult); + await _winoRequestDelegator.ExecuteAsync(preparationRequest); } [RelayCommand] - private void MoreDetails() + private void GoToEventComposePage() { if (SelectedQuickEventDate == null) return; - var startDate = SelectedQuickEventDate.Value.Date.AddHours(9); - var endDate = startDate.AddMinutes(30); + var startDate = SelectedQuickEventDate.Value; + var endDate = SelectedQuickEventDate.Value.AddMinutes(30); if (!IsAllDay) { @@ -632,28 +519,10 @@ public partial class CalendarPageViewModel : CalendarBaseViewModel, public void SelectQuickEventTimeRange(TimeSpan startTime, TimeSpan endTime) { IsAllDay = false; - SelectedStartTimeString = CurrentSettings.GetTimeString(startTime); SelectedEndTimeString = CurrentSettings.GetTimeString(endTime); } - // 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); - } - - if (newValue != null) - { - SelectCalendarItem(newValue); - } - } - - // 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); @@ -661,71 +530,27 @@ public partial class CalendarPageViewModel : CalendarBaseViewModel, { CurrentSettings = _preferencesService.GetCurrentCalendarSettings(); - // Populate the hour selection strings. var timeStrings = new List(); - for (int hour = 0; hour < 24; hour++) { for (int minute = 0; minute < 60; minute += 30) { var time = new DateTime(1, 1, 1, hour, minute, 0); - - if (CurrentSettings.DayHeaderDisplayType == DayHeaderDisplayType.TwentyFourHour) - { - timeStrings.Add(time.ToString("HH:mm")); - } - else - { - timeStrings.Add(time.ToString("h:mm tt")); - } + timeStrings.Add(CurrentSettings.DayHeaderDisplayType == DayHeaderDisplayType.TwentyFourHour + ? time.ToString("HH:mm") + : time.ToString("h:mm tt")); } } HourSelectionStrings = timeStrings; - } - partial void OnIsCalendarEnabledChanging(bool oldValue, bool newValue) => Messenger.Send(new CalendarEnableStatusChangedMessage(newValue)); - - private bool ShouldResetDayRanges(LoadCalendarMessage message) - { - if (message.ForceRedraw) return true; - - // Never reset if the initiative is from the app. - if (message.CalendarInitInitiative == CalendarInitInitiative.App) return false; - - // 1. Display type is different. - // 2. Day display count is different. - // 3. Display date is not in the visible range. - - if (DayRanges.DisplayRange == null) return false; - - return - (_currentDisplayType != StatePersistanceService.CalendarDisplayType || - _displayDayCount != StatePersistanceService.DayDisplayCount || - !(message.DisplayDate >= DayRanges.DisplayRange.StartDate && message.DisplayDate <= DayRanges.DisplayRange.EndDate)); - } - - 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. - - bool isRequestedVerticalCalendar = StatePersistanceService.CalendarDisplayType == CalendarDisplayType.Month; - bool isLastRenderedVerticalCalendar = _currentDisplayType == CalendarDisplayType.Month; - - if (isRequestedVerticalCalendar && !isLastRenderedVerticalCalendar) + if (CurrentVisibleRange != null) { - CalendarOrientation = CalendarOrientation.Vertical; - } - else - { - CalendarOrientation = CalendarOrientation.Horizontal; + VisibleDateRangeText = _calendarRangeTextFormatter.Format(CurrentVisibleRange, _dateContextProvider); } } - public async void Receive(LoadCalendarMessage message) + public async Task ApplyDisplayRequestAsync(CalendarDisplayRequest request, bool forceReload = false) { var lifetimeVersion = CurrentPageLifetimeVersion; var hasLoadingLock = await WaitForCalendarLoadingLockAsync(lifetimeVersion).ConfigureAwait(false); @@ -740,31 +565,40 @@ public partial class CalendarPageViewModel : CalendarBaseViewModel, if (!IsPageActive(lifetimeVersion)) return; - if (!ShouldResetDayRanges(message) && ShouldScrollToItem(message)) + RefreshSettings(); + + var today = _dateContextProvider.GetToday(); + var visibleRange = CalendarRangeResolver.Resolve(request, CurrentSettings, today); + var previousRange = CalendarRangeResolver.Navigate(visibleRange, -1, CurrentSettings, today); + var nextRange = CalendarRangeResolver.Navigate(visibleRange, 1, CurrentSettings, today); + var loadedDateWindow = new DateRange( + previousRange.StartDate.ToDateTime(TimeOnly.MinValue), + nextRange.EndDate.AddDays(1).ToDateTime(TimeOnly.MinValue)); + + var shouldReload = forceReload || !IsSameVisibleRange(CurrentVisibleRange, visibleRange) || !IsSameDateRange(LoadedDateWindow, loadedDateWindow); + + if (shouldReload) { - // Scroll to the selected date. - if (IsPageActive(lifetimeVersion)) + var loadedItems = await LoadCalendarItemsAsync(loadedDateWindow, lifetimeVersion).ConfigureAwait(false); + if (!IsPageActive(lifetimeVersion)) + return; + + await ExecuteUIThreadIfActiveAsync(lifetimeVersion, () => { - Messenger.Send(new ScrollToDateMessage(message.DisplayDate)); - } - - Debug.WriteLine("Scrolling to selected date."); - return; + _loadedCalendarItems = loadedItems; + }).ConfigureAwait(false); } - AdjustCalendarOrientation(); - - // This will replace the whole collection because the user initiated a new render. - await RenderDatesAsync(message.CalendarInitInitiative, - message.DisplayDate, - CalendarLoadDirection.Replace, - lifetimeVersion).ConfigureAwait(false); - - // Scroll to the current hour. - if (IsPageActive(lifetimeVersion)) + await ExecuteUIThreadIfActiveAsync(lifetimeVersion, () => { - Messenger.Send(new ScrollToHourMessage(TimeSpan.FromHours(DateTime.Now.Hour))); - } + CurrentVisibleRange = visibleRange; + LoadedDateWindow = loadedDateWindow; + VisibleDateRangeText = _calendarRangeTextFormatter.Format(visibleRange, _dateContextProvider); + if (DisplayDetailsCalendarItemViewModel != null && !IsCalendarActive(DisplayDetailsCalendarItemViewModel.AssignedCalendar?.Id)) + { + DisplayDetailsCalendarItemViewModel = null; + } + }).ConfigureAwait(false); } catch (OperationCanceledException) { @@ -777,391 +611,171 @@ public partial class CalendarPageViewModel : CalendarBaseViewModel, } catch (Exception ex) { - Log.Error(ex, "Error while loading calendar."); - Debugger.Break(); + Log.Error(ex, "Error while loading visible calendar range."); } finally { ReleaseCalendarLoadingLock(); - await ExecuteUIThreadIfActiveAsync(lifetimeVersion, () => IsCalendarEnabled = true).ConfigureAwait(false); } } - - private async Task AddDayRangeModelAsync(DayRangeRenderModel dayRangeRenderModel, long lifetimeVersion) + public Task ReloadCurrentVisibleRangeAsync() { - if (dayRangeRenderModel == null) return; + if (CurrentVisibleRange == null) + return Task.CompletedTask; - await ExecuteUIThreadIfActiveAsync(lifetimeVersion, () => - { - DayRanges.Add(dayRangeRenderModel); - }); + return ApplyDisplayRequestAsync(new CalendarDisplayRequest(CurrentVisibleRange.DisplayType, CurrentVisibleRange.AnchorDate), forceReload: true); } - private async Task InsertDayRangeModelAsync(DayRangeRenderModel dayRangeRenderModel, int index, long lifetimeVersion) + private async Task> LoadCalendarItemsAsync(DateRange loadedDateWindow, long lifetimeVersion) { - if (dayRangeRenderModel == null) return; - - await ExecuteUIThreadIfActiveAsync(lifetimeVersion, () => - { - DayRanges.Insert(index, dayRangeRenderModel); - }); - } - - private async Task RemoveDayRangeModelAsync(DayRangeRenderModel dayRangeRenderModel, long lifetimeVersion) - { - if (dayRangeRenderModel == null) return; - - await ExecuteUIThreadIfActiveAsync(lifetimeVersion, () => - { - DayRanges.Remove(dayRangeRenderModel); - }); - } - - private async Task ClearDayRangeModelsAsync(long lifetimeVersion) - { - await ExecuteUIThreadIfActiveAsync(lifetimeVersion, () => - { - DayRanges.Clear(); - }); - } - - private async Task ReplaceDayRangeModelsAsync(IEnumerable dayRangeRenderModels, DateTime displayDate, long lifetimeVersion) - { - var renderModels = dayRangeRenderModels?.ToList() ?? []; - - await ExecuteUIThreadIfActiveAsync(lifetimeVersion, () => - { - DayRanges.ReplaceRange(renderModels); - - if (renderModels.Count == 0) - { - SelectedDayRange = null; - SelectedDateRangeIndex = -1; - return; - } - - var selectedIndex = renderModels.FindIndex(model => - displayDate >= model.CalendarRenderOptions.DateRange.StartDate && - displayDate <= model.CalendarRenderOptions.DateRange.EndDate); - - if (selectedIndex < 0) - { - selectedIndex = 0; - } - - SelectedDateRangeIndex = selectedIndex; - SelectedDayRange = renderModels[selectedIndex]; - }); - } - - private async Task RenderDatesAsync(CalendarInitInitiative calendarInitInitiative, - DateTime? loadingDisplayDate = null, - CalendarLoadDirection calendarLoadDirection = CalendarLoadDirection.Replace, - long lifetimeVersion = 0) - { - if (!IsPageActive(lifetimeVersion)) - return; - - isLoadMoreBlocked = calendarLoadDirection == CalendarLoadDirection.Replace; - - // This is the part we arrange the flip view calendar logic. - - /* 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. - */ - - // 2 things are important: How many items should 1 flip have, and, where we should start loading? - - // User initiated renders must always have a date to start with. - if (calendarInitInitiative == CalendarInitInitiative.User) Guard.IsNotNull(loadingDisplayDate, nameof(loadingDisplayDate)); - - var strategy = GetDrawingStrategy(StatePersistanceService.CalendarDisplayType); - var displayDate = loadingDisplayDate.GetValueOrDefault(); - - // How many days should be placed in 1 flip view item? - int eachFlipItemCount = strategy.GetRenderDayCount(displayDate, StatePersistanceService.DayDisplayCount); - - DateRange flipLoadRange = null; - - - 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) - { - flipLoadRange = strategy.GetPreviousDateRange(DayRanges.DisplayRange, StatePersistanceService.DayDisplayCount); - } - else - { - flipLoadRange = strategy.GetNextDateRange(DayRanges.DisplayRange, StatePersistanceService.DayDisplayCount); - } - } - - List flipItemRanges = []; - - if (calendarLoadDirection == CalendarLoadDirection.Replace && - StatePersistanceService.CalendarDisplayType == CalendarDisplayType.Month) - { - var previousRange = strategy.GetPreviousDateRange(flipLoadRange, StatePersistanceService.DayDisplayCount); - var nextRange = strategy.GetNextDateRange(flipLoadRange, StatePersistanceService.DayDisplayCount); - - flipItemRanges.Add(previousRange); - flipItemRanges.Add(flipLoadRange); - flipItemRanges.Add(nextRange); - } - else - { - // Create day ranges for each flip item until we reach the total days to load. - int totalFlipItemCount = (int)Math.Ceiling((double)flipLoadRange.TotalDays / eachFlipItemCount); - - for (int i = 0; i < totalFlipItemCount; i++) - { - var startDate = flipLoadRange.StartDate.AddDays(i * eachFlipItemCount); - var endDate = startDate.AddDays(eachFlipItemCount); - - flipItemRanges.Add(new DateRange(startDate, endDate)); - } - } - - List renderModels = []; - - foreach (var range in flipItemRanges) - { - var renderOptions = new CalendarRenderOptions(range, CurrentSettings); - var dayRangeHeaderModel = new DayRangeRenderModel(renderOptions); - renderModels.Add(dayRangeHeaderModel); - } - - // Dates are loaded. Now load the events for them. - foreach (var renderModel in renderModels) - { - await InitializeCalendarEventsForDayRangeAsync(renderModel, lifetimeVersion).ConfigureAwait(false); - - if (!IsPageActive(lifetimeVersion)) - return; - } - - // Filter by active calendars. This is a quick operation, and things are not on the UI yet. - await FilterActiveCalendarsAsync(renderModels, lifetimeVersion).ConfigureAwait(false); - - if (!IsPageActive(lifetimeVersion)) - return; - - CalendarLoadDirection animationDirection = calendarLoadDirection; - - //bool removeCurrent = calendarLoadDirection == CalendarLoadDirection.Replace; - - if (calendarLoadDirection == CalendarLoadDirection.Replace) - { - isLoadMoreBlocked = true; - await ReplaceDayRangeModelsAsync(renderModels, displayDate, lifetimeVersion).ConfigureAwait(false); - isLoadMoreBlocked = false; - - if (calendarInitInitiative == CalendarInitInitiative.User) - { - _currentDisplayType = StatePersistanceService.CalendarDisplayType; - _displayDayCount = StatePersistanceService.DayDisplayCount; - - Messenger.Send(new ScrollToDateMessage(displayDate)); - } - - return; - } - - if (animationDirection == CalendarLoadDirection.Next) - { - foreach (var item in renderModels) - { - await AddDayRangeModelAsync(item, lifetimeVersion); - } - } - else if (animationDirection == CalendarLoadDirection.Previous) - { - // Wait for the animation to finish. - // Otherwise it somehow shutters a little, which is annoying. - - // if (!removeCurrent) await Task.Delay(350); - - // Insert each render model in reverse order. - for (int i = renderModels.Count - 1; i >= 0; i--) - { - await InsertDayRangeModelAsync(renderModels[i], 0, lifetimeVersion); - } - } - - Debug.WriteLine($"Flip count: ({DayRanges.Count})"); - - foreach (var item in DayRanges) - { - Debug.WriteLine($"- {item.CalendarRenderOptions.DateRange.ToString()}"); - } - - //if (removeCurrent) - //{ - // await RemoveDayRangeModelAsync(SelectedDayRange); - //} - - // TODO... - // await TryConsolidateItemsAsync(); - - isLoadMoreBlocked = false; - - // Only scroll if the render is initiated by user. - // Otherwise we'll scroll to the app rendered invisible date range. - if (calendarInitInitiative == CalendarInitInitiative.User) - { - // Save the current settings for the page for later comparison. - _currentDisplayType = StatePersistanceService.CalendarDisplayType; - _displayDayCount = StatePersistanceService.DayDisplayCount; - - Messenger.Send(new ScrollToDateMessage(displayDate)); - } - } - - private async Task InitializeCalendarEventsForDayRangeAsync(DayRangeRenderModel dayRangeRenderModel, long lifetimeVersion) - { - if (!IsPageActive(lifetimeVersion)) - return; - - // Clear all events first for all days. - foreach (var day in dayRangeRenderModel.CalendarDays) - { - await ExecuteUIThreadIfActiveAsync(lifetimeVersion, () => - { - day.EventsCollection.Clear(); - }); - } - - // 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 loadedItems = new Dictionary(); + var loadPeriod = new TimeRange(loadedDateWindow.StartDate, loadedDateWindow.EndDate); foreach (var calendarViewModel in AccountCalendarStateService.AllCalendars) { if (!IsPageActive(lifetimeVersion)) - return; + return []; - // 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); - - foreach (var @event in events) + var events = await _calendarService.GetCalendarEventsAsync(calendarViewModel, loadPeriod).ConfigureAwait(false); + foreach (var calendarItem in events) { - if (!IsPageActive(lifetimeVersion)) - return; + if (calendarItem.IsRecurringParent || calendarItem.IsHidden) + continue; - // Find the days that the event falls into. - var allDaysForEvent = dayRangeRenderModel.CalendarDays.Where(a => a.Period.OverlapsWith(@event.Period)); + calendarItem.AssignedCalendar ??= calendarViewModel; - foreach (var calendarDay in allDaysForEvent) + if (!loadedItems.ContainsKey(calendarItem.Id)) { - var calendarItemViewModel = new CalendarItemViewModel(@event); - await ExecuteUIThreadIfActiveAsync(lifetimeVersion, () => - { - calendarDay.EventsCollection.AddCalendarItem(calendarItemViewModel); - }); + loadedItems.Add(calendarItem.Id, new CalendarItemViewModel(calendarItem)); } } } + + return loadedItems.Values.ToList(); } - private async Task RefreshVisibleRangesAsync() + private static bool IsSameVisibleRange(VisibleDateRange current, VisibleDateRange next) { - var lifetimeVersion = CurrentPageLifetimeVersion; - var hasLoadingLock = false; + if (current == null && next == null) + return true; - try - { - hasLoadingLock = await WaitForCalendarLoadingLockAsync(lifetimeVersion).ConfigureAwait(false); + if (current == null || next == null) + return false; - if (!hasLoadingLock) - return; + return current.DisplayType == next.DisplayType && + current.AnchorDate == next.AnchorDate && + current.StartDate == next.StartDate && + current.EndDate == next.EndDate; + } - if (DayRanges == null || DayRanges.Count == 0) - return; + private static bool IsSameDateRange(DateRange current, DateRange next) + { + if (current == null && next == null) + return true; - RefreshSettings(); - await RenderDatesAsync(CalendarInitInitiative.User, - GetRestoreDate(), - CalendarLoadDirection.Replace, - lifetimeVersion).ConfigureAwait(false); - } - catch (OperationCanceledException) + if (current == null || next == null) + return false; + + return current.StartDate == next.StartDate && current.EndDate == next.EndDate; + } + + private bool IsCalendarActive(Guid? calendarId) + => calendarId.HasValue && AccountCalendarStateService.ActiveCalendars.Any(calendar => calendar.Id == calendarId.Value); + + public async void Receive(LoadCalendarMessage message) + => await ApplyDisplayRequestAsync(message.DisplayRequest, message.ForceReload); + + public void Receive(CalendarSettingsUpdatedMessage message) + { + RefreshSettings(); + _ = ReloadCurrentVisibleRangeAsync(); + } + + public void Receive(CalendarItemTappedMessage message) + { + if (message.CalendarItemViewModel == null) + return; + + DisplayDetailsCalendarItemViewModel = message.CalendarItemViewModel; + } + + public void Receive(CalendarItemDoubleTappedMessage message) + => NavigateEvent(message.CalendarItemViewModel, CalendarEventTargetType.Single); + + public void Receive(CalendarItemRightTappedMessage message) + { + } + + public async void Receive(AccountRemovedMessage message) + { + if (DisplayDetailsCalendarItemViewModel?.AssignedCalendar?.AccountId == message.Account.Id) { + DisplayDetailsCalendarItemViewModel = null; } - catch (COMException) when (!IsPageActive(lifetimeVersion)) + + SelectedQuickEventAccountCalendar = AccountCalendarStateService.ActiveCalendars.FirstOrDefault(a => a.IsPrimary) + ?? AccountCalendarStateService.ActiveCalendars.FirstOrDefault(); + + await ReloadCurrentVisibleRangeAsync().ConfigureAwait(false); + } + + protected override void OnCalendarItemDeleted(CalendarItem calendarItem) + { + base.OnCalendarItemDeleted(calendarItem); + + if (DisplayDetailsCalendarItemViewModel?.Id == calendarItem.Id || + DisplayDetailsCalendarItemViewModel?.CalendarItem?.RecurringCalendarItemId == calendarItem.Id) { + DisplayDetailsCalendarItemViewModel = null; } - catch (ObjectDisposedException) when (!IsPageActive(lifetimeVersion)) + + if (ShouldReloadFor(calendarItem)) { - } - catch (Exception ex) - { - Log.Error(ex, "Failed to refresh calendar ranges after navigation back."); - } - finally - { - if (hasLoadingLock) - { - ReleaseCalendarLoadingLock(); - } + _ = ReloadCurrentVisibleRangeAsync(); } } - private async Task TryConsolidateItemsAsync() + protected override void OnCalendarItemUpdated(CalendarItem calendarItem, CalendarItemUpdateSource source) { - // Check if trimming is necessary - if (DayRanges.Count > maxDayRangeSize) + base.OnCalendarItemUpdated(calendarItem, source); + + if (DisplayDetailsCalendarItemViewModel?.Id == calendarItem.Id) { - Debug.WriteLine("Trimming items."); + calendarItem.AssignedCalendar ??= DisplayDetailsCalendarItemViewModel.AssignedCalendar; + DisplayDetailsCalendarItemViewModel = new CalendarItemViewModel(calendarItem); + } - isLoadMoreBlocked = true; - - var removeCount = DayRanges.Count - maxDayRangeSize; - - await Task.Delay(500); - - // 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)); - } - - SelectedDateRangeIndex = DayRanges.IndexOf(SelectedDayRange); + if (ShouldReloadFor(calendarItem)) + { + _ = ReloadCurrentVisibleRangeAsync(); } } - private bool ShouldScrollToItem(LoadCalendarMessage message) + protected override void OnCalendarItemAdded(CalendarItem calendarItem) { - // Never scroll if the initiative is from the app. - if (message.CalendarInitInitiative == CalendarInitInitiative.App) return false; + base.OnCalendarItemAdded(calendarItem); - // Nothing to scroll. - if (DayRanges.Count == 0) return false; + if (calendarItem.IsRecurringParent) + { + _ = ReloadCurrentVisibleRangeAsync(); + return; + } - if (DayRanges.DisplayRange == null) return false; + if (ShouldReloadFor(calendarItem)) + { + _ = ReloadCurrentVisibleRangeAsync(); + } + } - var selectedDate = message.DisplayDate; + private bool ShouldReloadFor(CalendarItem calendarItem) + { + if (calendarItem == null || LoadedDateWindow == null) + return false; - return selectedDate >= DayRanges.DisplayRange.StartDate && selectedDate <= DayRanges.DisplayRange.EndDate; + var loadedWindow = new TimeRange(LoadedDateWindow.StartDate, LoadedDateWindow.EndDate); + return loadedWindow.OverlapsWith(calendarItem.Period); } partial void OnIsAllDayChanged(bool value) @@ -1207,439 +821,4 @@ public partial class CalendarPageViewModel : CalendarBaseViewModel, _previousSelectedEndTimeString = newValue; } } - - partial void OnSelectedDayRangeChanged(DayRangeRenderModel value) - { - DisplayDetailsCalendarItemViewModel = null; - - if (DayRanges.Count == 0 || SelectedDateRangeIndex < 0) return; - - var selectedRange = DayRanges[SelectedDateRangeIndex]; - - Messenger.Send(new VisibleDateRangeChangedMessage(new DateRange(selectedRange.Period.Start, selectedRange.Period.End))); - - if (isLoadMoreBlocked) return; - - _ = LoadMoreAsync(); - } - - private async Task LoadMoreAsync() - { - var lifetimeVersion = CurrentPageLifetimeVersion; - var hasLoadingLock = false; - - try - { - hasLoadingLock = await WaitForCalendarLoadingLockAsync(lifetimeVersion).ConfigureAwait(false); - - if (!hasLoadingLock) - return; - - // 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. - - if (SelectedDateRangeIndex == 0) - { - await RenderDatesAsync(CalendarInitInitiative.App, calendarLoadDirection: CalendarLoadDirection.Previous, lifetimeVersion: lifetimeVersion); - } - else if (SelectedDateRangeIndex == DayRanges.Count - 1) - { - await RenderDatesAsync(CalendarInitInitiative.App, calendarLoadDirection: CalendarLoadDirection.Next, lifetimeVersion: lifetimeVersion); - } - } - catch (OperationCanceledException) - { - } - catch (COMException) when (!IsPageActive(lifetimeVersion)) - { - } - catch (ObjectDisposedException) when (!IsPageActive(lifetimeVersion)) - { - } - catch (Exception ex) - { - Debug.WriteLine(ex); - Debugger.Break(); - } - finally - { - if (hasLoadingLock) - { - ReleaseCalendarLoadingLock(); - } - } - } - - public void Receive(CalendarSettingsUpdatedMessage message) - { - RefreshSettings(); - - // 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)); - } - - private IEnumerable GetCalendarItems(CalendarItemViewModel calendarItemViewModel, CalendarDayModel selectedDay) - { - // 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. - - if (calendarItemViewModel.IsRecurringEvent || calendarItemViewModel.IsMultiDayEvent) - { - return DayRanges - .SelectMany(a => a.CalendarDays) - .Select(b => b.EventsCollection.GetCalendarItem(calendarItemViewModel.Id)) - .Where(c => c != null) - .Cast() - .Distinct(); - } - else - { - // Single-day, non-recurring events only appear once - return [calendarItemViewModel]; - } - } - - private void UnselectCalendarItem(CalendarItemViewModel calendarItemViewModel, CalendarDayModel calendarDay = null) - { - if (calendarItemViewModel == null) return; - - var itemsToUnselect = GetCalendarItems(calendarItemViewModel, calendarDay); - - foreach (var item in itemsToUnselect) - { - item.IsSelected = false; - } - } - - private void SelectCalendarItem(CalendarItemViewModel calendarItemViewModel, CalendarDayModel calendarDay = null) - { - if (calendarItemViewModel == null) return; - - var itemsToSelect = GetCalendarItems(calendarItemViewModel, calendarDay); - - foreach (var item in itemsToSelect) - { - item.IsSelected = true; - } - } - - 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() - .Distinct(); - - foreach (var item in calendarItems) - { - item.IsBusy = isBusy; - } - } - - 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() - .FirstOrDefault(vm => vm.IsBusy && - vm.Id == clientItemId && - vm.AssignedCalendar?.Id == syncedItem.CalendarId); - } - - private static bool TryExtractClientItemIdFromRemoteEventId(string remoteEventId, out Guid clientItemId) - { - var trackingId = remoteEventId.GetClientTrackingId(); - clientItemId = trackingId ?? Guid.Empty; - return trackingId.HasValue; - } - - 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); - } - } - } - } - - public void Receive(CalendarItemTappedMessage message) - { - if (message.CalendarItemViewModel == null) return; - - DisplayDetailsCalendarItemViewModel = message.CalendarItemViewModel; - } - - public void Receive(CalendarItemDoubleTappedMessage message) => NavigateEvent(message.CalendarItemViewModel, CalendarEventTargetType.Single); - - public void Receive(CalendarItemRightTappedMessage message) { } - - public async void Receive(AccountRemovedMessage message) - { - var lifetimeVersion = CurrentPageLifetimeVersion; - - if (!IsPageActive(lifetimeVersion)) - return; - - var removedAccountId = message.Account.Id; - - await ExecuteUIThreadIfActiveAsync(lifetimeVersion, () => - { - 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); - }); - } - - protected override async void OnCalendarItemDeleted(CalendarItem calendarItem) - { - base.OnCalendarItemDeleted(calendarItem); - var lifetimeVersion = CurrentPageLifetimeVersion; - - Debug.WriteLine($"Calendar item deleted: {calendarItem.Id}"); - - // 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) - { - // Clear the details view since this item was deleted - DisplayDetailsCalendarItemViewModel = null; - } - - // Remove the event and its occurrences from all visible date ranges. - await ExecuteUIThreadIfActiveAsync(lifetimeVersion, () => - { - 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)); - } - } - }); - } - - protected override async void OnCalendarItemUpdated(CalendarItem calendarItem, CalendarItemUpdateSource source) - { - base.OnCalendarItemUpdated(calendarItem, source); - var lifetimeVersion = CurrentPageLifetimeVersion; - 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; - } - - // 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(); - - await ExecuteUIThreadIfActiveAsync(lifetimeVersion, () => - { - if (source == CalendarItemUpdateSource.ClientUpdated) - { - UpdateCalendarItemBusyState(calendarItem.Id, true); - } - else if (source == CalendarItemUpdateSource.ClientReverted || source == CalendarItemUpdateSource.Server) - { - UpdateCalendarItemBusyState(calendarItem.Id, false); - } - - // Update existing items in-place where the item should remain - foreach (var calendarDay in currentDaysWithItem) - { - if (targetDaysForItem.Contains(calendarDay)) - { - // 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; - } - } - } - else - { - // Item should no longer be in this day (time changed) - remove it - var existingItem = calendarDay.EventsCollection.GetCalendarItem(calendarItem.Id); - if (existingItem != null) - { - calendarDay.EventsCollection.RemoveCalendarItem(existingItem); - } - } - } - - // Add to new days where the item wasn't present before - foreach (var calendarDay in targetDaysForItem) - { - if (!currentDaysWithItem.Contains(calendarDay)) - { - var calendarItemViewModel = new CalendarItemViewModel(calendarItem); - calendarDay.EventsCollection.AddCalendarItem(calendarItemViewModel); - } - } - }); - - await FilterActiveCalendarsAsync(DayRanges).ConfigureAwait(false); - } - - protected override async void OnCalendarItemAdded(CalendarItem calendarItem) - { - base.OnCalendarItemAdded(calendarItem); - var lifetimeVersion = CurrentPageLifetimeVersion; - Debug.WriteLine($"Calendar item added: {calendarItem.Id}"); - - // 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. - if (calendarItem.IsRecurringParent) - { - Debug.WriteLine($"Skipping series master event: {calendarItem.Title}"); - await RestoreVisibleRecurringSeriesInstancesAsync(calendarItem); - return; - } - - // Check if event falls into the current date range. - if (DayRanges.DisplayRange == null) return; - - // 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. - if (!string.IsNullOrEmpty(calendarItem.RemoteEventId)) - { - var pendingMatch = FindPendingBusyMatchByRemoteEventId(calendarItem); - - if (pendingMatch != null) - { - Debug.WriteLine($"Mapped pending busy item {pendingMatch.Id} with synced server event {calendarItem.Id}."); - - await ExecuteUIThreadIfActiveAsync(lifetimeVersion, () => - { - RemoveCalendarItemEverywhere(pendingMatch.Id); - }); - } - } - - // Get all periods from the visible day ranges - // 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)); - - foreach (var calendarDay in allDaysForEvent) - { - var calendarItemViewModel = new CalendarItemViewModel(calendarItem) - { - IsBusy = string.IsNullOrEmpty(calendarItem.RemoteEventId) - }; - - await ExecuteUIThreadIfActiveAsync(lifetimeVersion, () => - { - calendarDay.EventsCollection.AddCalendarItem(calendarItemViewModel); - }); - } - - await FilterActiveCalendarsAsync(DayRanges).ConfigureAwait(false); - } - - private async Task RestoreVisibleRecurringSeriesInstancesAsync(CalendarItem recurringParent) - { - var lifetimeVersion = CurrentPageLifetimeVersion; - - 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 ExecuteUIThreadIfActiveAsync(lifetimeVersion, () => - { - 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) - }); - } - } - }); - - await FilterActiveCalendarsAsync(DayRanges).ConfigureAwait(false); - } } diff --git a/Wino.Core.Domain/Enums/CalendarInitInitiative.cs b/Wino.Core.Domain/Enums/CalendarInitInitiative.cs deleted file mode 100644 index d5e08a76..00000000 --- a/Wino.Core.Domain/Enums/CalendarInitInitiative.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace Wino.Core.Domain.Enums; - -/// -/// Trigger to load more data. -/// -public enum CalendarInitInitiative -{ - User, - App -} diff --git a/Wino.Core.Domain/Interfaces/IShellClient.cs b/Wino.Core.Domain/Interfaces/IShellClient.cs index 4d408f16..db5f6483 100644 --- a/Wino.Core.Domain/Interfaces/IShellClient.cs +++ b/Wino.Core.Domain/Interfaces/IShellClient.cs @@ -49,9 +49,12 @@ public interface ICalendarShellClient : IShellClient IStatePersistanceService StatePersistenceService { get; } IEnumerable DateNavigationHeaderItems { get; } int SelectedDateNavigationHeaderIndex { get; } - DateRange? HighlightedDateRange { get; } + VisibleDateRange? CurrentVisibleRange { get; } + string VisibleDateRangeText { get; } ICommand TodayClickedCommand { get; } ICommand DateClickedCommand { get; } + ICommand PreviousDateRangeCommand { get; } + ICommand NextDateRangeCommand { get; } IEnumerable GroupedAccountCalendars { get; } } diff --git a/Wino.Core.Domain/Models/Calendar/CalendarDisplayRequest.cs b/Wino.Core.Domain/Models/Calendar/CalendarDisplayRequest.cs new file mode 100644 index 00000000..bc172fae --- /dev/null +++ b/Wino.Core.Domain/Models/Calendar/CalendarDisplayRequest.cs @@ -0,0 +1,6 @@ +using System; +using Wino.Core.Domain.Enums; + +namespace Wino.Core.Domain.Models.Calendar; + +public readonly record struct CalendarDisplayRequest(CalendarDisplayType DisplayType, DateOnly AnchorDate); diff --git a/Wino.Core.Domain/Models/Calendar/CalendarRangeResolver.cs b/Wino.Core.Domain/Models/Calendar/CalendarRangeResolver.cs new file mode 100644 index 00000000..1c20bd79 --- /dev/null +++ b/Wino.Core.Domain/Models/Calendar/CalendarRangeResolver.cs @@ -0,0 +1,103 @@ +using System; +using System.Linq; +using Wino.Core.Domain.Enums; + +namespace Wino.Core.Domain.Models.Calendar; + +public static class CalendarRangeResolver +{ + public static VisibleDateRange Resolve(CalendarDisplayRequest request, CalendarSettings settings, DateOnly today) + { + var startDate = GetStartDate(request.DisplayType, request.AnchorDate, settings); + var endDate = GetEndDate(request.DisplayType, request.AnchorDate, startDate, settings); + var dayCount = endDate.DayNumber - startDate.DayNumber + 1; + var dates = Enumerable.Range(0, dayCount) + .Select(offset => startDate.AddDays(offset)) + .ToArray(); + + return new VisibleDateRange( + request.DisplayType, + request.AnchorDate, + startDate, + endDate, + request.AnchorDate, + dayCount, + today >= startDate && today <= endDate, + startDate.Year == endDate.Year && startDate.Month == endDate.Month, + dates); + } + + public static VisibleDateRange ChangeDisplayType(VisibleDateRange currentRange, CalendarDisplayType targetDisplayType, CalendarSettings settings, DateOnly today) + { + if (currentRange.DisplayType == targetDisplayType) + { + return currentRange; + } + + var anchorDate = currentRange.AnchorDate; + + if (currentRange.DisplayType == CalendarDisplayType.Month) + { + anchorDate = currentRange.Contains(today) ? today : currentRange.StartDate; + } + + return Resolve(new CalendarDisplayRequest(targetDisplayType, anchorDate), settings, today); + } + + public static VisibleDateRange Navigate(VisibleDateRange currentRange, int direction, CalendarSettings settings, DateOnly today) + { + if (direction == 0) + { + return currentRange; + } + + var normalizedDirection = Math.Sign(direction); + var anchorDate = currentRange.DisplayType switch + { + CalendarDisplayType.Day => currentRange.AnchorDate.AddDays(normalizedDirection), + CalendarDisplayType.Week => currentRange.AnchorDate.AddDays(7 * normalizedDirection), + CalendarDisplayType.WorkWeek => currentRange.AnchorDate.AddDays(7 * normalizedDirection), + CalendarDisplayType.Month => currentRange.AnchorDate.AddMonths(normalizedDirection), + _ => currentRange.AnchorDate + }; + + return Resolve(new CalendarDisplayRequest(currentRange.DisplayType, anchorDate), settings, today); + } + + private static DateOnly GetStartDate(CalendarDisplayType displayType, DateOnly anchorDate, CalendarSettings settings) + { + return displayType switch + { + CalendarDisplayType.Day => anchorDate, + CalendarDisplayType.Week => GetStartOfWeek(anchorDate, settings.FirstDayOfWeek), + CalendarDisplayType.WorkWeek => GetStartOfWorkWeek(anchorDate, settings), + CalendarDisplayType.Month => new DateOnly(anchorDate.Year, anchorDate.Month, 1), + _ => anchorDate + }; + } + + private static DateOnly GetEndDate(CalendarDisplayType displayType, DateOnly anchorDate, DateOnly startDate, CalendarSettings settings) + { + return displayType switch + { + CalendarDisplayType.Day => anchorDate, + CalendarDisplayType.Week => startDate.AddDays(6), + CalendarDisplayType.WorkWeek => startDate.AddDays(settings.WorkWeekDayCount - 1), + CalendarDisplayType.Month => new DateOnly(anchorDate.Year, anchorDate.Month, DateTime.DaysInMonth(anchorDate.Year, anchorDate.Month)), + _ => anchorDate + }; + } + + private static DateOnly GetStartOfWeek(DateOnly date, DayOfWeek firstDayOfWeek) + { + var offset = ((int)date.DayOfWeek - (int)firstDayOfWeek + 7) % 7; + return date.AddDays(-offset); + } + + private static DateOnly GetStartOfWorkWeek(DateOnly anchorDate, CalendarSettings settings) + { + var startOfWeek = GetStartOfWeek(anchorDate, settings.FirstDayOfWeek); + var offsetToWorkWeekStart = settings.GetWeekOffset(settings.WorkWeekStart); + return startOfWeek.AddDays(offsetToWorkWeekStart); + } +} diff --git a/Wino.Core.Domain/Models/Calendar/CalendarRangeTextFormatter.cs b/Wino.Core.Domain/Models/Calendar/CalendarRangeTextFormatter.cs new file mode 100644 index 00000000..055c8ffc --- /dev/null +++ b/Wino.Core.Domain/Models/Calendar/CalendarRangeTextFormatter.cs @@ -0,0 +1,24 @@ +using System; +using System.Globalization; +using Wino.Core.Domain.Enums; + +namespace Wino.Core.Domain.Models.Calendar; + +public sealed class CalendarRangeTextFormatter : ICalendarRangeTextFormatter +{ + public string Format(VisibleDateRange range, IDateContextProvider dateContextProvider) + { + var culture = dateContextProvider.Culture; + var startText = FormatDate(range.StartDate, culture); + + if (range.DisplayType == CalendarDisplayType.Day) + { + return startText; + } + + return $"{startText} - {FormatDate(range.EndDate, culture)}"; + } + + private static string FormatDate(DateOnly date, CultureInfo culture) + => date.ToString(culture.DateTimeFormat.ShortDatePattern, culture); +} diff --git a/Wino.Core.Domain/Models/Calendar/CalendarSettings.cs b/Wino.Core.Domain/Models/Calendar/CalendarSettings.cs index 573654c1..034fcf97 100644 --- a/Wino.Core.Domain/Models/Calendar/CalendarSettings.cs +++ b/Wino.Core.Domain/Models/Calendar/CalendarSettings.cs @@ -7,12 +7,33 @@ namespace Wino.Core.Domain.Models.Calendar; public record CalendarSettings(DayOfWeek FirstDayOfWeek, List WorkingDays, + DayOfWeek WorkWeekStart, + DayOfWeek WorkWeekEnd, TimeSpan WorkingHourStart, TimeSpan WorkingHourEnd, double HourHeight, DayHeaderDisplayType DayHeaderDisplayType, CultureInfo CultureInfo) { + public int WorkWeekDayCount + { + get + { + var startOffset = GetWeekOffset(WorkWeekStart); + var endOffset = GetWeekOffset(WorkWeekEnd); + + if (endOffset < startOffset) + { + endOffset += 7; + } + + return (endOffset - startOffset) + 1; + } + } + + public int GetWeekOffset(DayOfWeek dayOfWeek) + => ((int)dayOfWeek - (int)FirstDayOfWeek + 7) % 7; + public TimeSpan? GetTimeSpan(string selectedTime) { // Regardless of the format, we need to parse the time to a TimeSpan. diff --git a/Wino.Core.Domain/Models/Calendar/ICalendarRangeTextFormatter.cs b/Wino.Core.Domain/Models/Calendar/ICalendarRangeTextFormatter.cs new file mode 100644 index 00000000..78433f04 --- /dev/null +++ b/Wino.Core.Domain/Models/Calendar/ICalendarRangeTextFormatter.cs @@ -0,0 +1,6 @@ +namespace Wino.Core.Domain.Models.Calendar; + +public interface ICalendarRangeTextFormatter +{ + string Format(VisibleDateRange range, IDateContextProvider dateContextProvider); +} diff --git a/Wino.Core.Domain/Models/Calendar/IDateContextProvider.cs b/Wino.Core.Domain/Models/Calendar/IDateContextProvider.cs new file mode 100644 index 00000000..049d622d --- /dev/null +++ b/Wino.Core.Domain/Models/Calendar/IDateContextProvider.cs @@ -0,0 +1,11 @@ +using System; +using System.Globalization; + +namespace Wino.Core.Domain.Models.Calendar; + +public interface IDateContextProvider +{ + CultureInfo Culture { get; } + TimeZoneInfo TimeZone { get; } + DateOnly GetToday(); +} diff --git a/Wino.Core.Domain/Models/Calendar/SystemDateContextProvider.cs b/Wino.Core.Domain/Models/Calendar/SystemDateContextProvider.cs new file mode 100644 index 00000000..97bdc77d --- /dev/null +++ b/Wino.Core.Domain/Models/Calendar/SystemDateContextProvider.cs @@ -0,0 +1,17 @@ +using System; +using System.Globalization; + +namespace Wino.Core.Domain.Models.Calendar; + +public sealed class SystemDateContextProvider : IDateContextProvider +{ + public CultureInfo Culture => CultureInfo.CurrentCulture; + + public TimeZoneInfo TimeZone => TimeZoneInfo.Local; + + public DateOnly GetToday() + { + var localNow = TimeZoneInfo.ConvertTime(DateTimeOffset.UtcNow, TimeZone); + return DateOnly.FromDateTime(localNow.DateTime); + } +} diff --git a/Wino.Core.Domain/Models/Calendar/VisibleDateRange.cs b/Wino.Core.Domain/Models/Calendar/VisibleDateRange.cs new file mode 100644 index 00000000..d218f260 --- /dev/null +++ b/Wino.Core.Domain/Models/Calendar/VisibleDateRange.cs @@ -0,0 +1,52 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Itenso.TimePeriod; +using Wino.Core.Domain.Enums; + +namespace Wino.Core.Domain.Models.Calendar; + +public sealed record VisibleDateRange( + CalendarDisplayType DisplayType, + DateOnly AnchorDate, + DateOnly StartDate, + DateOnly EndDate, + DateOnly PrimaryDate, + int DayCount, + bool ContainsToday, + bool SpansSingleMonth, + IReadOnlyList Dates) +{ + public DateRange ToDateRangeExclusive() + => new(StartDate.ToDateTime(TimeOnly.MinValue), EndDate.AddDays(1).ToDateTime(TimeOnly.MinValue)); + + public ITimePeriod ToTimePeriod() + => new TimeRange(StartDate.ToDateTime(TimeOnly.MinValue), EndDate.AddDays(1).ToDateTime(TimeOnly.MinValue)); + + public bool Contains(DateOnly date) + => date >= StartDate && date <= EndDate; + + public bool Contains(DateTime date) + => Contains(DateOnly.FromDateTime(date)); + + public static VisibleDateRange FromDateRange(CalendarDisplayType displayType, DateRange dateRange, DateOnly anchorDate, DateOnly today) + { + var startDate = DateOnly.FromDateTime(dateRange.StartDate); + var endDate = DateOnly.FromDateTime(dateRange.EndDate.AddDays(-1)); + var dayCount = endDate.DayNumber - startDate.DayNumber + 1; + var dates = Enumerable.Range(0, dayCount) + .Select(offset => startDate.AddDays(offset)) + .ToArray(); + + return new VisibleDateRange( + displayType, + anchorDate, + startDate, + endDate, + anchorDate, + dayCount, + today >= startDate && today <= endDate, + startDate.Year == endDate.Year && startDate.Month == endDate.Month, + dates); + } +} diff --git a/Wino.Core.Domain/Models/Settings/SettingsNavigationItemInfo.cs b/Wino.Core.Domain/Models/Settings/SettingsNavigationItemInfo.cs index e77b7fdf..dbcbdc4c 100644 --- a/Wino.Core.Domain/Models/Settings/SettingsNavigationItemInfo.cs +++ b/Wino.Core.Domain/Models/Settings/SettingsNavigationItemInfo.cs @@ -40,7 +40,7 @@ public static class SettingsNavigationInfoProvider Translator.WinoAccount_SettingsSection_Title, Translator.WinoAccount_SettingsSection_Description, "\uE77B", - searchKeywords: Translator.SettingsSearch_WinoAccount_Keywords), + searchKeywords: string.Empty), new(null, Translator.SettingsOptions_GeneralSection, string.Empty, "\uE713", isSeparator: true), new(WinoPage.AppPreferencesPage, Translator.SettingsAppPreferences_Title, diff --git a/Wino.Core.Domain/Translations/en_US/resources.json b/Wino.Core.Domain/Translations/en_US/resources.json index df1a8873..a4271cff 100644 --- a/Wino.Core.Domain/Translations/en_US/resources.json +++ b/Wino.Core.Domain/Translations/en_US/resources.json @@ -18,6 +18,7 @@ "AccountCreationDialog_Initializing": "initializing", "AccountCreationDialog_PreparingFolders": "We are getting folder information at the moment.", "AccountCreationDialog_SigninIn": "Account information is being saved.", + "Purchased": "Purchased", "AccountEditDialog_Message": "Account Name", "AccountEditDialog_Title": "Edit Account", "AccountPickerDialog_Title": "Pick an account", @@ -1146,6 +1147,56 @@ "WelcomeWindow_SkipForNow": "Skip for now — I'll set it up later", "WelcomeWindow_AppDescription": "A fast, focused inbox — redesigned for Windows 11", "WelcomeWizard_Step1Title": "Welcome", + "SystemTrayMenu_Open": "Open", + "WinoAccount_Titlebar_SyncBenefitTitle": "Sync settings", + "WinoAccount_Titlebar_SyncBenefitDescription": "Keep your Wino preferences in sync across devices.", + "WinoAccount_Titlebar_AddonsBenefitTitle": "Unlock add-ons", + "WinoAccount_Titlebar_AddonsBenefitDescription": "Access premium features like Wino AI Pack.", + "WinoAccount_Management_Description": "Manage your Wino Account, AI Pack access, and synchronized settings.", + "WinoAccount_Management_SignedOutTitle": "Sign in to Wino Mail", + "WinoAccount_Management_SignedOutDescription": "Sign in or create an account to sync your email, access AI features, and manage your settings across devices.", + "WinoAccount_Management_ProfileSectionHeader": "Profile", + "WinoAccount_Management_AddOnsSectionHeader": "Wino Add-Ons", + "WinoAccount_Management_DataSectionHeader": "Data", + "WinoAccount_Management_AccountActionsSectionHeader": "Account actions", + "WinoAccount_Management_AccountCardTitle": "Account", + "WinoAccount_Management_AccountCardDescription": "Your Wino Account email address and current account state.", + "WinoAccount_Management_AiPackCardTitle": "AI Pack", + "WinoAccount_Management_AiPackCardDescription": "See whether Wino AI Pack is active and how much usage is left.", + "WinoAccount_Management_AiPackActive": "AI Pack is active", + "WinoAccount_Management_AiPackInactive": "AI Pack is not active", + "WinoAccount_Management_AiPackUsage": "{0} of {1} uses consumed. {2} remaining.", + "WinoAccount_Management_AiPackBillingPeriod": "Billing period: {0:d} - {1:d}", + "WinoAccount_Management_AiPackUnknownUsage": "Usage details are not available yet.", + "WinoAccount_Management_AiPackBuyDescription": "Buy Wino AI Pack to translate, rewrite or summarize emails with AI.", + "WinoAccount_Management_AiPackPromoTitle": "Unlock AI Pack", + "WinoAccount_Management_AiPackPromoDescription": "Supercharge your email workflow with AI-powered tools. Translate messages into 50+ languages, rewrite for clarity and tone, and get instant summaries of long threads.", + "WinoAccount_Management_AiPackPromoPrice": "$4.99 / mo", + "WinoAccount_Management_AiPackPromoRequests": "1,000 requests", + "WinoAccount_Management_AiPackGetButton": "Get AI Pack", + "WinoAccount_Management_PurchaseRequiresSignIn": "Sign in with your Wino Account to complete this purchase.", + "WinoAccount_Management_PurchaseStartFailed": "Wino could not start the checkout session for this add-on.", + "WinoAccount_Management_AiPackSubscriptionActive": "Your subscription is active", + "WinoAccount_Management_AiPackRenews": "Renews {0:d}", + "WinoAccount_Management_AiPackRequestsUsed": "Requests used this month", + "WinoAccount_Management_AiPackResets": "Resets {0:d}", + "WinoAccount_Management_AiPackFeatureTranslate": "Translate", + "WinoAccount_Management_AiPackFeatureRewrite": "Rewrite", + "WinoAccount_Management_AiPackFeatureSummarize": "Summarize", + "WinoAccount_Management_SyncPreferencesTitle": "Synchronize Preferences", + "WinoAccount_Management_SyncPreferencesDescription": "Import or export your preferences to cloud. Import them across devices.", + "WinoAccount_Management_SignOutTitle": "Sign out", + "WinoAccount_Management_SignOutDescription": "Sign out of your account on this device", + "WinoAccount_Management_StatusLabel": "Status: {0}", + "WinoAccount_Management_NoRemoteSettings": "There are no synchronized settings stored for this account yet.", + "WinoAccount_Management_ExportSucceeded": "Your settings were exported to your Wino Account.", + "WinoAccount_Management_ImportSucceeded": "Imported {0} settings from your Wino Account.", + "WinoAccount_Management_ImportPartial": "Imported {0} settings. {1} settings could not be restored.", + "WinoAccount_Management_SerializeFailed": "Wino could not serialize your current preferences.", + "WinoAccount_Management_EmptyExport": "There are no preference values to export.", + "WinoAccount_Management_ImportEmpty": "The synchronized settings payload does not contain any values to restore.", + "WinoAccount_Management_LoadFailed": "Wino could not load the latest Wino Account information.", + "WinoAccount_Management_ActionFailed": "The Wino Account request could not be completed.", "WinoAccount_SettingsSection_Title": "Wino Account", "WinoAccount_SettingsSection_Description": "Create or sign in to a Wino Account using your localhost auth service.", "WinoAccount_RegisterButton_Title": "Register account", @@ -1168,6 +1219,10 @@ "WinoAccount_RegisterDialog_DifferenceTitle": "Wino Account is separate from your mail accounts", "WinoAccount_RegisterDialog_DifferenceDescription": "Your Outlook, Gmail, IMAP, or other email accounts stay exactly as they are. A Wino Account only manages Wino-specific features and account-based add-ons.", "WinoAccount_RegisterDialog_PrimaryButton": "Register", + "WinoAccount_RegisterDialog_PrivacyTitle": "Privacy and API processing", + "WinoAccount_RegisterDialog_PrivacyDescription": "Optional add-ons such as Wino AI Pack may send selected email HTML content to the Wino API service only when you use those features.", + "WinoAccount_RegisterDialog_PrivacyLinkText": "Read the privacy policy", + "WinoAccount_RegisterDialog_PrivacyCheckbox": "I agree to the privacy policy.", "WinoAccount_LoginDialog_Title": "Sign In to Wino Account", "WinoAccount_LoginDialog_Description": "Sign in to your Wino Account to sync your Wino setup and access account-based features.", "WinoAccount_LoginDialog_HeroTitle": "Welcome back", @@ -1175,17 +1230,28 @@ "WinoAccount_LoginDialog_BenefitsDescription": "Use your Wino Account to continue syncing settings across devices and to access paid add-ons such as Wino AI Pack.", "WinoAccount_LoginDialog_DifferenceTitle": "This is not your email mailbox sign-in", "WinoAccount_LoginDialog_DifferenceDescription": "Signing in here does not add or replace your Outlook, Gmail, or IMAP accounts in Wino. It only signs you in to Wino-specific services.", + "WinoAccount_LoginDialog_ForgotPasswordLink": "Forgot password?", "WinoAccount_EmailLabel": "Email", "WinoAccount_EmailPlaceholder": "name@example.com", "WinoAccount_PasswordLabel": "Password", "WinoAccount_ConfirmPasswordLabel": "Confirm password", + "WinoAccount_ForgotPasswordDialog_Title": "Reset your password", + "WinoAccount_ForgotPasswordDialog_PrimaryButton": "Send reset email", + "WinoAccount_ForgotPasswordDialog_BackToSignIn": "Back to sign in", + "WinoAccount_ForgotPasswordDialog_Description": "Enter your Wino Account email address and we will send you a password reset link if the address is registered.", "WinoAccount_Validation_EmailRequired": "Email is required.", "WinoAccount_Validation_PasswordRequired": "Password is required.", "WinoAccount_Validation_PasswordMismatch": "Passwords do not match.", + "WinoAccount_Validation_PrivacyConsentRequired": "You must accept the privacy policy before creating a Wino Account.", "WinoAccount_Error_InvalidCredentials": "The email address or password is incorrect.", "WinoAccount_Error_AccountLocked": "This account is temporarily locked.", "WinoAccount_Error_AccountBanned": "This account has been banned.", "WinoAccount_Error_AccountSuspended": "This account has been suspended.", + "WinoAccount_Error_EmailNotConfirmed": "Please confirm your email address before signing in.", + "WinoAccount_Error_EmailConfirmationRequired": "Please confirm your email address before signing in.", + "WinoAccount_Error_EmailConfirmationResendNotAvailable": "A new confirmation email is not available yet.", + "WinoAccount_Error_EmailConfirmationResendInvalid": "This confirmation request is no longer valid. Please try signing in again.", + "WinoAccount_Error_EmailNotRegistered": "This email address is not registered.", "WinoAccount_Error_RefreshTokenInvalid": "Your session is no longer valid. Please sign in again.", "WinoAccount_Error_EmailAlreadyRegistered": "This email address is already registered.", "WinoAccount_Error_ExternalLoginEmailRequired": "An email address is required to complete external sign-in.", @@ -1196,14 +1262,25 @@ "WinoAccount_Error_ValidationFailed": "The request is invalid. Please review the entered values.", "WinoAccount_RegisterSuccessMessage": "Wino Account registration completed for {0}.", "WinoAccount_LoginSuccessMessage": "Signed in to Wino Account as {0}.", + "WinoAccount_EmailConfirmationSentDialog_Title": "Confirm your email address", + "WinoAccount_EmailConfirmationSentDialog_Message": "We sent an email confirmation to {0}. Please confirm it and try signing in again.", + "WinoAccount_EmailConfirmationPendingDialog_Title": "Email confirmation required", + "WinoAccount_EmailConfirmationPendingDialog_Message": "We are still waiting for you to confirm {0}.", + "WinoAccount_EmailConfirmationPendingDialog_ResendButton": "Resend confirmation email", + "WinoAccount_EmailConfirmationPendingDialog_Countdown": "You can resend the confirmation email in {0}.", + "WinoAccount_EmailConfirmationPendingDialog_ReadyToResend": "You can resend the confirmation email now.", + "WinoAccount_EmailConfirmationResentDialog_Title": "Confirmation email resent", + "WinoAccount_EmailConfirmationResentDialog_Message": "We sent another confirmation email to {0}. Please confirm it and try signing in again.", + "WinoAccount_ForgotPasswordDialog_SuccessTitle": "Password reset email sent", + "WinoAccount_ForgotPasswordDialog_SuccessMessage": "We sent a password reset email to {0}. Open that message to choose a new password.", + "WinoAccount_ChangePassword_Title": "Change password", + "WinoAccount_ChangePassword_Description": "Send a password reset email to this Wino Account.", + "WinoAccount_ChangePassword_Action": "Send reset email", + "WinoAccount_ChangePassword_ConfirmationMessage": "Do you want Wino to send a password reset email to {0}?", "WinoAccount_SignOut_SuccessMessage": "Signed out from Wino Account {0}.", "WinoAccount_SignOut_NoAccountMessage": "There is no active Wino Account to sign out.", "WinoAccount_Titlebar_SignedOutTitle": "Wino Account", "WinoAccount_Titlebar_SignedOutDescription": "Sign in or create a Wino Account to manage your Wino session.", - "WinoAccount_Titlebar_SyncBenefitTitle": "Sync settings", - "WinoAccount_Titlebar_SyncBenefitDescription": "Keep your Wino preferences in sync across devices.", - "WinoAccount_Titlebar_AddonsBenefitTitle": "Unlock add-ons", - "WinoAccount_Titlebar_AddonsBenefitDescription": "Access premium features like Wino AI Pack.", "WinoAccount_Titlebar_SignedInStatus": "Status: {0}", "WelcomeWizard_Step2Title": "Add Account", "WelcomeWizard_Step3Title": "Finish Setup", diff --git a/Wino.Core.Tests/CalendarPageViewModelTests.cs b/Wino.Core.Tests/CalendarPageViewModelTests.cs new file mode 100644 index 00000000..a0c06ca5 --- /dev/null +++ b/Wino.Core.Tests/CalendarPageViewModelTests.cs @@ -0,0 +1,225 @@ +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Globalization; +using CommunityToolkit.Mvvm.Collections; +using FluentAssertions; +using Itenso.TimePeriod; +using Moq; +using Wino.Calendar.ViewModels; +using Wino.Calendar.ViewModels.Data; +using Wino.Calendar.ViewModels.Interfaces; +using Wino.Core.Domain.Entities.Calendar; +using Wino.Core.Domain.Entities.Shared; +using Wino.Core.Domain.Enums; +using Wino.Core.Domain.Interfaces; +using Wino.Core.Domain.Models.Calendar; +using Xunit; + +namespace Wino.Core.Tests; + +public class CalendarPageViewModelTests +{ + [Fact] + public async Task ApplyDisplayRequestAsync_UpdatesVisibleRangeAndThreePeriodLoadWindow() + { + var settings = CreateSettings(firstDayOfWeek: DayOfWeek.Monday); + var today = new DateOnly(2026, 3, 20); + var preferencesService = CreatePreferencesService(settings); + var calendarService = new Mock(); + ITimePeriod? requestedPeriod = null; + + calendarService + .Setup(service => service.GetCalendarEventsAsync(It.IsAny(), It.IsAny())) + .Callback((_, period) => requestedPeriod = period) + .ReturnsAsync([]); + + var viewModel = CreateViewModel(calendarService.Object, preferencesService.Object, today); + var request = new CalendarDisplayRequest(CalendarDisplayType.Week, new DateOnly(2026, 3, 18)); + + await viewModel.ApplyDisplayRequestAsync(request); + + viewModel.CurrentVisibleRange.StartDate.Should().Be(new DateOnly(2026, 3, 16)); + viewModel.CurrentVisibleRange.EndDate.Should().Be(new DateOnly(2026, 3, 22)); + viewModel.LoadedDateWindow.StartDate.Should().Be(new DateTime(2026, 3, 9)); + viewModel.LoadedDateWindow.EndDate.Should().Be(new DateTime(2026, 3, 30)); + viewModel.VisibleDateRangeText.Should().Be("3/16/2026 - 3/22/2026"); + + requestedPeriod.Should().NotBeNull(); + requestedPeriod!.Start.Should().Be(new DateTime(2026, 3, 9)); + requestedPeriod.End.Should().Be(new DateTime(2026, 3, 30)); + calendarService.Verify(service => service.GetCalendarEventsAsync(It.IsAny(), It.IsAny()), Times.Once); + } + + [Fact] + public async Task ApplyDisplayRequestAsync_DoesNotReloadWhenResolvedRangeIsUnchanged() + { + var settings = CreateSettings(); + var preferencesService = CreatePreferencesService(settings); + var calendarService = new Mock(); + + calendarService + .Setup(service => service.GetCalendarEventsAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync([]); + + var viewModel = CreateViewModel(calendarService.Object, preferencesService.Object, new DateOnly(2026, 3, 20)); + var request = new CalendarDisplayRequest(CalendarDisplayType.Day, new DateOnly(2026, 3, 20)); + + await viewModel.ApplyDisplayRequestAsync(request); + await viewModel.ApplyDisplayRequestAsync(request); + + calendarService.Verify(service => service.GetCalendarEventsAsync(It.IsAny(), It.IsAny()), Times.Once); + } + + [Fact] + public async Task ReloadCurrentVisibleRangeAsync_RecomputesWhenCalendarSettingsChange() + { + var currentSettings = CreateSettings(firstDayOfWeek: DayOfWeek.Monday); + var preferencesService = CreatePreferencesService(() => currentSettings); + var calendarService = new Mock(); + + calendarService + .Setup(service => service.GetCalendarEventsAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync([]); + + var viewModel = CreateViewModel(calendarService.Object, preferencesService.Object, new DateOnly(2026, 3, 20)); + var request = new CalendarDisplayRequest(CalendarDisplayType.Week, new DateOnly(2026, 3, 18)); + + await viewModel.ApplyDisplayRequestAsync(request); + viewModel.CurrentVisibleRange.StartDate.Should().Be(new DateOnly(2026, 3, 16)); + + currentSettings = CreateSettings(firstDayOfWeek: DayOfWeek.Sunday); + await viewModel.ReloadCurrentVisibleRangeAsync(); + + viewModel.CurrentVisibleRange.StartDate.Should().Be(new DateOnly(2026, 3, 15)); + calendarService.Verify(service => service.GetCalendarEventsAsync(It.IsAny(), It.IsAny()), Times.Exactly(2)); + } + + private static CalendarPageViewModel CreateViewModel( + ICalendarService calendarService, + IPreferencesService preferencesService, + DateOnly today) + { + var account = new MailAccount + { + Id = Guid.NewGuid(), + Name = "Primary", + SenderName = "Primary", + Address = "primary@example.com", + ProviderType = MailProviderType.Outlook + }; + + var calendar = new AccountCalendar + { + Id = Guid.NewGuid(), + AccountId = account.Id, + Name = "Calendar", + RemoteCalendarId = "calendar", + SynchronizationDeltaToken = string.Empty, + TextColorHex = "#000000", + BackgroundColorHex = "#ffffff", + TimeZone = TimeZoneInfo.Utc.Id, + IsExtended = true, + IsPrimary = true, + IsSynchronizationEnabled = true + }; + + var accountCalendarViewModel = new AccountCalendarViewModel(account, calendar); + var accountCalendarStateService = new FakeAccountCalendarStateService([accountCalendarViewModel]); + + var statePersistenceService = new Mock(); + statePersistenceService.SetupAllProperties(); + statePersistenceService.Object.ApplicationMode = WinoApplicationMode.Calendar; + statePersistenceService.Object.CalendarDisplayType = CalendarDisplayType.Week; + + return new CalendarPageViewModel( + statePersistenceService.Object, + calendarService, + Mock.Of(), + Mock.Of(), + Mock.Of(), + accountCalendarStateService, + preferencesService, + Mock.Of(), + Mock.Of(), + new TestDateContextProvider("en-US", today), + new CalendarRangeTextFormatter()); + } + + private static Mock CreatePreferencesService(CalendarSettings settings) + => CreatePreferencesService(() => settings); + + private static Mock CreatePreferencesService(Func settingsFactory) + { + var preferencesService = new Mock(); + preferencesService.Setup(service => service.GetCurrentCalendarSettings()).Returns(settingsFactory); + return preferencesService; + } + + private static CalendarSettings CreateSettings( + DayOfWeek firstDayOfWeek = DayOfWeek.Monday, + DayOfWeek workWeekStart = DayOfWeek.Monday, + DayOfWeek workWeekEnd = DayOfWeek.Friday, + string cultureName = "en-US") + { + return new CalendarSettings( + firstDayOfWeek, + [DayOfWeek.Monday, DayOfWeek.Tuesday, DayOfWeek.Wednesday, DayOfWeek.Thursday, DayOfWeek.Friday], + workWeekStart, + workWeekEnd, + TimeSpan.FromHours(8), + TimeSpan.FromHours(17), + 64, + DayHeaderDisplayType.TwentyFourHour, + CultureInfo.GetCultureInfo(cultureName)); + } + + private sealed class FakeAccountCalendarStateService : IAccountCalendarStateService + { + private readonly List _calendars; + private readonly ObservableCollection _groupedCalendars = []; + + public FakeAccountCalendarStateService(IEnumerable calendars) + { + _calendars = calendars.ToList(); + GroupedAccountCalendars = new ReadOnlyObservableCollection(_groupedCalendars); + } + + public IDispatcher Dispatcher { get; set; } = null!; + public ReadOnlyObservableCollection GroupedAccountCalendars { get; } + + public event EventHandler? CollectiveAccountGroupSelectionStateChanged + { + add { } + remove { } + } + + public event EventHandler? AccountCalendarSelectionStateChanged + { + add { } + remove { } + } + + public event PropertyChangedEventHandler? PropertyChanged + { + add { } + remove { } + } + + public IEnumerable ActiveCalendars => _calendars; + public IEnumerable AllCalendars => _calendars; + public ReadOnlyObservableGroupedCollection GroupedCalendars { get; set; } = null!; + + public void AddGroupedAccountCalendar(GroupedAccountCalendarViewModel groupedAccountCalendar) => _groupedCalendars.Add(groupedAccountCalendar); + public void RemoveGroupedAccountCalendar(GroupedAccountCalendarViewModel groupedAccountCalendar) => _groupedCalendars.Remove(groupedAccountCalendar); + public void ClearGroupedAccountCalendars() => _groupedCalendars.Clear(); + public void AddAccountCalendar(AccountCalendarViewModel accountCalendar) => _calendars.Add(accountCalendar); + public void RemoveAccountCalendar(AccountCalendarViewModel accountCalendar) => _calendars.Remove(accountCalendar); + } + + private sealed class TestDateContextProvider(string cultureName, DateOnly today) : IDateContextProvider + { + public CultureInfo Culture => CultureInfo.GetCultureInfo(cultureName); + public TimeZoneInfo TimeZone => TimeZoneInfo.Utc; + public DateOnly GetToday() => today; + } +} diff --git a/Wino.Core.Tests/CalendarRangeResolverTests.cs b/Wino.Core.Tests/CalendarRangeResolverTests.cs new file mode 100644 index 00000000..c72dcf42 --- /dev/null +++ b/Wino.Core.Tests/CalendarRangeResolverTests.cs @@ -0,0 +1,189 @@ +using System.Globalization; +using FluentAssertions; +using Wino.Core.Domain.Enums; +using Wino.Core.Domain.Models.Calendar; +using Xunit; + +namespace Wino.Core.Tests; + +public class CalendarRangeResolverTests +{ + [Fact] + public void Resolve_Day_ReturnsAnchorDateOnly() + { + var settings = CreateSettings(); + var today = new DateOnly(2026, 3, 20); + + var range = CalendarRangeResolver.Resolve(new CalendarDisplayRequest(CalendarDisplayType.Day, today), settings, today); + + range.StartDate.Should().Be(today); + range.EndDate.Should().Be(today); + range.DayCount.Should().Be(1); + range.ContainsToday.Should().BeTrue(); + range.Dates.Should().ContainSingle().Which.Should().Be(today); + } + + [Fact] + public void Resolve_Week_HonorsConfiguredFirstDayOfWeek() + { + var settings = CreateSettings(firstDayOfWeek: DayOfWeek.Sunday); + var anchor = new DateOnly(2026, 3, 18); + + var range = CalendarRangeResolver.Resolve(new CalendarDisplayRequest(CalendarDisplayType.Week, anchor), settings, today: anchor); + + range.StartDate.Should().Be(new DateOnly(2026, 3, 15)); + range.EndDate.Should().Be(new DateOnly(2026, 3, 21)); + range.DayCount.Should().Be(7); + } + + [Fact] + public void Resolve_WorkWeek_UsesConfiguredBounds() + { + var settings = CreateSettings( + firstDayOfWeek: DayOfWeek.Sunday, + workWeekStart: DayOfWeek.Monday, + workWeekEnd: DayOfWeek.Thursday); + var anchor = new DateOnly(2026, 3, 18); + + var range = CalendarRangeResolver.Resolve(new CalendarDisplayRequest(CalendarDisplayType.WorkWeek, anchor), settings, today: anchor); + + range.StartDate.Should().Be(new DateOnly(2026, 3, 16)); + range.EndDate.Should().Be(new DateOnly(2026, 3, 19)); + range.DayCount.Should().Be(4); + } + + [Fact] + public void Resolve_Month_CoversEntireAnchorMonth() + { + var settings = CreateSettings(); + var anchor = new DateOnly(2026, 2, 14); + + var range = CalendarRangeResolver.Resolve(new CalendarDisplayRequest(CalendarDisplayType.Month, anchor), settings, today: anchor); + + range.StartDate.Should().Be(new DateOnly(2026, 2, 1)); + range.EndDate.Should().Be(new DateOnly(2026, 2, 28)); + range.DayCount.Should().Be(28); + range.SpansSingleMonth.Should().BeTrue(); + } + + [Theory] + [InlineData(CalendarDisplayType.Day, 2026, 3, 18, 2026, 3, 19, 2026, 3, 17)] + [InlineData(CalendarDisplayType.Week, 2026, 3, 18, 2026, 3, 25, 2026, 3, 11)] + [InlineData(CalendarDisplayType.WorkWeek, 2026, 3, 18, 2026, 3, 25, 2026, 3, 11)] + [InlineData(CalendarDisplayType.Month, 2026, 3, 18, 2026, 4, 18, 2026, 2, 18)] + public void Navigate_MovesExactlyOnePeriod( + CalendarDisplayType displayType, + int year, + int month, + int day, + int nextYear, + int nextMonth, + int nextDay, + int previousYear, + int previousMonth, + int previousDay) + { + var settings = CreateSettings(); + var today = new DateOnly(2026, 3, 20); + var current = CalendarRangeResolver.Resolve( + new CalendarDisplayRequest(displayType, new DateOnly(year, month, day)), + settings, + today); + + var next = CalendarRangeResolver.Navigate(current, 1, settings, today); + var previous = CalendarRangeResolver.Navigate(current, -1, settings, today); + + next.AnchorDate.Should().Be(new DateOnly(nextYear, nextMonth, nextDay)); + previous.AnchorDate.Should().Be(new DateOnly(previousYear, previousMonth, previousDay)); + } + + [Fact] + public void ChangeDisplayType_FromMonth_UsesTodayWhenTodayIsInsideCurrentMonth() + { + var settings = CreateSettings(); + var today = new DateOnly(2026, 3, 20); + var monthRange = CalendarRangeResolver.Resolve( + new CalendarDisplayRequest(CalendarDisplayType.Month, new DateOnly(2026, 3, 5)), + settings, + today); + + var dayRange = CalendarRangeResolver.ChangeDisplayType(monthRange, CalendarDisplayType.Day, settings, today); + + dayRange.AnchorDate.Should().Be(today); + dayRange.StartDate.Should().Be(today); + dayRange.EndDate.Should().Be(today); + } + + [Fact] + public void Formatter_Day_UsesSingleDate() + { + var formatter = new CalendarRangeTextFormatter(); + var range = new VisibleDateRange( + CalendarDisplayType.Day, + new DateOnly(2026, 3, 20), + new DateOnly(2026, 3, 20), + new DateOnly(2026, 3, 20), + new DateOnly(2026, 3, 20), + 1, + true, + true, + [new DateOnly(2026, 3, 20)]); + + var text = formatter.Format(range, new TestDateContextProvider("en-US", today: new DateOnly(2026, 3, 20))); + + text.Should().Be("3/20/2026"); + } + + [Fact] + public void Formatter_Range_UsesCultureShortDatePattern() + { + var formatter = new CalendarRangeTextFormatter(); + var range = new VisibleDateRange( + CalendarDisplayType.Week, + new DateOnly(2026, 3, 20), + new DateOnly(2026, 3, 16), + new DateOnly(2026, 3, 22), + new DateOnly(2026, 3, 20), + 7, + true, + true, + [ + new DateOnly(2026, 3, 16), + new DateOnly(2026, 3, 17), + new DateOnly(2026, 3, 18), + new DateOnly(2026, 3, 19), + new DateOnly(2026, 3, 20), + new DateOnly(2026, 3, 21), + new DateOnly(2026, 3, 22) + ]); + + var text = formatter.Format(range, new TestDateContextProvider("de-DE", today: new DateOnly(2026, 3, 20))); + + text.Should().Be("16.03.2026 - 22.03.2026"); + } + + private static CalendarSettings CreateSettings( + DayOfWeek firstDayOfWeek = DayOfWeek.Monday, + DayOfWeek workWeekStart = DayOfWeek.Monday, + DayOfWeek workWeekEnd = DayOfWeek.Friday, + string cultureName = "en-US") + { + return new CalendarSettings( + firstDayOfWeek, + [DayOfWeek.Monday, DayOfWeek.Tuesday, DayOfWeek.Wednesday, DayOfWeek.Thursday, DayOfWeek.Friday], + workWeekStart, + workWeekEnd, + TimeSpan.FromHours(8), + TimeSpan.FromHours(17), + 64, + DayHeaderDisplayType.TwentyFourHour, + CultureInfo.GetCultureInfo(cultureName)); + } + + private sealed class TestDateContextProvider(string cultureName, DateOnly today) : IDateContextProvider + { + public CultureInfo Culture => CultureInfo.GetCultureInfo(cultureName); + public TimeZoneInfo TimeZone => TimeZoneInfo.Utc; + public DateOnly GetToday() => today; + } +} diff --git a/Wino.Core.Tests/Wino.Core.Tests.csproj b/Wino.Core.Tests/Wino.Core.Tests.csproj index 485bec64..13802c8e 100644 --- a/Wino.Core.Tests/Wino.Core.Tests.csproj +++ b/Wino.Core.Tests/Wino.Core.Tests.csproj @@ -21,6 +21,7 @@ + diff --git a/Wino.Mail.WinUI/App.xaml b/Wino.Mail.WinUI/App.xaml index e6f54ed8..90fae1a3 100644 --- a/Wino.Mail.WinUI/App.xaml +++ b/Wino.Mail.WinUI/App.xaml @@ -116,12 +116,9 @@ - - - diff --git a/Wino.Mail.WinUI/App.xaml.cs b/Wino.Mail.WinUI/App.xaml.cs index bc369bc3..d7f8286b 100644 --- a/Wino.Mail.WinUI/App.xaml.cs +++ b/Wino.Mail.WinUI/App.xaml.cs @@ -283,6 +283,8 @@ public partial class App : WinoApplication, services.AddTransient(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); } private void RegisterViewModels(IServiceCollection services) diff --git a/Wino.Mail.WinUI/Controls/Calendar/WinoCalendarControl.cs b/Wino.Mail.WinUI/Controls/Calendar/WinoCalendarControl.cs deleted file mode 100644 index b32a9d17..00000000 --- a/Wino.Mail.WinUI/Controls/Calendar/WinoCalendarControl.cs +++ /dev/null @@ -1,381 +0,0 @@ -using System; -using System.Collections.ObjectModel; -using System.Linq; -using System.Threading.Tasks; -using CommunityToolkit.WinUI; -using Microsoft.UI.Xaml; -using Microsoft.UI.Xaml.Controls; -using Wino.Calendar.Args; -using Wino.Calendar.ViewModels.Data; -using Wino.Core.Domain.Enums; -using Wino.Core.Domain.Models.Calendar; -using Wino.Helpers; - -namespace Wino.Calendar.Controls; - -public partial class WinoCalendarControl : Control, IDisposable -{ - private const string PART_WinoFlipView = nameof(PART_WinoFlipView); - private const string PART_IdleGrid = nameof(PART_IdleGrid); - - public event EventHandler? TimelineCellSelected; - public event EventHandler? TimelineCellUnselected; - - public event EventHandler? ScrollPositionChanging; - - #region Dependency Properties - - /// - /// Gets or sets the collection of day ranges to render. - /// Each day range usually represents a week, but it may support other ranges. - /// - [GeneratedDependencyProperty] - public partial ObservableCollection? DayRanges { get; set; } - - [GeneratedDependencyProperty(DefaultValue = -1)] - public partial int SelectedFlipViewIndex { get; set; } - - [GeneratedDependencyProperty] - public partial DayRangeRenderModel? SelectedFlipViewDayRange { get; set; } - - [GeneratedDependencyProperty] - public partial WinoDayTimelineCanvas? ActiveCanvas { get; set; } - - [GeneratedDependencyProperty(DefaultValue = true)] - public partial bool IsFlipIdle { get; set; } - - [GeneratedDependencyProperty] - public partial ScrollViewer? ActiveScrollViewer { get; set; } - - [GeneratedDependencyProperty] - public partial ItemsPanelTemplate? VerticalItemsPanelTemplate { get; set; } - - [GeneratedDependencyProperty] - public partial ItemsPanelTemplate? HorizontalItemsPanelTemplate { get; set; } - - [GeneratedDependencyProperty(DefaultValue = CalendarOrientation.Horizontal)] - public partial CalendarOrientation Orientation { get; set; } - - /// - /// Gets or sets the day-week-month-year display type. - /// Orientation is not determined by this property, but Orientation property. - /// This property is used to determine the template to use for the calendar. - /// - [GeneratedDependencyProperty(DefaultValue = CalendarDisplayType.Day)] - public partial CalendarDisplayType DisplayType { get; set; } - - #endregion - - private WinoCalendarFlipView? InternalFlipView; - private Grid? IdleGrid; - - private ScrollViewer? _previousScrollViewer; - private WinoDayTimelineCanvas? _previousCanvas; - - public WinoCalendarControl() - { - DefaultStyleKey = typeof(WinoCalendarControl); - SizeChanged += CalendarSizeChanged; - } - - partial void OnVerticalItemsPanelTemplateChanged(ItemsPanelTemplate? newValue) - => ManageCalendarOrientation(); - - partial void OnHorizontalItemsPanelTemplateChanged(ItemsPanelTemplate? newValue) - => ManageCalendarOrientation(); - - partial void OnOrientationChanged(CalendarOrientation newValue) - => ManageCalendarOrientation(); - - partial void OnDisplayTypeChanged(CalendarDisplayType newValue) - => ManageDisplayType(); - - partial void OnIsFlipIdleChanged(bool newValue) - => UpdateIdleState(); - - partial void OnDayRangesChanged(ObservableCollection? newValue) - => EnsureStableSelection(); - - partial void OnSelectedFlipViewDayRangeChanged(DayRangeRenderModel? newValue) - => EnsureStableSelection(); - - partial void OnActiveScrollViewerPropertyChanged(DependencyPropertyChangedEventArgs e) - { - var newValue = e.NewValue as ScrollViewer; - if (_previousScrollViewer != null) - { - DeregisterScrollChanges(_previousScrollViewer); - } - - if (newValue != null) - { - RegisterScrollChanges(newValue); - } - - _previousScrollViewer = newValue; - ManageHighlightedDateRange(); - } - - partial void OnActiveCanvasPropertyChanged(DependencyPropertyChangedEventArgs e) - { - var newValue = e.NewValue as WinoDayTimelineCanvas; - if (_previousCanvas != null) - { - DeregisterCanvas(_previousCanvas); - } - - if (newValue != null) - { - RegisterCanvas(newValue); - } - - _previousCanvas = newValue; - ManageHighlightedDateRange(); - } - - private void ManageCalendarOrientation() - { - if (InternalFlipView == null || HorizontalItemsPanelTemplate == null || VerticalItemsPanelTemplate == null) return; - - InternalFlipView.ItemsPanel = Orientation == CalendarOrientation.Horizontal ? HorizontalItemsPanelTemplate : VerticalItemsPanelTemplate; - } - - private void ManageDisplayType() - { - if (InternalFlipView == null) return; - - InternalFlipView.DisplayType = DisplayType; - } - - private void ManageHighlightedDateRange() - { - if (InternalFlipView?.IsProgrammaticNavigationInProgress == true) - { - return; - } - - SelectedFlipViewDayRange = InternalFlipView?.SelectedItem as DayRangeRenderModel; - } - - private void DeregisterCanvas(WinoDayTimelineCanvas canvas) - { - if (canvas == null) return; - - canvas.SelectedDateTime = null; - canvas.TimelineCellSelected -= ActiveTimelineCellSelected; - canvas.TimelineCellUnselected -= ActiveTimelineCellUnselected; - } - - private void RegisterCanvas(WinoDayTimelineCanvas canvas) - { - if (canvas == null) return; - - canvas.SelectedDateTime = null; - canvas.TimelineCellSelected += ActiveTimelineCellSelected; - canvas.TimelineCellUnselected += ActiveTimelineCellUnselected; - } - - private void RegisterScrollChanges(ScrollViewer scrollViewer) - { - if (scrollViewer == null) return; - - scrollViewer.ViewChanging += ScrollViewChanging; - } - - private void DeregisterScrollChanges(ScrollViewer scrollViewer) - { - if (scrollViewer == null) return; - - scrollViewer.ViewChanging -= ScrollViewChanging; - } - - private void ScrollViewChanging(object? sender, ScrollViewerViewChangingEventArgs e) - => ScrollPositionChanging?.Invoke(this, EventArgs.Empty); - - private void CalendarSizeChanged(object sender, SizeChangedEventArgs e) - { - if (ActiveCanvas == null) return; - - ActiveCanvas.SelectedDateTime = null; - } - - protected override void OnApplyTemplate() - { - base.OnApplyTemplate(); - - if (InternalFlipView != null) - { - InternalFlipView.ProgrammaticNavigationCompleted -= InternalFlipViewProgrammaticNavigationCompleted; - - if (InternalFlipView is IDisposable disposableFlipView) - { - disposableFlipView.Dispose(); - } - } - - if (_previousScrollViewer != null) - { - DeregisterScrollChanges(_previousScrollViewer); - _previousScrollViewer = null; - } - - if (_previousCanvas != null) - { - DeregisterCanvas(_previousCanvas); - _previousCanvas = null; - } - - InternalFlipView = GetTemplateChild(PART_WinoFlipView) as WinoCalendarFlipView; - IdleGrid = GetTemplateChild(PART_IdleGrid) as Grid; - - if (InternalFlipView != null) - { - InternalFlipView.ProgrammaticNavigationCompleted += InternalFlipViewProgrammaticNavigationCompleted; - } - - UpdateIdleState(); - ManageCalendarOrientation(); - ManageDisplayType(); - EnsureStableSelection(); - } - - private void InternalFlipViewProgrammaticNavigationCompleted(object? sender, ProgrammaticNavigationCompletedEventArgs e) - { - SelectedFlipViewDayRange = e.DayRange; - } - - private void UpdateIdleState() - { - if (InternalFlipView != null) - { - InternalFlipView.Opacity = IsFlipIdle ? 0 : 1; - } - - if (IdleGrid != null) - { - IdleGrid.Visibility = IsFlipIdle ? Visibility.Visible : Visibility.Collapsed; - } - } - - private void EnsureStableSelection() - { - if (InternalFlipView == null || DayRanges == null || DayRanges.Count == 0) - return; - - var targetIndex = SelectedFlipViewIndex; - - if (SelectedFlipViewDayRange != null) - { - var selectedRangeIndex = DayRanges.IndexOf(SelectedFlipViewDayRange); - if (selectedRangeIndex >= 0) - { - targetIndex = selectedRangeIndex; - } - } - - if (targetIndex < 0 || targetIndex >= DayRanges.Count) - { - targetIndex = 0; - } - - if (InternalFlipView.SelectedIndex != targetIndex) - { - InternalFlipView.SelectedIndex = targetIndex; - } - } - - private void ActiveTimelineCellUnselected(object? sender, TimelineCellUnselectedArgs e) - => TimelineCellUnselected?.Invoke(this, e); - - private void ActiveTimelineCellSelected(object? sender, TimelineCellSelectedArgs e) - => TimelineCellSelected?.Invoke(this, e); - - public void NavigateToDay(DateTime dateTime) => InternalFlipView?.NavigateToDay(dateTime); - - public async void NavigateToHour(TimeSpan timeSpan) - { - if (ActiveScrollViewer == null) return; - - // Total height of the FlipViewItem is the same as vertical ScrollViewer to position day headers. - - await Task.Yield(); - await DispatcherQueue.EnqueueAsync(() => - { - if (ActiveScrollViewer == null) return; - - double hourHeght = 60; - double totalHeight = ActiveScrollViewer.ScrollableHeight; - double scrollPosition = timeSpan.TotalHours * hourHeght; - - ActiveScrollViewer.ChangeView(null, scrollPosition, null, disableAnimation: false); - }); - } - public void ResetTimelineSelection() - { - if (ActiveCanvas == null) return; - - ActiveCanvas.SelectedDateTime = null; - } - - public void GoNextRange() - { - if (InternalFlipView == null) return; - - InternalFlipView.GoNextFlip(); - } - - public void GoPreviousRange() - { - if (InternalFlipView == null) return; - - InternalFlipView.GoPreviousFlip(); - } - - public void UnselectActiveTimelineCell() - { - if (ActiveCanvas == null) return; - - ActiveCanvas.SelectedDateTime = null; - } - - public CalendarItemControl GetCalendarItemControl(CalendarItemViewModel calendarItemViewModel) - { - return this.FindDescendants().FirstOrDefault(a => a.CalendarItem == calendarItemViewModel)!; - } - - public void Dispose() - { - SizeChanged -= CalendarSizeChanged; - - if (_previousScrollViewer != null) - { - DeregisterScrollChanges(_previousScrollViewer); - _previousScrollViewer = null; - } - - if (_previousCanvas != null) - { - DeregisterCanvas(_previousCanvas); - _previousCanvas = null; - } - - if (InternalFlipView != null) - { - InternalFlipView.ProgrammaticNavigationCompleted -= InternalFlipViewProgrammaticNavigationCompleted; - - if (InternalFlipView is IDisposable disposableFlipView) - { - disposableFlipView.Dispose(); - } - - InternalFlipView = null; - } - - IdleGrid = null; - ActiveCanvas = null; - ActiveScrollViewer = null; - TimelineCellSelected = null; - TimelineCellUnselected = null; - ScrollPositionChanging = null; - } -} diff --git a/Wino.Mail.WinUI/Controls/Calendar/WinoCalendarFlipView.cs b/Wino.Mail.WinUI/Controls/Calendar/WinoCalendarFlipView.cs deleted file mode 100644 index 2876b875..00000000 --- a/Wino.Mail.WinUI/Controls/Calendar/WinoCalendarFlipView.cs +++ /dev/null @@ -1,321 +0,0 @@ -using System; -using System.Collections.Specialized; -using System.Linq; -using System.Threading.Tasks; -using CommunityToolkit.WinUI; -using Microsoft.UI.Xaml; -using Microsoft.UI.Xaml.Controls; -using Wino.Core.Domain.Collections; -using Wino.Core.Domain.Models.Calendar; - -namespace Wino.Calendar.Controls; - -public partial class WinoCalendarFlipView : CustomCalendarFlipView, IDisposable -{ - public static readonly DependencyProperty IsIdleProperty = DependencyProperty.Register(nameof(IsIdle), typeof(bool), typeof(WinoCalendarFlipView), new PropertyMetadata(true)); - public static readonly DependencyProperty ActiveCanvasProperty = DependencyProperty.Register(nameof(ActiveCanvas), typeof(WinoDayTimelineCanvas), typeof(WinoCalendarFlipView), new PropertyMetadata(null)); - public static readonly DependencyProperty ActiveVerticalScrollViewerProperty = DependencyProperty.Register(nameof(ActiveVerticalScrollViewer), typeof(ScrollViewer), typeof(WinoCalendarFlipView), new PropertyMetadata(null)); - - /// - /// Gets or sets the active canvas that is currently displayed in the flip view. - /// Each day-range of flip view item has a canvas that displays the day timeline. - /// - public WinoDayTimelineCanvas? ActiveCanvas - { - get { return (WinoDayTimelineCanvas?)GetValue(ActiveCanvasProperty); } - set { SetValue(ActiveCanvasProperty, value); } - } - - /// - /// Gets or sets the scroll viewer that is currently active in the flip view. - /// It's the vertical scroll that scrolls the timeline only, not the header part that belongs - /// to parent FlipView control. - /// - public ScrollViewer? ActiveVerticalScrollViewer - { - get { return (ScrollViewer?)GetValue(ActiveVerticalScrollViewerProperty); } - set { SetValue(ActiveVerticalScrollViewerProperty, value); } - } - - public bool IsIdle - { - get { return (bool)GetValue(IsIdleProperty); } - set { SetValue(IsIdleProperty, value); } - } - - internal bool IsProgrammaticNavigationInProgress { get; private set; } - - internal int? PendingTargetIndex { get; private set; } - - internal event EventHandler? ProgrammaticNavigationCompleted; - - private INotifyCollectionChanged? _trackedItemsSource; - private readonly long _itemsSourceCallbackToken; - private FrameworkElement? _pendingActiveElementContainer; - private bool _isActiveElementResolutionPending; - - public WinoCalendarFlipView() - { - _itemsSourceCallbackToken = RegisterPropertyChangedCallback(ItemsSourceProperty, new DependencyPropertyChangedCallback(OnItemsSourceChanged)); - } - - private static void OnItemsSourceChanged(DependencyObject d, DependencyProperty e) - { - if (d is WinoCalendarFlipView flipView) - { - flipView.RegisterItemsSourceChange(); - } - } - - private void RegisterItemsSourceChange() - { - if (_trackedItemsSource != null) - { - _trackedItemsSource.CollectionChanged -= ItemsSourceUpdated; - } - - _trackedItemsSource = GetItemsSource(); - - if (_trackedItemsSource != null) - { - _trackedItemsSource.CollectionChanged += ItemsSourceUpdated; - } - - UpdateIdleState(); - } - - protected override void OnSelectedItemChanged(object? oldValue, object? newValue) - { - base.OnSelectedItemChanged(oldValue, newValue); - - UpdateActiveElements(); - } - - protected override void OnContainerPrepared(DependencyObject element, object item) - { - base.OnContainerPrepared(element, item); - - // Check if this is the currently selected item's container - var index = IndexFromContainer(element); - if (index >= 0 && index == SelectedIndex) - { - // Container for selected item is now ready, update active elements - UpdateActiveElements(); - } - } - - private void ItemsSourceUpdated(object? sender, NotifyCollectionChangedEventArgs e) - { - UpdateIdleState(); - } - - private void UpdateIdleState() - { - var itemsSource = GetItemsSource(); - IsIdle = itemsSource == null || itemsSource.Count == 0; - } - - private void UpdateActiveElements() - { - var itemsSource = GetItemsSource(); - - if (SelectedIndex < 0) - { - CancelPendingActiveElementResolution(); - - if (itemsSource == null || itemsSource.Count == 0) - { - ActiveCanvas = null; - ActiveVerticalScrollViewer = null; - } - - return; - } - - if (TryResolveActiveElements()) - { - CancelPendingActiveElementResolution(); - } - else - { - ScheduleActiveElementResolution(); - } - } - - private bool TryResolveActiveElements() - { - if (SelectedIndex < 0) - { - return false; - } - - if (ContainerFromIndex(SelectedIndex) is not FlipViewItem container) - { - return false; - } - - var activeCanvas = container.FindDescendant(); - var activeVerticalScrollViewer = container.FindDescendant(); - - if (activeCanvas == null && activeVerticalScrollViewer == null) - { - return false; - } - - ActiveCanvas = activeCanvas; - ActiveVerticalScrollViewer = activeVerticalScrollViewer; - - return true; - } - - private void ScheduleActiveElementResolution() - { - if (ContainerFromIndex(SelectedIndex) is FrameworkElement container && - !ReferenceEquals(_pendingActiveElementContainer, container)) - { - if (_pendingActiveElementContainer != null) - { - _pendingActiveElementContainer.Loaded -= PendingActiveElementContainerLoaded; - } - - _pendingActiveElementContainer = container; - _pendingActiveElementContainer.Loaded += PendingActiveElementContainerLoaded; - } - - if (_isActiveElementResolutionPending) - { - return; - } - - _isActiveElementResolutionPending = true; - LayoutUpdated += FlipViewLayoutUpdated; - - DispatcherQueue.TryEnqueue(RetryActiveElementResolution); - } - - private void RetryActiveElementResolution() - { - if (TryResolveActiveElements()) - { - CancelPendingActiveElementResolution(); - } - } - - private void PendingActiveElementContainerLoaded(object sender, RoutedEventArgs e) - => RetryActiveElementResolution(); - - private void FlipViewLayoutUpdated(object? sender, object e) - => RetryActiveElementResolution(); - - private void CancelPendingActiveElementResolution() - { - if (_pendingActiveElementContainer != null) - { - _pendingActiveElementContainer.Loaded -= PendingActiveElementContainerLoaded; - _pendingActiveElementContainer = null; - } - - if (_isActiveElementResolutionPending) - { - LayoutUpdated -= FlipViewLayoutUpdated; - _isActiveElementResolutionPending = false; - } - } - - /// - /// Navigates to the specified date in the calendar. - /// - /// Date to navigate. - public async void NavigateToDay(DateTime dateTime) - { - await Task.Yield(); - - await DispatcherQueue.EnqueueAsync(() => - { - // Find the day range that contains the date. - var dayRanges = GetItemsSource(); - var dayRange = dayRanges?.FirstOrDefault(a => a.CalendarDays.Any(b => b.RepresentingDate.Date == dateTime.Date)); - - if (dayRange != null && dayRanges != null) - { - var navigationItemIndex = dayRanges.IndexOf(dayRange); - var hasNavigationWork = navigationItemIndex != SelectedIndex; - - IsProgrammaticNavigationInProgress = hasNavigationWork; - PendingTargetIndex = navigationItemIndex; - - if (!hasNavigationWork) - { - PendingTargetIndex = null; - return; - } - - try - { - if (Math.Abs(navigationItemIndex - SelectedIndex) > 4) - { - // Difference between dates are high. - // No need to animate this much, just go without animating. - - SelectedIndex = navigationItemIndex; - } - else - { - // Until we reach the day in the flip, simulate next-prev button clicks. - // This will make sure the FlipView animations are triggered. - // Setting SelectedIndex directly doesn't trigger the animations. - - while (SelectedIndex != navigationItemIndex) - { - if (SelectedIndex > navigationItemIndex) - { - GoPreviousFlip(); - } - else - { - GoNextFlip(); - } - } - } - } - finally - { - if (SelectedIndex == PendingTargetIndex) - { - ProgrammaticNavigationCompleted?.Invoke(this, new ProgrammaticNavigationCompletedEventArgs(SelectedItem as DayRangeRenderModel ?? dayRange)); - } - - IsProgrammaticNavigationInProgress = false; - PendingTargetIndex = null; - } - } - }); - } - - private ObservableRangeCollection? GetItemsSource() - => ItemsSource as ObservableRangeCollection; - - public void Dispose() - { - CancelPendingActiveElementResolution(); - - if (_trackedItemsSource != null) - { - _trackedItemsSource.CollectionChanged -= ItemsSourceUpdated; - _trackedItemsSource = null; - } - - UnregisterPropertyChangedCallback(ItemsSourceProperty, _itemsSourceCallbackToken); - ProgrammaticNavigationCompleted = null; - } -} - -internal sealed class ProgrammaticNavigationCompletedEventArgs : EventArgs -{ - public ProgrammaticNavigationCompletedEventArgs(DayRangeRenderModel dayRange) - { - DayRange = dayRange; - } - - public DayRangeRenderModel DayRange { get; } -} diff --git a/Wino.Mail.WinUI/Controls/Calendar/WinoCalendarView.cs b/Wino.Mail.WinUI/Controls/Calendar/WinoCalendarView.cs deleted file mode 100644 index e00305d3..00000000 --- a/Wino.Mail.WinUI/Controls/Calendar/WinoCalendarView.cs +++ /dev/null @@ -1,178 +0,0 @@ -using System; -using System.Windows.Input; -using CommunityToolkit.Diagnostics; -using Microsoft.UI.Xaml; -using Microsoft.UI.Xaml.Controls; -using Microsoft.UI.Xaml.Media; -using Windows.UI; -using Wino.Core.Domain.Models.Calendar; -using Wino.Helpers; - -namespace Wino.Calendar.Controls; - -public partial class WinoCalendarView : Control, IDisposable -{ - private const string PART_DayViewItemBorder = nameof(PART_DayViewItemBorder); - private const string PART_CalendarView = nameof(PART_CalendarView); - - public static readonly DependencyProperty HighlightedDateRangeProperty = DependencyProperty.Register(nameof(HighlightedDateRange), typeof(DateRange), typeof(WinoCalendarView), new PropertyMetadata(null, new PropertyChangedCallback(OnHighlightedDateRangeChanged))); - public static readonly DependencyProperty VisibleDateBackgroundProperty = DependencyProperty.Register(nameof(VisibleDateBackground), typeof(Brush), typeof(WinoCalendarView), new PropertyMetadata(null, new PropertyChangedCallback(OnPropertiesChanged))); - public static readonly DependencyProperty DateClickedCommandProperty = DependencyProperty.Register(nameof(DateClickedCommand), typeof(ICommand), typeof(WinoCalendarView), new PropertyMetadata(null)); - public static readonly DependencyProperty TodayBackgroundColorProperty = DependencyProperty.Register(nameof(TodayBackgroundColor), typeof(Color), typeof(WinoCalendarView), new PropertyMetadata(new Color())); - - public Color TodayBackgroundColor - { - get { return (Color)GetValue(TodayBackgroundColorProperty); } - set { SetValue(TodayBackgroundColorProperty, value); } - } - - /// - /// Gets or sets the command to execute when a date is picked. - /// Unused. - /// - public ICommand? DateClickedCommand - { - get { return (ICommand?)GetValue(DateClickedCommandProperty); } - set { SetValue(DateClickedCommandProperty, value); } - } - - /// - /// Gets or sets the highlighted range of dates. - /// - public DateRange? HighlightedDateRange - { - get { return (DateRange?)GetValue(HighlightedDateRangeProperty); } - set { SetValue(HighlightedDateRangeProperty, value); } - } - - public Brush? VisibleDateBackground - { - get { return (Brush?)GetValue(VisibleDateBackgroundProperty); } - set { SetValue(VisibleDateBackgroundProperty, value); } - } - - - - private CalendarView? CalendarView; - private long _displayModeCallbackToken = -1; - - public WinoCalendarView() - { - DefaultStyleKey = typeof(WinoCalendarView); - } - - protected override void OnApplyTemplate() - { - base.OnApplyTemplate(); - - if (CalendarView != null) - { - CalendarView.SelectedDatesChanged -= InternalCalendarViewSelectionChanged; - - if (_displayModeCallbackToken != -1) - { - CalendarView.UnregisterPropertyChangedCallback(CalendarView.DisplayModeProperty, _displayModeCallbackToken); - _displayModeCallbackToken = -1; - } - } - - CalendarView = GetTemplateChild(PART_CalendarView) as CalendarView; - - Guard.IsNotNull(CalendarView, nameof(CalendarView)); - if (CalendarView == null) return; - - CalendarView.SelectedDatesChanged -= InternalCalendarViewSelectionChanged; - CalendarView.SelectedDatesChanged += InternalCalendarViewSelectionChanged; - - // TODO: Should come from settings. - CalendarView.FirstDayOfWeek = Windows.Globalization.DayOfWeek.Monday; - - // Everytime display mode changes, update the visible date range backgrounds. - // If users go back from year -> month -> day, we need to update the visible date range backgrounds. - - _displayModeCallbackToken = CalendarView.RegisterPropertyChangedCallback(CalendarView.DisplayModeProperty, (s, e) => UpdateVisibleDateRangeBackgrounds()); - } - - private void InternalCalendarViewSelectionChanged(CalendarView sender, CalendarViewSelectedDatesChangedEventArgs args) - { - if (args.AddedDates?.Count > 0) - { - var clickedDate = args.AddedDates[0].Date; - SetInnerDisplayDate(clickedDate); - - var clickArgs = new CalendarViewDayClickedEventArgs(clickedDate); - DateClickedCommand?.Execute(clickArgs); - } - - // Reset selection, we don't show selected dates but react to them. - CalendarView?.SelectedDates.Clear(); - } - - private static void OnPropertiesChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) - { - if (d is WinoCalendarView control) - { - control.UpdateVisibleDateRangeBackgrounds(); - } - } - - private void SetInnerDisplayDate(DateTime dateTime) => CalendarView?.SetDisplayDate(dateTime); - - // Changing selected dates will trigger the selection changed event. - // It will behave like user clicked the date. - public void GoToDay(DateTime dateTime) => CalendarView?.SelectedDates.Add(dateTime); - - private static void OnHighlightedDateRangeChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) - { - if (d is WinoCalendarView control) - { - if (control.HighlightedDateRange == null) return; - - control.SetInnerDisplayDate(control.HighlightedDateRange.StartDate); - control.UpdateVisibleDateRangeBackgrounds(); - } - } - - public void UpdateVisibleDateRangeBackgrounds() - { - if (HighlightedDateRange == null || VisibleDateBackground == null || CalendarView == null) return; - - var markDateCalendarDayItems = WinoVisualTreeHelper.FindDescendants(CalendarView); - - foreach (var calendarDayItem in markDateCalendarDayItems) - { - var border = WinoVisualTreeHelper.GetChildObject(calendarDayItem, PART_DayViewItemBorder); - - if (border == null) return; - - if (calendarDayItem.Date.Date == DateTime.Today.Date) - { - border.Background = new SolidColorBrush(TodayBackgroundColor); - } - else if (calendarDayItem.Date.Date >= HighlightedDateRange.StartDate.Date && calendarDayItem.Date.Date < HighlightedDateRange.EndDate.Date) - { - border.Background = VisibleDateBackground; - } - else - { - border.Background = null; - } - } - } - - public void Dispose() - { - if (CalendarView == null) - return; - - CalendarView.SelectedDatesChanged -= InternalCalendarViewSelectionChanged; - - if (_displayModeCallbackToken != -1) - { - CalendarView.UnregisterPropertyChangedCallback(CalendarView.DisplayModeProperty, _displayModeCallbackToken); - _displayModeCallbackToken = -1; - } - - CalendarView = null; - } -} diff --git a/Wino.Mail.WinUI/Controls/Calendar/WinoDayTimelineCanvas.cs b/Wino.Mail.WinUI/Controls/Calendar/WinoDayTimelineCanvas.cs deleted file mode 100644 index c1c52900..00000000 --- a/Wino.Mail.WinUI/Controls/Calendar/WinoDayTimelineCanvas.cs +++ /dev/null @@ -1,332 +0,0 @@ -using System; -using System.Diagnostics; -using System.Linq; -using CommunityToolkit.WinUI; -using Microsoft.UI.Input; -using Microsoft.UI.Xaml; -using Microsoft.UI.Xaml.Controls; -using Microsoft.UI.Xaml.Input; -using Microsoft.UI.Xaml.Media; -using SkiaSharp; -using SkiaSharp.Views.Windows; -using Windows.Foundation; -using Wino.Calendar.Args; -using Wino.Core.Domain.Models.Calendar; - -namespace Wino.Calendar.Controls; - -public partial class WinoDayTimelineCanvas : Control, IDisposable -{ - public event EventHandler? TimelineCellSelected; - public event EventHandler? TimelineCellUnselected; - - private const string PART_InternalCanvas = nameof(PART_InternalCanvas); - private SKXamlCanvas? Canvas; - - public static readonly DependencyProperty RenderOptionsProperty = DependencyProperty.Register(nameof(RenderOptions), typeof(CalendarRenderOptions), typeof(WinoDayTimelineCanvas), new PropertyMetadata(null, new PropertyChangedCallback(OnRenderingPropertiesChanged))); - public static readonly DependencyProperty SeperatorColorProperty = DependencyProperty.Register(nameof(SeperatorColor), typeof(SolidColorBrush), typeof(WinoDayTimelineCanvas), new PropertyMetadata(null, new PropertyChangedCallback(OnRenderingPropertiesChanged))); - public static readonly DependencyProperty HalfHourSeperatorColorProperty = DependencyProperty.Register(nameof(HalfHourSeperatorColor), typeof(SolidColorBrush), typeof(WinoDayTimelineCanvas), new PropertyMetadata(null, new PropertyChangedCallback(OnRenderingPropertiesChanged))); - public static readonly DependencyProperty SelectedCellBackgroundBrushProperty = DependencyProperty.Register(nameof(SelectedCellBackgroundBrush), typeof(SolidColorBrush), typeof(WinoDayTimelineCanvas), new PropertyMetadata(null, new PropertyChangedCallback(OnRenderingPropertiesChanged))); - public static readonly DependencyProperty WorkingHourCellBackgroundColorProperty = DependencyProperty.Register(nameof(WorkingHourCellBackgroundColor), typeof(SolidColorBrush), typeof(WinoDayTimelineCanvas), new PropertyMetadata(null, new PropertyChangedCallback(OnRenderingPropertiesChanged))); - public static readonly DependencyProperty SelectedDateTimeProperty = DependencyProperty.Register(nameof(SelectedDateTime), typeof(DateTime?), typeof(WinoDayTimelineCanvas), new PropertyMetadata(null, new PropertyChangedCallback(OnSelectedDateTimeChanged))); - public static readonly DependencyProperty PositionerUIElementProperty = DependencyProperty.Register(nameof(PositionerUIElement), typeof(UIElement), typeof(WinoDayTimelineCanvas), new PropertyMetadata(null)); - - public UIElement? PositionerUIElement - { - get { return (UIElement?)GetValue(PositionerUIElementProperty); } - set { SetValue(PositionerUIElementProperty, value); } - } - - public CalendarRenderOptions? RenderOptions - { - get { return (CalendarRenderOptions?)GetValue(RenderOptionsProperty); } - set { SetValue(RenderOptionsProperty, value); } - } - - public SolidColorBrush? HalfHourSeperatorColor - { - get { return (SolidColorBrush?)GetValue(HalfHourSeperatorColorProperty); } - set { SetValue(HalfHourSeperatorColorProperty, value); } - } - - public SolidColorBrush? SeperatorColor - { - get { return (SolidColorBrush?)GetValue(SeperatorColorProperty); } - set { SetValue(SeperatorColorProperty, value); } - } - - public SolidColorBrush? WorkingHourCellBackgroundColor - { - get { return (SolidColorBrush?)GetValue(WorkingHourCellBackgroundColorProperty); } - set { SetValue(WorkingHourCellBackgroundColorProperty, value); } - } - - public SolidColorBrush? SelectedCellBackgroundBrush - { - get { return (SolidColorBrush?)GetValue(SelectedCellBackgroundBrushProperty); } - set { SetValue(SelectedCellBackgroundBrushProperty, value); } - } - - public DateTime? SelectedDateTime - { - get { return (DateTime?)GetValue(SelectedDateTimeProperty); } - set { SetValue(SelectedDateTimeProperty, value); } - } - - protected override void OnApplyTemplate() - { - base.OnApplyTemplate(); - - Canvas = GetTemplateChild(PART_InternalCanvas) as SKXamlCanvas; - - if (Canvas != null) - { - Canvas.PaintSurface += OnCanvasPaintSurface; - Canvas.PointerPressed += OnCanvasPointerPressed; - } - - ForceDraw(); - } - - private static void OnSelectedDateTimeChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) - { - if (d is WinoDayTimelineCanvas control) - { - if (e.OldValue != null && e.NewValue == null) - { - control.RaiseCellUnselected(); - } - - control.ForceDraw(); - } - } - - private void RaiseCellUnselected() - { - TimelineCellUnselected?.Invoke(this, new TimelineCellUnselectedArgs()); - } - - private void OnCanvasPointerPressed(object? sender, PointerRoutedEventArgs e) - { - if (RenderOptions == null) return; - var canvas = Canvas; - if (canvas == null) return; - - var hourHeight = RenderOptions.CalendarSettings.HourHeight; - - // When users click to cell we need to find the day, hour and minutes (first 30 minutes or second 30 minutes) that it represents on the timeline. - - if (PositionerUIElement == null) - { - PositionerUIElement = this.FindParents().LastOrDefault(a => a is Grid); - } - - if (PositionerUIElement == null) - return; - - PointerPoint positionerRootPoint = e.GetCurrentPoint(PositionerUIElement); - PointerPoint canvasPointerPoint = e.GetCurrentPoint(canvas); - - Point touchPoint = canvasPointerPoint.Position; - - var singleDayWidth = (canvas.ActualWidth / RenderOptions.TotalDayCount); - - int day = (int)(touchPoint.X / singleDayWidth); - int hour = (int)(touchPoint.Y / hourHeight); - - bool isSecondHalf = touchPoint.Y % hourHeight > (hourHeight / 2); - - var diffX = positionerRootPoint.Position.X - touchPoint.X; - var diffY = positionerRootPoint.Position.Y - touchPoint.Y; - - var cellStartRelativePositionX = diffX + (day * singleDayWidth); - var cellEndRelativePositionX = cellStartRelativePositionX + singleDayWidth; - - var cellStartRelativePositionY = diffY + (hour * hourHeight) + (isSecondHalf ? hourHeight / 2 : 0); - var cellEndRelativePositionY = cellStartRelativePositionY + (isSecondHalf ? (hourHeight / 2) : hourHeight); - - var cellSize = new Size(cellEndRelativePositionX - cellStartRelativePositionX, hourHeight / 2); - var positionerPoint = new Point(cellStartRelativePositionX, cellStartRelativePositionY); - - var clickedDateTime = RenderOptions.DateRange.StartDate.AddDays(day).AddHours(hour).AddMinutes(isSecondHalf ? 30 : 0); - - // If there is already a selected date, in order to mimic the popup behavior, we need to dismiss the previous selection first. - // Next click will be a new selection. - - // Raise the events directly here instead of DP to not lose pointer position. - if (clickedDateTime == SelectedDateTime || SelectedDateTime != null) - { - SelectedDateTime = null; - } - else - { - SelectedDateTime = clickedDateTime; - TimelineCellSelected?.Invoke(this, new TimelineCellSelectedArgs(clickedDateTime, touchPoint, positionerPoint, cellSize)); - } - - Debug.WriteLine($"Clicked: {clickedDateTime}"); - } - - public WinoDayTimelineCanvas() - { - DefaultStyleKey = typeof(WinoDayTimelineCanvas); - } - - private static void OnRenderingPropertiesChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) - { - if (d is WinoDayTimelineCanvas control) - { - control.ForceDraw(); - } - } - - private void ForceDraw() => Canvas?.Invalidate(); - - private bool CanDrawTimeline() - { - return RenderOptions != null - && Canvas != null - && WorkingHourCellBackgroundColor != null - && SeperatorColor != null - && HalfHourSeperatorColor != null - && SelectedCellBackgroundBrush != null; - } - - private void OnCanvasPaintSurface(object? sender, SKPaintSurfaceEventArgs e) - { - if (!CanDrawTimeline()) return; - var renderOptions = RenderOptions!; - var workingHourCellBackgroundColor = WorkingHourCellBackgroundColor!; - var seperatorColor = SeperatorColor!; - var halfHourSeperatorColor = HalfHourSeperatorColor!; - var selectedCellBackgroundBrush = SelectedCellBackgroundBrush!; - - var canvas = e.Surface.Canvas; - canvas.Clear(SKColors.Transparent); - - int hours = 24; - - double canvasWidth = e.Info.Width; - double canvasHeight = e.Info.Height; - - if (canvasWidth == 0 || canvasHeight == 0) return; - - // Calculate the width of each rectangle (1 day column) - // Equal distribution of the whole width. - double rectWidth = canvasWidth / renderOptions.TotalDayCount; - - // Calculate the height of each rectangle (1 hour row) - double rectHeight = renderOptions.CalendarSettings.HourHeight; - - // Define stroke and fill colors - var strokeColor = ToSKColor(seperatorColor.Color); - float strokeThickness = 0.5f; - - // Create paints for drawing - using var strokePaint = new SKPaint - { - Color = strokeColor, - StrokeWidth = strokeThickness, - Style = SKPaintStyle.Stroke, - IsAntialias = true - }; - - using var fillPaint = new SKPaint - { - Style = SKPaintStyle.Fill, - IsAntialias = true - }; - - using var dashedPaint = new SKPaint - { - Color = ToSKColor(halfHourSeperatorColor.Color), - StrokeWidth = strokeThickness, - Style = SKPaintStyle.Stroke, - PathEffect = SKPathEffect.CreateDash([2f, 2f], 0), - IsAntialias = true - }; - - for (int day = 0; day < renderOptions.TotalDayCount; day++) - { - var currentDay = renderOptions.DateRange.StartDate.AddDays(day); - - bool isWorkingDay = renderOptions.CalendarSettings.WorkingDays.Contains(currentDay.DayOfWeek); - - // Loop through each hour (rows) - for (int hour = 0; hour < hours; hour++) - { - var renderTime = TimeSpan.FromHours(hour); - - var representingDateTime = currentDay.AddHours(hour); - - // Calculate the position and size of the rectangle - float x = (float)(day * rectWidth); - float y = (float)(hour * rectHeight); - float width = (float)rectWidth; - float height = (float)rectHeight; - - var rectangle = new SKRect(x, y, x + width, y + height); - - // Draw the rectangle border. - // This is the main rectangle. - canvas.DrawRect(rectangle, strokePaint); - - // Fill another rectangle with the working hour background color - // This rectangle must be placed with -1 margin to prevent invisible borders of the main rectangle. - if (isWorkingDay && renderTime >= renderOptions.CalendarSettings.WorkingHourStart && renderTime <= renderOptions.CalendarSettings.WorkingHourEnd) - { - var backgroundRectangle = new SKRect(x + 1, y + 1, x + width - 1, y + height - 1); - - canvas.DrawRect(backgroundRectangle, strokePaint); - fillPaint.Color = ToSKColor(workingHourCellBackgroundColor.Color); - canvas.DrawRect(backgroundRectangle, fillPaint); - } - - // Draw a line in the center of the rectangle for representing half hours. - float lineY = y + height / 2; - canvas.DrawLine(x, lineY, x + width, lineY, dashedPaint); - } - - // Draw selected item background color for the date if possible. - if (SelectedDateTime != null) - { - var selectedDateTime = SelectedDateTime.Value; - if (selectedDateTime.Date == currentDay.Date) - { - var selectionRectHeight = rectHeight / 2; - var selectedY = selectedDateTime.Hour * rectHeight + (selectedDateTime.Minute / 60) * rectHeight; - - // Second half of the hour is selected. - if (selectedDateTime.TimeOfDay.Minutes == 30) - { - selectedY += rectHeight / 2; - } - - var selectedRectangle = new SKRect( - (float)(day * rectWidth), - (float)selectedY, - (float)(day * rectWidth + rectWidth), - (float)(selectedY + selectionRectHeight)); - - fillPaint.Color = ToSKColor(selectedCellBackgroundBrush.Color); - canvas.DrawRect(selectedRectangle, fillPaint); - } - } - } - } - - private static SKColor ToSKColor(Windows.UI.Color color) - { - return new SKColor(color.R, color.G, color.B, color.A); - } - - public void Dispose() - { - if (Canvas == null) return; - - Canvas.PaintSurface -= OnCanvasPaintSurface; - Canvas.PointerPressed -= OnCanvasPointerPressed; - - Canvas = null; - } -} diff --git a/Wino.Mail.WinUI/Controls/UpdateNotesFlipViewControl.xaml b/Wino.Mail.WinUI/Controls/UpdateNotesFlipViewControl.xaml index 54c06e84..7bc0a5df 100644 --- a/Wino.Mail.WinUI/Controls/UpdateNotesFlipViewControl.xaml +++ b/Wino.Mail.WinUI/Controls/UpdateNotesFlipViewControl.xaml @@ -2,7 +2,6 @@ x:Class="Wino.Mail.WinUI.Controls.UpdateNotesFlipViewControl" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" - xmlns:controls="using:CommunityToolkit.WinUI.Controls" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:models="using:Wino.Core.Domain.Models.Updates" @@ -22,14 +21,21 @@ - + - + diff --git a/Wino.Mail.WinUI/Services/NavigationService.cs b/Wino.Mail.WinUI/Services/NavigationService.cs index d886fc4e..ba69920c 100644 --- a/Wino.Mail.WinUI/Services/NavigationService.cs +++ b/Wino.Mail.WinUI/Services/NavigationService.cs @@ -474,17 +474,14 @@ public class NavigationService : NavigationServiceBase, INavigationService return true; } - private static LoadCalendarMessage CreateLoadCalendarMessage(CalendarPageNavigationArgs args) + private LoadCalendarMessage CreateLoadCalendarMessage(CalendarPageNavigationArgs args) { var targetDate = args.RequestDefaultNavigation - ? DateTime.Now.Date - : args.NavigationDate; + ? DateOnly.FromDateTime(DateTime.Now.Date) + : DateOnly.FromDateTime(args.NavigationDate.Date); - var initiative = args.RequestDefaultNavigation - ? CalendarInitInitiative.App - : CalendarInitInitiative.User; - - return new LoadCalendarMessage(targetDate, initiative); + var displayRequest = new CalendarDisplayRequest(_statePersistanceService.CalendarDisplayType, targetDate); + return new LoadCalendarMessage(displayRequest); } private bool NavigateInnerShellFrame(Frame frame, Type pageType, object? parameter, NavigationTransitionType transition) diff --git a/Wino.Mail.WinUI/Services/PreferencesService.cs b/Wino.Mail.WinUI/Services/PreferencesService.cs index d4dc1d8c..34df1f1f 100644 --- a/Wino.Mail.WinUI/Services/PreferencesService.cs +++ b/Wino.Mail.WinUI/Services/PreferencesService.cs @@ -396,6 +396,8 @@ public class PreferencesService(IConfigurationService configurationService) : Ob return new CalendarSettings(FirstDayOfWeek, workingDays, + WorkingDayStart, + WorkingDayEnd, WorkingHourStart, WorkingHourEnd, HourHeight, diff --git a/Wino.Mail.WinUI/Styles/WinoCalendarResources.xaml b/Wino.Mail.WinUI/Styles/WinoCalendarResources.xaml deleted file mode 100644 index bd138e12..00000000 --- a/Wino.Mail.WinUI/Styles/WinoCalendarResources.xaml +++ /dev/null @@ -1,447 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/Wino.Mail.WinUI/Styles/WinoCalendarResources.xaml.cs b/Wino.Mail.WinUI/Styles/WinoCalendarResources.xaml.cs deleted file mode 100644 index e066da1c..00000000 --- a/Wino.Mail.WinUI/Styles/WinoCalendarResources.xaml.cs +++ /dev/null @@ -1,49 +0,0 @@ -using CommunityToolkit.WinUI.Controls; -using Microsoft.UI.Xaml; -using Microsoft.UI.Xaml.Controls; -using Wino.Calendar.Controls; -using Wino.Core.Domain.Models.Calendar; - -namespace Wino.Styles; - -public sealed partial class WinoCalendarResources : ResourceDictionary -{ - public WinoCalendarResources() - { - this.InitializeComponent(); - } - - private void OnRegularEventItemsControlLoaded(object sender, RoutedEventArgs e) - { - if (sender is ItemsControl itemsControl && itemsControl.DataContext is CalendarDayModel dayModel) - { - if (itemsControl.ItemsPanelRoot is WinoCalendarPanel panel) - { - panel.HourHeight = dayModel.CalendarRenderOptions.CalendarSettings.HourHeight; - panel.Period = dayModel.Period; - } - } - } - - private void OnDayColumnsItemsControlLoaded(object sender, RoutedEventArgs e) - { - if (sender is ItemsControl itemsControl && itemsControl.DataContext is DayRangeRenderModel rangeModel) - { - if (itemsControl.ItemsPanelRoot is UniformGrid uniformGrid) - { - uniformGrid.Columns = rangeModel.CalendarRenderOptions.TotalDayCount; - } - } - } - - private void OnEventGridsItemsControlLoaded(object sender, RoutedEventArgs e) - { - if (sender is ItemsControl itemsControl && itemsControl.DataContext is DayRangeRenderModel rangeModel) - { - if (itemsControl.ItemsPanelRoot is UniformGrid uniformGrid) - { - uniformGrid.Columns = rangeModel.CalendarRenderOptions.TotalDayCount; - } - } - } -} diff --git a/Wino.Mail.WinUI/Styles/WinoCalendarView.xaml b/Wino.Mail.WinUI/Styles/WinoCalendarView.xaml deleted file mode 100644 index 8259f570..00000000 --- a/Wino.Mail.WinUI/Styles/WinoCalendarView.xaml +++ /dev/null @@ -1,48 +0,0 @@ - - - - - - - - - - - - - - - diff --git a/Wino.Mail.WinUI/Styles/WinoDayTimelineCanvas.xaml b/Wino.Mail.WinUI/Styles/WinoDayTimelineCanvas.xaml deleted file mode 100644 index eb48c8ef..00000000 --- a/Wino.Mail.WinUI/Styles/WinoDayTimelineCanvas.xaml +++ /dev/null @@ -1,19 +0,0 @@ - - - - - diff --git a/Wino.Mail.WinUI/Views/Calendar/CalendarAppShell.xaml b/Wino.Mail.WinUI/Views/Calendar/CalendarAppShell.xaml index b6d548ab..ad3f5a72 100644 --- a/Wino.Mail.WinUI/Views/Calendar/CalendarAppShell.xaml +++ b/Wino.Mail.WinUI/Views/Calendar/CalendarAppShell.xaml @@ -131,13 +131,26 @@ Text="{x:Bind domain:Translator.CalendarEventCompose_NewEventButton, Mode=OneTime}" /> - + Margin="16,0,16,12" + Padding="12" + Background="{ThemeResource CardBackgroundFillColorDefaultBrush}" + CornerRadius="8"> + + + + + + - - @@ -273,28 +284,14 @@ - - - - - - - - - - - - - + - - - + diff --git a/Wino.Mail.WinUI/Views/Calendar/CalendarAppShell.xaml.cs b/Wino.Mail.WinUI/Views/Calendar/CalendarAppShell.xaml.cs index da19dd2a..04d77cde 100644 --- a/Wino.Mail.WinUI/Views/Calendar/CalendarAppShell.xaml.cs +++ b/Wino.Mail.WinUI/Views/Calendar/CalendarAppShell.xaml.cs @@ -1,5 +1,6 @@ using System; using System.Diagnostics; +using System.ComponentModel; using System.Threading.Tasks; using CommunityToolkit.Mvvm.Messaging; using Microsoft.Extensions.DependencyInjection; @@ -11,6 +12,7 @@ using Wino.Core.Domain.Enums; using Wino.Core.Domain.Interfaces; using Wino.Core.Domain.MenuItems; using Wino.Core.Domain.Models; +using Wino.Core.Domain.Models.Calendar; using Wino.Mail.Views.Abstract; using Wino.Messaging.Client.Calendar; using Windows.System; @@ -22,6 +24,7 @@ public sealed partial class CalendarAppShell : CalendarAppShellAbstract, { private const string STATE_HorizontalCalendar = "HorizontalCalendar"; private const string STATE_VerticalCalendar = "VerticalCalendar"; + private bool _isSynchronizingVisibleDateRangeCalendar; public Frame GetShellFrame() => InnerShellFrame; @@ -29,12 +32,14 @@ public sealed partial class CalendarAppShell : CalendarAppShellAbstract, { InitializeComponent(); + ViewModel.PropertyChanged += ViewModelPropertyChanged; ManageCalendarDisplayType(ViewModel.StatePersistenceService.CalendarDisplayType); } private void OnLoaded(object sender, RoutedEventArgs e) { UpdateNavigationPaneLayout(navigationView.DisplayMode); + SynchronizeVisibleDateRangeCalendar(); } private void ManageCalendarDisplayType(Core.Domain.Enums.CalendarDisplayType displayType) @@ -49,9 +54,11 @@ public sealed partial class CalendarAppShell : CalendarAppShellAbstract, } } - private void PreviousDateClicked(object sender, RoutedEventArgs e) => WeakReferenceMessenger.Default.Send(new GoPreviousDateRequestedMessage()); + private void PreviousDateClicked(object sender, RoutedEventArgs e) + => ViewModel.PreviousDateRangeCommand.Execute(null); - private void NextDateClicked(object sender, RoutedEventArgs e) => WeakReferenceMessenger.Default.Send(new GoNextDateRequestedMessage()); + private void NextDateClicked(object sender, RoutedEventArgs e) + => ViewModel.NextDateRangeCommand.Execute(null); private async void NewCalendarEventNavigationItemTapped(object sender, TappedRoutedEventArgs e) { @@ -59,6 +66,33 @@ public sealed partial class CalendarAppShell : CalendarAppShellAbstract, await InvokeNewCalendarEventAsync(); } + private void VisibleDateRangeCalendarViewSelectedDatesChanged(CalendarView sender, CalendarViewSelectedDatesChangedEventArgs args) + { + if (_isSynchronizingVisibleDateRangeCalendar) + return; + + DateTimeOffset? interactedDate = null; + + if (args.AddedDates.Count > 0) + { + interactedDate = args.AddedDates[0]; + } + else if (args.RemovedDates.Count > 0) + { + interactedDate = args.RemovedDates[0]; + } + + if (interactedDate is null) + return; + + var clickedArgs = new CalendarViewDayClickedEventArgs(interactedDate.Value.DateTime); + + if (ViewModel.DateClickedCommand.CanExecute(clickedArgs)) + { + ViewModel.DateClickedCommand.Execute(clickedArgs); + } + } + private async void NewCalendarEventNavigationItemKeyDown(object sender, KeyRoutedEventArgs e) { if (e.Key is not (VirtualKey.Enter or VirtualKey.Space)) @@ -102,6 +136,15 @@ public sealed partial class CalendarAppShell : CalendarAppShellAbstract, public void Receive(CalendarDisplayTypeChangedMessage message) { ManageCalendarDisplayType(message.NewDisplayType); + SynchronizeVisibleDateRangeCalendar(); + } + + private void ViewModelPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName is nameof(ViewModel.CurrentVisibleRange) or nameof(ViewModel.VisibleDateRangeText)) + { + SynchronizeVisibleDateRangeCalendar(); + } } protected override void OnNavigatedFrom(NavigationEventArgs e) @@ -226,4 +269,39 @@ public sealed partial class CalendarAppShell : CalendarAppShellAbstract, _ => key.ToString() }; } + + private void SynchronizeVisibleDateRangeCalendar() + { + if (!DispatcherQueue.HasThreadAccess) + { + var enqueued = DispatcherQueue.TryEnqueue(SynchronizeVisibleDateRangeCalendar); + + if (!enqueued) + throw new InvalidOperationException("Could not marshal visible date range calendar synchronization onto the UI thread."); + + return; + } + + _isSynchronizingVisibleDateRangeCalendar = true; + + try + { + VisibleDateRangeCalendarView.SelectedDates.Clear(); + + var currentRange = ViewModel.CurrentVisibleRange; + if (currentRange == null) + return; + + foreach (var date in currentRange.Dates) + { + VisibleDateRangeCalendarView.SelectedDates.Add(new DateTimeOffset(date.ToDateTime(TimeOnly.MinValue))); + } + + VisibleDateRangeCalendarView.SetDisplayDate(new DateTimeOffset(currentRange.AnchorDate.ToDateTime(TimeOnly.MinValue))); + } + finally + { + _isSynchronizingVisibleDateRangeCalendar = false; + } + } } diff --git a/Wino.Mail.WinUI/Views/Calendar/CalendarPage.xaml b/Wino.Mail.WinUI/Views/Calendar/CalendarPage.xaml index 6cda28bc..d90d3ab3 100644 --- a/Wino.Mail.WinUI/Views/Calendar/CalendarPage.xaml +++ b/Wino.Mail.WinUI/Views/Calendar/CalendarPage.xaml @@ -1,403 +1,42 @@ - - - - - - - - + + + + + + - - - - + - - - - - - - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -