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}">