UI visuals for mail calendar items, calendar reminders.

This commit is contained in:
Burak Kaan Köse
2026-02-11 01:49:29 +01:00
parent 870a5e2bf6
commit 52ee5f1d8a
21 changed files with 639 additions and 77 deletions
@@ -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);
}
}
+3
View File
@@ -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";
@@ -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();
}
}
@@ -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;
}
/// <summary>
/// Converts a datetime from source timezone into local timezone.
/// If timezone lookup fails, returns original value.
/// </summary>
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;
}
}
/// <summary>
/// Converts local datetime into target timezone.
/// If timezone lookup fails, returns original value.
/// </summary>
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);
}
@@ -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<CalendarEventAttendee> attendees);
/// <summary>
/// Retrieves calendar events for a given calendar within the specified time period.
/// </summary>
@@ -26,7 +27,7 @@ public interface ICalendarService
/// <param name="period">The time period to query events for.</param>
/// <returns>List of calendar items that fall within the requested period.</returns>
Task<List<CalendarItem>> GetCalendarEventsAsync(IAccountCalendar calendar, ITimePeriod period);
Task<CalendarItem> GetCalendarItemAsync(Guid accountCalendarId, string remoteEventId);
Task UpdateCalendarDeltaSynchronizationToken(Guid calendarId, string deltaToken);
@@ -42,6 +43,11 @@ public interface ICalendarService
Task<List<Reminder>> GetRemindersAsync(Guid calendarItemId);
Task SaveRemindersAsync(Guid calendarItemId, List<Reminder> reminders);
/// <summary>
/// Checks due reminder windows and returns reminder notifications that should trigger now.
/// </summary>
Task<List<CalendarReminderNotificationRequest>> CheckAndNotifyAsync(DateTime lastCheckLocal, DateTime nowLocal, ISet<string> sentReminderKeys, CancellationToken cancellationToken = default);
/// <summary>
/// Gets predefined reminder options in minutes (1 Hour, 30 Min, 15 Min, 5 Min, 1 Min).
/// </summary>
@@ -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; }
@@ -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
/// </summary>
/// <param name="account">Account that needs attention.</param>
void CreateAttentionRequiredNotification(MailAccount account);
/// <summary>
/// Creates a calendar reminder toast for the specified calendar item.
/// </summary>
Task CreateCalendarReminderNotificationAsync(CalendarItem calendarItem, long reminderDurationInSeconds);
}
@@ -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;
}
@@ -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<string> 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<string> 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<string> 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<string> 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<string> 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<string> sentReminderKeys = [];
var due = await _calendarService.CheckAndNotifyAsync(lastCheckLocal, nowLocal, sentReminderKeys);
due.Should().BeEmpty();
}
private async Task<CalendarItem> 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;
}
}
@@ -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;
@@ -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));
@@ -79,6 +79,11 @@ public partial class ThreadMailItemViewModel : ObservableRecipient, IMailListIte
/// </summary>
public bool HasAttachments => ThreadEmails.Any(e => e.HasAttachments);
/// <summary>
/// Gets whether any email in this thread is a calendar invitation.
/// </summary>
public bool IsCalendarEvent => ThreadEmails.Any(e => e.IsCalendarEvent);
/// <summary>
/// Gets whether any email in this thread is flagged
/// </summary>
@@ -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));
+36
View File
@@ -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<ICalendarService>();
var navigationService = Services.GetRequiredService<INavigationService>();
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);
}
/// <summary>
/// Handles toast notification click for navigation.
/// Creates window if not running, sets up navigation parameter.
@@ -7,7 +7,6 @@
xmlns:helpers="using:Wino.Helpers"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch">
<Grid x:DefaultBindMode="OneWay">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
@@ -23,14 +22,30 @@
Prefer24HourTimeFormat="{x:Bind Prefer24HourTimeFormat, Mode=OneWay}"
ShowPreviewText="False" />
<TextBlock
x:Name="EventDateText"
<Grid
x:Name="EventDateContainer"
Grid.Row="1"
Margin="53,0,12,8"
FontSize="12"
Opacity="0.75"
Text="{x:Bind EventDateRangeText, Mode=OneWay}"
TextTrimming="CharacterEllipsis" />
Margin="46,0,12,8">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<ContentPresenter
x:Name="CalendarInvitationContent"
Grid.Column="0"
Margin="0,0,4,0"
VerticalAlignment="Center"
x:Load="{x:Bind MailItem.IsCalendarEvent, Mode=OneWay}"
ContentTemplate="{StaticResource CalendarInvitationSymbolControlTemplate}" />
<TextBlock
x:Name="EventDateText"
Grid.Column="1"
Margin="4,0,2,0"
FontSize="12"
Opacity="0.75"
Text="{x:Bind EventDateRangeText, Mode=OneWay}"
TextTrimming="CharacterEllipsis" />
</Grid>
</Grid>
<VisualStateManager.VisualStateGroups>
+2
View File
@@ -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<IStoreRatingService, StoreRatingService>();
services.AddTransient<IKeyPressService, KeyPressService>();
services.AddTransient<INotificationBuilder, NotificationBuilder>();
services.AddSingleton<ICalendarReminderServer, CalendarReminderServer>();
services.AddTransient<IClipboardService, ClipboardService>();
services.AddTransient<IStartupBehaviorService, StartupBehaviorService>();
services.AddSingleton<IPrintService, PrintService>();
@@ -0,0 +1,8 @@
using System.Threading.Tasks;
namespace Wino.Mail.WinUI.Interfaces;
public interface ICalendarReminderServer
{
Task StartAsync();
}
@@ -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<CalendarReminderServer>();
private readonly SemaphoreSlim _startLock = new(1, 1);
private readonly HashSet<string> _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;
}
}
@@ -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;
}
}
+25
View File
@@ -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<SynchronizationActionItem> 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<ICalendarReminderServer>();
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;
@@ -29,4 +29,14 @@
Icon="Attachment" />
</Viewbox>
</DataTemplate>
<!-- Calendar invitation -->
<DataTemplate x:Key="CalendarInvitationSymbolControlTemplate">
<Viewbox
Width="14"
HorizontalAlignment="Center"
VerticalAlignment="Bottom">
<controls:WinoFontIcon Margin="2,0,0,0" Icon="Calendar" />
</Viewbox>
</DataTemplate>
</ResourceDictionary>
+64
View File
@@ -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<List<CalendarReminderNotificationRequest>> CheckAndNotifyAsync(DateTime lastCheckLocal, DateTime nowLocal, ISet<string> sentReminderKeys, CancellationToken cancellationToken = default)
{
if (sentReminderKeys == null)
return [];
var candidates = await Connection.QueryAsync<CalendarReminderCandidate>(
@"
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<CalendarReminderNotificationRequest>();
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<List<CalendarAttachment>> 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; }
}
}