From a8f9b2d12641e6a73dc9f90dffb6cdbb6237b7cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Burak=20Kaan=20K=C3=B6se?= Date: Sun, 8 Mar 2026 01:33:47 +0100 Subject: [PATCH] Calendar improvements. --- Directory.Packages.props | 3 +- .../CalendarAppShellViewModel.cs | 8 +- .../CalendarEventComposePageViewModel.cs | 52 +++++++--- .../Translations/en_US/resources.json | 1 + Wino.Core/Synchronizers/GmailSynchronizer.cs | 94 ++++++++++++++++++- Wino.Core/Wino.Core.csproj | 3 +- .../Calendar/CalendarEventComposePage.xaml | 11 ++- .../Calendar/CalendarEventComposePage.xaml.cs | 8 +- Wino.Mail.WinUI/Views/Mail/ComposePage.xaml | 1 - Wino.Mail.WinUI/Wino.Mail.WinUI.csproj | 1 + 10 files changed, 162 insertions(+), 20 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index a58a3fe9..ba77f6c7 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -56,6 +56,7 @@ + @@ -74,4 +75,4 @@ - \ No newline at end of file + diff --git a/Wino.Calendar.ViewModels/CalendarAppShellViewModel.cs b/Wino.Calendar.ViewModels/CalendarAppShellViewModel.cs index ed992c09..9277eb6c 100644 --- a/Wino.Calendar.ViewModels/CalendarAppShellViewModel.cs +++ b/Wino.Calendar.ViewModels/CalendarAppShellViewModel.cs @@ -119,9 +119,13 @@ public partial class CalendarAppShellViewModel : CalendarBaseViewModel, { base.OnNavigatedTo(mode, parameters); - // Account list may have changed while this shell was inactive. - if (mode == NavigationMode.Back) + // Preserve the existing calendar shell frame state when the user switches + // between Mail and Calendar modes. Back/forward restoration should not + // force a new CalendarPage navigation, otherwise pages like + // CalendarEventComposePage get dropped from the inner frame stack. + if (mode != NavigationMode.New) { + UpdateDateNavigationHeaderItems(); await InitializeAccountCalendarsAsync(); ValidateConfiguredNewEventCalendar(); return; diff --git a/Wino.Calendar.ViewModels/CalendarEventComposePageViewModel.cs b/Wino.Calendar.ViewModels/CalendarEventComposePageViewModel.cs index fd3a8458..a23ff4a9 100644 --- a/Wino.Calendar.ViewModels/CalendarEventComposePageViewModel.cs +++ b/Wino.Calendar.ViewModels/CalendarEventComposePageViewModel.cs @@ -102,8 +102,15 @@ public partial class CalendarEventComposePageViewModel : CalendarBaseViewModel public CalendarSettings CurrentSettings { get; } public string TimePickerClockIdentifier => CurrentSettings.DayHeaderDisplayType == DayHeaderDisplayType.TwentyFourHour ? "24HourClock" : "12HourClock"; public bool HasAttachments => Attachments.Count > 0; + public bool IsSelectedCalendarCalDav => SelectedCalendar?.Account?.ProviderType == MailProviderType.IMAP4 && + SelectedCalendar.Account.ServerInformation?.CalendarSupportMode == ImapCalendarSupportMode.CalDav; + public bool CanAddAttachments => !IsSelectedCalendarCalDav; + public string AttachmentsDisabledTooltipText => IsSelectedCalendarCalDav + ? Translator.CalendarEventCompose_AttachmentsNotSupportedForCalDav + : string.Empty; public string SelectedCalendarDisplayText => SelectedCalendar?.Name ?? Translator.CalendarEventCompose_SelectCalendar; public string SelectedCalendarAccountText => SelectedCalendar?.Account?.Address ?? string.Empty; + public bool IsDailyRecurrenceSelected => SelectedRecurrenceFrequencyOption?.Frequency == CalendarItemRecurrenceFrequency.Daily; public CalendarEventComposePageViewModel(IAccountService accountService, ICalendarService calendarService, @@ -183,6 +190,15 @@ public partial class CalendarEventComposePageViewModel : CalendarBaseViewModel SelectedShowAsOption = ShowAsOptions.FirstOrDefault(option => option.ShowAs == value.DefaultShowAs) ?? ShowAsOptions.FirstOrDefault(); + + if (IsSelectedCalendarCalDav && Attachments.Count > 0) + { + Attachments.Clear(); + } + + OnPropertyChanged(nameof(IsSelectedCalendarCalDav)); + OnPropertyChanged(nameof(CanAddAttachments)); + OnPropertyChanged(nameof(AttachmentsDisabledTooltipText)); OnPropertyChanged(nameof(SelectedCalendarDisplayText)); OnPropertyChanged(nameof(SelectedCalendarAccountText)); } @@ -228,12 +244,19 @@ public partial class CalendarEventComposePageViewModel : CalendarBaseViewModel UpdateRecurrenceSummary(); } partial void OnSelectedRecurrenceIntervalChanged(int value) => UpdateRecurrenceSummary(); - partial void OnSelectedRecurrenceFrequencyOptionChanged(CalendarComposeFrequencyOption value) => UpdateRecurrenceSummary(); + partial void OnSelectedRecurrenceFrequencyOptionChanged(CalendarComposeFrequencyOption value) + { + OnPropertyChanged(nameof(IsDailyRecurrenceSelected)); + UpdateRecurrenceSummary(); + } partial void OnRecurrenceEndDateChanged(DateTimeOffset? value) => UpdateRecurrenceSummary(); [RelayCommand] private async Task AddAttachmentsAsync() { + if (!CanAddAttachments) + return; + var pickedFiles = await _dialogService.PickFilesMetadataAsync("*"); if (pickedFiles.Count == 0) return; @@ -471,7 +494,9 @@ public partial class CalendarEventComposePageViewModel : CalendarBaseViewModel ShowAs = SelectedShowAsOption?.ShowAs ?? SelectedCalendar?.DefaultShowAs ?? CalendarItemShowAs.Busy, SelectedReminders = BuildSelectedReminders(), Attendees = BuildAttendees(uniqueAttendees), - Attachments = Attachments.Select(attachment => attachment.ToDraftModel()).ToList(), + Attachments = CanAddAttachments + ? Attachments.Select(attachment => attachment.ToDraftModel()).ToList() + : [], Recurrence = BuildRecurrenceRule(), RecurrenceSummary = RecurrenceSummary }; @@ -527,10 +552,12 @@ public partial class CalendarEventComposePageViewModel : CalendarBaseViewModel var effectiveStart = GetEffectiveStartDateTime(); var effectiveEnd = GetEffectiveEndDateTime(); - var selectedDays = WeekdayOptions - .Where(option => option.IsSelected) - .Select(option => option.DayOfWeek) - .ToList(); + var selectedDays = IsDailyRecurrenceSelected + ? WeekdayOptions + .Where(option => option.IsSelected) + .Select(option => option.DayOfWeek) + .ToList() + : []; RecurrenceSummary = CalendarRecurrenceSummaryFormatter.BuildSummary( IsRecurring, @@ -565,10 +592,12 @@ public partial class CalendarEventComposePageViewModel : CalendarBaseViewModel $"INTERVAL={SelectedRecurrenceInterval}" }; - var selectedDays = WeekdayOptions - .Where(option => option.IsSelected) - .Select(option => option.RuleValue) - .ToList(); + var selectedDays = IsDailyRecurrenceSelected + ? WeekdayOptions + .Where(option => option.IsSelected) + .Select(option => option.RuleValue) + .ToList() + : []; if (selectedDays.Count > 0) { @@ -649,7 +678,8 @@ public partial class CalendarEventComposePageViewModel : CalendarBaseViewModel private bool TryAddAttachment(string fileName, string filePath, string fileExtension, long size) { - if (string.IsNullOrWhiteSpace(filePath) || + if (!CanAddAttachments || + string.IsNullOrWhiteSpace(filePath) || Attachments.Any(existing => existing.FilePath.Equals(filePath, StringComparison.OrdinalIgnoreCase))) { return false; diff --git a/Wino.Core.Domain/Translations/en_US/resources.json b/Wino.Core.Domain/Translations/en_US/resources.json index 450d873a..b01acc19 100644 --- a/Wino.Core.Domain/Translations/en_US/resources.json +++ b/Wino.Core.Domain/Translations/en_US/resources.json @@ -126,6 +126,7 @@ "CalendarAttendeeStatus_Tentative": "Tentative", "CalendarEventDetails_Attachments": "Attachments", "CalendarEventCompose_AddAttachment": "Add attachment", + "CalendarEventCompose_AttachmentsNotSupportedForCalDav": "Attachments are not supported for CalDAV calendars.", "CalendarEventCompose_AllDay": "All Day", "CalendarEventCompose_EndDate": "End date", "CalendarEventCompose_EndTime": "End time", diff --git a/Wino.Core/Synchronizers/GmailSynchronizer.cs b/Wino.Core/Synchronizers/GmailSynchronizer.cs index f2b46696..0d5a8edf 100644 --- a/Wino.Core/Synchronizers/GmailSynchronizer.cs +++ b/Wino.Core/Synchronizers/GmailSynchronizer.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.IO; using System.Linq; using System.Net.Http; using System.Text.Json.Serialization; @@ -9,12 +10,14 @@ using System.Web; using CommunityToolkit.Mvvm.Messaging; using Google; using Google.Apis.Calendar.v3.Data; +using Google.Apis.Drive.v3; using Google.Apis.Gmail.v1; using Google.Apis.Gmail.v1.Data; using Google.Apis.Http; using Google.Apis.PeopleService.v1; using Google.Apis.Requests; using Google.Apis.Services; +using Google.Apis.Upload; using MailKit; using Microsoft.IdentityModel.Tokens; using MimeKit; @@ -42,12 +45,15 @@ using Wino.Core.Requests.Mail; using Wino.Messaging.UI; using Wino.Services; using CalendarService = Google.Apis.Calendar.v3.CalendarService; +using DriveFile = Google.Apis.Drive.v3.Data.File; +using DriveService = Google.Apis.Drive.v3.DriveService; namespace Wino.Core.Synchronizers.Mail; [JsonSerializable(typeof(Message))] [JsonSerializable(typeof(Label))] [JsonSerializable(typeof(Draft))] +[JsonSerializable(typeof(Event))] public partial class GmailSynchronizerJsonContext : JsonSerializerContext; /// @@ -88,6 +94,7 @@ public class GmailSynchronizer : WinoSynchronizer eventBundle && eventBundle.Request is CreateCalendarEventRequest createCalendarEventRequest) + { + var createdEvent = await eventBundle.DeserializeBundleAsync(httpResponseMessage, GmailSynchronizerJsonContext.Default.Event, cancellationToken).ConfigureAwait(false); + + if (createdEvent == null || string.IsNullOrWhiteSpace(createdEvent.Id)) + return; + + await UploadCalendarEventAttachmentsAsync(createCalendarEventRequest, createdEvent, cancellationToken).ConfigureAwait(false); + } else if (bundle is HttpRequestBundle draftBundle && draftBundle.Request is CreateDraftRequest createDraftRequest) { // New draft mail is created. @@ -2355,7 +2372,7 @@ public class GmailSynchronizer : WinoSynchronizer(insertRequest, request)]; + return [new HttpRequestBundle(insertRequest, request)]; } public override List> AcceptEvent(AcceptEventRequest request) @@ -2596,9 +2613,84 @@ public class GmailSynchronizer : WinoSynchronizer 25) + throw new InvalidOperationException("Google Calendar supports at most 25 attachments per event."); + + var eventAttachments = createdEvent.Attachments? + .Where(attachment => attachment != null && !string.IsNullOrWhiteSpace(attachment.FileUrl)) + .ToList() ?? []; + + foreach (var attachment in composeAttachments.Where(a => !string.IsNullOrWhiteSpace(a.FilePath) && File.Exists(a.FilePath))) + { + cancellationToken.ThrowIfCancellationRequested(); + eventAttachments.Add(await UploadAttachmentToDriveAsync(attachment, cancellationToken).ConfigureAwait(false)); + } + + if (eventAttachments.Count == 0) + return; + + var patchRequest = _calendarService.Events.Patch(new Event + { + Attachments = eventAttachments + }, request.AssignedCalendar.RemoteCalendarId, createdEvent.Id); + + patchRequest.SupportsAttachments = true; + patchRequest.SendUpdates = Google.Apis.Calendar.v3.EventsResource.PatchRequest.SendUpdatesEnum.None; + + await patchRequest.ExecuteAsync(cancellationToken).ConfigureAwait(false); + } + + private async Task UploadAttachmentToDriveAsync( + Wino.Core.Domain.Models.Calendar.CalendarEventComposeAttachmentDraft attachment, + CancellationToken cancellationToken) + { + var fileName = string.IsNullOrWhiteSpace(attachment.FileName) + ? Path.GetFileName(attachment.FilePath) + : attachment.FileName; + var contentType = MimeTypes.GetMimeType(fileName); + + await using var fileStream = File.OpenRead(attachment.FilePath); + + var uploadRequest = _driveService.Files.Create(new DriveFile + { + Name = fileName, + MimeType = contentType + }, fileStream, contentType); + uploadRequest.Fields = "id,name,mimeType,webViewLink"; + + var uploadProgress = await uploadRequest.UploadAsync(cancellationToken).ConfigureAwait(false); + + if (uploadProgress.Status != UploadStatus.Completed) + { + throw new InvalidOperationException( + $"Failed to upload '{fileName}' to Google Drive. Upload status: {uploadProgress.Status}."); + } + + var uploadedFile = uploadRequest.ResponseBody; + if (uploadedFile == null || string.IsNullOrWhiteSpace(uploadedFile.Id) || string.IsNullOrWhiteSpace(uploadedFile.WebViewLink)) + { + throw new InvalidOperationException($"Google Drive did not return a valid attachment link for '{fileName}'."); + } + + return new EventAttachment + { + FileId = uploadedFile.Id, + FileUrl = uploadedFile.WebViewLink, + MimeType = uploadedFile.MimeType ?? contentType, + Title = uploadedFile.Name ?? fileName + }; + } + private static TimeSpan ResolveOffset(DateTime dateTime, string timeZoneId) { if (string.IsNullOrWhiteSpace(timeZoneId)) diff --git a/Wino.Core/Wino.Core.csproj b/Wino.Core/Wino.Core.csproj index 433f43a1..7734fc96 100644 --- a/Wino.Core/Wino.Core.csproj +++ b/Wino.Core/Wino.Core.csproj @@ -15,6 +15,7 @@ + @@ -43,4 +44,4 @@ - \ No newline at end of file + diff --git a/Wino.Mail.WinUI/Views/Calendar/CalendarEventComposePage.xaml b/Wino.Mail.WinUI/Views/Calendar/CalendarEventComposePage.xaml index 75977043..3295d3e9 100644 --- a/Wino.Mail.WinUI/Views/Calendar/CalendarEventComposePage.xaml +++ b/Wino.Mail.WinUI/Views/Calendar/CalendarEventComposePage.xaml @@ -327,8 +327,13 @@ - + Text="{x:Bind domain:Translator.CalendarEventCompose_ForWeekdays}" + Visibility="{x:Bind ViewModel.IsDailyRecurrenceSelected, Mode=OneWay}" /> + @@ -472,11 +477,13 @@ DragLeave="AttachmentsPane_DragLeave" DragOver="AttachmentsPane_DragOver" Drop="AttachmentsPane_Drop" + ToolTipService.ToolTip="{x:Bind ViewModel.AttachmentsDisabledTooltipText, Mode=OneWay}" Visibility="{x:Bind AttachmentsToggle.IsChecked, Mode=OneWay}">