diff --git a/Wino.Calendar.ViewModels/Data/CalendarItemViewModel.cs b/Wino.Calendar.ViewModels/Data/CalendarItemViewModel.cs index 07a386f6..f0771b1e 100644 --- a/Wino.Calendar.ViewModels/Data/CalendarItemViewModel.cs +++ b/Wino.Calendar.ViewModels/Data/CalendarItemViewModel.cs @@ -4,6 +4,7 @@ using CommunityToolkit.Mvvm.ComponentModel; using Itenso.TimePeriod; using Wino.Core.Domain; using Wino.Core.Domain.Entities.Calendar; +using Wino.Core.Domain.Extensions; using Wino.Core.Domain.Interfaces; using Wino.Core.Domain.Models.Calendar; @@ -32,26 +33,8 @@ public partial class CalendarItemViewModel : ObservableObject, ICalendarItem, IC } set { - // When setting from UI (in local time), convert to event's timezone for storage - if (!string.IsNullOrEmpty(CalendarItem.StartTimeZone)) - { - try - { - var sourceTimeZone = TimeZoneInfo.Local; - var targetTimeZone = TimeZoneInfo.FindSystemTimeZoneById(CalendarItem.StartTimeZone); - CalendarItem.StartDate = TimeZoneInfo.ConvertTime(value, sourceTimeZone, targetTimeZone); - } - catch - { - // If timezone lookup fails, set as-is - CalendarItem.StartDate = value; - } - } - else - { - // No timezone info, set as-is - CalendarItem.StartDate = value; - } + // When setting from UI (in local time), convert to event's timezone for storage. + CalendarItem.StartDate = value.ToTimeZoneFromLocal(CalendarItem.StartTimeZone); } } diff --git a/Wino.Core.Domain/Constants.cs b/Wino.Core.Domain/Constants.cs index fda60349..cc4c0e77 100644 --- a/Wino.Core.Domain/Constants.cs +++ b/Wino.Core.Domain/Constants.cs @@ -13,6 +13,9 @@ public static class Constants public const string ToastMailUniqueIdKey = nameof(ToastMailUniqueIdKey); public const string ToastActionKey = nameof(ToastActionKey); public const string ToastMailAccountIdKey = nameof(ToastMailAccountIdKey); + public const string ToastCalendarItemIdKey = nameof(ToastCalendarItemIdKey); + public const string ToastCalendarActionKey = nameof(ToastCalendarActionKey); + public const string ToastCalendarNavigateAction = nameof(ToastCalendarNavigateAction); public const string ClientLogFile = "Client_.log"; public const string ServerLogFile = "Server_.log"; diff --git a/Wino.Core.Domain/Entities/Calendar/CalendarItem.cs b/Wino.Core.Domain/Entities/Calendar/CalendarItem.cs index 2bc2e652..86d6235a 100644 --- a/Wino.Core.Domain/Entities/Calendar/CalendarItem.cs +++ b/Wino.Core.Domain/Entities/Calendar/CalendarItem.cs @@ -3,6 +3,7 @@ using System.Diagnostics; using Itenso.TimePeriod; using SQLite; using Wino.Core.Domain.Enums; +using Wino.Core.Domain.Extensions; using Wino.Core.Domain.Interfaces; using Wino.Core.Domain.Models.Calendar; @@ -168,28 +169,7 @@ public class CalendarItem : ICalendarItem { get { - if (string.IsNullOrEmpty(StartTimeZone)) - { - // No timezone info, return as-is - return StartDate; - } - - try - { - var sourceTimeZone = TimeZoneInfo.FindSystemTimeZoneById(StartTimeZone); - var localTimeZone = TimeZoneInfo.Local; - - // Ensure DateTime is Unspecified kind before conversion - var unspecifiedDateTime = DateTime.SpecifyKind(StartDate, DateTimeKind.Unspecified); - - // Convert from source timezone to local timezone - return TimeZoneInfo.ConvertTime(unspecifiedDateTime, sourceTimeZone, localTimeZone); - } - catch - { - // If timezone lookup fails, return as-is - return StartDate; - } + return this.GetLocalStartDate(); } } @@ -202,28 +182,7 @@ public class CalendarItem : ICalendarItem { get { - if (string.IsNullOrEmpty(EndTimeZone)) - { - // No timezone info, return as-is - return EndDate; - } - - try - { - var sourceTimeZone = TimeZoneInfo.FindSystemTimeZoneById(EndTimeZone); - var localTimeZone = TimeZoneInfo.Local; - - // Ensure DateTime is Unspecified kind before conversion - var unspecifiedDateTime = DateTime.SpecifyKind(EndDate, DateTimeKind.Unspecified); - - // Convert from source timezone to local timezone - return TimeZoneInfo.ConvertTime(unspecifiedDateTime, sourceTimeZone, localTimeZone); - } - catch - { - // If timezone lookup fails, return as-is - return EndDate; - } + return this.GetLocalEndDate(); } } diff --git a/Wino.Core.Domain/Extensions/DateTimeExtensions.cs b/Wino.Core.Domain/Extensions/DateTimeExtensions.cs index f026c80f..91ae3380 100644 --- a/Wino.Core.Domain/Extensions/DateTimeExtensions.cs +++ b/Wino.Core.Domain/Extensions/DateTimeExtensions.cs @@ -1,4 +1,5 @@ -using System; +using System; +using Wino.Core.Domain.Entities.Calendar; using Wino.Core.Domain.Models.Calendar; namespace Wino.Core.Domain.Extensions; @@ -29,4 +30,56 @@ public static class DateTimeExtensions // Start loading from this date instead of visible date. return date.AddDays(-diff).Date; } + + /// + /// Converts a datetime from source timezone into local timezone. + /// If timezone lookup fails, returns original value. + /// + public static DateTime ToLocalTimeFromTimeZone(this DateTime dateTime, string sourceTimeZoneId) + { + if (string.IsNullOrWhiteSpace(sourceTimeZoneId)) + return dateTime; + + try + { + var sourceTimeZone = TimeZoneInfo.FindSystemTimeZoneById(sourceTimeZoneId); + var localTimeZone = TimeZoneInfo.Local; + var unspecifiedDateTime = DateTime.SpecifyKind(dateTime, DateTimeKind.Unspecified); + + return TimeZoneInfo.ConvertTime(unspecifiedDateTime, sourceTimeZone, localTimeZone); + } + catch + { + return dateTime; + } + } + + /// + /// Converts local datetime into target timezone. + /// If timezone lookup fails, returns original value. + /// + public static DateTime ToTimeZoneFromLocal(this DateTime localDateTime, string targetTimeZoneId) + { + if (string.IsNullOrWhiteSpace(targetTimeZoneId)) + return localDateTime; + + try + { + var sourceTimeZone = TimeZoneInfo.Local; + var targetTimeZone = TimeZoneInfo.FindSystemTimeZoneById(targetTimeZoneId); + var unspecifiedDateTime = DateTime.SpecifyKind(localDateTime, DateTimeKind.Unspecified); + + return TimeZoneInfo.ConvertTime(unspecifiedDateTime, sourceTimeZone, targetTimeZone); + } + catch + { + return localDateTime; + } + } + + public static DateTime GetLocalStartDate(this CalendarItem calendarItem) + => calendarItem.StartDate.ToLocalTimeFromTimeZone(calendarItem.StartTimeZone); + + public static DateTime GetLocalEndDate(this CalendarItem calendarItem) + => calendarItem.EndDate.ToLocalTimeFromTimeZone(calendarItem.EndTimeZone); } diff --git a/Wino.Core.Domain/Interfaces/ICalendarService.cs b/Wino.Core.Domain/Interfaces/ICalendarService.cs index 762c9649..6e2ba3c1 100644 --- a/Wino.Core.Domain/Interfaces/ICalendarService.cs +++ b/Wino.Core.Domain/Interfaces/ICalendarService.cs @@ -1,5 +1,6 @@ -using System; +using System; using System.Collections.Generic; +using System.Threading; using System.Threading.Tasks; using Itenso.TimePeriod; using Wino.Core.Domain.Entities.Calendar; @@ -18,7 +19,7 @@ public interface ICalendarService Task InsertAccountCalendarAsync(AccountCalendar accountCalendar); Task UpdateAccountCalendarAsync(AccountCalendar accountCalendar); Task CreateNewCalendarItemAsync(CalendarItem calendarItem, List attendees); - + /// /// Retrieves calendar events for a given calendar within the specified time period. /// @@ -26,7 +27,7 @@ public interface ICalendarService /// The time period to query events for. /// List of calendar items that fall within the requested period. Task> GetCalendarEventsAsync(IAccountCalendar calendar, ITimePeriod period); - + Task GetCalendarItemAsync(Guid accountCalendarId, string remoteEventId); Task UpdateCalendarDeltaSynchronizationToken(Guid calendarId, string deltaToken); @@ -42,6 +43,11 @@ public interface ICalendarService Task> GetRemindersAsync(Guid calendarItemId); Task SaveRemindersAsync(Guid calendarItemId, List reminders); + /// + /// Checks due reminder windows and returns reminder notifications that should trigger now. + /// + Task> CheckAndNotifyAsync(DateTime lastCheckLocal, DateTime nowLocal, ISet sentReminderKeys, CancellationToken cancellationToken = default); + /// /// Gets predefined reminder options in minutes (1 Hour, 30 Min, 15 Min, 5 Min, 1 Min). /// diff --git a/Wino.Core.Domain/Interfaces/IMailItemDisplayInformation.cs b/Wino.Core.Domain/Interfaces/IMailItemDisplayInformation.cs index 38798dea..af9dd221 100644 --- a/Wino.Core.Domain/Interfaces/IMailItemDisplayInformation.cs +++ b/Wino.Core.Domain/Interfaces/IMailItemDisplayInformation.cs @@ -17,6 +17,7 @@ public interface IMailItemDisplayInformation : INotifyPropertyChanged bool IsRead { get; } bool IsDraft { get; } bool HasAttachments { get; } + bool IsCalendarEvent { get; } bool IsFlagged { get; } DateTime CreationDate { get; } string Base64ContactPicture { get; } diff --git a/Wino.Core.Domain/Interfaces/INotificationBuilder.cs b/Wino.Core.Domain/Interfaces/INotificationBuilder.cs index dd0af63e..6b7fe872 100644 --- a/Wino.Core.Domain/Interfaces/INotificationBuilder.cs +++ b/Wino.Core.Domain/Interfaces/INotificationBuilder.cs @@ -1,6 +1,7 @@ -using System; +using System; using System.Collections.Generic; using System.Threading.Tasks; +using Wino.Core.Domain.Entities.Calendar; using Wino.Core.Domain.Entities.Mail; using Wino.Core.Domain.Entities.Shared; @@ -29,4 +30,9 @@ public interface INotificationBuilder /// /// Account that needs attention. void CreateAttentionRequiredNotification(MailAccount account); + + /// + /// Creates a calendar reminder toast for the specified calendar item. + /// + Task CreateCalendarReminderNotificationAsync(CalendarItem calendarItem, long reminderDurationInSeconds); } diff --git a/Wino.Core.Domain/Models/Calendar/CalendarReminderNotificationRequest.cs b/Wino.Core.Domain/Models/Calendar/CalendarReminderNotificationRequest.cs new file mode 100644 index 00000000..4a0c634e --- /dev/null +++ b/Wino.Core.Domain/Models/Calendar/CalendarReminderNotificationRequest.cs @@ -0,0 +1,10 @@ +using Wino.Core.Domain.Entities.Calendar; + +namespace Wino.Core.Domain.Models.Calendar; + +public sealed class CalendarReminderNotificationRequest +{ + public CalendarItem CalendarItem { get; init; } = null!; + public long ReminderDurationInSeconds { get; init; } + public string ReminderKey { get; init; } = string.Empty; +} diff --git a/Wino.Core.Tests/Services/CalendarReminderServiceTests.cs b/Wino.Core.Tests/Services/CalendarReminderServiceTests.cs new file mode 100644 index 00000000..00261663 --- /dev/null +++ b/Wino.Core.Tests/Services/CalendarReminderServiceTests.cs @@ -0,0 +1,227 @@ +using FluentAssertions; +using Wino.Core.Domain.Entities.Calendar; +using Wino.Core.Domain.Entities.Shared; +using Wino.Core.Domain.Enums; +using Wino.Core.Tests.Helpers; +using Wino.Services; +using Xunit; + +namespace Wino.Core.Tests.Services; + +public class CalendarReminderServiceTests : IAsyncLifetime +{ + private InMemoryDatabaseService _databaseService = null!; + private CalendarService _calendarService = null!; + private AccountCalendar _testCalendar = null!; + + public async Task InitializeAsync() + { + _databaseService = new InMemoryDatabaseService(); + await _databaseService.InitializeAsync(); + _calendarService = new CalendarService(_databaseService); + + var account = new MailAccount + { + Id = Guid.NewGuid(), + Name = "Reminder Test", + Address = "reminder@test.local", + SenderName = "Reminder Test", + IsCalendarAccessGranted = true + }; + + await _databaseService.Connection.InsertAsync(account, typeof(MailAccount)); + + _testCalendar = new AccountCalendar + { + Id = Guid.NewGuid(), + AccountId = account.Id, + Name = "Test Calendar", + TimeZone = "UTC", + IsPrimary = true, + BackgroundColorHex = "#0A84FF", + TextColorHex = "#FFFFFF" + }; + + await _calendarService.InsertAccountCalendarAsync(_testCalendar); + } + + public async Task DisposeAsync() + { + await _databaseService.DisposeAsync(); + } + + [Fact] + public async Task CheckAndNotifyAsync_WhenReminderFallsWithinWindow_ReturnsDueReminder() + { + var nowLocal = new DateTime(2026, 1, 1, 10, 0, 0); + var lastCheckLocal = nowLocal.AddSeconds(-30); + + var calendarItem = await CreateCalendarItemWithReminderAsync( + startDate: nowLocal.AddMinutes(5), + reminderDurationInSeconds: 5 * 60, + reminderType: CalendarItemReminderType.Popup); + + HashSet sentReminderKeys = []; + + var due = await _calendarService.CheckAndNotifyAsync(lastCheckLocal, nowLocal, sentReminderKeys); + + due.Should().HaveCount(1); + due[0].CalendarItem.Id.Should().Be(calendarItem.Id); + due[0].ReminderDurationInSeconds.Should().Be(5 * 60); + due[0].ReminderKey.Should().Be($"{calendarItem.Id:N}:{5 * 60}"); + sentReminderKeys.Should().Contain($"{calendarItem.Id:N}:{5 * 60}"); + } + + [Fact] + public async Task CheckAndNotifyAsync_WhenReminderIsOutsideWindow_ReturnsEmpty() + { + var nowLocal = new DateTime(2026, 1, 1, 10, 0, 0); + var lastCheckLocal = nowLocal.AddSeconds(-30); + + await CreateCalendarItemWithReminderAsync( + startDate: nowLocal.AddMinutes(20), + reminderDurationInSeconds: 5 * 60, + reminderType: CalendarItemReminderType.Popup); + + HashSet sentReminderKeys = []; + + var due = await _calendarService.CheckAndNotifyAsync(lastCheckLocal, nowLocal, sentReminderKeys); + + due.Should().BeEmpty(); + } + + [Fact] + public async Task CheckAndNotifyAsync_WhenReminderAlreadySent_DoesNotReturnDuplicate() + { + var nowLocal = new DateTime(2026, 1, 1, 10, 0, 0); + var lastCheckLocal = nowLocal.AddSeconds(-30); + + var calendarItem = await CreateCalendarItemWithReminderAsync( + startDate: nowLocal.AddMinutes(5), + reminderDurationInSeconds: 5 * 60, + reminderType: CalendarItemReminderType.Popup); + + HashSet sentReminderKeys = []; + + var firstRun = await _calendarService.CheckAndNotifyAsync(lastCheckLocal, nowLocal, sentReminderKeys); + var secondRun = await _calendarService.CheckAndNotifyAsync(lastCheckLocal, nowLocal, sentReminderKeys); + + firstRun.Should().HaveCount(1); + secondRun.Should().BeEmpty(); + sentReminderKeys.Should().Contain($"{calendarItem.Id:N}:{5 * 60}"); + } + + [Fact] + public async Task CheckAndNotifyAsync_WhenCalendarAccessNotGranted_ReturnsEmpty() + { + var restrictedAccount = new MailAccount + { + Id = Guid.NewGuid(), + Name = "No Calendar Access", + Address = "restricted@test.local", + SenderName = "Restricted", + IsCalendarAccessGranted = false + }; + await _databaseService.Connection.InsertAsync(restrictedAccount, typeof(MailAccount)); + + var restrictedCalendar = new AccountCalendar + { + Id = Guid.NewGuid(), + AccountId = restrictedAccount.Id, + Name = "Restricted Calendar", + TimeZone = "UTC", + IsPrimary = true, + BackgroundColorHex = "#111111", + TextColorHex = "#FFFFFF" + }; + await _calendarService.InsertAccountCalendarAsync(restrictedCalendar); + + var nowLocal = new DateTime(2026, 1, 1, 10, 0, 0); + var lastCheckLocal = nowLocal.AddSeconds(-30); + + await CreateCalendarItemWithReminderAsync( + startDate: nowLocal.AddMinutes(5), + reminderDurationInSeconds: 5 * 60, + reminderType: CalendarItemReminderType.Popup, + calendarId: restrictedCalendar.Id); + + HashSet sentReminderKeys = []; + + var due = await _calendarService.CheckAndNotifyAsync(lastCheckLocal, nowLocal, sentReminderKeys); + + due.Should().BeEmpty(); + } + + [Fact] + public async Task CheckAndNotifyAsync_WhenReminderTypeIsEmail_ReturnsEmpty() + { + var nowLocal = new DateTime(2026, 1, 1, 10, 0, 0); + var lastCheckLocal = nowLocal.AddSeconds(-30); + + await CreateCalendarItemWithReminderAsync( + startDate: nowLocal.AddMinutes(5), + reminderDurationInSeconds: 5 * 60, + reminderType: CalendarItemReminderType.Email); + + HashSet sentReminderKeys = []; + + var due = await _calendarService.CheckAndNotifyAsync(lastCheckLocal, nowLocal, sentReminderKeys); + + due.Should().BeEmpty(); + } + + [Fact] + public async Task CheckAndNotifyAsync_WhenItemIsRecurringParent_ReturnsEmpty() + { + var nowLocal = new DateTime(2026, 1, 1, 10, 0, 0); + var lastCheckLocal = nowLocal.AddSeconds(-30); + + await CreateCalendarItemWithReminderAsync( + startDate: nowLocal.AddMinutes(5), + reminderDurationInSeconds: 5 * 60, + reminderType: CalendarItemReminderType.Popup, + recurrence: "RRULE:FREQ=DAILY;COUNT=5"); + + HashSet sentReminderKeys = []; + + var due = await _calendarService.CheckAndNotifyAsync(lastCheckLocal, nowLocal, sentReminderKeys); + + due.Should().BeEmpty(); + } + + private async Task CreateCalendarItemWithReminderAsync( + DateTime startDate, + long reminderDurationInSeconds, + CalendarItemReminderType reminderType, + Guid? calendarId = null, + string? recurrence = null) + { + var item = new CalendarItem + { + Id = Guid.NewGuid(), + Title = "Reminder Test Event", + StartDate = startDate, + StartTimeZone = string.Empty, + EndTimeZone = string.Empty, + DurationInSeconds = 60 * 30, + CalendarId = calendarId ?? _testCalendar.Id, + IsHidden = false, + Recurrence = recurrence ?? string.Empty + }; + + await _calendarService.CreateNewCalendarItemAsync(item, null); + + await _calendarService.SaveRemindersAsync(item.Id, + [ + new Reminder + { + Id = Guid.NewGuid(), + CalendarItemId = item.Id, + DurationInSeconds = reminderDurationInSeconds, + ReminderType = reminderType + } + ]); + + return item; + } +} diff --git a/Wino.Core.ViewModels/PersonalizationPageViewModel.cs b/Wino.Core.ViewModels/PersonalizationPageViewModel.cs index f9620780..c9d90391 100644 --- a/Wino.Core.ViewModels/PersonalizationPageViewModel.cs +++ b/Wino.Core.ViewModels/PersonalizationPageViewModel.cs @@ -338,6 +338,7 @@ public partial class PersonalizationPageViewModel : CoreBaseViewModel public bool IsRead { get; } = false; public bool IsDraft { get; } = false; public bool HasAttachments { get; } = false; + public bool IsCalendarEvent { get; } = false; public bool IsFlagged { get; } = false; public DateTime CreationDate { get; } = DateTime.Now; public string Base64ContactPicture { get; } = string.Empty; diff --git a/Wino.Mail.ViewModels/Data/MailItemViewModel.cs b/Wino.Mail.ViewModels/Data/MailItemViewModel.cs index 1c4ec5b5..dce77d34 100644 --- a/Wino.Mail.ViewModels/Data/MailItemViewModel.cs +++ b/Wino.Mail.ViewModels/Data/MailItemViewModel.cs @@ -26,6 +26,7 @@ public partial class MailItemViewModel(MailCopy mailCopy) : ObservableRecipient, [NotifyPropertyChangedFor(nameof(PreviewText))] [NotifyPropertyChangedFor(nameof(FromAddress))] [NotifyPropertyChangedFor(nameof(HasAttachments))] + [NotifyPropertyChangedFor(nameof(IsCalendarEvent))] [NotifyPropertyChangedFor(nameof(Importance))] [NotifyPropertyChangedFor(nameof(ThreadId))] [NotifyPropertyChangedFor(nameof(MessageId))] @@ -140,6 +141,8 @@ public partial class MailItemViewModel(MailCopy mailCopy) : ObservableRecipient, set => SetProperty(MailCopy.HasAttachments, value, MailCopy, (u, n) => u.HasAttachments = n); } + public bool IsCalendarEvent => MailCopy.ItemType == MailItemType.CalendarInvitation; + public MailImportance Importance { get => MailCopy.Importance; @@ -258,6 +261,7 @@ public partial class MailItemViewModel(MailCopy mailCopy) : ObservableRecipient, OnPropertyChanged(nameof(PreviewText)); OnPropertyChanged(nameof(FromAddress)); OnPropertyChanged(nameof(HasAttachments)); + OnPropertyChanged(nameof(IsCalendarEvent)); OnPropertyChanged(nameof(Importance)); OnPropertyChanged(nameof(ThreadId)); OnPropertyChanged(nameof(MessageId)); diff --git a/Wino.Mail.ViewModels/Data/ThreadMailItemViewModel.cs b/Wino.Mail.ViewModels/Data/ThreadMailItemViewModel.cs index 0fcbdb1b..62c8163f 100644 --- a/Wino.Mail.ViewModels/Data/ThreadMailItemViewModel.cs +++ b/Wino.Mail.ViewModels/Data/ThreadMailItemViewModel.cs @@ -79,6 +79,11 @@ public partial class ThreadMailItemViewModel : ObservableRecipient, IMailListIte /// public bool HasAttachments => ThreadEmails.Any(e => e.HasAttachments); + /// + /// Gets whether any email in this thread is a calendar invitation. + /// + public bool IsCalendarEvent => ThreadEmails.Any(e => e.IsCalendarEvent); + /// /// Gets whether any email in this thread is flagged /// @@ -167,6 +172,7 @@ public partial class ThreadMailItemViewModel : ObservableRecipient, IMailListIte [NotifyPropertyChangedFor(nameof(FromAddress))] [NotifyPropertyChangedFor(nameof(PreviewText))] [NotifyPropertyChangedFor(nameof(HasAttachments))] + [NotifyPropertyChangedFor(nameof(IsCalendarEvent))] [NotifyPropertyChangedFor(nameof(IsFlagged))] [NotifyPropertyChangedFor(nameof(IsFocused))] [NotifyPropertyChangedFor(nameof(IsRead))] @@ -275,6 +281,7 @@ public partial class ThreadMailItemViewModel : ObservableRecipient, IMailListIte OnPropertyChanged(nameof(FromAddress)); OnPropertyChanged(nameof(PreviewText)); OnPropertyChanged(nameof(HasAttachments)); + OnPropertyChanged(nameof(IsCalendarEvent)); OnPropertyChanged(nameof(IsFlagged)); OnPropertyChanged(nameof(IsFocused)); OnPropertyChanged(nameof(IsRead)); diff --git a/Wino.Mail.WinUI/App.xaml.cs b/Wino.Mail.WinUI/App.xaml.cs index 1309dc8c..35965c35 100644 --- a/Wino.Mail.WinUI/App.xaml.cs +++ b/Wino.Mail.WinUI/App.xaml.cs @@ -15,6 +15,7 @@ using Wino.Core; using Wino.Core.Domain; using Wino.Core.Domain.Enums; using Wino.Core.Domain.Interfaces; +using Wino.Core.Domain.Models.Calendar; using Wino.Core.Domain.Models.MailItem; using Wino.Core.Domain.Models.Synchronization; using Wino.Mail.Services; @@ -179,6 +180,16 @@ public partial class App : WinoApplication, { var toastArguments = ToastArguments.Parse(toastArgs.Argument); + // Check calendar reminder toast activation first. + if (toastArguments.TryGetValue(Constants.ToastCalendarActionKey, out string calendarAction) && + calendarAction == Constants.ToastCalendarNavigateAction && + toastArguments.TryGetValue(Constants.ToastCalendarItemIdKey, out string calendarItemIdString) && + Guid.TryParse(calendarItemIdString, out Guid calendarItemId)) + { + await HandleCalendarToastNavigationAsync(calendarItemId); + return; + } + // Check if this is a navigation toast (user clicked the notification). if (toastArguments.TryGetValue(Constants.ToastActionKey, out MailOperation action) && Guid.TryParse(toastArguments[Constants.ToastMailUniqueIdKey], out Guid mailItemUniqueId)) @@ -198,6 +209,31 @@ public partial class App : WinoApplication, } } + private async Task HandleCalendarToastNavigationAsync(Guid calendarItemId) + { + var calendarService = Services.GetRequiredService(); + var navigationService = Services.GetRequiredService(); + + var calendarItem = await calendarService.GetCalendarItemAsync(calendarItemId).ConfigureAwait(false); + if (calendarItem == null) + return; + + var target = new CalendarItemTarget(calendarItem, CalendarEventTargetType.Single); + + if (!IsAppRunning()) + { + await CreateAndActivateWindow(null!); + } + else + { + MainWindow.BringToFront(); + MainWindow.Activate(); + } + + navigationService.ChangeApplicationMode(Core.Domain.Enums.WinoApplicationMode.Calendar); + navigationService.Navigate(WinoPage.EventDetailsPage, target); + } + /// /// Handles toast notification click for navigation. /// Creates window if not running, sets up navigation parameter. diff --git a/Wino.Mail.WinUI/Controls/CalendarMailItemDisplayInformationControl.xaml b/Wino.Mail.WinUI/Controls/CalendarMailItemDisplayInformationControl.xaml index 3fc1132a..dcbeb24e 100644 --- a/Wino.Mail.WinUI/Controls/CalendarMailItemDisplayInformationControl.xaml +++ b/Wino.Mail.WinUI/Controls/CalendarMailItemDisplayInformationControl.xaml @@ -7,7 +7,6 @@ xmlns:helpers="using:Wino.Helpers" HorizontalAlignment="Stretch" VerticalAlignment="Stretch"> - @@ -23,14 +22,30 @@ Prefer24HourTimeFormat="{x:Bind Prefer24HourTimeFormat, Mode=OneWay}" ShowPreviewText="False" /> - + Margin="46,0,12,8"> + + + + + + + diff --git a/Wino.Mail.WinUI/CoreUWPContainerSetup.cs b/Wino.Mail.WinUI/CoreUWPContainerSetup.cs index 6e3225bf..fe804730 100644 --- a/Wino.Mail.WinUI/CoreUWPContainerSetup.cs +++ b/Wino.Mail.WinUI/CoreUWPContainerSetup.cs @@ -3,6 +3,7 @@ using Microsoft.UI.Xaml; using Wino.Core.Domain.Interfaces; using Wino.Core.ViewModels; using Wino.Core.WinUI.Services; +using Wino.Mail.WinUI.Interfaces; using Wino.Mail.WinUI.Services; using Wino.Services; @@ -29,6 +30,7 @@ public static class CoreUWPContainerSetup services.AddTransient(); services.AddTransient(); services.AddTransient(); + services.AddSingleton(); services.AddTransient(); services.AddTransient(); services.AddSingleton(); diff --git a/Wino.Mail.WinUI/Interfaces/ICalendarReminderServer.cs b/Wino.Mail.WinUI/Interfaces/ICalendarReminderServer.cs new file mode 100644 index 00000000..896fbc4e --- /dev/null +++ b/Wino.Mail.WinUI/Interfaces/ICalendarReminderServer.cs @@ -0,0 +1,8 @@ +using System.Threading.Tasks; + +namespace Wino.Mail.WinUI.Interfaces; + +public interface ICalendarReminderServer +{ + Task StartAsync(); +} diff --git a/Wino.Mail.WinUI/Services/CalendarReminderServer.cs b/Wino.Mail.WinUI/Services/CalendarReminderServer.cs new file mode 100644 index 00000000..397d50e5 --- /dev/null +++ b/Wino.Mail.WinUI/Services/CalendarReminderServer.cs @@ -0,0 +1,107 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Serilog; +using Wino.Core.Domain.Interfaces; +using Wino.Mail.WinUI.Interfaces; + +namespace Wino.Mail.WinUI.Services; + +public class CalendarReminderServer : ICalendarReminderServer +{ + private static readonly TimeSpan PollingInterval = TimeSpan.FromSeconds(30); + + private readonly ICalendarService _calendarService; + private readonly IAccountService _accountService; + private readonly INotificationBuilder _notificationBuilder; + private readonly ILogger _logger = Log.ForContext(); + private readonly SemaphoreSlim _startLock = new(1, 1); + private readonly HashSet _sentReminderKeys = []; + + private Task? _loopTask; + private CancellationTokenSource? _loopCts; + private DateTime _lastCheckLocal = DateTime.MinValue; + + public CalendarReminderServer(ICalendarService calendarService, IAccountService accountService, INotificationBuilder notificationBuilder) + { + _calendarService = calendarService; + _accountService = accountService; + _notificationBuilder = notificationBuilder; + } + + public async Task StartAsync() + { + await _startLock.WaitAsync().ConfigureAwait(false); + + try + { + if (_loopTask != null) + return; + + var accounts = await _accountService.GetAccountsAsync().ConfigureAwait(false); + + var hasCalendarAccess = accounts.Exists(a => a.IsCalendarAccessGranted); + + if (!hasCalendarAccess) + { + _logger.Information("Calendar reminder server will not start because no account has calendar access."); + return; + } + + _lastCheckLocal = DateTime.Now.AddSeconds(-30); + _loopCts = new CancellationTokenSource(); + _loopTask = RunLoopAsync(_loopCts.Token); + + _logger.Information("Calendar reminder server started."); + } + finally + { + _startLock.Release(); + } + } + + private async Task RunLoopAsync(CancellationToken cancellationToken) + { + using var timer = new PeriodicTimer(PollingInterval); + + try + { + while (await timer.WaitForNextTickAsync(cancellationToken).ConfigureAwait(false)) + { + await ExecuteTickAsync(cancellationToken).ConfigureAwait(false); + } + } + catch (OperationCanceledException) + { + // no-op + } + catch (Exception ex) + { + _logger.Error(ex, "Calendar reminder server loop terminated unexpectedly."); + } + } + + private async Task ExecuteTickAsync(CancellationToken cancellationToken) + { + var nowLocal = DateTime.Now; + + if (_lastCheckLocal == DateTime.MinValue) + _lastCheckLocal = nowLocal.AddSeconds(-PollingInterval.TotalSeconds); + + var dueNotifications = await _calendarService + .CheckAndNotifyAsync(_lastCheckLocal, nowLocal, _sentReminderKeys, cancellationToken) + .ConfigureAwait(false); + + foreach (var reminder in dueNotifications) + { + cancellationToken.ThrowIfCancellationRequested(); + + await _notificationBuilder + .CreateCalendarReminderNotificationAsync(reminder.CalendarItem, reminder.ReminderDurationInSeconds) + .ConfigureAwait(false); + } + + _lastCheckLocal = nowLocal; + } +} diff --git a/Wino.Mail.WinUI/Services/NotificationBuilder.cs b/Wino.Mail.WinUI/Services/NotificationBuilder.cs index 56f3ed5c..dbf27526 100644 --- a/Wino.Mail.WinUI/Services/NotificationBuilder.cs +++ b/Wino.Mail.WinUI/Services/NotificationBuilder.cs @@ -7,6 +7,7 @@ using Microsoft.Toolkit.Uwp.Notifications; using Serilog; using Windows.Data.Xml.Dom; using Windows.UI.Notifications; +using Wino.Core.Domain.Entities.Calendar; using Wino.Core.Domain; using Wino.Core.Domain.Entities.Mail; using Wino.Core.Domain.Entities.Shared; @@ -250,4 +251,38 @@ public class NotificationBuilder : INotificationBuilder builder.AddButton(new ToastButton().SetContent(Translator.Buttons_FixAccount)); builder.Show(); } + + public Task CreateCalendarReminderNotificationAsync(CalendarItem calendarItem, long reminderDurationInSeconds) + { + if (calendarItem == null) + return Task.CompletedTask; + + var builder = new ToastContentBuilder(); + builder.SetToastScenario(ToastScenario.Reminder); + + var localStart = calendarItem.LocalStartDate; + var reminderMinutes = (int)Math.Max(0, reminderDurationInSeconds / 60); + var reminderContext = reminderMinutes > 0 + ? $"Starts in {reminderMinutes} minute{(reminderMinutes == 1 ? string.Empty : "s")}" + : "Starting now"; + + builder.AddText(calendarItem.Title); + builder.AddText($"{reminderContext} - {localStart:g}"); + + if (!string.IsNullOrWhiteSpace(calendarItem.Location)) + builder.AddText(calendarItem.Location); + + builder.AddArgument(Constants.ToastCalendarActionKey, Constants.ToastCalendarNavigateAction); + builder.AddArgument(Constants.ToastCalendarItemIdKey, calendarItem.Id.ToString()); + builder.AddButton(GetDismissButton()); + builder.AddAudio(new ToastAudio() + { + Src = new Uri("ms-winsoundevent:Notification.Reminder") + }); + + var tag = $"calendar-reminder-{calendarItem.Id:N}-{reminderDurationInSeconds}"; + builder.Show(toast => toast.Tag = tag); + + return Task.CompletedTask; + } } diff --git a/Wino.Mail.WinUI/ShellWindow.xaml.cs b/Wino.Mail.WinUI/ShellWindow.xaml.cs index 2c7f6b9d..43d46fcd 100644 --- a/Wino.Mail.WinUI/ShellWindow.xaml.cs +++ b/Wino.Mail.WinUI/ShellWindow.xaml.cs @@ -1,6 +1,7 @@ using System; using System.Collections.ObjectModel; using System.Linq; +using System.Threading.Tasks; using System.Windows.Input; using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Messaging; @@ -34,6 +35,7 @@ public sealed partial class ShellWindow : WindowEx, IWinoShellWindow, public ICommand ExitWinoCommand { get; set; } public ObservableCollection SyncActionItems { get; } = new(); + private bool _calendarReminderServerStartAttempted; public ShellWindow() { @@ -155,6 +157,12 @@ public sealed partial class ShellWindow : WindowEx, IWinoShellWindow, private void MainFrameNavigated(object sender, Microsoft.UI.Xaml.Navigation.NavigationEventArgs e) { + if (!_calendarReminderServerStartAttempted) + { + _calendarReminderServerStartAttempted = true; + _ = StartCalendarReminderServerAsync(); + } + // Mail shell has shell content only for mail list page // Thus, we check if the current content is MailAppShell @@ -164,6 +172,23 @@ public sealed partial class ShellWindow : WindowEx, IWinoShellWindow, ShellTitleBar.Content = basePage.ShellContent; } + private async Task StartCalendarReminderServerAsync() + { + try + { + var reminderServer = WinoApplication.Current.Services.GetService(); + if (reminderServer != null) + { + await reminderServer.StartAsync(); + } + } + catch (Exception ex) + { + _calendarReminderServerStartAttempted = false; + Serilog.Log.Error(ex, "Failed to start calendar reminder server."); + } + } + private void PaneButtonClicked(Microsoft.UI.Xaml.Controls.TitleBar sender, object args) { PreferencesService.IsNavigationPaneOpened = !PreferencesService.IsNavigationPaneOpened; diff --git a/Wino.Mail.WinUI/Styles/ContentPresenters.xaml b/Wino.Mail.WinUI/Styles/ContentPresenters.xaml index 2b54abba..ede0e3e3 100644 --- a/Wino.Mail.WinUI/Styles/ContentPresenters.xaml +++ b/Wino.Mail.WinUI/Styles/ContentPresenters.xaml @@ -29,4 +29,14 @@ Icon="Attachment" /> + + + + + + + diff --git a/Wino.Services/CalendarService.cs b/Wino.Services/CalendarService.cs index bd463f24..b6b4f258 100644 --- a/Wino.Services/CalendarService.cs +++ b/Wino.Services/CalendarService.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Diagnostics; using System.Linq; +using System.Threading; using System.Threading.Tasks; using CommunityToolkit.Mvvm.Messaging; using Itenso.TimePeriod; @@ -9,6 +10,7 @@ using Serilog; using Wino.Core.Domain.Entities.Calendar; using Wino.Core.Domain.Entities.Shared; using Wino.Core.Domain.Enums; +using Wino.Core.Domain.Extensions; using Wino.Core.Domain.Interfaces; using Wino.Core.Domain.Models.Calendar; using Wino.Messaging.Client.Calendar; @@ -332,6 +334,60 @@ public class CalendarService : BaseDatabaseService, ICalendarService }); } + public async Task> CheckAndNotifyAsync(DateTime lastCheckLocal, DateTime nowLocal, ISet sentReminderKeys, CancellationToken cancellationToken = default) + { + if (sentReminderKeys == null) + return []; + + var candidates = await Connection.QueryAsync( + @" + SELECT + c.Id AS CalendarItemId, + c.StartDate, + c.StartTimeZone, + r.DurationInSeconds AS ReminderDurationInSeconds + FROM CalendarItem c + INNER JOIN Reminder r ON r.CalendarItemId = c.Id + INNER JOIN AccountCalendar ac ON ac.Id = c.CalendarId + INNER JOIN MailAccount ma ON ma.Id = ac.AccountId + WHERE + c.IsHidden = 0 + AND ma.IsCalendarAccessGranted = 1 + AND r.ReminderType = 0 + AND NOT (IFNULL(c.Recurrence, '') != '' AND c.RecurringCalendarItemId IS NULL)") + .ConfigureAwait(false); + + var dueNotifications = new List(); + + foreach (var candidate in candidates) + { + cancellationToken.ThrowIfCancellationRequested(); + + var eventStartLocal = candidate.StartDate.ToLocalTimeFromTimeZone(candidate.StartTimeZone); + var triggerTimeLocal = eventStartLocal.AddSeconds(-candidate.ReminderDurationInSeconds); + + if (triggerTimeLocal <= lastCheckLocal || triggerTimeLocal > nowLocal) + continue; + + var reminderKey = $"{candidate.CalendarItemId:N}:{candidate.ReminderDurationInSeconds}"; + if (!sentReminderKeys.Add(reminderKey)) + continue; + + var calendarItem = await GetCalendarItemAsync(candidate.CalendarItemId).ConfigureAwait(false); + if (calendarItem == null) + continue; + + dueNotifications.Add(new CalendarReminderNotificationRequest() + { + CalendarItem = calendarItem, + ReminderDurationInSeconds = candidate.ReminderDurationInSeconds, + ReminderKey = reminderKey + }); + } + + return dueNotifications; + } + #region Attachments public Task> GetAttachmentsAsync(Guid calendarItemId) @@ -379,4 +435,12 @@ public class CalendarService : BaseDatabaseService, ICalendarService } #endregion + + private sealed class CalendarReminderCandidate + { + public Guid CalendarItemId { get; set; } + public DateTime StartDate { get; set; } + public string StartTimeZone { get; set; } + public long ReminderDurationInSeconds { get; set; } + } }