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; }
+ }
}