diff --git a/Directory.Packages.props b/Directory.Packages.props index ebc19048..9eb02f44 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -7,13 +7,13 @@ - - - - - - - + + + + + + + @@ -23,22 +23,22 @@ - - - - + + + + - - - + + + - - + + @@ -46,24 +46,24 @@ - + - + - + - - - - + + + + - - - + + + diff --git a/Wino.Calendar.ViewModels/AccountDetailsPageViewModel.cs b/Wino.Calendar.ViewModels/AccountDetailsPageViewModel.cs deleted file mode 100644 index a110d386..00000000 --- a/Wino.Calendar.ViewModels/AccountDetailsPageViewModel.cs +++ /dev/null @@ -1,37 +0,0 @@ -using CommunityToolkit.Mvvm.Input; -using CommunityToolkit.Mvvm.Messaging; -using Wino.Calendar.ViewModels.Interfaces; -using Wino.Core.Domain; -using Wino.Core.Domain.Enums; -using Wino.Core.Domain.Interfaces; -using Wino.Core.Domain.Models.Navigation; -using Wino.Core.ViewModels; -using Wino.Mail.ViewModels.Data; -using Wino.Messaging.Client.Navigation; - -namespace Wino.Calendar.ViewModels; - -public partial class AccountDetailsPageViewModel : CalendarBaseViewModel -{ - private readonly IAccountService _accountService; - - public AccountProviderDetailViewModel Account { get; private set; } - public ICalendarDialogService CalendarDialogService { get; } - public IAccountCalendarStateService AccountCalendarStateService { get; } - - public AccountDetailsPageViewModel(ICalendarDialogService calendarDialogService, IAccountService accountService, IAccountCalendarStateService accountCalendarStateService) - { - CalendarDialogService = calendarDialogService; - _accountService = accountService; - AccountCalendarStateService = accountCalendarStateService; - } - - [RelayCommand] - private void EditAccountDetails() - => Messenger.Send(new BreadcrumbNavigationRequested(Translator.SettingsEditAccountDetails_Title, WinoPage.EditAccountDetailsPage, Account)); - - public override void OnNavigatedTo(NavigationMode mode, object parameters) - { - base.OnNavigatedTo(mode, parameters); - } -} diff --git a/Wino.Calendar.ViewModels/AccountManagementViewModel.cs b/Wino.Calendar.ViewModels/AccountManagementViewModel.cs deleted file mode 100644 index f7eb9563..00000000 --- a/Wino.Calendar.ViewModels/AccountManagementViewModel.cs +++ /dev/null @@ -1,146 +0,0 @@ -using System; -using System.Threading; -using System.Threading.Tasks; -using CommunityToolkit.Mvvm.Input; -using Wino.Core.Domain; -using Wino.Core.Domain.Entities.Shared; -using Wino.Core.Domain.Enums; -using Wino.Core.Domain.Exceptions; -using Wino.Core.Domain.Interfaces; -using Wino.Core.Domain.Models.Authentication; -using Wino.Core.Domain.Models.Navigation; -using Wino.Core.Domain.Models.Synchronization; -using Wino.Core.ViewModels; -using Wino.Messaging.Server; - -namespace Wino.Calendar.ViewModels; - -public partial class AccountManagementViewModel : AccountManagementPageViewModelBase -{ - private readonly IProviderService _providerService; - - public AccountManagementViewModel(ICalendarDialogService dialogService, - IWinoServerConnectionManager winoServerConnectionManager, - INavigationService navigationService, - IAccountService accountService, - IProviderService providerService, - IStoreManagementService storeManagementService, - IAuthenticationProvider authenticationProvider, - IPreferencesService preferencesService) : base(dialogService, winoServerConnectionManager, navigationService, accountService, providerService, storeManagementService, authenticationProvider, preferencesService) - { - CalendarDialogService = dialogService; - _providerService = providerService; - } - - public ICalendarDialogService CalendarDialogService { get; } - - public override async void OnNavigatedTo(NavigationMode mode, object parameters) - { - base.OnNavigatedTo(mode, parameters); - - await InitializeAccountsAsync(); - } - - public override async Task InitializeAccountsAsync() - { - Accounts.Clear(); - - var accounts = await AccountService.GetAccountsAsync().ConfigureAwait(false); - - await ExecuteUIThread(() => - { - foreach (var account in accounts) - { - var accountDetails = GetAccountProviderDetails(account); - - Accounts.Add(accountDetails); - } - }); - - await ManageStorePurchasesAsync().ConfigureAwait(false); - } - - [RelayCommand] - private async Task AddNewAccountAsync() - { - if (IsAccountCreationBlocked) - { - var isPurchaseClicked = await DialogService.ShowConfirmationDialogAsync(Translator.DialogMessage_AccountLimitMessage, Translator.DialogMessage_AccountLimitTitle, Translator.Buttons_Purchase); - - if (!isPurchaseClicked) return; - - await PurchaseUnlimitedAccountAsync(); - - return; - } - - var availableProviders = _providerService.GetAvailableProviders(); - - var accountCreationDialogResult = await DialogService.ShowAccountProviderSelectionDialogAsync(availableProviders); - - if (accountCreationDialogResult == null) return; - - var accountCreationCancellationTokenSource = new CancellationTokenSource(); - var accountCreationDialog = CalendarDialogService.GetAccountCreationDialog(accountCreationDialogResult); - - await accountCreationDialog.ShowDialogAsync(accountCreationCancellationTokenSource); - await Task.Delay(500); - - // For OAuth authentications, we just generate token and assign it to the MailAccount. - - var createdAccount = new MailAccount() - { - ProviderType = accountCreationDialogResult.ProviderType, - Name = accountCreationDialogResult.AccountName, - Id = Guid.NewGuid() - }; - - var tokenInformationResponse = await WinoServerConnectionManager - .GetResponseAsync(new AuthorizationRequested(accountCreationDialogResult.ProviderType, - createdAccount, - createdAccount.ProviderType == MailProviderType.Gmail), accountCreationCancellationTokenSource.Token); - - if (accountCreationDialog.State == AccountCreationDialogState.Canceled) - throw new AccountSetupCanceledException(); - - tokenInformationResponse.ThrowIfFailed(); - - await AccountService.CreateAccountAsync(createdAccount, null); - - // Sync profile information if supported. - if (createdAccount.IsProfileInfoSyncSupported) - { - // Start profile information synchronization. - // It's only available for Outlook and Gmail synchronizers. - - var profileSyncOptions = new MailSynchronizationOptions() - { - AccountId = createdAccount.Id, - Type = MailSynchronizationType.UpdateProfile - }; - - var profileSynchronizationResponse = await WinoServerConnectionManager.GetResponseAsync(new NewMailSynchronizationRequested(profileSyncOptions, SynchronizationSource.Client)); - - var profileSynchronizationResult = profileSynchronizationResponse.Data; - - if (profileSynchronizationResult.CompletedState != SynchronizationCompletedState.Success) - throw new Exception(Translator.Exception_FailedToSynchronizeProfileInformation); - - createdAccount.SenderName = profileSynchronizationResult.ProfileInformation.SenderName; - createdAccount.Base64ProfilePictureData = profileSynchronizationResult.ProfileInformation.Base64ProfilePictureData; - - await AccountService.UpdateProfileInformationAsync(createdAccount.Id, profileSynchronizationResult.ProfileInformation); - } - - accountCreationDialog.State = AccountCreationDialogState.FetchingEvents; - - // Start synchronizing events. - var synchronizationOptions = new CalendarSynchronizationOptions() - { - AccountId = createdAccount.Id, - Type = CalendarSynchronizationType.CalendarMetadata - }; - - var synchronizationResponse = await WinoServerConnectionManager.GetResponseAsync(new NewCalendarSynchronizationRequested(synchronizationOptions, SynchronizationSource.Client)); - } -} diff --git a/Wino.Calendar.ViewModels/AppShellViewModel.cs b/Wino.Calendar.ViewModels/CalendarAppShellViewModel.cs similarity index 98% rename from Wino.Calendar.ViewModels/AppShellViewModel.cs rename to Wino.Calendar.ViewModels/CalendarAppShellViewModel.cs index d6529511..8f656180 100644 --- a/Wino.Calendar.ViewModels/AppShellViewModel.cs +++ b/Wino.Calendar.ViewModels/CalendarAppShellViewModel.cs @@ -23,7 +23,7 @@ using Wino.Messaging.Server; namespace Wino.Calendar.ViewModels; -public partial class AppShellViewModel : CalendarBaseViewModel, +public partial class CalendarAppShellViewModel : CalendarBaseViewModel, IRecipient, IRecipient, IRecipient, @@ -69,7 +69,7 @@ public partial class AppShellViewModel : CalendarBaseViewModel, // For updating account calendars asynchronously. private SemaphoreSlim _accountCalendarUpdateSemaphoreSlim = new(1); - public AppShellViewModel(IPreferencesService preferencesService, + public CalendarAppShellViewModel(IPreferencesService preferencesService, IStatePersistanceService statePersistanceService, IAccountService accountService, ICalendarService calendarService, @@ -223,7 +223,7 @@ public partial class AppShellViewModel : CalendarBaseViewModel, { AccountId = account.Id, Type = CalendarSynchronizationType.CalendarMetadata - }, SynchronizationSource.Client); + }); Messenger.Send(t); } diff --git a/Wino.Calendar.ViewModels/CalendarPageViewModel.cs b/Wino.Calendar.ViewModels/CalendarPageViewModel.cs index 99cd7a94..22012b17 100644 --- a/Wino.Calendar.ViewModels/CalendarPageViewModel.cs +++ b/Wino.Calendar.ViewModels/CalendarPageViewModel.cs @@ -711,7 +711,7 @@ public partial class CalendarPageViewModel : CalendarBaseViewModel, } } - partial void OnSelectedStartTimeStringChanged(string newValue) + partial void OnSelectedStartTimeStringChanged(string oldValue, string newValue) { var parsedTime = CurrentSettings.GetTimeSpan(newValue); @@ -725,7 +725,7 @@ public partial class CalendarPageViewModel : CalendarBaseViewModel, } } - partial void OnSelectedEndTimeStringChanged(string newValue) + partial void OnSelectedEndTimeStringChanged(string oldValue, string newValue) { var parsedTime = CurrentSettings.GetTimeSpan(newValue); diff --git a/Wino.Calendar.ViewModels/CalendarViewModelContainerSetup.cs b/Wino.Calendar.ViewModels/CalendarViewModelContainerSetup.cs deleted file mode 100644 index 2f8b452d..00000000 --- a/Wino.Calendar.ViewModels/CalendarViewModelContainerSetup.cs +++ /dev/null @@ -1,12 +0,0 @@ -using Microsoft.Extensions.DependencyInjection; -using Wino.Core; - -namespace Wino.Calendar.ViewModels; - -public static class CalendarViewModelContainerSetup -{ - public static void RegisterCalendarViewModelServices(this IServiceCollection services) - { - services.RegisterCoreServices(); - } -} diff --git a/Wino.Calendar.ViewModels/Data/GroupedAccountCalendarViewModel.cs b/Wino.Calendar.ViewModels/Data/GroupedAccountCalendarViewModel.cs index 92b388ba..28b05f6a 100644 --- a/Wino.Calendar.ViewModels/Data/GroupedAccountCalendarViewModel.cs +++ b/Wino.Calendar.ViewModels/Data/GroupedAccountCalendarViewModel.cs @@ -98,7 +98,7 @@ public partial class GroupedAccountCalendarViewModel : ObservableObject _isExternalPropChangeBlocked = false; } - partial void OnIsCheckedStateChanged(bool? newValue) + partial void OnIsCheckedStateChanged(bool? oldValue, bool? newValue) { if (_isExternalPropChangeBlocked) return; diff --git a/Wino.Calendar.ViewModels/Wino.Calendar.ViewModels.csproj b/Wino.Calendar.ViewModels/Wino.Calendar.ViewModels.csproj index ddc81fa8..c1a3c878 100644 --- a/Wino.Calendar.ViewModels/Wino.Calendar.ViewModels.csproj +++ b/Wino.Calendar.ViewModels/Wino.Calendar.ViewModels.csproj @@ -1,6 +1,6 @@  - net9.0 + net10.0 x86;x64;arm64 win-x86;win-x64;win-arm64 true diff --git a/Wino.Core.Domain/Entities/Calendar/CalendarItem.cs b/Wino.Core.Domain/Entities/Calendar/CalendarItem.cs index de03e74e..4baba8cd 100644 --- a/Wino.Core.Domain/Entities/Calendar/CalendarItem.cs +++ b/Wino.Core.Domain/Entities/Calendar/CalendarItem.cs @@ -30,6 +30,18 @@ public class CalendarItem : ICalendarItem public TimeSpan StartDateOffset { get; set; } public TimeSpan EndDateOffset { get; set; } + /// + /// IANA timezone identifier for the start time (e.g., "America/New_York", "Europe/London"). + /// If null or empty, UTC is assumed. + /// + public string StartTimeZone { get; set; } + + /// + /// IANA timezone identifier for the end time (e.g., "America/New_York", "Europe/London"). + /// If null or empty, UTC is assumed. + /// + public string EndTimeZone { get; set; } + private ITimePeriod _period; public ITimePeriod Period { @@ -170,10 +182,70 @@ public class CalendarItem : ICalendarItem HtmlLink = HtmlLink, StartDateOffset = StartDateOffset, EndDateOffset = EndDateOffset, + StartTimeZone = StartTimeZone, + EndTimeZone = EndTimeZone, RemoteEventId = RemoteEventId, IsHidden = IsHidden, IsLocked = IsLocked, IsOccurrence = true }; } + + /// + /// Gets the start date as a DateTimeOffset with the correct timezone. + /// If StartTimeZone is available, uses it to calculate the offset. + /// Otherwise, uses the stored StartDateOffset or assumes UTC. + /// + [Ignore] + public DateTimeOffset StartDateTimeOffset + { + get + { + if (!string.IsNullOrEmpty(StartTimeZone)) + { + try + { + var timeZoneInfo = TimeZoneInfo.FindSystemTimeZoneById(StartTimeZone); + var offset = timeZoneInfo.GetUtcOffset(StartDate); + return new DateTimeOffset(StartDate, offset); + } + catch + { + // If timezone lookup fails, fall back to stored offset + } + } + + // Fall back to stored offset, or UTC if offset is zero + return new DateTimeOffset(StartDate, StartDateOffset); + } + } + + /// + /// Gets the end date as a DateTimeOffset with the correct timezone. + /// If EndTimeZone is available, uses it to calculate the offset. + /// Otherwise, uses the stored EndDateOffset or assumes UTC. + /// + [Ignore] + public DateTimeOffset EndDateTimeOffset + { + get + { + if (!string.IsNullOrEmpty(EndTimeZone)) + { + try + { + var timeZoneInfo = TimeZoneInfo.FindSystemTimeZoneById(EndTimeZone); + var offset = timeZoneInfo.GetUtcOffset(EndDate); + return new DateTimeOffset(EndDate, offset); + } + catch + { + // If timezone lookup fails, fall back to stored offset + } + } + + // Fall back to stored offset, or UTC if offset is zero + return new DateTimeOffset(EndDate, EndDateOffset); + } + } } diff --git a/Wino.Core.Domain/Enums/NavigationReferenceFrame.cs b/Wino.Core.Domain/Enums/NavigationReferenceFrame.cs index 3495a98e..e7279206 100644 --- a/Wino.Core.Domain/Enums/NavigationReferenceFrame.cs +++ b/Wino.Core.Domain/Enums/NavigationReferenceFrame.cs @@ -3,5 +3,6 @@ public enum NavigationReferenceFrame { ShellFrame, + InnerShellFrame, RenderingFrame } diff --git a/Wino.Core.Domain/Enums/WinoApplicationMode.cs b/Wino.Core.Domain/Enums/WinoApplicationMode.cs new file mode 100644 index 00000000..3c51ece4 --- /dev/null +++ b/Wino.Core.Domain/Enums/WinoApplicationMode.cs @@ -0,0 +1,7 @@ +namespace Wino.Core.Domain.Enums; + +public enum WinoApplicationMode +{ + Mail, + Calendar +} diff --git a/Wino.Core.Domain/Interfaces/ISynchronizationManager.cs b/Wino.Core.Domain/Interfaces/ISynchronizationManager.cs index 65df1c23..61017244 100644 --- a/Wino.Core.Domain/Interfaces/ISynchronizationManager.cs +++ b/Wino.Core.Domain/Interfaces/ISynchronizationManager.cs @@ -64,6 +64,12 @@ public interface ISynchronizationManager Task SynchronizeProfileAsync(Guid accountId, CancellationToken cancellationToken = default); + /// + /// Handles calendar synchronization for the given account. + /// + Task SynchronizeCalendarAsync(CalendarSynchronizationOptions options, + CancellationToken cancellationToken = default); + /// /// Downloads a MIME message for the given mail item. /// diff --git a/Wino.Core.Domain/Interfaces/IWinoNavigationService.cs b/Wino.Core.Domain/Interfaces/IWinoNavigationService.cs index 28b015c7..b343dd00 100644 --- a/Wino.Core.Domain/Interfaces/IWinoNavigationService.cs +++ b/Wino.Core.Domain/Interfaces/IWinoNavigationService.cs @@ -8,9 +8,10 @@ public interface INavigationService { bool Navigate(WinoPage page, object parameter = null, - NavigationReferenceFrame frame = NavigationReferenceFrame.ShellFrame, + NavigationReferenceFrame frame = NavigationReferenceFrame.InnerShellFrame, NavigationTransitionType transition = NavigationTransitionType.None); Type GetPageType(WinoPage winoPage); void GoBack(); + bool ChangeApplicationMode(WinoApplicationMode mode); } diff --git a/Wino.Core/Extensions/GoogleIntegratorExtensions.cs b/Wino.Core/Extensions/GoogleIntegratorExtensions.cs index bbfe9a90..31c6aa83 100644 --- a/Wino.Core/Extensions/GoogleIntegratorExtensions.cs +++ b/Wino.Core/Extensions/GoogleIntegratorExtensions.cs @@ -177,6 +177,15 @@ public static class GoogleIntegratorExtensions return null; } + /// + /// Extracts the timezone string from EventDateTime. + /// Returns null for all-day events or if timezone is not specified. + /// + public static string GetEventTimeZone(EventDateTime eventDateTime) + { + return eventDateTime?.TimeZone; + } + /// /// RRULE, EXRULE, RDATE and EXDATE lines for a recurring event, as specified in RFC5545. /// diff --git a/Wino.Core/Extensions/OutlookIntegratorExtensions.cs b/Wino.Core/Extensions/OutlookIntegratorExtensions.cs index 18f6e2f7..56368809 100644 --- a/Wino.Core/Extensions/OutlookIntegratorExtensions.cs +++ b/Wino.Core/Extensions/OutlookIntegratorExtensions.cs @@ -229,23 +229,37 @@ public static class OutlookIntegratorExtensions public static DateTimeOffset GetDateTimeOffsetFromDateTimeTimeZone(DateTimeTimeZone dateTimeTimeZone) { - if (dateTimeTimeZone == null || string.IsNullOrEmpty(dateTimeTimeZone.DateTime) || string.IsNullOrEmpty(dateTimeTimeZone.TimeZone)) + if (dateTimeTimeZone == null || string.IsNullOrEmpty(dateTimeTimeZone.DateTime)) { - throw new ArgumentException("DateTimeTimeZone is null or empty."); + throw new ArgumentException("DateTimeTimeZone or DateTime is null or empty."); } try { // Parse the DateTime string - if (DateTime.TryParse(dateTimeTimeZone.DateTime, out DateTime parsedDateTime)) + if (!DateTime.TryParse(dateTimeTimeZone.DateTime, out DateTime parsedDateTime)) + { + throw new ArgumentException("DateTime string is not in a valid format."); + } + + // If no timezone is provided, assume UTC + if (string.IsNullOrEmpty(dateTimeTimeZone.TimeZone)) + { + return new DateTimeOffset(parsedDateTime, TimeSpan.Zero); + } + + try { // Get TimeZoneInfo to get the offset TimeZoneInfo timeZoneInfo = TimeZoneInfo.FindSystemTimeZoneById(dateTimeTimeZone.TimeZone); TimeSpan offset = timeZoneInfo.GetUtcOffset(parsedDateTime); return new DateTimeOffset(parsedDateTime, offset); } - else - throw new ArgumentException("DateTime string is not in a valid format."); + catch (TimeZoneNotFoundException) + { + // If timezone is not found, assume UTC as fallback + return new DateTimeOffset(parsedDateTime, TimeSpan.Zero); + } } catch (Exception) { diff --git a/Wino.Core/Integration/Processors/GmailChangeProcessor.cs b/Wino.Core/Integration/Processors/GmailChangeProcessor.cs index e7d6ab90..5fe2f6ca 100644 --- a/Wino.Core/Integration/Processors/GmailChangeProcessor.cs +++ b/Wino.Core/Integration/Processors/GmailChangeProcessor.cs @@ -102,6 +102,10 @@ public class GmailChangeProcessor : DefaultChangeProcessor, IGmailChangeProcesso DurationInSeconds = totalDurationInSeconds, Location = string.IsNullOrEmpty(calendarEvent.Location) ? parentRecurringEvent.Location : calendarEvent.Location, + // Store timezone information + StartTimeZone = GoogleIntegratorExtensions.GetEventTimeZone(calendarEvent.Start) ?? parentRecurringEvent.StartTimeZone, + EndTimeZone = GoogleIntegratorExtensions.GetEventTimeZone(calendarEvent.End) ?? parentRecurringEvent.EndTimeZone, + // Leave it empty if it's not populated. Recurrence = GoogleIntegratorExtensions.GetRecurrenceString(calendarEvent) == null ? string.Empty : GoogleIntegratorExtensions.GetRecurrenceString(calendarEvent), Status = GetStatus(calendarEvent.Status), @@ -137,6 +141,11 @@ public class GmailChangeProcessor : DefaultChangeProcessor, IGmailChangeProcesso EndDateOffset = eventEndDateTimeOffset.Value.Offset, DurationInSeconds = totalDurationInSeconds, Location = calendarEvent.Location, + + // Store timezone information from Google Calendar event + StartTimeZone = GoogleIntegratorExtensions.GetEventTimeZone(calendarEvent.Start), + EndTimeZone = GoogleIntegratorExtensions.GetEventTimeZone(calendarEvent.End), + Recurrence = GoogleIntegratorExtensions.GetRecurrenceString(calendarEvent), Status = GetStatus(calendarEvent.Status), Title = calendarEvent.Summary, diff --git a/Wino.Core/Integration/Processors/OutlookChangeProcessor.cs b/Wino.Core/Integration/Processors/OutlookChangeProcessor.cs index b597d788..371c6876 100644 --- a/Wino.Core/Integration/Processors/OutlookChangeProcessor.cs +++ b/Wino.Core/Integration/Processors/OutlookChangeProcessor.cs @@ -69,6 +69,11 @@ public class OutlookChangeProcessor(IDatabaseService databaseService, savingItem.EndDateOffset = eventEndDateTimeOffset.Offset; savingItem.DurationInSeconds = durationInSeconds; + // Store the timezone information from the event + // This preserves the original timezone from Outlook, allowing proper reconstruction later + savingItem.StartTimeZone = calendarEvent.Start?.TimeZone; + savingItem.EndTimeZone = calendarEvent.End?.TimeZone; + savingItem.Title = calendarEvent.Subject; savingItem.Description = calendarEvent.Body?.Content; savingItem.Location = calendarEvent.Location?.DisplayName; @@ -103,6 +108,34 @@ public class OutlookChangeProcessor(IDatabaseService databaseService, savingItem.OrganizerDisplayName = calendarEvent.Organizer?.EmailAddress?.Name; savingItem.IsHidden = false; + // Set timestamps + if (calendarEvent.CreatedDateTime.HasValue) + savingItem.CreatedAt = calendarEvent.CreatedDateTime.Value; + + if (calendarEvent.LastModifiedDateTime.HasValue) + savingItem.UpdatedAt = calendarEvent.LastModifiedDateTime.Value; + + // Set visibility + if (calendarEvent.Sensitivity != null) + { + savingItem.Visibility = calendarEvent.Sensitivity.Value switch + { + Sensitivity.Normal => CalendarItemVisibility.Public, + Sensitivity.Personal => CalendarItemVisibility.Private, + Sensitivity.Private => CalendarItemVisibility.Private, + Sensitivity.Confidential => CalendarItemVisibility.Confidential, + _ => CalendarItemVisibility.Public + }; + } + else + { + savingItem.Visibility = CalendarItemVisibility.Public; + } + + // Set IsLocked based on whether the user is the organizer + // Read-only events are those where the current user is not the organizer + savingItem.IsLocked = calendarEvent.IsOrganizer.HasValue && !calendarEvent.IsOrganizer.Value; + if (calendarEvent.ResponseStatus?.Response != null) { switch (calendarEvent.ResponseStatus.Response.Value) diff --git a/Wino.Core/Services/SynchronizationManager.cs b/Wino.Core/Services/SynchronizationManager.cs index 563f2074..65c1cf77 100644 --- a/Wino.Core/Services/SynchronizationManager.cs +++ b/Wino.Core/Services/SynchronizationManager.cs @@ -299,6 +299,56 @@ public class SynchronizationManager : ISynchronizationManager return await SynchronizeMailAsync(options, cancellationToken); } + /// + /// Handles calendar synchronization for the given account. + /// + /// Calendar synchronization options + /// Cancellation token + /// Synchronization result + public async Task SynchronizeCalendarAsync(CalendarSynchronizationOptions options, + CancellationToken cancellationToken = default) + { + EnsureInitialized(); + + var synchronizer = await GetOrCreateSynchronizerAsync(options.AccountId); + if (synchronizer == null) + { + _logger.Error("Could not find or create synchronizer for account {AccountId}", options.AccountId); + return CalendarSynchronizationResult.Failed; + } + + _logger.Information("Starting calendar synchronization for account {AccountId} with type {SyncType}", + options.AccountId, options.Type); + + try + { + var result = await synchronizer.SynchronizeCalendarEventsAsync(options, cancellationToken); + + _logger.Information("Calendar synchronization completed for account {AccountId} with state {State}", + options.AccountId, result.CompletedState); + + // TODO: Create notifications for new calendar events when INotificationBuilder supports it + // if (result.DownloadedEvents?.Any() ?? false) + // await _notificationBuilder.CreateCalendarNotificationsAsync(result.DownloadedEvents); + + return result; + } + catch (AuthenticationAttentionException authEx) + { + _logger.Warning("Account {AccountId} requires attention due to authentication issues", options.AccountId); + + // Create app notification for authentication attention + _notificationBuilder.CreateAttentionRequiredNotification(authEx.Account); + + return CalendarSynchronizationResult.Failed; + } + catch (Exception ex) + { + _logger.Error(ex, "Calendar synchronization failed for account {AccountId}", options.AccountId); + return CalendarSynchronizationResult.Failed; + } + } + /// /// Downloads a MIME message for the given mail item. /// diff --git a/Wino.Core/Synchronizers/OutlookSynchronizer.cs b/Wino.Core/Synchronizers/OutlookSynchronizer.cs index 5c63e609..19ea1421 100644 --- a/Wino.Core/Synchronizers/OutlookSynchronizer.cs +++ b/Wino.Core/Synchronizers/OutlookSynchronizer.cs @@ -1631,8 +1631,8 @@ public class OutlookSynchronizer : WinoSynchronizer { @@ -1658,20 +1658,7 @@ public class OutlookSynchronizer : WinoSynchronizer - { - - //requestConfiguration.QueryParameters.StartDateTime = startDate; - //requestConfiguration.QueryParameters.EndDateTime = endDate; - }); - - //var requestInformation = _graphClient.Me.Calendars[calendar.RemoteCalendarId].CalendarView.Delta.ToGetRequestInformation((config) => - //{ - // config.QueryParameters.Top = (int)InitialMessageDownloadCountPerFolder; - // config.QueryParameters.Select = outlookMessageSelectParameters; - // config.QueryParameters.Orderby = ["receivedDateTime desc"]; - //}); - + var requestInformation = _graphClient.Me.Calendars[calendar.RemoteCalendarId].CalendarView.Delta.ToGetRequestInformation(); requestInformation.UrlTemplate = requestInformation.UrlTemplate.Insert(requestInformation.UrlTemplate.Length - 1, ",%24deltatoken"); requestInformation.QueryParameters.Add("%24deltatoken", currentDeltaToken); @@ -1726,11 +1713,12 @@ public class OutlookSynchronizer : WinoSynchronizer, IRecipient, IRecipient, @@ -83,7 +83,7 @@ public partial class AppShellViewModel : MailBaseViewModel, private readonly SemaphoreSlim accountInitFolderUpdateSlim = new SemaphoreSlim(1); - public AppShellViewModel(IMailDialogService dialogService, + public MailAppShellViewModel(IMailDialogService dialogService, INavigationService navigationService, IMimeFileService mimeFileService, INativeAppService nativeAppService, @@ -392,7 +392,7 @@ public partial class AppShellViewModel : MailBaseViewModel, var args = new NavigateMailFolderEventArgs(baseFolderMenuItem, folderInitAwaitTask); - NavigationService.Navigate(WinoPage.MailListPage, args, NavigationReferenceFrame.ShellFrame); + NavigationService.Navigate(WinoPage.MailListPage, args, NavigationReferenceFrame.InnerShellFrame); UpdateWindowTitleForFolder(baseFolderMenuItem); }); @@ -587,15 +587,15 @@ public partial class AppShellViewModel : MailBaseViewModel, } else if (clickedMenuItem is SettingsItem) { - NavigationService.Navigate(WinoPage.SettingsPage, parameter, NavigationReferenceFrame.ShellFrame, NavigationTransitionType.None); + NavigationService.Navigate(WinoPage.SettingsPage, parameter, NavigationReferenceFrame.InnerShellFrame, NavigationTransitionType.None); } else if (clickedMenuItem is ManageAccountsMenuItem) { - NavigationService.Navigate(WinoPage.ManageAccountsPage, parameter, NavigationReferenceFrame.ShellFrame, NavigationTransitionType.None); + NavigationService.Navigate(WinoPage.ManageAccountsPage, parameter, NavigationReferenceFrame.InnerShellFrame, NavigationTransitionType.None); } else if (clickedMenuItem is ContactsMenuItem) { - NavigationService.Navigate(WinoPage.ContactsPage, parameter, NavigationReferenceFrame.ShellFrame, NavigationTransitionType.None); + NavigationService.Navigate(WinoPage.ContactsPage, parameter, NavigationReferenceFrame.InnerShellFrame, NavigationTransitionType.None); } else if (clickedMenuItem is IAccountMenuItem clickedAccountMenuItem) { @@ -1003,7 +1003,7 @@ public partial class AppShellViewModel : MailBaseViewModel, accountMenuItem.TotalItemsToSync = message.TotalItemsToSync; accountMenuItem.RemainingItemsToSync = message.RemainingItemsToSync; accountMenuItem.SynchronizationStatus = message.SynchronizationStatus; - + // If this account is part of a merged inbox, update the merged inbox progress as well if (accountMenuItem.ParentMenuItem is MergedAccountMenuItem mergedAccountMenuItem) { @@ -1040,6 +1040,9 @@ public partial class AppShellViewModel : MailBaseViewModel, { base.UnregisterRecipients(); + Messenger.Unregister(this); + Messenger.Unregister(this); + Messenger.Unregister(this); Messenger.Unregister(this); Messenger.Unregister(this); Messenger.Unregister(this); diff --git a/Wino.Mail.ViewModels/MailBaseViewModel.cs b/Wino.Mail.ViewModels/MailBaseViewModel.cs index 75887487..e27324ce 100644 --- a/Wino.Mail.ViewModels/MailBaseViewModel.cs +++ b/Wino.Mail.ViewModels/MailBaseViewModel.cs @@ -43,7 +43,9 @@ public class MailBaseViewModel : CoreBaseViewModel, protected override void RegisterRecipients() { base.RegisterRecipients(); - + + UnregisterRecipients(); + Messenger.Register(this); Messenger.Register(this); Messenger.Register(this); @@ -58,7 +60,7 @@ public class MailBaseViewModel : CoreBaseViewModel, protected override void UnregisterRecipients() { base.UnregisterRecipients(); - + Messenger.Unregister(this); Messenger.Unregister(this); Messenger.Unregister(this); diff --git a/Wino.Mail.ViewModels/MailViewModelsContainerSetup.cs b/Wino.Mail.ViewModels/MailViewModelsContainerSetup.cs deleted file mode 100644 index d1526993..00000000 --- a/Wino.Mail.ViewModels/MailViewModelsContainerSetup.cs +++ /dev/null @@ -1,13 +0,0 @@ -using Microsoft.Extensions.DependencyInjection; -using Wino.Core; - -namespace Wino.Mail.ViewModels; - -public static class MailViewModelsContainerSetup -{ - public static void RegisterViewModelService(this IServiceCollection services) - { - // View models use core services. - services.RegisterCoreServices(); - } -} diff --git a/Wino.Mail.WinUI/App.xaml b/Wino.Mail.WinUI/App.xaml index de6023e7..c9a30e23 100644 --- a/Wino.Mail.WinUI/App.xaml +++ b/Wino.Mail.WinUI/App.xaml @@ -18,7 +18,14 @@ + + + + + + + diff --git a/Wino.Mail.WinUI/App.xaml.cs b/Wino.Mail.WinUI/App.xaml.cs index 052c5a75..10e8722b 100644 --- a/Wino.Mail.WinUI/App.xaml.cs +++ b/Wino.Mail.WinUI/App.xaml.cs @@ -9,6 +9,9 @@ using Microsoft.UI.Xaml; using Microsoft.Windows.AppLifecycle; using Microsoft.Windows.AppNotifications; using MimeKit.Cryptography; +using Wino.Calendar.ViewModels; +using Wino.Calendar.ViewModels.Interfaces; +using Wino.Core; using Wino.Core.Domain; using Wino.Core.Domain.Enums; using Wino.Core.Domain.Interfaces; @@ -17,12 +20,15 @@ using Wino.Core.Domain.Models.Synchronization; using Wino.Mail.Services; using Wino.Mail.ViewModels; using Wino.Mail.WinUI.Interfaces; +using Wino.Mail.WinUI.Services; using Wino.Messaging.Client.Accounts; using Wino.Messaging.Server; using Wino.Services; namespace Wino.Mail.WinUI; -public partial class App : WinoApplication, IRecipient +public partial class App : WinoApplication, + IRecipient, + IRecipient { private ISynchronizationManager? _synchronizationManager; @@ -60,11 +66,13 @@ public partial class App : WinoApplication, IRecipient(); services.AddTransient(); services.AddSingleton(); + services.AddSingleton(); } private void RegisterViewModels(IServiceCollection services) { - services.AddSingleton(typeof(AppShellViewModel)); + services.AddSingleton(typeof(MailAppShellViewModel)); + services.AddSingleton(typeof(CalendarAppShellViewModel)); services.AddTransient(typeof(MailListPageViewModel)); services.AddTransient(typeof(MailRenderingPageViewModel)); @@ -85,6 +93,10 @@ public partial class App : WinoApplication, IRecipient(this); + WeakReferenceMessenger.Default.Register(this); } - public void Receive(NewMailSynchronizationRequested message) - { - _synchronizationManager?.SynchronizeMailAsync(message.Options); - } + public void Receive(NewMailSynchronizationRequested message) => _synchronizationManager?.SynchronizeMailAsync(message.Options); + public void Receive(NewCalendarSynchronizationRequested message) => _synchronizationManager?.SynchronizeCalendarAsync(message.Options); /// /// Handles activation redirected from another instance (single-instancing). @@ -378,4 +389,5 @@ public partial class App : WinoApplication, IRecipient + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Wino.Mail.WinUI/Controls/Calendar/CalendarItemControl.xaml.cs b/Wino.Mail.WinUI/Controls/Calendar/CalendarItemControl.xaml.cs new file mode 100644 index 00000000..e714cd5e --- /dev/null +++ b/Wino.Mail.WinUI/Controls/Calendar/CalendarItemControl.xaml.cs @@ -0,0 +1,197 @@ +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.Messaging; +using Itenso.TimePeriod; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Input; +using Wino.Calendar.ViewModels.Data; +using Wino.Calendar.ViewModels.Messages; +using Wino.Core.Domain; +using Wino.Core.Domain.Models.Calendar; + +namespace Wino.Calendar.Controls; + +public sealed partial class CalendarItemControl : UserControl +{ + // Single tap has a delay to report double taps properly. + private bool isSingleTap = false; + + public static readonly DependencyProperty CalendarItemProperty = DependencyProperty.Register(nameof(CalendarItem), typeof(CalendarItemViewModel), typeof(CalendarItemControl), new PropertyMetadata(null, new PropertyChangedCallback(OnCalendarItemChanged))); + public static readonly DependencyProperty IsDraggingProperty = DependencyProperty.Register(nameof(IsDragging), typeof(bool), typeof(CalendarItemControl), new PropertyMetadata(false)); + public static readonly DependencyProperty IsCustomEventAreaProperty = DependencyProperty.Register(nameof(IsCustomEventArea), typeof(bool), typeof(CalendarItemControl), new PropertyMetadata(false)); + public static readonly DependencyProperty CalendarItemTitleProperty = DependencyProperty.Register(nameof(CalendarItemTitle), typeof(string), typeof(CalendarItemControl), new PropertyMetadata(string.Empty)); + public static readonly DependencyProperty DisplayingDateProperty = DependencyProperty.Register(nameof(DisplayingDate), typeof(CalendarDayModel), typeof(CalendarItemControl), new PropertyMetadata(null, new PropertyChangedCallback(OnDisplayDateChanged))); + + /// + /// Whether the control is displaying as regular event or all-multi day area in the day control. + /// + public bool IsCustomEventArea + { + get { return (bool)GetValue(IsCustomEventAreaProperty); } + set { SetValue(IsCustomEventAreaProperty, value); } + } + + /// + /// Day that the calendar item is rendered at. + /// It's needed for title manipulation and some other adjustments later on. + /// + public CalendarDayModel DisplayingDate + { + get { return (CalendarDayModel)GetValue(DisplayingDateProperty); } + set { SetValue(DisplayingDateProperty, value); } + } + + public string CalendarItemTitle + { + get { return (string)GetValue(CalendarItemTitleProperty); } + set { SetValue(CalendarItemTitleProperty, value); } + } + + public CalendarItemViewModel CalendarItem + { + get { return (CalendarItemViewModel)GetValue(CalendarItemProperty); } + set { SetValue(CalendarItemProperty, value); } + } + + public bool IsDragging + { + get { return (bool)GetValue(IsDraggingProperty); } + set { SetValue(IsDraggingProperty, value); } + } + + public CalendarItemControl() + { + InitializeComponent(); + } + + private static void OnDisplayDateChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d is CalendarItemControl control) + { + control.UpdateControlVisuals(); + } + } + + private static void OnCalendarItemChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d is CalendarItemControl control) + { + control.UpdateControlVisuals(); + } + } + + private void UpdateControlVisuals() + { + // Depending on the calendar item's duration and attributes, we might need to change the display title. + // 1. Multi-Day events should display the start date and end date. + // 2. Multi-Day events that occupy the whole day just shows 'all day'. + // 3. Other events should display the title. + + if (CalendarItem == null) return; + if (DisplayingDate == null) return; + + if (CalendarItem.IsMultiDayEvent) + { + // Multi day events are divided into 3 categories: + // 1. All day events + // 2. Events that started after the period. + // 3. Events that started before the period and finishes within the period. + + var periodRelation = CalendarItem.Period.GetRelation(DisplayingDate.Period); + + if (periodRelation == Itenso.TimePeriod.PeriodRelation.StartInside || + periodRelation == PeriodRelation.EnclosingStartTouching) + { + // hour -> title + CalendarItemTitle = $"{DisplayingDate.CalendarRenderOptions.CalendarSettings.GetTimeString(CalendarItem.StartDate.TimeOfDay)} -> {CalendarItem.Title}"; + } + else if ( + periodRelation == PeriodRelation.EndInside || + periodRelation == PeriodRelation.EnclosingEndTouching) + { + // title <- hour + CalendarItemTitle = $"{CalendarItem.Title} <- {DisplayingDate.CalendarRenderOptions.CalendarSettings.GetTimeString(CalendarItem.EndDate.TimeOfDay)}"; + } + else if (periodRelation == PeriodRelation.Enclosing) + { + // This event goes all day and it's multi-day. + // Item must be hidden in the calendar but displayed on the custom area at the top. + + CalendarItemTitle = $"{Translator.CalendarItemAllDay} {CalendarItem.Title}"; + } + else + { + // Not expected, but there it is. + CalendarItemTitle = CalendarItem.Title; + } + + // Debug.WriteLine($"{CalendarItem.Title} Period relation with {DisplayingDate.Period.ToString()}: {periodRelation}"); + } + else + { + CalendarItemTitle = CalendarItem.Title; + } + + UpdateVisualStates(); + } + + private void UpdateVisualStates() + { + if (CalendarItem == null) return; + + if (CalendarItem.IsAllDayEvent) + { + VisualStateManager.GoToState(this, "AllDayEvent", true); + } + else if (CalendarItem.IsMultiDayEvent) + { + if (IsCustomEventArea) + { + VisualStateManager.GoToState(this, "CustomAreaMultiDayEvent", true); + } + else + { + // Hide it. + VisualStateManager.GoToState(this, "MultiDayEvent", true); + } + } + else + { + VisualStateManager.GoToState(this, "RegularEvent", true); + } + } + + private void ControlDragStarting(UIElement sender, DragStartingEventArgs args) => IsDragging = true; + + private void ControlDropped(UIElement sender, DropCompletedEventArgs args) => IsDragging = false; + + private async void ControlTapped(object sender, TappedRoutedEventArgs e) + { + if (CalendarItem == null) return; + + isSingleTap = true; + + await Task.Delay(100); + + if (isSingleTap) + { + WeakReferenceMessenger.Default.Send(new CalendarItemTappedMessage(CalendarItem, DisplayingDate)); + } + } + + private void ControlDoubleTapped(object sender, DoubleTappedRoutedEventArgs e) + { + if (CalendarItem == null) return; + + isSingleTap = false; + + WeakReferenceMessenger.Default.Send(new CalendarItemDoubleTappedMessage(CalendarItem)); + } + + private void ControlRightTapped(object sender, RightTappedRoutedEventArgs e) + { + if (CalendarItem == null) return; + + WeakReferenceMessenger.Default.Send(new CalendarItemRightTappedMessage(CalendarItem)); + } +} diff --git a/Wino.Mail.WinUI/Controls/Calendar/CustomCalendarFlipView.cs b/Wino.Mail.WinUI/Controls/Calendar/CustomCalendarFlipView.cs new file mode 100644 index 00000000..3cc32ec7 --- /dev/null +++ b/Wino.Mail.WinUI/Controls/Calendar/CustomCalendarFlipView.cs @@ -0,0 +1,42 @@ +using Microsoft.UI.Xaml.Automation.Peers; +using Microsoft.UI.Xaml.Controls; + +namespace Wino.Calendar.Controls; + +/// +/// FlipView that hides the navigation buttons and exposes methods to navigate to the next and previous items with animations. +/// +public partial class CustomCalendarFlipView : FlipView +{ + private const string PART_PreviousButton = "PreviousButtonHorizontal"; + private const string PART_NextButton = "NextButtonHorizontal"; + + private Button PreviousButton; + private Button NextButton; + + protected override void OnApplyTemplate() + { + base.OnApplyTemplate(); + + PreviousButton = GetTemplateChild(PART_PreviousButton) as Button; + NextButton = GetTemplateChild(PART_NextButton) as Button; + + // Hide navigation buttons + PreviousButton.Opacity = NextButton.Opacity = 0; + PreviousButton.IsHitTestVisible = NextButton.IsHitTestVisible = false; + + var t = FindName("ScrollingHost"); + } + + public void GoPreviousFlip() + { + var backPeer = new ButtonAutomationPeer(PreviousButton); + backPeer.Invoke(); + } + + public void GoNextFlip() + { + var nextPeer = new ButtonAutomationPeer(NextButton); + nextPeer.Invoke(); + } +} diff --git a/Wino.Mail.WinUI/Controls/Calendar/DayColumnControl.cs b/Wino.Mail.WinUI/Controls/Calendar/DayColumnControl.cs new file mode 100644 index 00000000..50ccc128 --- /dev/null +++ b/Wino.Mail.WinUI/Controls/Calendar/DayColumnControl.cs @@ -0,0 +1,77 @@ +using System; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Wino.Core.Domain.Models.Calendar; + +namespace Wino.Calendar.Controls; + +public partial class DayColumnControl : Control +{ + private const string PART_HeaderDateDayText = nameof(PART_HeaderDateDayText); + private const string PART_IsTodayBorder = nameof(PART_IsTodayBorder); + private const string PART_ColumnHeaderText = nameof(PART_ColumnHeaderText); + + private const string PART_AllDayItemsControl = nameof(PART_AllDayItemsControl); + + private const string TodayState = nameof(TodayState); + private const string NotTodayState = nameof(NotTodayState); + + private TextBlock HeaderDateDayText; + private TextBlock ColumnHeaderText; + private Border IsTodayBorder; + private ItemsControl AllDayItemsControl; + + public CalendarDayModel DayModel + { + get { return (CalendarDayModel)GetValue(DayModelProperty); } + set { SetValue(DayModelProperty, value); } + } + + public static readonly DependencyProperty DayModelProperty = DependencyProperty.Register(nameof(DayModel), typeof(CalendarDayModel), typeof(DayColumnControl), new PropertyMetadata(null, new PropertyChangedCallback(OnRenderingPropertiesChanged))); + + public DayColumnControl() + { + DefaultStyleKey = typeof(DayColumnControl); + } + + protected override void OnApplyTemplate() + { + base.OnApplyTemplate(); + + HeaderDateDayText = GetTemplateChild(PART_HeaderDateDayText) as TextBlock; + ColumnHeaderText = GetTemplateChild(PART_ColumnHeaderText) as TextBlock; + IsTodayBorder = GetTemplateChild(PART_IsTodayBorder) as Border; + AllDayItemsControl = GetTemplateChild(PART_AllDayItemsControl) as ItemsControl; + + UpdateValues(); + } + + private static void OnRenderingPropertiesChanged(DependencyObject control, DependencyPropertyChangedEventArgs e) + { + if (control is DayColumnControl columnControl) + { + columnControl.UpdateValues(); + } + } + + private void UpdateValues() + { + if (HeaderDateDayText == null || IsTodayBorder == null || DayModel == null) return; + + HeaderDateDayText.Text = DayModel.RepresentingDate.Day.ToString(); + + // Monthly template does not use it. + if (ColumnHeaderText != null) + { + ColumnHeaderText.Text = DayModel.RepresentingDate.ToString("dddd", DayModel.CalendarRenderOptions.CalendarSettings.CultureInfo); + } + + AllDayItemsControl.ItemsSource = DayModel.EventsCollection.AllDayEvents; + + bool isToday = DayModel.RepresentingDate.Date == DateTime.Now.Date; + + VisualStateManager.GoToState(this, isToday ? TodayState : NotTodayState, false); + + UpdateLayout(); + } +} diff --git a/Wino.Mail.WinUI/Controls/Calendar/DayHeaderControl.cs b/Wino.Mail.WinUI/Controls/Calendar/DayHeaderControl.cs new file mode 100644 index 00000000..d7154ef2 --- /dev/null +++ b/Wino.Mail.WinUI/Controls/Calendar/DayHeaderControl.cs @@ -0,0 +1,56 @@ +using System; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Wino.Core.Domain.Enums; + +namespace Wino.Calendar.Controls; + +public partial class DayHeaderControl : Control +{ + private const string PART_DayHeaderTextBlock = nameof(PART_DayHeaderTextBlock); + private TextBlock HeaderTextblock; + + public DayHeaderDisplayType DisplayType + { + get { return (DayHeaderDisplayType)GetValue(DisplayTypeProperty); } + set { SetValue(DisplayTypeProperty, value); } + } + + public DateTime Date + { + get { return (DateTime)GetValue(DateProperty); } + set { SetValue(DateProperty, value); } + } + + public static readonly DependencyProperty DateProperty = DependencyProperty.Register(nameof(Date), typeof(DateTime), typeof(DayHeaderControl), new PropertyMetadata(default(DateTime), new PropertyChangedCallback(OnHeaderPropertyChanged))); + public static readonly DependencyProperty DisplayTypeProperty = DependencyProperty.Register(nameof(DisplayType), typeof(DayHeaderDisplayType), typeof(DayHeaderControl), new PropertyMetadata(DayHeaderDisplayType.TwentyFourHour, new PropertyChangedCallback(OnHeaderPropertyChanged))); + + public DayHeaderControl() + { + DefaultStyleKey = typeof(DayHeaderControl); + } + + protected override void OnApplyTemplate() + { + base.OnApplyTemplate(); + + HeaderTextblock = GetTemplateChild(PART_DayHeaderTextBlock) as TextBlock; + UpdateHeaderText(); + } + + private static void OnHeaderPropertyChanged(DependencyObject control, DependencyPropertyChangedEventArgs e) + { + if (control is DayHeaderControl headerControl) + { + headerControl.UpdateHeaderText(); + } + } + + private void UpdateHeaderText() + { + if (HeaderTextblock != null) + { + HeaderTextblock.Text = DisplayType == DayHeaderDisplayType.TwelveHour ? Date.ToString("h tt") : Date.ToString("HH:mm"); + } + } +} diff --git a/Wino.Mail.WinUI/Controls/Calendar/WinoCalendarControl.cs b/Wino.Mail.WinUI/Controls/Calendar/WinoCalendarControl.cs new file mode 100644 index 00000000..eb32d8bb --- /dev/null +++ b/Wino.Mail.WinUI/Controls/Calendar/WinoCalendarControl.cs @@ -0,0 +1,299 @@ +using System; +using System.Collections.ObjectModel; +using System.Linq; +using System.Threading.Tasks; +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 +{ + 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 + + public static readonly DependencyProperty DayRangesProperty = DependencyProperty.Register(nameof(DayRanges), typeof(ObservableCollection), typeof(WinoCalendarControl), new PropertyMetadata(null)); + public static readonly DependencyProperty SelectedFlipViewIndexProperty = DependencyProperty.Register(nameof(SelectedFlipViewIndex), typeof(int), typeof(WinoCalendarControl), new PropertyMetadata(-1)); + public static readonly DependencyProperty SelectedFlipViewDayRangeProperty = DependencyProperty.Register(nameof(SelectedFlipViewDayRange), typeof(DayRangeRenderModel), typeof(WinoCalendarControl), new PropertyMetadata(null)); + public static readonly DependencyProperty ActiveCanvasProperty = DependencyProperty.Register(nameof(ActiveCanvas), typeof(WinoDayTimelineCanvas), typeof(WinoCalendarControl), new PropertyMetadata(null, new PropertyChangedCallback(OnActiveCanvasChanged))); + public static readonly DependencyProperty IsFlipIdleProperty = DependencyProperty.Register(nameof(IsFlipIdle), typeof(bool), typeof(WinoCalendarControl), new PropertyMetadata(true, new PropertyChangedCallback(OnIdleStateChanged))); + public static readonly DependencyProperty ActiveScrollViewerProperty = DependencyProperty.Register(nameof(ActiveScrollViewer), typeof(ScrollViewer), typeof(WinoCalendarControl), new PropertyMetadata(null, new PropertyChangedCallback(OnActiveVerticalScrollViewerChanged))); + + public static readonly DependencyProperty VerticalItemsPanelTemplateProperty = DependencyProperty.Register(nameof(VerticalItemsPanelTemplate), typeof(ItemsPanelTemplate), typeof(WinoCalendarControl), new PropertyMetadata(null, new PropertyChangedCallback(OnCalendarOrientationPropertiesUpdated))); + public static readonly DependencyProperty HorizontalItemsPanelTemplateProperty = DependencyProperty.Register(nameof(HorizontalItemsPanelTemplate), typeof(ItemsPanelTemplate), typeof(WinoCalendarControl), new PropertyMetadata(null, new PropertyChangedCallback(OnCalendarOrientationPropertiesUpdated))); + public static readonly DependencyProperty OrientationProperty = DependencyProperty.Register(nameof(Orientation), typeof(CalendarOrientation), typeof(WinoCalendarControl), new PropertyMetadata(CalendarOrientation.Horizontal, new PropertyChangedCallback(OnCalendarOrientationPropertiesUpdated))); + public static readonly DependencyProperty DisplayTypeProperty = DependencyProperty.Register(nameof(DisplayType), typeof(CalendarDisplayType), typeof(WinoCalendarControl), new PropertyMetadata(CalendarDisplayType.Day)); + + /// + /// 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. + /// + public CalendarDisplayType DisplayType + { + get { return (CalendarDisplayType)GetValue(DisplayTypeProperty); } + set { SetValue(DisplayTypeProperty, value); } + } + + public CalendarOrientation Orientation + { + get { return (CalendarOrientation)GetValue(OrientationProperty); } + set { SetValue(OrientationProperty, value); } + } + + public ItemsPanelTemplate VerticalItemsPanelTemplate + { + get { return (ItemsPanelTemplate)GetValue(VerticalItemsPanelTemplateProperty); } + set { SetValue(VerticalItemsPanelTemplateProperty, value); } + } + + public ItemsPanelTemplate HorizontalItemsPanelTemplate + { + get { return (ItemsPanelTemplate)GetValue(HorizontalItemsPanelTemplateProperty); } + set { SetValue(HorizontalItemsPanelTemplateProperty, value); } + } + + public DayRangeRenderModel SelectedFlipViewDayRange + { + get { return (DayRangeRenderModel)GetValue(SelectedFlipViewDayRangeProperty); } + set { SetValue(SelectedFlipViewDayRangeProperty, value); } + } + + public ScrollViewer ActiveScrollViewer + { + get { return (ScrollViewer)GetValue(ActiveScrollViewerProperty); } + set { SetValue(ActiveScrollViewerProperty, value); } + } + + public WinoDayTimelineCanvas ActiveCanvas + { + get { return (WinoDayTimelineCanvas)GetValue(ActiveCanvasProperty); } + set { SetValue(ActiveCanvasProperty, value); } + } + + public bool IsFlipIdle + { + get { return (bool)GetValue(IsFlipIdleProperty); } + set { SetValue(IsFlipIdleProperty, value); } + } + + /// + /// Gets or sets the collection of day ranges to render. + /// Each day range usually represents a week, but it may support other ranges. + /// + public ObservableCollection DayRanges + { + get { return (ObservableCollection)GetValue(DayRangesProperty); } + set { SetValue(DayRangesProperty, value); } + } + + public int SelectedFlipViewIndex + { + get { return (int)GetValue(SelectedFlipViewIndexProperty); } + set { SetValue(SelectedFlipViewIndexProperty, value); } + } + + #endregion + + private WinoCalendarFlipView InternalFlipView; + private Grid IdleGrid; + + public WinoCalendarControl() + { + DefaultStyleKey = typeof(WinoCalendarControl); + SizeChanged += CalendarSizeChanged; + } + + private static void OnCalendarOrientationPropertiesUpdated(DependencyObject calendar, DependencyPropertyChangedEventArgs e) + { + if (calendar is WinoCalendarControl control) + { + control.ManageCalendarOrientation(); + } + } + + private static void OnIdleStateChanged(DependencyObject calendar, DependencyPropertyChangedEventArgs e) + { + if (calendar is WinoCalendarControl calendarControl) + { + calendarControl.UpdateIdleState(); + } + } + + + private static void OnActiveVerticalScrollViewerChanged(DependencyObject calendar, DependencyPropertyChangedEventArgs e) + { + if (calendar is WinoCalendarControl calendarControl) + { + if (e.OldValue is ScrollViewer oldScrollViewer) + { + calendarControl.DeregisterScrollChanges(oldScrollViewer); + } + + if (e.NewValue is ScrollViewer newScrollViewer) + { + calendarControl.RegisterScrollChanges(newScrollViewer); + } + + calendarControl.ManageHighlightedDateRange(); + } + } + + + private static void OnActiveCanvasChanged(DependencyObject calendar, DependencyPropertyChangedEventArgs e) + { + if (calendar is WinoCalendarControl calendarControl) + { + if (e.OldValue is WinoDayTimelineCanvas oldCanvas) + { + // Dismiss any selection on the old canvas. + calendarControl.DeregisterCanvas(oldCanvas); + } + + if (e.NewValue is WinoDayTimelineCanvas newCanvas) + { + calendarControl.RegisterCanvas(newCanvas); + } + + calendarControl.ManageHighlightedDateRange(); + } + } + + private void ManageCalendarOrientation() + { + if (InternalFlipView == null || HorizontalItemsPanelTemplate == null || VerticalItemsPanelTemplate == null) return; + + InternalFlipView.ItemsPanel = Orientation == CalendarOrientation.Horizontal ? HorizontalItemsPanelTemplate : VerticalItemsPanelTemplate; + } + + private void ManageHighlightedDateRange() + => 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(); + + InternalFlipView = GetTemplateChild(PART_WinoFlipView) as WinoCalendarFlipView; + IdleGrid = GetTemplateChild(PART_IdleGrid) as Grid; + + UpdateIdleState(); + ManageCalendarOrientation(); + } + + private void UpdateIdleState() + { + InternalFlipView.Opacity = IsFlipIdle ? 0 : 1; + IdleGrid.Visibility = IsFlipIdle ? Visibility.Visible : Visibility.Collapsed; + } + + 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 Dispatcher.RunAsync(Windows.UI.Core.CoreDispatcherPriority.High, () => + { + 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); + } +} diff --git a/Wino.Mail.WinUI/Controls/Calendar/WinoCalendarFlipView.cs b/Wino.Mail.WinUI/Controls/Calendar/WinoCalendarFlipView.cs new file mode 100644 index 00000000..0270b002 --- /dev/null +++ b/Wino.Mail.WinUI/Controls/Calendar/WinoCalendarFlipView.cs @@ -0,0 +1,185 @@ +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 +{ + 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); } + } + + public WinoCalendarFlipView() + { + RegisterPropertyChangedCallback(SelectedIndexProperty, new DependencyPropertyChangedCallback(OnSelectedIndexUpdated)); + RegisterPropertyChangedCallback(ItemsSourceProperty, new DependencyPropertyChangedCallback(OnItemsSourceChanged)); + } + + private static void OnItemsSourceChanged(DependencyObject d, DependencyProperty e) + { + if (d is WinoCalendarFlipView flipView) + { + flipView.RegisterItemsSourceChange(); + } + } + + private static void OnSelectedIndexUpdated(DependencyObject d, DependencyProperty e) + { + if (d is WinoCalendarFlipView flipView) + { + flipView.UpdateActiveCanvas(); + flipView.UpdateActiveScrollViewer(); + } + } + + private void RegisterItemsSourceChange() + { + if (GetItemsSource() is INotifyCollectionChanged notifyCollectionChanged) + { + notifyCollectionChanged.CollectionChanged += ItemsSourceUpdated; + } + } + + private void ItemsSourceUpdated(object sender, NotifyCollectionChangedEventArgs e) + { + IsIdle = e.Action == NotifyCollectionChangedAction.Reset || e.Action == NotifyCollectionChangedAction.Replace; + } + + private async Task GetCurrentFlipViewItem() + { + // TODO: Refactor this mechanism by listening to PrepareContainerForItemOverride and Loaded events together. + while (ContainerFromIndex(SelectedIndex) == null) + { + await Task.Delay(100); + } + + return ContainerFromIndex(SelectedIndex) as FlipViewItem; + + + } + + private void UpdateActiveScrollViewer() + { + if (SelectedIndex < 0) + ActiveVerticalScrollViewer = null; + else + { + GetCurrentFlipViewItem().ContinueWith(task => + { + if (task.IsCompletedSuccessfully) + { + var flipViewItem = task.Result; + + _ = Dispatcher.TryRunAsync(Windows.UI.Core.CoreDispatcherPriority.Normal, () => + { + ActiveVerticalScrollViewer = flipViewItem.FindDescendant(); + }); + } + }); + } + } + + public void UpdateActiveCanvas() + { + if (SelectedIndex < 0) + ActiveCanvas = null; + else + { + GetCurrentFlipViewItem().ContinueWith(task => + { + if (task.IsCompletedSuccessfully) + { + var flipViewItem = task.Result; + + _ = Dispatcher.TryRunAsync(Windows.UI.Core.CoreDispatcherPriority.Normal, () => + { + ActiveCanvas = flipViewItem.FindDescendant(); + }); + } + }); + } + } + + /// + /// Navigates to the specified date in the calendar. + /// + /// Date to navigate. + public async void NavigateToDay(DateTime dateTime) + { + await Task.Yield(); + + await Dispatcher.RunAsync(Windows.UI.Core.CoreDispatcherPriority.High, () => + { + // Find the day range that contains the date. + var dayRange = GetItemsSource()?.FirstOrDefault(a => a.CalendarDays.Any(b => b.RepresentingDate.Date == dateTime.Date)); + + if (dayRange != null) + { + var navigationItemIndex = GetItemsSource().IndexOf(dayRange); + + 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(); + } + } + } + } + }); + } + + private ObservableRangeCollection GetItemsSource() + => ItemsSource as ObservableRangeCollection; +} diff --git a/Wino.Mail.WinUI/Controls/Calendar/WinoCalendarPanel.cs b/Wino.Mail.WinUI/Controls/Calendar/WinoCalendarPanel.cs new file mode 100644 index 00000000..76e29ed7 --- /dev/null +++ b/Wino.Mail.WinUI/Controls/Calendar/WinoCalendarPanel.cs @@ -0,0 +1,293 @@ + +using System; +using System.Collections.Generic; +using System.Linq; +using CommunityToolkit.WinUI; +using Itenso.TimePeriod; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Windows.Foundation; +using Wino.Calendar.Models; +using Wino.Calendar.ViewModels.Data; +using Wino.Core.Domain.Interfaces; + +namespace Wino.Calendar.Controls; + +public partial class WinoCalendarPanel : Panel +{ + private const double LastItemRightExtraMargin = 12d; + + // Store each ICalendarItem measurements by their Id. + private readonly Dictionary _measurements = new Dictionary(); + + public static readonly DependencyProperty EventItemMarginProperty = DependencyProperty.Register(nameof(EventItemMargin), typeof(Thickness), typeof(WinoCalendarPanel), new PropertyMetadata(new Thickness(0, 0, 0, 0))); + public static readonly DependencyProperty HourHeightProperty = DependencyProperty.Register(nameof(HourHeight), typeof(double), typeof(WinoCalendarPanel), new PropertyMetadata(0d)); + public static readonly DependencyProperty PeriodProperty = DependencyProperty.Register(nameof(Period), typeof(ITimePeriod), typeof(WinoCalendarPanel), new PropertyMetadata(null)); + + public ITimePeriod Period + { + get { return (ITimePeriod)GetValue(PeriodProperty); } + set { SetValue(PeriodProperty, value); } + } + + public double HourHeight + { + get { return (double)GetValue(HourHeightProperty); } + set { SetValue(HourHeightProperty, value); } + } + + public Thickness EventItemMargin + { + get { return (Thickness)GetValue(EventItemMarginProperty); } + set { SetValue(EventItemMarginProperty, value); } + } + + private void ResetMeasurements() => _measurements.Clear(); + + private double GetChildTopMargin(ICalendarItem calendarItemViewModel, double availableHeight) + { + var childStart = calendarItemViewModel.StartDate; + + if (childStart <= Period.Start) + { + // Event started before or exactly at the periods tart. This might be a multi-day event. + // We can simply consider event must not have a top margin. + + return 0d; + } + + double minutesFromStart = (childStart - Period.Start).TotalMinutes; + return (minutesFromStart / 1440) * availableHeight; + } + + private double GetChildWidth(CalendarItemMeasurement calendarItemMeasurement, double availableWidth) + { + return (calendarItemMeasurement.Right - calendarItemMeasurement.Left) * availableWidth; + } + + private double GetChildLeftMargin(CalendarItemMeasurement calendarItemMeasurement, double availableWidth) + => availableWidth * calendarItemMeasurement.Left; + + private double GetChildHeight(ICalendarItem child) + { + // All day events are not measured. + if (child.IsAllDayEvent) return 0; + + double childDurationInMinutes = 0d; + double availableHeight = HourHeight * 24; + + var periodRelation = child.Period.GetRelation(Period); + + // Debug.WriteLine($"Render relation of {child.Title} ({child.Period.Start} - {child.Period.End}) is {periodRelation} with {Period.Start.Day}"); + + if (!child.IsMultiDayEvent) + { + childDurationInMinutes = child.Period.Duration.TotalMinutes; + } + else + { + // Multi-day event. + // Check how many of the event falls into the current period. + childDurationInMinutes = (child.Period.End - Period.Start).TotalMinutes; + } + + return (childDurationInMinutes / 1440) * availableHeight; + } + + protected override Size MeasureOverride(Size availableSize) + { + ResetMeasurements(); + return base.MeasureOverride(availableSize); + } + + protected override Size ArrangeOverride(Size finalSize) + { + if (Period == null || HourHeight == 0d) return finalSize; + + // Measure/arrange each child height and width. + // This is a vertical calendar. Therefore the height of each child is the duration of the event. + // Children weights for left and right will be saved if they don't exist. + // This is important because we don't want to measure the weights again. + // They don't change until new event is added or removed. + // Width of the each child may depend on the rectangle packing algorithm. + // Children are first categorized into columns. Then each column is shifted to the left until + // no overlap occurs. The width of each child is calculated based on the number of columns it spans. + + double availableHeight = finalSize.Height; + double availableWidth = finalSize.Width; + + var calendarControls = Children.Cast(); + + if (!calendarControls.Any()) return base.ArrangeOverride(finalSize); + + var events = calendarControls.Select(a => a.Content as CalendarItemViewModel); + + LayoutEvents(events); + + foreach (var control in calendarControls) + { + // We can't arrange this child. + if (!(control.Content is ICalendarItem child)) continue; + + bool isHorizontallyLastItem = false; + + double childWidth = 0, + childHeight = Math.Max(0, GetChildHeight(child)), + childTop = Math.Max(0, GetChildTopMargin(child, availableHeight)), + childLeft = 0; + + // No need to measure anything here. + if (childHeight == 0) continue; + + if (!_measurements.ContainsKey(child)) + { + // Multi-day event. + + childLeft = 0; + childWidth = availableWidth; + } + else + { + var childMeasurement = _measurements[child]; + + childWidth = Math.Max(0, GetChildWidth(childMeasurement, finalSize.Width)); + childLeft = Math.Max(0, GetChildLeftMargin(childMeasurement, availableWidth)); + + isHorizontallyLastItem = childMeasurement.Right == 1; + } + + // Add additional right margin to items that falls on the right edge of the panel. + double extraRightMargin = 0; + + // Multi-day events don't have any margin and their hit test is disabled. + if (!child.IsMultiDayEvent) + { + // Max of 5% of the width or 20px max. + extraRightMargin = isHorizontallyLastItem ? Math.Max(LastItemRightExtraMargin, finalSize.Width * 5 / 100) : 0; + } + + if (childWidth < 0) childWidth = 1; + + // Regular events must have 2px margin + if (!child.IsMultiDayEvent && !child.IsAllDayEvent) + { + childLeft += 2; + childTop += 2; + childHeight -= 2; + childWidth -= 2; + } + + var arrangementRect = new Rect(childLeft + EventItemMargin.Left, childTop + EventItemMargin.Top, Math.Max(childWidth - extraRightMargin, 1), childHeight); + + // Make sure measured size will fit in the arranged box. + var measureSize = arrangementRect.ToSize(); + control.Measure(measureSize); + control.Arrange(arrangementRect); + + //Debug.WriteLine($"{child.Title}, Measured: {measureSize}, Arranged: {arrangementRect}"); + } + + + return finalSize; + } + + #region ColumSpanning and Packing Algorithm + + private void AddOrUpdateMeasurement(ICalendarItem calendarItem, CalendarItemMeasurement measurement) + { + if (_measurements.ContainsKey(calendarItem)) + { + _measurements[calendarItem] = measurement; + } + else + { + _measurements.Add(calendarItem, measurement); + } + } + + // Pick the left and right positions of each event, such that there are no overlap. + private void LayoutEvents(IEnumerable events) + { + var columns = new List>(); + DateTime? lastEventEnding = null; + + foreach (var ev in events.OrderBy(ev => ev.StartDate).ThenBy(ev => ev.EndDate)) + { + // Multi-day events are not measured. + if (ev.IsMultiDayEvent) continue; + + if (ev.Period.Start >= lastEventEnding) + { + PackEvents(columns); + columns.Clear(); + lastEventEnding = null; + } + + bool placed = false; + + foreach (var col in columns) + { + if (!col.Last().Period.OverlapsWith(ev.Period)) + { + col.Add(ev); + placed = true; + break; + } + } + if (!placed) + { + columns.Add(new List { ev }); + } + if (lastEventEnding == null || ev.Period.End > lastEventEnding.Value) + { + lastEventEnding = ev.Period.End; + } + } + if (columns.Count > 0) + { + PackEvents(columns); + } + } + + // Set the left and right positions for each event in the connected group. + private void PackEvents(List> columns) + { + float numColumns = columns.Count; + int iColumn = 0; + + foreach (var col in columns) + { + foreach (var ev in col) + { + int colSpan = ExpandEvent(ev, iColumn, columns); + + var leftWeight = iColumn / numColumns; + var rightWeight = (iColumn + colSpan) / numColumns; + + AddOrUpdateMeasurement(ev, new CalendarItemMeasurement(leftWeight, rightWeight)); + } + + iColumn++; + } + } + + // Checks how many columns the event can expand into, without colliding with other events. + private int ExpandEvent(ICalendarItem ev, int iColumn, List> columns) + { + int colSpan = 1; + + foreach (var col in columns.Skip(iColumn + 1)) + { + foreach (var ev1 in col) + { + if (ev1.Period.OverlapsWith(ev.Period)) return colSpan; + } + + colSpan++; + } + + return colSpan; + } + + #endregion +} diff --git a/Wino.Mail.WinUI/Controls/Calendar/WinoCalendarTypeSelectorControl.cs b/Wino.Mail.WinUI/Controls/Calendar/WinoCalendarTypeSelectorControl.cs new file mode 100644 index 00000000..e2fbfa82 --- /dev/null +++ b/Wino.Mail.WinUI/Controls/Calendar/WinoCalendarTypeSelectorControl.cs @@ -0,0 +1,91 @@ +using System.Windows.Input; +using CommunityToolkit.Diagnostics; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Wino.Core.Domain.Enums; + +namespace Wino.Calendar.Controls; + +public partial class WinoCalendarTypeSelectorControl : Control +{ + private const string PART_TodayButton = nameof(PART_TodayButton); + private const string PART_DayToggle = nameof(PART_DayToggle); + private const string PART_WeekToggle = nameof(PART_WeekToggle); + private const string PART_MonthToggle = nameof(PART_MonthToggle); + private const string PART_YearToggle = nameof(PART_YearToggle); + + public static readonly DependencyProperty SelectedTypeProperty = DependencyProperty.Register(nameof(SelectedType), typeof(CalendarDisplayType), typeof(WinoCalendarTypeSelectorControl), new PropertyMetadata(CalendarDisplayType.Week)); + public static readonly DependencyProperty DisplayDayCountProperty = DependencyProperty.Register(nameof(DisplayDayCount), typeof(int), typeof(WinoCalendarTypeSelectorControl), new PropertyMetadata(0)); + public static readonly DependencyProperty TodayClickedCommandProperty = DependencyProperty.Register(nameof(TodayClickedCommand), typeof(ICommand), typeof(WinoCalendarTypeSelectorControl), new PropertyMetadata(null)); + + public ICommand TodayClickedCommand + { + get { return (ICommand)GetValue(TodayClickedCommandProperty); } + set { SetValue(TodayClickedCommandProperty, value); } + } + + public CalendarDisplayType SelectedType + { + get { return (CalendarDisplayType)GetValue(SelectedTypeProperty); } + set { SetValue(SelectedTypeProperty, value); } + } + + public int DisplayDayCount + { + get { return (int)GetValue(DisplayDayCountProperty); } + set { SetValue(DisplayDayCountProperty, value); } + } + + private AppBarButton _todayButton; + private AppBarToggleButton _dayToggle; + private AppBarToggleButton _weekToggle; + private AppBarToggleButton _monthToggle; + private AppBarToggleButton _yearToggle; + + public WinoCalendarTypeSelectorControl() + { + DefaultStyleKey = typeof(WinoCalendarTypeSelectorControl); + } + + protected override void OnApplyTemplate() + { + base.OnApplyTemplate(); + + _todayButton = GetTemplateChild(PART_TodayButton) as AppBarButton; + _dayToggle = GetTemplateChild(PART_DayToggle) as AppBarToggleButton; + _weekToggle = GetTemplateChild(PART_WeekToggle) as AppBarToggleButton; + _monthToggle = GetTemplateChild(PART_MonthToggle) as AppBarToggleButton; + _yearToggle = GetTemplateChild(PART_YearToggle) as AppBarToggleButton; + + Guard.IsNotNull(_todayButton, nameof(_todayButton)); + Guard.IsNotNull(_dayToggle, nameof(_dayToggle)); + Guard.IsNotNull(_weekToggle, nameof(_weekToggle)); + Guard.IsNotNull(_monthToggle, nameof(_monthToggle)); + Guard.IsNotNull(_yearToggle, nameof(_yearToggle)); + + _todayButton.Click += TodayClicked; + + _dayToggle.Click += (s, e) => { SetSelectedType(CalendarDisplayType.Day); }; + _weekToggle.Click += (s, e) => { SetSelectedType(CalendarDisplayType.Week); }; + _monthToggle.Click += (s, e) => { SetSelectedType(CalendarDisplayType.Month); }; + _yearToggle.Click += (s, e) => { SetSelectedType(CalendarDisplayType.Year); }; + + UpdateToggleButtonStates(); + } + + private void TodayClicked(object sender, RoutedEventArgs e) => TodayClickedCommand?.Execute(null); + + private void SetSelectedType(CalendarDisplayType type) + { + SelectedType = type; + UpdateToggleButtonStates(); + } + + private void UpdateToggleButtonStates() + { + _dayToggle.IsChecked = SelectedType == CalendarDisplayType.Day; + _weekToggle.IsChecked = SelectedType == CalendarDisplayType.Week; + _monthToggle.IsChecked = SelectedType == CalendarDisplayType.Month; + _yearToggle.IsChecked = SelectedType == CalendarDisplayType.Year; + } +} diff --git a/Wino.Mail.WinUI/Controls/Calendar/WinoCalendarView.cs b/Wino.Mail.WinUI/Controls/Calendar/WinoCalendarView.cs new file mode 100644 index 00000000..ea2f3c66 --- /dev/null +++ b/Wino.Mail.WinUI/Controls/Calendar/WinoCalendarView.cs @@ -0,0 +1,147 @@ +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 +{ + 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(null)); + + 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; + + public WinoCalendarView() + { + DefaultStyleKey = typeof(WinoCalendarView); + } + + protected override void OnApplyTemplate() + { + base.OnApplyTemplate(); + + CalendarView = GetTemplateChild(PART_CalendarView) as CalendarView; + + Guard.IsNotNull(CalendarView, nameof(CalendarView)); + + 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. + + 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) + { + control.SetInnerDisplayDate(control.HighlightedDateRange.StartDate); + control.UpdateVisibleDateRangeBackgrounds(); + } + } + + public void UpdateVisibleDateRangeBackgrounds() + { + if (HighlightedDateRange == null || VisibleDateBackground == null || TodayBackgroundColor == 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; + } + } + } +} diff --git a/Wino.Mail.WinUI/Controls/Calendar/WinoDayTimelineCanvas.cs b/Wino.Mail.WinUI/Controls/Calendar/WinoDayTimelineCanvas.cs new file mode 100644 index 00000000..7ba154e5 --- /dev/null +++ b/Wino.Mail.WinUI/Controls/Calendar/WinoDayTimelineCanvas.cs @@ -0,0 +1,278 @@ +using System; +using System.Diagnostics; +using Microsoft.Graphics.Canvas.Geometry; +using Microsoft.Graphics.Canvas.UI.Xaml; +using Microsoft.UI.Input; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Input; +using Microsoft.UI.Xaml.Media; +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 CanvasControl 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 CanvasControl; + + // TODO: These will leak. Dispose them properly when needed. + Canvas.Draw += OnCanvasDraw; + 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 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. + + 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 + && Canvas.ReadyToDraw + && WorkingHourCellBackgroundColor != null + && SeperatorColor != null + && HalfHourSeperatorColor != null + && SelectedCellBackgroundBrush != null; + } + + private void OnCanvasDraw(CanvasControl sender, CanvasDrawEventArgs args) + { + if (!CanDrawTimeline()) return; + + int hours = 24; + + double canvasWidth = Canvas.ActualWidth; + double canvasHeight = Canvas.ActualHeight; + + 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 = SeperatorColor.Color; + float strokeThickness = 0.5f; + + 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 + double x = day * rectWidth; + double y = hour * rectHeight; + + var rectangle = new Rect(x, y, rectWidth, rectHeight); + + // Draw the rectangle border. + // This is the main rectangle. + args.DrawingSession.DrawRectangle(rectangle, strokeColor, strokeThickness); + + // 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 Rect(x + 1, y + 1, rectWidth - 1, rectHeight - 1); + + args.DrawingSession.DrawRectangle(backgroundRectangle, strokeColor, strokeThickness); + args.DrawingSession.FillRectangle(backgroundRectangle, WorkingHourCellBackgroundColor.Color); + } + + // Draw a line in the center of the rectangle for representing half hours. + double lineY = y + rectHeight / 2; + + args.DrawingSession.DrawLine((float)x, (float)lineY, (float)(x + rectWidth), (float)lineY, HalfHourSeperatorColor.Color, strokeThickness, new CanvasStrokeStyle() + { + DashStyle = CanvasDashStyle.Dot + }); + } + + // 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 Rect(day * rectWidth, selectedY, rectWidth, selectionRectHeight); + args.DrawingSession.FillRectangle(selectedRectangle, SelectedCellBackgroundBrush.Color); + } + } + } + } + + public void Dispose() + { + if (Canvas == null) return; + + Canvas.Draw -= OnCanvasDraw; + Canvas.PointerPressed -= OnCanvasPointerPressed; + Canvas.RemoveFromVisualTree(); + + Canvas = null; + } +} diff --git a/Wino.Mail.WinUI/Helpers/CalendarXamlHelpers.cs b/Wino.Mail.WinUI/Helpers/CalendarXamlHelpers.cs new file mode 100644 index 00000000..86267f58 --- /dev/null +++ b/Wino.Mail.WinUI/Helpers/CalendarXamlHelpers.cs @@ -0,0 +1,106 @@ +using System.Linq; +using System.Text.RegularExpressions; +using Ical.Net.CalendarComponents; +using Ical.Net.DataTypes; +using Microsoft.UI.Xaml.Controls.Primitives; +using Wino.Calendar.ViewModels.Data; +using Wino.Core.Domain; +using Wino.Core.Domain.Collections; +using Wino.Core.Domain.Enums; +using Wino.Core.Domain.Models.Calendar; +using Wino.Helpers; + +namespace Wino.Calendar.Helpers; + +public static class CalendarXamlHelpers +{ + public static CalendarItemViewModel GetFirstAllDayEvent(CalendarEventCollection collection) + => (CalendarItemViewModel)collection.AllDayEvents.FirstOrDefault(); + + /// + /// Returns full date + duration info in Event Details page details title. + /// + public static string GetEventDetailsDateString(CalendarItemViewModel calendarItemViewModel, CalendarSettings settings) + { + if (calendarItemViewModel == null || settings == null) return string.Empty; + + var start = calendarItemViewModel.Period.Start; + var end = calendarItemViewModel.Period.End; + + string timeFormat = settings.DayHeaderDisplayType == DayHeaderDisplayType.TwelveHour ? "h:mm tt" : "HH:mm"; + string dateFormat = settings.DayHeaderDisplayType == DayHeaderDisplayType.TwelveHour ? "dddd, dd MMMM h:mm tt" : "dddd, dd MMMM HH:mm"; + + if (calendarItemViewModel.IsMultiDayEvent) + { + return $"{start.ToString($"dd MMMM ddd {timeFormat}", settings.CultureInfo)} - {end.ToString($"dd MMMM ddd {timeFormat}", settings.CultureInfo)}"; + } + else + { + return $"{start.ToString(dateFormat, settings.CultureInfo)} - {end.ToString(timeFormat, settings.CultureInfo)}"; + } + } + + public static string GetRecurrenceString(CalendarItemViewModel calendarItemViewModel) + { + if (calendarItemViewModel == null || !calendarItemViewModel.IsRecurringChild) return string.Empty; + + // Parse recurrence rules + var calendarEvent = new CalendarEvent + { + Start = new CalDateTime(calendarItemViewModel.StartDate), + End = new CalDateTime(calendarItemViewModel.EndDate), + }; + + var recurrenceLines = Regex.Split(calendarItemViewModel.CalendarItem.Recurrence, Constants.CalendarEventRecurrenceRuleSeperator); + + foreach (var line in recurrenceLines) + { + calendarEvent.RecurrenceRules.Add(new RecurrencePattern(line)); + } + + if (calendarEvent.RecurrenceRules == null || !calendarEvent.RecurrenceRules.Any()) + { + return "No recurrence pattern."; + } + + var recurrenceRule = calendarEvent.RecurrenceRules.First(); + var daysOfWeek = string.Join(", ", recurrenceRule.ByDay.Select(day => day.DayOfWeek.ToString())); + string timeZone = calendarEvent.DtStart.TzId ?? "UTC"; + + return $"Every {daysOfWeek}, effective {calendarEvent.DtStart.Value.ToShortDateString()} " + + $"from {calendarEvent.DtStart.Value.ToShortTimeString()} to {calendarEvent.DtEnd.Value.ToShortTimeString()} " + + $"{timeZone}."; + } + + public static string GetDetailsPopupDurationString(CalendarItemViewModel calendarItemViewModel, CalendarSettings settings) + { + if (calendarItemViewModel == null || settings == null) return string.Empty; + + // Single event in a day. + if (!calendarItemViewModel.IsAllDayEvent && !calendarItemViewModel.IsMultiDayEvent) + { + return $"{calendarItemViewModel.Period.Start.ToString("d", settings.CultureInfo)} {settings.GetTimeString(calendarItemViewModel.Period.Duration)}"; + } + else if (calendarItemViewModel.IsMultiDayEvent) + { + return $"{calendarItemViewModel.Period.Start.ToString("d", settings.CultureInfo)} - {calendarItemViewModel.Period.End.ToString("d", settings.CultureInfo)}"; + } + else + { + // All day event. + return $"{calendarItemViewModel.Period.Start.ToString("d", settings.CultureInfo)} ({Translator.CalendarItemAllDay})"; + } + } + + public static PopupPlacementMode GetDesiredPlacementModeForEventsDetailsPopup( + CalendarItemViewModel calendarItemViewModel, + CalendarDisplayType calendarDisplayType) + { + if (calendarItemViewModel == null) return PopupPlacementMode.Auto; + + // All and/or multi day events always go to the top of the screen. + if (calendarItemViewModel.IsAllDayEvent || calendarItemViewModel.IsMultiDayEvent) return PopupPlacementMode.Bottom; + + return XamlHelpers.GetPlaccementModeForCalendarType(calendarDisplayType); + } +} diff --git a/Wino.Mail.WinUI/AppShell.xaml b/Wino.Mail.WinUI/MailAppShell.xaml similarity index 99% rename from Wino.Mail.WinUI/AppShell.xaml rename to Wino.Mail.WinUI/MailAppShell.xaml index 176f55d3..4eda9d55 100644 --- a/Wino.Mail.WinUI/AppShell.xaml +++ b/Wino.Mail.WinUI/MailAppShell.xaml @@ -1,5 +1,5 @@ - @@ -475,4 +475,4 @@ - + diff --git a/Wino.Mail.WinUI/AppShell.xaml.cs b/Wino.Mail.WinUI/MailAppShell.xaml.cs similarity index 91% rename from Wino.Mail.WinUI/AppShell.xaml.cs rename to Wino.Mail.WinUI/MailAppShell.xaml.cs index 85197069..7b3e992d 100644 --- a/Wino.Mail.WinUI/AppShell.xaml.cs +++ b/Wino.Mail.WinUI/MailAppShell.xaml.cs @@ -9,7 +9,6 @@ using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; using Microsoft.UI.Xaml.Controls.Primitives; using Microsoft.UI.Xaml.Input; -using Microsoft.UI.Xaml.Navigation; using Windows.Foundation; using Wino.Core.Domain; using Wino.Core.Domain.Entities.Mail; @@ -18,10 +17,10 @@ using Wino.Core.Domain.Interfaces; using Wino.Core.Domain.Models.Folders; using Wino.Core.Domain.Models.MailItem; using Wino.Core.Domain.Models.Navigation; -using Wino.Mail.WinUI; -using Wino.Mail.WinUI.Controls; using Wino.Extensions; using Wino.Mail.ViewModels.Data; +using Wino.Mail.WinUI; +using Wino.Mail.WinUI.Controls; using Wino.MenuFlyouts; using Wino.MenuFlyouts.Context; using Wino.Messaging.Client.Accounts; @@ -32,7 +31,7 @@ using Wino.Views.Abstract; namespace Wino.Views; -public sealed partial class AppShell : AppShellAbstract, +public sealed partial class MailAppShell : MailAppShellAbstract, IRecipient, IRecipient, IRecipient, @@ -41,26 +40,11 @@ public sealed partial class AppShell : AppShellAbstract, [GeneratedDependencyProperty] public partial UIElement? TopShellContent { get; set; } - public AppShell() : base() + public MailAppShell() : base() { InitializeComponent(); } - //protected override void OnNavigatedTo(NavigationEventArgs e) - //{ - // base.OnNavigatedTo(e); - - // WeakReferenceMessenger.Default.Register(this); - // WeakReferenceMessenger.Default.Register(this); - // WeakReferenceMessenger.Default.Register(this); - // WeakReferenceMessenger.Default.Register(this); - //} - - protected override void OnNavigatingFrom(NavigatingCancelEventArgs e) - { - base.OnNavigatingFrom(e); - } - private async void ItemDroppedOnFolder(object sender, DragEventArgs e) { // Validate package content. @@ -224,7 +208,7 @@ public sealed partial class AppShell : AppShellAbstract, { var navigateFolderArgs = new NavigateMailFolderEventArgs(message.BaseFolderMenuItem, message.FolderInitLoadAwaitTask); - ViewModel.NavigationService.Navigate(WinoPage.MailListPage, navigateFolderArgs, NavigationReferenceFrame.ShellFrame); + ViewModel.NavigationService.Navigate(WinoPage.MailListPage, navigateFolderArgs, NavigationReferenceFrame.InnerShellFrame); // Prevent double navigation. navigationView.SelectionChanged -= MenuSelectionChanged; @@ -330,15 +314,15 @@ public sealed partial class AppShell : AppShellAbstract, }); } - private void NavigationViewDisplayModeChanged(Microsoft.UI.Xaml.Controls.NavigationView sender, Microsoft.UI.Xaml.Controls.NavigationViewDisplayModeChangedEventArgs args) + private void NavigationViewDisplayModeChanged(NavigationView sender, NavigationViewDisplayModeChangedEventArgs args) { - if (args.DisplayMode == Microsoft.UI.Xaml.Controls.NavigationViewDisplayMode.Minimal) + if (args.DisplayMode == NavigationViewDisplayMode.Minimal) { - ShellFrame.Margin = new Thickness(7, 0, 0, 0); + InnerShellFrame.Margin = new Thickness(7, 0, 0, 0); } else { - ShellFrame.Margin = new Thickness(0); + InnerShellFrame.Margin = new Thickness(0); } } diff --git a/Wino.Mail.WinUI/MainWindow.xaml b/Wino.Mail.WinUI/MainWindow.xaml deleted file mode 100644 index 126da732..00000000 --- a/Wino.Mail.WinUI/MainWindow.xaml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - - - - diff --git a/Wino.Mail.WinUI/MainWindow.xaml.cs b/Wino.Mail.WinUI/MainWindow.xaml.cs deleted file mode 100644 index 88396eee..00000000 --- a/Wino.Mail.WinUI/MainWindow.xaml.cs +++ /dev/null @@ -1,29 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Runtime.InteropServices.WindowsRuntime; -using Microsoft.UI.Xaml; -using Microsoft.UI.Xaml.Controls; -using Microsoft.UI.Xaml.Controls.Primitives; -using Microsoft.UI.Xaml.Data; -using Microsoft.UI.Xaml.Input; -using Microsoft.UI.Xaml.Media; -using Microsoft.UI.Xaml.Navigation; -using Windows.Foundation; -using Windows.Foundation.Collections; - -// To learn more about WinUI, the WinUI project structure, -// and more about our project templates, see: http://aka.ms/winui-project-info. - -namespace Wino.Mail.WinUI; -/// -/// An empty window that can be used on its own or navigated to within a Frame. -/// -public sealed partial class MainWindow : Window -{ - public MainWindow() - { - InitializeComponent(); - } -} diff --git a/Wino.Mail.WinUI/Models/Args/TimelineCellSelectedArgs.cs b/Wino.Mail.WinUI/Models/Args/TimelineCellSelectedArgs.cs new file mode 100644 index 00000000..481e0182 --- /dev/null +++ b/Wino.Mail.WinUI/Models/Args/TimelineCellSelectedArgs.cs @@ -0,0 +1,40 @@ +using System; +using Windows.Foundation; + +namespace Wino.Calendar.Args; + +/// +/// When a new timeline cell is selected. +/// +public class TimelineCellSelectedArgs : EventArgs +{ + public TimelineCellSelectedArgs(DateTime clickedDate, Point canvasPoint, Point positionerPoint, Size cellSize) + { + ClickedDate = clickedDate; + CanvasPoint = canvasPoint; + PositionerPoint = positionerPoint; + CellSize = cellSize; + } + + /// + /// Clicked date and time information for the cell. + /// + public DateTime ClickedDate { get; set; } + + /// + /// Position relative to the cell drawing part of the canvas. + /// Used to detect clicked cell from the position. + /// + public Point CanvasPoint { get; } + + /// + /// Position relative to the main root positioner element of the drawing canvas. + /// Used to show the create event dialog teaching tip in correct position. + /// + public Point PositionerPoint { get; } + + /// + /// Size of the cell. + /// + public Size CellSize { get; } +} diff --git a/Wino.Mail.WinUI/Models/Args/TimelineCellUnselectedArgs.cs b/Wino.Mail.WinUI/Models/Args/TimelineCellUnselectedArgs.cs new file mode 100644 index 00000000..7cbeb841 --- /dev/null +++ b/Wino.Mail.WinUI/Models/Args/TimelineCellUnselectedArgs.cs @@ -0,0 +1,8 @@ +using System; + +namespace Wino.Calendar.Args; + +/// +/// When selected timeline cell is unselected. +/// +public class TimelineCellUnselectedArgs : EventArgs { } diff --git a/Wino.Mail.WinUI/Models/CalendarItemMeasurement.cs b/Wino.Mail.WinUI/Models/CalendarItemMeasurement.cs new file mode 100644 index 00000000..dbb8cf9b --- /dev/null +++ b/Wino.Mail.WinUI/Models/CalendarItemMeasurement.cs @@ -0,0 +1,16 @@ +namespace Wino.Calendar.Models; + +public struct CalendarItemMeasurement +{ + // Where to start? + public double Left { get; set; } + + // Extend until where? + public double Right { get; set; } + + public CalendarItemMeasurement(double left, double right) + { + Left = left; + Right = right; + } +} diff --git a/Wino.Mail.WinUI/Selectors/CustomAreaCalendarItemSelector.cs b/Wino.Mail.WinUI/Selectors/CustomAreaCalendarItemSelector.cs new file mode 100644 index 00000000..35996d01 --- /dev/null +++ b/Wino.Mail.WinUI/Selectors/CustomAreaCalendarItemSelector.cs @@ -0,0 +1,21 @@ +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Wino.Calendar.ViewModels.Data; + +namespace Wino.Calendar.Selectors; + +public partial class CustomAreaCalendarItemSelector : DataTemplateSelector +{ + public DataTemplate AllDayTemplate { get; set; } + public DataTemplate MultiDayTemplate { get; set; } + + protected override DataTemplate SelectTemplateCore(object item, DependencyObject container) + { + if (item is CalendarItemViewModel calendarItemViewModel) + { + return calendarItemViewModel.IsMultiDayEvent ? MultiDayTemplate : AllDayTemplate; + } + + return base.SelectTemplateCore(item, container); + } +} diff --git a/Wino.Mail.WinUI/Selectors/WinoCalendarItemTemplateSelector.cs b/Wino.Mail.WinUI/Selectors/WinoCalendarItemTemplateSelector.cs new file mode 100644 index 00000000..2d3ec477 --- /dev/null +++ b/Wino.Mail.WinUI/Selectors/WinoCalendarItemTemplateSelector.cs @@ -0,0 +1,33 @@ +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Wino.Core.Domain.Enums; + +namespace Wino.Selectors; + +public partial class WinoCalendarItemTemplateSelector : DataTemplateSelector +{ + public CalendarDisplayType DisplayType { get; set; } + + public DataTemplate DayWeekWorkWeekTemplate { get; set; } + public DataTemplate MonthlyTemplate { get; set; } + + + protected override DataTemplate SelectTemplateCore(object item, DependencyObject container) + { + switch (DisplayType) + { + case CalendarDisplayType.Day: + case CalendarDisplayType.Week: + case CalendarDisplayType.WorkWeek: + return DayWeekWorkWeekTemplate; + case CalendarDisplayType.Month: + return MonthlyTemplate; + case CalendarDisplayType.Year: + break; + default: + break; + } + + return base.SelectTemplateCore(item, container); + } +} diff --git a/Wino.Mail.WinUI/Services/AccountCalendarStateService.cs b/Wino.Mail.WinUI/Services/AccountCalendarStateService.cs new file mode 100644 index 00000000..a0bb5212 --- /dev/null +++ b/Wino.Mail.WinUI/Services/AccountCalendarStateService.cs @@ -0,0 +1,113 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using CommunityToolkit.Mvvm.ComponentModel; +using Wino.Calendar.ViewModels.Data; +using Wino.Calendar.ViewModels.Interfaces; +using Wino.Core.Domain.Entities.Shared; + +namespace Wino.Mail.WinUI.Services; + +/// +/// Encapsulated state manager for collectively managing the state of account calendars. +/// Callers must react to the events to update their state only from this service. +/// +public partial class AccountCalendarStateService : ObservableObject, IAccountCalendarStateService +{ + public event EventHandler CollectiveAccountGroupSelectionStateChanged; + public event EventHandler AccountCalendarSelectionStateChanged; + + [ObservableProperty] + public partial ReadOnlyObservableCollection GroupedAccountCalendars { get; set; } + + private ObservableCollection _internalGroupedAccountCalendars = new ObservableCollection(); + + public IEnumerable ActiveCalendars + { + get + { + return GroupedAccountCalendars + .SelectMany(a => a.AccountCalendars) + .Where(b => b.IsChecked); + } + } + + public IEnumerable> GroupedAccountCalendarsEnumerable + { + get + { + return GroupedAccountCalendars + .Select(a => a.AccountCalendars) + .SelectMany(b => b) + .GroupBy(c => c.Account); + } + } + + public AccountCalendarStateService() + { + GroupedAccountCalendars = new ReadOnlyObservableCollection(_internalGroupedAccountCalendars); + } + + private void SingleGroupCalendarCollectiveStateChanged(object sender, EventArgs e) + => CollectiveAccountGroupSelectionStateChanged?.Invoke(this, sender as GroupedAccountCalendarViewModel); + + private void SingleCalendarSelectionStateChanged(object sender, AccountCalendarViewModel e) + => AccountCalendarSelectionStateChanged?.Invoke(this, e); + + public void AddGroupedAccountCalendar(GroupedAccountCalendarViewModel groupedAccountCalendar) + { + groupedAccountCalendar.CalendarSelectionStateChanged += SingleCalendarSelectionStateChanged; + groupedAccountCalendar.CollectiveSelectionStateChanged += SingleGroupCalendarCollectiveStateChanged; + + _internalGroupedAccountCalendars.Add(groupedAccountCalendar); + } + + public void RemoveGroupedAccountCalendar(GroupedAccountCalendarViewModel groupedAccountCalendar) + { + groupedAccountCalendar.CalendarSelectionStateChanged -= SingleCalendarSelectionStateChanged; + groupedAccountCalendar.CollectiveSelectionStateChanged -= SingleGroupCalendarCollectiveStateChanged; + + _internalGroupedAccountCalendars.Remove(groupedAccountCalendar); + } + + public void ClearGroupedAccountCalendar() + { + foreach (var groupedAccountCalendar in _internalGroupedAccountCalendars) + { + RemoveGroupedAccountCalendar(groupedAccountCalendar); + } + } + + public void AddAccountCalendar(AccountCalendarViewModel accountCalendar) + { + // Find the group that this calendar belongs to. + var group = _internalGroupedAccountCalendars.FirstOrDefault(g => g.Account.Id == accountCalendar.Account.Id); + + if (group == null) + { + // If the group doesn't exist, create it. + group = new GroupedAccountCalendarViewModel(accountCalendar.Account, new[] { accountCalendar }); + AddGroupedAccountCalendar(group); + } + else + { + group.AccountCalendars.Add(accountCalendar); + } + } + + public void RemoveAccountCalendar(AccountCalendarViewModel accountCalendar) + { + var group = _internalGroupedAccountCalendars.FirstOrDefault(g => g.Account.Id == accountCalendar.Account.Id); + + // We don't expect but just in case. + if (group == null) return; + + group.AccountCalendars.Remove(accountCalendar); + + if (group.AccountCalendars.Count == 0) + { + RemoveGroupedAccountCalendar(group); + } + } +} diff --git a/Wino.Mail.WinUI/Services/NavigationService.cs b/Wino.Mail.WinUI/Services/NavigationService.cs index 684740fc..76da25d8 100644 --- a/Wino.Mail.WinUI/Services/NavigationService.cs +++ b/Wino.Mail.WinUI/Services/NavigationService.cs @@ -3,18 +3,21 @@ using System.Linq; using CommunityToolkit.Mvvm.Messaging; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; +using Wino.Calendar.Views; using Wino.Core.Domain.Enums; using Wino.Core.Domain.Interfaces; using Wino.Core.Domain.Models.Navigation; -using Wino.Mail.WinUI; -using Wino.Mail.WinUI.Interfaces; -using Wino.Mail.WinUI.Services; using Wino.Helpers; using Wino.Mail.ViewModels.Data; using Wino.Mail.ViewModels.Messages; +using Wino.Mail.WinUI; +using Wino.Mail.WinUI.Interfaces; +using Wino.Mail.WinUI.Services; +using Wino.Mail.WinUI.Views.Calendar; using Wino.Messaging.Client.Mails; using Wino.Views; using Wino.Views.Account; +using Wino.Views.Mail; using Wino.Views.Settings; namespace Wino.Services; @@ -62,6 +65,8 @@ public class NavigationService : NavigationServiceBase, INavigationService WinoPage.KeyboardShortcutsPage => typeof(KeyboardShortcutsPage), WinoPage.ContactsPage => typeof(ContactsPage), WinoPage.SignatureAndEncryptionPage => typeof(SignatureAndEncryptionPage), + WinoPage.CalendarPage => typeof(CalendarPage), + WinoPage.EventDetailsPage => typeof(EventDetailsPage), _ => null, }; } @@ -71,16 +76,36 @@ public class NavigationService : NavigationServiceBase, INavigationService if (WinoApplication.MainWindow is not IWinoShellWindow shellWindow) throw new ArgumentException("MainWindow must implement IWinoShellWindow"); if (shellWindow.GetMainFrame() is not Frame mainFrame) throw new ArgumentException("MainFrame cannot be null."); + if (frameType == NavigationReferenceFrame.ShellFrame) return shellWindow.GetMainFrame(); + return WinoVisualTreeHelper.GetChildObject(mainFrame.Content as UIElement, frameType.ToString()); } + public bool ChangeApplicationMode(WinoApplicationMode mode) + { + var coreFrame = GetCoreFrame(NavigationReferenceFrame.ShellFrame); + + if (coreFrame == null) return false; + + if (mode == WinoApplicationMode.Mail) + { + coreFrame.Navigate(typeof(MailAppShell), null); + } + else + { + coreFrame.Navigate(typeof(CalendarAppShell), null); + } + + return true; + } + public bool Navigate(WinoPage page, object? parameter = null, - NavigationReferenceFrame frame = NavigationReferenceFrame.ShellFrame, + NavigationReferenceFrame frame = NavigationReferenceFrame.InnerShellFrame, NavigationTransitionType transition = NavigationTransitionType.None) { var pageType = GetPageType(page); - Frame shellFrame = GetCoreFrame(NavigationReferenceFrame.ShellFrame); + Frame shellFrame = GetCoreFrame(NavigationReferenceFrame.InnerShellFrame); _statePersistanceService.IsReadingMail = _renderingPageTypes.Contains(page); diff --git a/Wino.Mail.WinUI/ShellWindow.xaml b/Wino.Mail.WinUI/ShellWindow.xaml index 4819459d..7bf5173a 100644 --- a/Wino.Mail.WinUI/ShellWindow.xaml +++ b/Wino.Mail.WinUI/ShellWindow.xaml @@ -3,6 +3,7 @@ x:Class="Wino.Mail.WinUI.ShellWindow" 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:domain="using:Wino.Core.Domain" xmlns:local="using:Wino.Mail.WinUI" @@ -31,7 +32,30 @@ Background="Transparent" IsBackButtonVisible="{x:Bind StatePersistanceService.IsBackButtonVisible, Mode=OneWay}" IsPaneToggleButtonVisible="True" - PaneToggleRequested="PaneButtonClicked" /> + PaneToggleRequested="PaneButtonClicked"> + + + + + + + + + + + + + + + + + () ?? throw new Exception("StatePersistanceService not registered in DI container."); public IPreferencesService PreferencesService { get; } = WinoApplication.Current.Services.GetService() ?? throw new Exception("PreferencesService not registered in DI container."); + public INavigationService NavigationService { get; } = WinoApplication.Current.Services.GetService() ?? throw new Exception("NavigationService not registered in DI container."); public ICommand ShowWinoCommand { get; set; } public ICommand ExitWinoCommand { get; set; } @@ -70,7 +71,7 @@ public sealed partial class ShellWindow : WindowEx, IWinoShellWindow, IRecipient { // TODO: Handle protocol activations. - MainShellFrame.Navigate(typeof(AppShell)); + MainShellFrame.Navigate(typeof(MailAppShell)); } public Microsoft.UI.Xaml.Controls.TitleBar GetTitleBar() => ShellTitleBar; @@ -98,7 +99,7 @@ public sealed partial class ShellWindow : WindowEx, IWinoShellWindow, IRecipient public void Receive(TitleBarShellContentUpdated message) { - if (MainShellFrame.Content is AppShell shellPage) + if (MainShellFrame.Content is MailAppShell shellPage) { ShellTitleBar.Content = shellPage.TopShellContent; } @@ -161,6 +162,7 @@ public sealed partial class ShellWindow : WindowEx, IWinoShellWindow, IRecipient private void RestoreFromTray() { + this.Show(); BringToFront(); } @@ -193,4 +195,19 @@ public sealed partial class ShellWindow : WindowEx, IWinoShellWindow, IRecipient WeakReferenceMessenger.Default.Unregister(this); WeakReferenceMessenger.Default.Unregister(this); } + + private void SegmentedChanged(object sender, SelectionChangedEventArgs e) + { + if (sender is Segmented segmentedControl) + { + if (segmentedControl.SelectedIndex == 0) + { + NavigationService.ChangeApplicationMode(Core.Domain.Enums.WinoApplicationMode.Mail); + } + else + { + NavigationService.ChangeApplicationMode(Core.Domain.Enums.WinoApplicationMode.Calendar); + } + } + } } diff --git a/Wino.Mail.WinUI/Styles/CalendarThemeResources.xaml b/Wino.Mail.WinUI/Styles/CalendarThemeResources.xaml new file mode 100644 index 00000000..c72bfed6 --- /dev/null +++ b/Wino.Mail.WinUI/Styles/CalendarThemeResources.xaml @@ -0,0 +1,39 @@ + + + + + + + + #b2bec3 + #dfe4ea + #2e86de + + + #2e86de + #dfe4ea + + + #000000 + #000000 + #000000 + + + + + + #525252 + #262626 + #121212 + + + #3d3d3d + #4b4b4b + + + #000000 + #FFFFFF + #000000 + + + diff --git a/Wino.Mail.WinUI/Styles/DayHeaderControl.xaml b/Wino.Mail.WinUI/Styles/DayHeaderControl.xaml new file mode 100644 index 00000000..396a0cf0 --- /dev/null +++ b/Wino.Mail.WinUI/Styles/DayHeaderControl.xaml @@ -0,0 +1,23 @@ + + + + + + diff --git a/Wino.Mail.WinUI/Styles/WinoCalendarResources.xaml b/Wino.Mail.WinUI/Styles/WinoCalendarResources.xaml new file mode 100644 index 00000000..38ee859e --- /dev/null +++ b/Wino.Mail.WinUI/Styles/WinoCalendarResources.xaml @@ -0,0 +1,424 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Wino.Mail.WinUI/Styles/WinoCalendarResources.xaml.cs b/Wino.Mail.WinUI/Styles/WinoCalendarResources.xaml.cs new file mode 100644 index 00000000..9fbc60f5 --- /dev/null +++ b/Wino.Mail.WinUI/Styles/WinoCalendarResources.xaml.cs @@ -0,0 +1,11 @@ +using Microsoft.UI.Xaml; + +namespace Wino.Styles; + +public sealed partial class WinoCalendarResources : ResourceDictionary +{ + public WinoCalendarResources() + { + this.InitializeComponent(); + } +} diff --git a/Wino.Mail.WinUI/Styles/WinoCalendarTypeSelectorControl.xaml b/Wino.Mail.WinUI/Styles/WinoCalendarTypeSelectorControl.xaml new file mode 100644 index 00000000..48fb8cb6 --- /dev/null +++ b/Wino.Mail.WinUI/Styles/WinoCalendarTypeSelectorControl.xaml @@ -0,0 +1,83 @@ + + + + + diff --git a/Wino.Mail.WinUI/Styles/WinoCalendarView.xaml b/Wino.Mail.WinUI/Styles/WinoCalendarView.xaml new file mode 100644 index 00000000..8259f570 --- /dev/null +++ b/Wino.Mail.WinUI/Styles/WinoCalendarView.xaml @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + diff --git a/Wino.Mail.WinUI/Styles/WinoDayTimelineCanvas.xaml b/Wino.Mail.WinUI/Styles/WinoDayTimelineCanvas.xaml new file mode 100644 index 00000000..96257f2f --- /dev/null +++ b/Wino.Mail.WinUI/Styles/WinoDayTimelineCanvas.xaml @@ -0,0 +1,19 @@ + + + + + diff --git a/Wino.Mail.WinUI/Views/Abstract/CalendarAppShellAbstract .cs b/Wino.Mail.WinUI/Views/Abstract/CalendarAppShellAbstract .cs new file mode 100644 index 00000000..9a92e942 --- /dev/null +++ b/Wino.Mail.WinUI/Views/Abstract/CalendarAppShellAbstract .cs @@ -0,0 +1,6 @@ +using Wino.Calendar.ViewModels; +using Wino.Mail.WinUI; + +namespace Wino.Mail.Views.Abstract; + +public abstract class CalendarAppShellAbstract : BasePage { } diff --git a/Wino.Mail.WinUI/Views/Abstract/CalendarPageAbstract.cs b/Wino.Mail.WinUI/Views/Abstract/CalendarPageAbstract.cs new file mode 100644 index 00000000..4a7ac8f8 --- /dev/null +++ b/Wino.Mail.WinUI/Views/Abstract/CalendarPageAbstract.cs @@ -0,0 +1,6 @@ +using Wino.Calendar.ViewModels; +using Wino.Mail.WinUI; + +namespace Wino.Calendar.Views.Abstract; + +public abstract class CalendarPageAbstract : BasePage { } diff --git a/Wino.Mail.WinUI/Views/Abstract/EventDetailsPageAbstract.cs b/Wino.Mail.WinUI/Views/Abstract/EventDetailsPageAbstract.cs new file mode 100644 index 00000000..620935a5 --- /dev/null +++ b/Wino.Mail.WinUI/Views/Abstract/EventDetailsPageAbstract.cs @@ -0,0 +1,5 @@ +using Wino.Calendar.ViewModels; + +namespace Wino.Mail.WinUI.Views.Abstract; + +public abstract class EventDetailsPageAbstract : BasePage { } diff --git a/Wino.Mail.WinUI/Views/Abstract/AppShellAbstract.cs b/Wino.Mail.WinUI/Views/Abstract/MailAppShellAbstract.cs similarity index 53% rename from Wino.Mail.WinUI/Views/Abstract/AppShellAbstract.cs rename to Wino.Mail.WinUI/Views/Abstract/MailAppShellAbstract.cs index 4a28d0d8..f48ceae9 100644 --- a/Wino.Mail.WinUI/Views/Abstract/AppShellAbstract.cs +++ b/Wino.Mail.WinUI/Views/Abstract/MailAppShellAbstract.cs @@ -1,8 +1,8 @@ -using Wino.Mail.WinUI; using Wino.Mail.ViewModels; +using Wino.Mail.WinUI; namespace Wino.Views.Abstract; -public abstract class AppShellAbstract : BasePage +public abstract class MailAppShellAbstract : BasePage { } diff --git a/Wino.Mail.WinUI/Views/Calendar/CalendarAppShell.xaml b/Wino.Mail.WinUI/Views/Calendar/CalendarAppShell.xaml new file mode 100644 index 00000000..919878c8 --- /dev/null +++ b/Wino.Mail.WinUI/Views/Calendar/CalendarAppShell.xaml @@ -0,0 +1,402 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +