UI visuals for mail calendar items, calendar reminders.
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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));
|
||||
|
||||
@@ -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,15 +22,31 @@
|
||||
Prefer24HourTimeFormat="{x:Bind Prefer24HourTimeFormat, Mode=OneWay}"
|
||||
ShowPreviewText="False" />
|
||||
|
||||
<Grid
|
||||
x:Name="EventDateContainer"
|
||||
Grid.Row="1"
|
||||
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.Row="1"
|
||||
Margin="53,0,12,8"
|
||||
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>
|
||||
<VisualStateGroup x:Name="SizingStates">
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user