diff --git a/Wino.Calendar.ViewModels/Data/AccountCalendarViewModel.cs b/Wino.Calendar.ViewModels/Data/AccountCalendarViewModel.cs index df55f72f..8d36afc4 100644 --- a/Wino.Calendar.ViewModels/Data/AccountCalendarViewModel.cs +++ b/Wino.Calendar.ViewModels/Data/AccountCalendarViewModel.cs @@ -66,4 +66,5 @@ public partial class AccountCalendarViewModel : ObservableObject, IAccountCalend set => SetProperty(AccountCalendar.RemoteCalendarId, value, AccountCalendar, (u, r) => u.RemoteCalendarId = r); } public Guid Id { get => ((IAccountCalendar)AccountCalendar).Id; set => ((IAccountCalendar)AccountCalendar).Id = value; } + public MailAccount MailAccount { get => MailAccount; set => MailAccount = value; } } diff --git a/Wino.Calendar.ViewModels/EventDetailsPageViewModel.cs b/Wino.Calendar.ViewModels/EventDetailsPageViewModel.cs index 068e85b2..fe33de02 100644 --- a/Wino.Calendar.ViewModels/EventDetailsPageViewModel.cs +++ b/Wino.Calendar.ViewModels/EventDetailsPageViewModel.cs @@ -36,7 +36,10 @@ public partial class EventDetailsPageViewModel : CalendarBaseViewModel [NotifyPropertyChangedFor(nameof(CanViewSeries))] [NotifyPropertyChangedFor(nameof(CanEditSeries))] [NotifyPropertyChangedFor(nameof(IsCurrentUserOrganizer))] + [NotifyPropertyChangedFor(nameof(CurrentRsvpText))] + [NotifyPropertyChangedFor(nameof(CurrentRsvpStatus))] public partial CalendarItemViewModel CurrentEvent { get; set; } + [ObservableProperty] public partial CalendarItemViewModel SeriesParent { get; set; } [ObservableProperty] @@ -80,6 +83,45 @@ public partial class EventDetailsPageViewModel : CalendarBaseViewModel #endregion + #region RSVP Panel + + [ObservableProperty] + public partial bool IsRsvpPanelVisible { get; set; } + + public bool IncludeRsvpMessage => !string.IsNullOrEmpty(RsvpMessage); + + [ObservableProperty] + public partial string RsvpMessage { get; set; } = string.Empty; + + public ObservableCollection RsvpStatusOptions { get; } = new ObservableCollection(); + + public CalendarItemStatus CurrentRsvpStatus + { + get + { + return CurrentEvent?.CalendarItem?.Status ?? CalendarItemStatus.NotResponded; + } + } + + public string CurrentRsvpText + { + get + { + if (CurrentEvent?.CalendarItem == null) return Translator.CalendarEventResponse_Accept; + + return CurrentEvent.CalendarItem.Status switch + { + CalendarItemStatus.Accepted => Translator.CalendarEventResponse_AcceptedResponse, + CalendarItemStatus.Tentative => Translator.CalendarEventResponse_TentativeResponse, + CalendarItemStatus.Cancelled => Translator.CalendarEventResponse_DeclinedResponse, + CalendarItemStatus.NotResponded => Translator.CalendarEventResponse_NotResponded, + _ => throw new NotImplementedException() + }; + } + } + + #endregion + public EventDetailsPageViewModel(ICalendarService calendarService, INativeAppService nativeAppService, IPreferencesService preferencesService, @@ -95,6 +137,11 @@ public partial class EventDetailsPageViewModel : CalendarBaseViewModel _navigationService = navigationService; CurrentSettings = _preferencesService.GetCurrentCalendarSettings(); + + // Initialize RSVP status options + RsvpStatusOptions.Add(new RsvpStatusOption(CalendarItemStatus.Accepted)); + RsvpStatusOptions.Add(new RsvpStatusOption(CalendarItemStatus.Tentative)); + RsvpStatusOptions.Add(new RsvpStatusOption(CalendarItemStatus.Cancelled)); } public override async void OnNavigatedTo(NavigationMode mode, object parameters) @@ -109,6 +156,17 @@ public partial class EventDetailsPageViewModel : CalendarBaseViewModel await LoadCalendarItemTargetAsync(args); } + protected override void OnCalendarItemDeleted(CalendarItem calendarItem) + { + base.OnCalendarItemDeleted(calendarItem); + + // If the current event was deleted, navigate back + if (CurrentEvent?.CalendarItem?.Id == calendarItem.Id || CurrentEvent?.CalendarItem.RecurringCalendarItemId == calendarItem.Id) + { + _navigationService.GoBack(); + } + } + private async Task LoadCalendarItemTargetAsync(CalendarItemTarget target) { try @@ -265,11 +323,68 @@ public partial class EventDetailsPageViewModel : CalendarBaseViewModel } [RelayCommand] - private async Task RespondAsync(CalendarItemStatus status) + private void ToggleRsvpPanel() + { + IsRsvpPanelVisible = !IsRsvpPanelVisible; + + if (IsRsvpPanelVisible && CurrentEvent?.CalendarItem != null) + { + // Initialize selection based on current status + foreach (var item in RsvpStatusOptions) + { + item.IsSelected = CurrentEvent?.CalendarItem?.Status == item.Status; + } + } + } + + [RelayCommand] + private void CloseRsvpPanel() + { + IsRsvpPanelVisible = false; + RsvpMessage = string.Empty; + } + + [RelayCommand] + private async Task SendRsvpResponse(AttendeeStatus status) { if (CurrentEvent == null) return; - // TODO: Implement response + try + { + // Get the optional response message if user wants to include it + var responseMessage = IncludeRsvpMessage ? RsvpMessage : null; + + // Map status to operation + CalendarSynchronizerOperation operation = status switch + { + AttendeeStatus.Accepted => CalendarSynchronizerOperation.AcceptEvent, + AttendeeStatus.Tentative => CalendarSynchronizerOperation.TentativeEvent, + AttendeeStatus.Declined => CalendarSynchronizerOperation.DeclineEvent, + _ => throw new InvalidOperationException($"Invalid RSVP status: {status}") + }; + + // Create preparation request with the optional message + var preparationRequest = new CalendarOperationPreparationRequest( + operation, + CurrentEvent.CalendarItem, + null, + responseMessage); + + await _winoRequestDelegator.ExecuteAsync(preparationRequest); + + OnPropertyChanged(nameof(CurrentRsvpText)); + OnPropertyChanged(nameof(CurrentRsvpStatus)); + + CloseRsvpPanel(); + } + catch (Exception ex) + { + Debug.WriteLine($"Error sending RSVP response: {ex.Message}"); + _dialogService.InfoBarMessage( + Translator.Info_AttachmentSaveFailedTitle, + ex.Message, + InfoBarMessageType.Error); + } } [RelayCommand] @@ -323,3 +438,30 @@ public partial class ReminderOption : ObservableObject IsCustom = isCustom; } } + +public partial class RsvpStatusOption : ObservableObject +{ + public CalendarItemStatus Status { get; } + + public string StatusText + { + get + { + return Status switch + { + CalendarItemStatus.Accepted => Translator.CalendarEventResponse_Accept, + CalendarItemStatus.Tentative => Translator.CalendarEventResponse_Tentative, + CalendarItemStatus.Cancelled => Translator.CalendarEventResponse_Decline, + _ => Translator.CalendarEventResponse_Accept + }; + } + } + + [ObservableProperty] + public partial bool IsSelected { get; set; } + + public RsvpStatusOption(CalendarItemStatus status) + { + Status = status; + } +} diff --git a/Wino.Calendar/Views/EventDetailsPage.xaml b/Wino.Calendar/Views/EventDetailsPage.xaml index 875b5689..c82ca17d 100644 --- a/Wino.Calendar/Views/EventDetailsPage.xaml +++ b/Wino.Calendar/Views/EventDetailsPage.xaml @@ -258,18 +258,32 @@ Height="40" DisplayName="{x:Bind Name}" /> - - + + + + + diff --git a/Wino.Core.Domain/Entities/Calendar/AccountCalendar.cs b/Wino.Core.Domain/Entities/Calendar/AccountCalendar.cs index d8d81f7d..1b8d06bd 100644 --- a/Wino.Core.Domain/Entities/Calendar/AccountCalendar.cs +++ b/Wino.Core.Domain/Entities/Calendar/AccountCalendar.cs @@ -1,5 +1,6 @@ using System; using SQLite; +using Wino.Core.Domain.Entities.Shared; using Wino.Core.Domain.Interfaces; namespace Wino.Core.Domain.Entities.Calendar; @@ -22,4 +23,7 @@ public class AccountCalendar : IAccountCalendar public string TextColorHex { get; set; } public string BackgroundColorHex { get; set; } public string TimeZone { get; set; } + + [Ignore] + public MailAccount MailAccount { get; set; } } diff --git a/Wino.Core.Domain/Enums/CalendarItemStatus.cs b/Wino.Core.Domain/Enums/CalendarItemStatus.cs index e8605573..c6d08b52 100644 --- a/Wino.Core.Domain/Enums/CalendarItemStatus.cs +++ b/Wino.Core.Domain/Enums/CalendarItemStatus.cs @@ -3,7 +3,7 @@ public enum CalendarItemStatus { NotResponded, - Confirmed, + Accepted, Tentative, Cancelled, } diff --git a/Wino.Core.Domain/Enums/MailOperation.cs b/Wino.Core.Domain/Enums/MailOperation.cs index e90f22a7..3e6f285f 100644 --- a/Wino.Core.Domain/Enums/MailOperation.cs +++ b/Wino.Core.Domain/Enums/MailOperation.cs @@ -26,6 +26,9 @@ public enum CalendarSynchronizerOperation CreateEvent, UpdateEvent, DeleteEvent, + AcceptEvent, + DeclineEvent, + TentativeEvent, } // UI requests diff --git a/Wino.Core.Domain/Interfaces/IAccountCalendar.cs b/Wino.Core.Domain/Interfaces/IAccountCalendar.cs index 85a340c3..3c4e8c4a 100644 --- a/Wino.Core.Domain/Interfaces/IAccountCalendar.cs +++ b/Wino.Core.Domain/Interfaces/IAccountCalendar.cs @@ -1,4 +1,5 @@ using System; +using Wino.Core.Domain.Entities.Shared; namespace Wino.Core.Domain.Interfaces; @@ -12,4 +13,5 @@ public interface IAccountCalendar string RemoteCalendarId { get; set; } bool IsExtended { get; set; } Guid Id { get; set; } + MailAccount MailAccount { get; set; } } diff --git a/Wino.Core.Domain/Models/Calendar/CalendarOperationPreparationRequest.cs b/Wino.Core.Domain/Models/Calendar/CalendarOperationPreparationRequest.cs index 0705681e..b5bba4e7 100644 --- a/Wino.Core.Domain/Models/Calendar/CalendarOperationPreparationRequest.cs +++ b/Wino.Core.Domain/Models/Calendar/CalendarOperationPreparationRequest.cs @@ -7,7 +7,8 @@ namespace Wino.Core.Domain.Models.Calendar; /// /// Encapsulates the options for preparing calendar operation requests. /// -/// Calendar operation to execute (Create, Update, Delete). +/// Calendar operation to execute (Create, Update, Delete, Accept, Decline, Tentative). /// Calendar item to operate on. /// List of attendees for the calendar event. -public record CalendarOperationPreparationRequest(CalendarSynchronizerOperation Operation, CalendarItem CalendarItem, List Attendees); +/// Optional message to include with event responses (Accept, Decline, Tentative). +public record CalendarOperationPreparationRequest(CalendarSynchronizerOperation Operation, CalendarItem CalendarItem, List Attendees, string ResponseMessage = null); diff --git a/Wino.Core.Domain/Translations/en_US/resources.json b/Wino.Core.Domain/Translations/en_US/resources.json index d7fa3316..8168f1b6 100644 --- a/Wino.Core.Domain/Translations/en_US/resources.json +++ b/Wino.Core.Domain/Translations/en_US/resources.json @@ -84,14 +84,26 @@ "CalendarDisplayOptions_Color": "Color", "CalendarDisplayOptions_Expand": "Expand", "CalendarEventResponse_Accept": "Accept", + "CalendarEventResponse_AcceptedResponse": "Accepted", "CalendarEventResponse_Decline": "Decline", + "CalendarEventResponse_DeclinedResponse": "Declined", + "CalendarEventResponse_NotResponded": "Not Responded", "CalendarEventResponse_Tentative": "Tentative", + "CalendarEventResponse_TentativeResponse": "Tentative", + "CalendarEventRsvpPanel_Accept": "Accept", + "CalendarEventRsvpPanel_AddMessage": "Add a message to your response... (optional)", + "CalendarEventRsvpPanel_Decline": "Decline", + "CalendarEventRsvpPanel_Message": "Message", + "CalendarEventRsvpPanel_SendReplyMessage": "Send a reply message", + "CalendarEventRsvpPanel_Tentative": "Tentative", + "CalendarEventRsvpPanel_Title": "Response Options", "CalendarEventDetails_Attachments": "Attachments", "CalendarEventDetails_Details": "Details", "CalendarEventDetails_EditSeries": "Edit Series", "CalendarEventDetails_Editing": "Editing", "CalendarEventDetails_InviteSomeone": "Invite someone", "CalendarEventDetails_JoinOnline": "Join Online", + "CalendarEventDetails_Organizer": "Organizer", "CalendarEventDetails_People": "People", "CalendarEventDetails_ReadOnlyEvent": "Read-only event", "CalendarEventDetails_Reminder": "Reminder", diff --git a/Wino.Core/Integration/Processors/GmailChangeProcessor.cs b/Wino.Core/Integration/Processors/GmailChangeProcessor.cs index 9329752c..e975aba7 100644 --- a/Wino.Core/Integration/Processors/GmailChangeProcessor.cs +++ b/Wino.Core/Integration/Processors/GmailChangeProcessor.cs @@ -349,10 +349,10 @@ public class GmailChangeProcessor : DefaultChangeProcessor, IGmailChangeProcesso { return status switch { - "confirmed" => CalendarItemStatus.Confirmed, + "confirmed" => CalendarItemStatus.Accepted, "tentative" => CalendarItemStatus.Tentative, "cancelled" => CalendarItemStatus.Cancelled, - _ => CalendarItemStatus.Confirmed + _ => CalendarItemStatus.Accepted }; } diff --git a/Wino.Core/Integration/Processors/OutlookChangeProcessor.cs b/Wino.Core/Integration/Processors/OutlookChangeProcessor.cs index 176ac7dc..60ce4103 100644 --- a/Wino.Core/Integration/Processors/OutlookChangeProcessor.cs +++ b/Wino.Core/Integration/Processors/OutlookChangeProcessor.cs @@ -152,7 +152,7 @@ public class OutlookChangeProcessor(IDatabaseService databaseService, break; case ResponseType.Accepted: case ResponseType.Organizer: - savingItem.Status = CalendarItemStatus.Confirmed; + savingItem.Status = CalendarItemStatus.Accepted; break; case ResponseType.Declined: savingItem.Status = CalendarItemStatus.Cancelled; @@ -164,7 +164,7 @@ public class OutlookChangeProcessor(IDatabaseService databaseService, } else { - savingItem.Status = CalendarItemStatus.Confirmed; + savingItem.Status = CalendarItemStatus.Accepted; } // Prepare attendees list diff --git a/Wino.Core/Requests/Calendar/AcceptEventRequest.cs b/Wino.Core/Requests/Calendar/AcceptEventRequest.cs new file mode 100644 index 00000000..aa43a3a3 --- /dev/null +++ b/Wino.Core/Requests/Calendar/AcceptEventRequest.cs @@ -0,0 +1,39 @@ +using CommunityToolkit.Mvvm.Messaging; +using Wino.Core.Domain.Entities.Calendar; +using Wino.Core.Domain.Enums; +using Wino.Core.Domain.Models.Requests; +using Wino.Messaging.Client.Calendar; + +namespace Wino.Core.Requests.Calendar; + +/// +/// Request to accept a calendar event invitation on the server. +/// The calendar item status should be updated locally before queuing this request. +/// +public record AcceptEventRequest(CalendarItem Item, string ResponseMessage = null) : CalendarRequestBase(Item) +{ + private readonly CalendarItemStatus _previousStatus = Item.Status; + + public override CalendarSynchronizerOperation Operation => CalendarSynchronizerOperation.AcceptEvent; + + /// + /// After successful acceptance, we need to resync to get updated status. + /// + public override int ResynchronizationDelay => 2000; + + public override void ApplyUIChanges() + { + // Update the item status locally + Item.Status = CalendarItemStatus.Accepted; + + // Notify UI that the event status was updated + WeakReferenceMessenger.Default.Send(new CalendarItemUpdated(Item)); + } + + public override void RevertUIChanges() + { + // If acceptance fails, revert to the previous status + Item.Status = _previousStatus; + WeakReferenceMessenger.Default.Send(new CalendarItemUpdated(Item)); + } +} diff --git a/Wino.Core/Requests/Calendar/DeclineEventRequest.cs b/Wino.Core/Requests/Calendar/DeclineEventRequest.cs new file mode 100644 index 00000000..34ac0257 --- /dev/null +++ b/Wino.Core/Requests/Calendar/DeclineEventRequest.cs @@ -0,0 +1,39 @@ +using CommunityToolkit.Mvvm.Messaging; +using Wino.Core.Domain.Entities.Calendar; +using Wino.Core.Domain.Enums; +using Wino.Core.Domain.Models.Requests; +using Wino.Messaging.Client.Calendar; + +namespace Wino.Core.Requests.Calendar; + +/// +/// Request to decline a calendar event invitation on the server. +/// The calendar item status should be updated locally before queuing this request. +/// +public record DeclineEventRequest(CalendarItem Item, string ResponseMessage = null) : CalendarRequestBase(Item) +{ + private readonly CalendarItemStatus _previousStatus = Item.Status; + + public override CalendarSynchronizerOperation Operation => CalendarSynchronizerOperation.DeclineEvent; + + /// + /// After successful decline, we need to resync to get updated status. + /// + public override int ResynchronizationDelay => 2000; + + public override void ApplyUIChanges() + { + // Update the item status locally + Item.Status = CalendarItemStatus.Cancelled; + + // Notify UI that the event status was updated + WeakReferenceMessenger.Default.Send(new CalendarItemUpdated(Item)); + } + + public override void RevertUIChanges() + { + // If decline fails, revert to the previous status + Item.Status = _previousStatus; + WeakReferenceMessenger.Default.Send(new CalendarItemUpdated(Item)); + } +} diff --git a/Wino.Core/Requests/Calendar/OutlookDeclineEventRequest.cs b/Wino.Core/Requests/Calendar/OutlookDeclineEventRequest.cs new file mode 100644 index 00000000..c7e87147 --- /dev/null +++ b/Wino.Core/Requests/Calendar/OutlookDeclineEventRequest.cs @@ -0,0 +1,38 @@ +using CommunityToolkit.Mvvm.Messaging; +using Wino.Core.Domain.Entities.Calendar; +using Wino.Core.Domain.Enums; +using Wino.Core.Domain.Models.Requests; +using Wino.Messaging.Client.Calendar; + +namespace Wino.Core.Requests.Calendar; + +/// +/// Outlook-specific request to decline a calendar event invitation. +/// In Outlook, declined events are removed from the calendar by the API after synchronization, +/// so this request sends a delete notification to remove the event from the UI. +/// +public record OutlookDeclineEventRequest(CalendarItem Item, string ResponseMessage = null) : CalendarRequestBase(Item) +{ + private readonly CalendarItemStatus _previousStatus = Item.Status; + + public override CalendarSynchronizerOperation Operation => CalendarSynchronizerOperation.DeclineEvent; + + /// + /// After successful decline, we need to resync to confirm the event is removed. + /// + public override int ResynchronizationDelay => 2000; + + public override void ApplyUIChanges() + { + // In Outlook, declined events are deleted from the calendar after sync + // Send deleted message to remove from UI immediately + WeakReferenceMessenger.Default.Send(new CalendarItemDeleted(Item)); + } + + public override void RevertUIChanges() + { + // If decline fails, restore the previous status and re-add the event + Item.Status = _previousStatus; + WeakReferenceMessenger.Default.Send(new CalendarItemAdded(Item)); + } +} diff --git a/Wino.Core/Requests/Calendar/TentativeEventRequest.cs b/Wino.Core/Requests/Calendar/TentativeEventRequest.cs new file mode 100644 index 00000000..43f90d0d --- /dev/null +++ b/Wino.Core/Requests/Calendar/TentativeEventRequest.cs @@ -0,0 +1,39 @@ +using CommunityToolkit.Mvvm.Messaging; +using Wino.Core.Domain.Entities.Calendar; +using Wino.Core.Domain.Enums; +using Wino.Core.Domain.Models.Requests; +using Wino.Messaging.Client.Calendar; + +namespace Wino.Core.Requests.Calendar; + +/// +/// Request to tentatively accept a calendar event invitation on the server. +/// The calendar item status should be updated locally before queuing this request. +/// +public record TentativeEventRequest(CalendarItem Item, string ResponseMessage = null) : CalendarRequestBase(Item) +{ + private readonly CalendarItemStatus _previousStatus = Item.Status; + + public override CalendarSynchronizerOperation Operation => CalendarSynchronizerOperation.TentativeEvent; + + /// + /// After successful tentative acceptance, we need to resync to get updated status. + /// + public override int ResynchronizationDelay => 2000; + + public override void ApplyUIChanges() + { + // Update the item status locally + Item.Status = CalendarItemStatus.Tentative; + + // Notify UI that the event status was updated + WeakReferenceMessenger.Default.Send(new CalendarItemUpdated(Item)); + } + + public override void RevertUIChanges() + { + // If tentative acceptance fails, revert to the previous status + Item.Status = _previousStatus; + WeakReferenceMessenger.Default.Send(new CalendarItemUpdated(Item)); + } +} diff --git a/Wino.Core/Services/WinoRequestDelegator.cs b/Wino.Core/Services/WinoRequestDelegator.cs index 2edf164c..fa4a881e 100644 --- a/Wino.Core/Services/WinoRequestDelegator.cs +++ b/Wino.Core/Services/WinoRequestDelegator.cs @@ -5,6 +5,7 @@ using System.Threading.Tasks; using CommunityToolkit.Mvvm.Messaging; using Serilog; using Wino.Core.Domain; +using Wino.Core.Domain.Entities.Calendar; using Wino.Core.Domain.Enums; using Wino.Core.Domain.Exceptions; using Wino.Core.Domain.Interfaces; @@ -141,6 +142,9 @@ public class WinoRequestDelegator : IWinoRequestDelegator { CalendarSynchronizerOperation.CreateEvent => new CreateCalendarEventRequest(calendarPreparationRequest.CalendarItem, calendarPreparationRequest.Attendees), CalendarSynchronizerOperation.DeleteEvent => new DeleteCalendarEventRequest(calendarPreparationRequest.CalendarItem), + CalendarSynchronizerOperation.AcceptEvent => new AcceptEventRequest(calendarPreparationRequest.CalendarItem, calendarPreparationRequest.ResponseMessage), + CalendarSynchronizerOperation.DeclineEvent => CreateDeclineRequest(calendarPreparationRequest.CalendarItem, calendarPreparationRequest.ResponseMessage), + CalendarSynchronizerOperation.TentativeEvent => new TentativeEventRequest(calendarPreparationRequest.CalendarItem, calendarPreparationRequest.ResponseMessage), // Future support for update operations // CalendarSynchronizerOperation.UpdateEvent => new UpdateCalendarEventRequest(calendarPreparationRequest.CalendarItem, calendarPreparationRequest.Attendees), _ => throw new NotImplementedException($"Calendar operation {calendarPreparationRequest.Operation} is not implemented yet.") @@ -150,6 +154,18 @@ public class WinoRequestDelegator : IWinoRequestDelegator await QueueCalendarSynchronizationAsync(calendarPreparationRequest.CalendarItem.AssignedCalendar.AccountId); } + private IRequestBase CreateDeclineRequest(CalendarItem calendarItem, string responseMessage) + { + // For Outlook accounts, declined events are deleted by the server after synchronization. + // Use OutlookDeclineEventRequest to handle UI removal. + if (calendarItem.AssignedCalendar?.MailAccount?.ProviderType == MailProviderType.Outlook) + { + return new OutlookDeclineEventRequest(calendarItem, responseMessage); + } + + return new DeclineEventRequest(calendarItem, responseMessage); + } + private async Task QueueRequestAsync(IRequestBase request, Guid accountId) { // Don't trigger synchronization for individual requests - we'll trigger it once for all requests diff --git a/Wino.Core/Synchronizers/GmailSynchronizer.cs b/Wino.Core/Synchronizers/GmailSynchronizer.cs index ab01ffe3..a169aee3 100644 --- a/Wino.Core/Synchronizers/GmailSynchronizer.cs +++ b/Wino.Core/Synchronizers/GmailSynchronizer.cs @@ -8,6 +8,7 @@ using System.Threading.Tasks; using System.Web; using CommunityToolkit.Mvvm.Messaging; using Google; +using Google.Apis.Calendar.v3; using Google.Apis.Calendar.v3.Data; using Google.Apis.Gmail.v1; using Google.Apis.Gmail.v1.Data; @@ -1648,7 +1649,7 @@ public class GmailSynchronizer : WinoSynchronizer(insertRequest, request)]; } + public override List> AcceptEvent(AcceptEventRequest request) + { + var calendarItem = request.Item; + var calendar = calendarItem.AssignedCalendar; + + if (calendar == null) + { + throw new InvalidOperationException("Calendar item must have an assigned calendar"); + } + + if (string.IsNullOrEmpty(calendarItem.RemoteEventId)) + { + throw new InvalidOperationException("Cannot accept event without remote event ID"); + } + + // For Gmail, we need to patch the event with the user's response status + // Get the current user's email from the account + var userEmail = Account.Address; + + // Create a patch event to update only the attendee response + var patchEvent = new Event(); + + // We need to get the event first to update the specific attendee + // However, for efficiency, we'll use the patch method with sendUpdates parameter + var patchRequest = _calendarService.Events.Patch(new Event + { + // The API will handle updating the current user's attendee status + Attendees = new List + { + new EventAttendee + { + Email = userEmail, + ResponseStatus = "accepted" + } + } + }, calendar.RemoteCalendarId, calendarItem.RemoteEventId); + + // Send updates to other attendees if there's a message + patchRequest.SendUpdates = !string.IsNullOrEmpty(request.ResponseMessage) + ? Google.Apis.Calendar.v3.EventsResource.PatchRequest.SendUpdatesEnum.All + : Google.Apis.Calendar.v3.EventsResource.PatchRequest.SendUpdatesEnum.None; + + return [new HttpRequestBundle(patchRequest, request)]; + } + + public override List> DeclineEvent(DeclineEventRequest request) + { + var calendarItem = request.Item; + var calendar = calendarItem.AssignedCalendar; + + if (calendar == null) + { + throw new InvalidOperationException("Calendar item must have an assigned calendar"); + } + + if (string.IsNullOrEmpty(calendarItem.RemoteEventId)) + { + throw new InvalidOperationException("Cannot decline event without remote event ID"); + } + + var userEmail = Account.Address; + + var patchRequest = _calendarService.Events.Patch(new Event + { + Attendees = new List + { + new EventAttendee + { + Email = userEmail, + ResponseStatus = "declined", + Comment = request.ResponseMessage + } + } + }, calendar.RemoteCalendarId, calendarItem.RemoteEventId); + + patchRequest.SendUpdates = !string.IsNullOrEmpty(request.ResponseMessage) + ? Google.Apis.Calendar.v3.EventsResource.PatchRequest.SendUpdatesEnum.All + : Google.Apis.Calendar.v3.EventsResource.PatchRequest.SendUpdatesEnum.None; + + return [new HttpRequestBundle(patchRequest, request)]; + } + + public override List> TentativeEvent(TentativeEventRequest request) + { + var calendarItem = request.Item; + var calendar = calendarItem.AssignedCalendar; + + if (calendar == null) + { + throw new InvalidOperationException("Calendar item must have an assigned calendar"); + } + + if (string.IsNullOrEmpty(calendarItem.RemoteEventId)) + { + throw new InvalidOperationException("Cannot tentatively accept event without remote event ID"); + } + + var userEmail = Account.Address; + + var patchRequest = _calendarService.Events.Patch(new Event + { + Attendees = new List + { + new EventAttendee + { + Email = userEmail, + ResponseStatus = "tentative", + Comment = request.ResponseMessage + } + } + }, calendar.RemoteCalendarId, calendarItem.RemoteEventId); + + patchRequest.SendUpdates = !string.IsNullOrEmpty(request.ResponseMessage) + ? Google.Apis.Calendar.v3.EventsResource.PatchRequest.SendUpdatesEnum.All + : Google.Apis.Calendar.v3.EventsResource.PatchRequest.SendUpdatesEnum.None; + + return [new HttpRequestBundle(patchRequest, request)]; + } + #endregion public override async Task KillSynchronizerAsync() diff --git a/Wino.Core/Synchronizers/OutlookSynchronizer.cs b/Wino.Core/Synchronizers/OutlookSynchronizer.cs index 89a91639..9aa09f0c 100644 --- a/Wino.Core/Synchronizers/OutlookSynchronizer.cs +++ b/Wino.Core/Synchronizers/OutlookSynchronizer.cs @@ -1680,6 +1680,9 @@ public class OutlookSynchronizer : WinoSynchronizer(createRequest, request)]; } + public override List> AcceptEvent(AcceptEventRequest request) + { + var calendarItem = request.Item; + var calendar = calendarItem.AssignedCalendar; + + if (calendar == null) + { + throw new InvalidOperationException("Calendar item must have an assigned calendar"); + } + + if (string.IsNullOrEmpty(calendarItem.RemoteEventId)) + { + throw new InvalidOperationException("Cannot accept event without remote event ID"); + } + + var acceptRequestInfo = _graphClient.Me.Calendars[calendar.RemoteCalendarId].Events[calendarItem.RemoteEventId].Accept.ToPostRequestInformation(new Microsoft.Graph.Me.Calendars.Item.Events.Item.Accept.AcceptPostRequestBody + { + Comment = request.ResponseMessage, + SendResponse = !string.IsNullOrEmpty(request.ResponseMessage) + }); + + return [new HttpRequestBundle(acceptRequestInfo, request)]; + } + + public override List> OutlookDeclineEvent(OutlookDeclineEventRequest request) + { + var responseMessage = request.ResponseMessage; + + var calendarItem = request.Item; + var calendar = calendarItem.AssignedCalendar; + + if (calendar == null) + { + throw new InvalidOperationException("Calendar item must have an assigned calendar"); + } + + if (string.IsNullOrEmpty(calendarItem.RemoteEventId)) + { + throw new InvalidOperationException("Cannot decline event without remote event ID"); + } + + var declineRequestInfo = _graphClient.Me.Calendars[calendar.RemoteCalendarId].Events[calendarItem.RemoteEventId].Decline.ToPostRequestInformation(new Microsoft.Graph.Me.Calendars.Item.Events.Item.Decline.DeclinePostRequestBody + { + Comment = responseMessage, + SendResponse = !string.IsNullOrEmpty(responseMessage) + }); + + return [new HttpRequestBundle(declineRequestInfo, request)]; + } + + public override List> TentativeEvent(TentativeEventRequest request) + { + var calendarItem = request.Item; + var calendar = calendarItem.AssignedCalendar; + + if (calendar == null) + { + throw new InvalidOperationException("Calendar item must have an assigned calendar"); + } + + if (string.IsNullOrEmpty(calendarItem.RemoteEventId)) + { + throw new InvalidOperationException("Cannot tentatively accept event without remote event ID"); + } + + var tentativelyAcceptRequestInfo = _graphClient.Me.Calendars[calendar.RemoteCalendarId].Events[calendarItem.RemoteEventId].TentativelyAccept.ToPostRequestInformation(new Microsoft.Graph.Me.Calendars.Item.Events.Item.TentativelyAccept.TentativelyAcceptPostRequestBody + { + Comment = request.ResponseMessage, + SendResponse = !string.IsNullOrEmpty(request.ResponseMessage) + }); + + return [new HttpRequestBundle(tentativelyAcceptRequestInfo, request)]; + } + #endregion public override async Task KillSynchronizerAsync() diff --git a/Wino.Core/Synchronizers/WinoSynchronizer.cs b/Wino.Core/Synchronizers/WinoSynchronizer.cs index eef70a74..54562779 100644 --- a/Wino.Core/Synchronizers/WinoSynchronizer.cs +++ b/Wino.Core/Synchronizers/WinoSynchronizer.cs @@ -364,6 +364,22 @@ public abstract class WinoSynchronizer> CreateCalendarEvent(CreateCalendarEventRequest request) => throw new NotSupportedException(string.Format(Translator.Exception_UnsupportedSynchronizerOperation, this.GetType())); + public virtual List> AcceptEvent(AcceptEventRequest request) => throw new NotSupportedException(string.Format(Translator.Exception_UnsupportedSynchronizerOperation, this.GetType())); + public virtual List> DeclineEvent(DeclineEventRequest request) => throw new NotSupportedException(string.Format(Translator.Exception_UnsupportedSynchronizerOperation, this.GetType())); + public virtual List> OutlookDeclineEvent(OutlookDeclineEventRequest request) => throw new NotSupportedException(string.Format(Translator.Exception_UnsupportedSynchronizerOperation, this.GetType())); + public virtual List> TentativeEvent(TentativeEventRequest request) => throw new NotSupportedException(string.Format(Translator.Exception_UnsupportedSynchronizerOperation, this.GetType())); #endregion diff --git a/Wino.Mail.WinUI/Assets/WinoIcons.ttf b/Wino.Mail.WinUI/Assets/WinoIcons.ttf index ef8acbf8..cd38942c 100644 Binary files a/Wino.Mail.WinUI/Assets/WinoIcons.ttf and b/Wino.Mail.WinUI/Assets/WinoIcons.ttf differ diff --git a/Wino.Mail.WinUI/Controls/ControlConstants.cs b/Wino.Mail.WinUI/Controls/ControlConstants.cs index 32a774a5..0e7d8887 100644 --- a/Wino.Mail.WinUI/Controls/ControlConstants.cs +++ b/Wino.Mail.WinUI/Controls/ControlConstants.cs @@ -100,6 +100,15 @@ public static class ControlConstants { WinoIconGlyph.EventJoinOnline, "\uE926" }, { WinoIconGlyph.ViewMessageSource, "\uE943" }, { WinoIconGlyph.Apple, "\uE92B" }, - { WinoIconGlyph.Yahoo, "\uE92C" } + { WinoIconGlyph.Yahoo, "\uE92C" }, + { WinoIconGlyph.People, "\uF007" }, + { WinoIconGlyph.AttachmentNew, "\uF006" }, + { WinoIconGlyph.CalendarSettings, "\uF005" }, + { WinoIconGlyph.SettingsNew, "\uF004" }, + { WinoIconGlyph.ManageAccounts, "\uF003" }, + { WinoIconGlyph.SendNew, "\uF002" }, + { WinoIconGlyph.CalendarShowAs, "\uF001" }, + { WinoIconGlyph.EventDecline, "\uF000" }, + { WinoIconGlyph.Dismiss, "\uF008" }, }; } diff --git a/Wino.Mail.WinUI/Controls/WinoFontIcon.cs b/Wino.Mail.WinUI/Controls/WinoFontIcon.cs index 3901ef45..b934ef2a 100644 --- a/Wino.Mail.WinUI/Controls/WinoFontIcon.cs +++ b/Wino.Mail.WinUI/Controls/WinoFontIcon.cs @@ -101,7 +101,15 @@ public enum WinoIconGlyph EventJoinOnline, ViewMessageSource, Apple, - Yahoo + Yahoo, + People, + AttachmentNew, + CalendarSettings, + SettingsNew, + ManageAccounts, + SendNew, + CalendarShowAs, + Dismiss } public partial class WinoFontIcon : FontIcon diff --git a/Wino.Mail.WinUI/Selectors/RsvpStatusIconTemplateSelector.cs b/Wino.Mail.WinUI/Selectors/RsvpStatusIconTemplateSelector.cs new file mode 100644 index 00000000..fd07abd1 --- /dev/null +++ b/Wino.Mail.WinUI/Selectors/RsvpStatusIconTemplateSelector.cs @@ -0,0 +1,30 @@ +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Wino.Core.Domain.Enums; + +namespace Wino.Mail.WinUI.Selectors; + +public partial class RsvpStatusIconTemplateSelector : DataTemplateSelector +{ + public DataTemplate NotRespondedTemplate { get; set; } + public DataTemplate ConfirmedTemplate { get; set; } + public DataTemplate TentativeTemplate { get; set; } + public DataTemplate CancelledTemplate { get; set; } + + protected override DataTemplate SelectTemplateCore(object item, DependencyObject container) + { + if (item is CalendarItemStatus status) + { + return status switch + { + CalendarItemStatus.NotResponded => NotRespondedTemplate, + CalendarItemStatus.Accepted => ConfirmedTemplate, + CalendarItemStatus.Tentative => TentativeTemplate, + CalendarItemStatus.Cancelled => CancelledTemplate, + _ => NotRespondedTemplate + }; + } + + return base.SelectTemplateCore(item, container); + } +} diff --git a/Wino.Mail.WinUI/Styles/DataTemplates.xaml b/Wino.Mail.WinUI/Styles/DataTemplates.xaml index 27725268..e4ec232c 100644 --- a/Wino.Mail.WinUI/Styles/DataTemplates.xaml +++ b/Wino.Mail.WinUI/Styles/DataTemplates.xaml @@ -28,10 +28,11 @@ - + @@ -55,14 +56,15 @@ - + + diff --git a/Wino.Mail.WinUI/Views/Calendar/EventDetailsPage.xaml b/Wino.Mail.WinUI/Views/Calendar/EventDetailsPage.xaml index be4932d5..23967693 100644 --- a/Wino.Mail.WinUI/Views/Calendar/EventDetailsPage.xaml +++ b/Wino.Mail.WinUI/Views/Calendar/EventDetailsPage.xaml @@ -8,16 +8,64 @@ xmlns:calendarHelpers="using:Wino.Calendar.Helpers" xmlns:calendarViewModels="using:Wino.Calendar.ViewModels" xmlns:coreControls="using:Wino.Mail.WinUI.Controls" + xmlns:ctControls="using:CommunityToolkit.WinUI.Controls" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:domain="using:Wino.Core.Domain" xmlns:enums="using:Wino.Core.Domain.Enums" xmlns:helpers="using:Wino.Helpers" xmlns:local="using:Wino.Calendar.Views" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" + xmlns:selectors="using:Wino.Mail.WinUI.Selectors" Style="{StaticResource PageStyle}" mc:Ignorable="d"> + + + + + + + + + + + + + + + + + + + + + + + + + - -