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 Itenso.TimePeriod;
|
||||||
using Wino.Core.Domain;
|
using Wino.Core.Domain;
|
||||||
using Wino.Core.Domain.Entities.Calendar;
|
using Wino.Core.Domain.Entities.Calendar;
|
||||||
|
using Wino.Core.Domain.Extensions;
|
||||||
using Wino.Core.Domain.Interfaces;
|
using Wino.Core.Domain.Interfaces;
|
||||||
using Wino.Core.Domain.Models.Calendar;
|
using Wino.Core.Domain.Models.Calendar;
|
||||||
|
|
||||||
@@ -32,26 +33,8 @@ public partial class CalendarItemViewModel : ObservableObject, ICalendarItem, IC
|
|||||||
}
|
}
|
||||||
set
|
set
|
||||||
{
|
{
|
||||||
// When setting from UI (in local time), convert to event's timezone for storage
|
// When setting from UI (in local time), convert to event's timezone for storage.
|
||||||
if (!string.IsNullOrEmpty(CalendarItem.StartTimeZone))
|
CalendarItem.StartDate = value.ToTimeZoneFromLocal(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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ public static class Constants
|
|||||||
public const string ToastMailUniqueIdKey = nameof(ToastMailUniqueIdKey);
|
public const string ToastMailUniqueIdKey = nameof(ToastMailUniqueIdKey);
|
||||||
public const string ToastActionKey = nameof(ToastActionKey);
|
public const string ToastActionKey = nameof(ToastActionKey);
|
||||||
public const string ToastMailAccountIdKey = nameof(ToastMailAccountIdKey);
|
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 ClientLogFile = "Client_.log";
|
||||||
public const string ServerLogFile = "Server_.log";
|
public const string ServerLogFile = "Server_.log";
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using System.Diagnostics;
|
|||||||
using Itenso.TimePeriod;
|
using Itenso.TimePeriod;
|
||||||
using SQLite;
|
using SQLite;
|
||||||
using Wino.Core.Domain.Enums;
|
using Wino.Core.Domain.Enums;
|
||||||
|
using Wino.Core.Domain.Extensions;
|
||||||
using Wino.Core.Domain.Interfaces;
|
using Wino.Core.Domain.Interfaces;
|
||||||
using Wino.Core.Domain.Models.Calendar;
|
using Wino.Core.Domain.Models.Calendar;
|
||||||
|
|
||||||
@@ -168,28 +169,7 @@ public class CalendarItem : ICalendarItem
|
|||||||
{
|
{
|
||||||
get
|
get
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(StartTimeZone))
|
return this.GetLocalStartDate();
|
||||||
{
|
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,28 +182,7 @@ public class CalendarItem : ICalendarItem
|
|||||||
{
|
{
|
||||||
get
|
get
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(EndTimeZone))
|
return this.GetLocalEndDate();
|
||||||
{
|
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System;
|
using System;
|
||||||
|
using Wino.Core.Domain.Entities.Calendar;
|
||||||
using Wino.Core.Domain.Models.Calendar;
|
using Wino.Core.Domain.Models.Calendar;
|
||||||
|
|
||||||
namespace Wino.Core.Domain.Extensions;
|
namespace Wino.Core.Domain.Extensions;
|
||||||
@@ -29,4 +30,56 @@ public static class DateTimeExtensions
|
|||||||
// Start loading from this date instead of visible date.
|
// Start loading from this date instead of visible date.
|
||||||
return date.AddDays(-diff).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.Collections.Generic;
|
||||||
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Itenso.TimePeriod;
|
using Itenso.TimePeriod;
|
||||||
using Wino.Core.Domain.Entities.Calendar;
|
using Wino.Core.Domain.Entities.Calendar;
|
||||||
@@ -18,7 +19,7 @@ public interface ICalendarService
|
|||||||
Task InsertAccountCalendarAsync(AccountCalendar accountCalendar);
|
Task InsertAccountCalendarAsync(AccountCalendar accountCalendar);
|
||||||
Task UpdateAccountCalendarAsync(AccountCalendar accountCalendar);
|
Task UpdateAccountCalendarAsync(AccountCalendar accountCalendar);
|
||||||
Task CreateNewCalendarItemAsync(CalendarItem calendarItem, List<CalendarEventAttendee> attendees);
|
Task CreateNewCalendarItemAsync(CalendarItem calendarItem, List<CalendarEventAttendee> attendees);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Retrieves calendar events for a given calendar within the specified time period.
|
/// Retrieves calendar events for a given calendar within the specified time period.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -26,7 +27,7 @@ public interface ICalendarService
|
|||||||
/// <param name="period">The time period to query events for.</param>
|
/// <param name="period">The time period to query events for.</param>
|
||||||
/// <returns>List of calendar items that fall within the requested period.</returns>
|
/// <returns>List of calendar items that fall within the requested period.</returns>
|
||||||
Task<List<CalendarItem>> GetCalendarEventsAsync(IAccountCalendar calendar, ITimePeriod period);
|
Task<List<CalendarItem>> GetCalendarEventsAsync(IAccountCalendar calendar, ITimePeriod period);
|
||||||
|
|
||||||
Task<CalendarItem> GetCalendarItemAsync(Guid accountCalendarId, string remoteEventId);
|
Task<CalendarItem> GetCalendarItemAsync(Guid accountCalendarId, string remoteEventId);
|
||||||
Task UpdateCalendarDeltaSynchronizationToken(Guid calendarId, string deltaToken);
|
Task UpdateCalendarDeltaSynchronizationToken(Guid calendarId, string deltaToken);
|
||||||
|
|
||||||
@@ -42,6 +43,11 @@ public interface ICalendarService
|
|||||||
Task<List<Reminder>> GetRemindersAsync(Guid calendarItemId);
|
Task<List<Reminder>> GetRemindersAsync(Guid calendarItemId);
|
||||||
Task SaveRemindersAsync(Guid calendarItemId, List<Reminder> reminders);
|
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>
|
/// <summary>
|
||||||
/// Gets predefined reminder options in minutes (1 Hour, 30 Min, 15 Min, 5 Min, 1 Min).
|
/// Gets predefined reminder options in minutes (1 Hour, 30 Min, 15 Min, 5 Min, 1 Min).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ public interface IMailItemDisplayInformation : INotifyPropertyChanged
|
|||||||
bool IsRead { get; }
|
bool IsRead { get; }
|
||||||
bool IsDraft { get; }
|
bool IsDraft { get; }
|
||||||
bool HasAttachments { get; }
|
bool HasAttachments { get; }
|
||||||
|
bool IsCalendarEvent { get; }
|
||||||
bool IsFlagged { get; }
|
bool IsFlagged { get; }
|
||||||
DateTime CreationDate { get; }
|
DateTime CreationDate { get; }
|
||||||
string Base64ContactPicture { get; }
|
string Base64ContactPicture { get; }
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
using Wino.Core.Domain.Entities.Calendar;
|
||||||
using Wino.Core.Domain.Entities.Mail;
|
using Wino.Core.Domain.Entities.Mail;
|
||||||
using Wino.Core.Domain.Entities.Shared;
|
using Wino.Core.Domain.Entities.Shared;
|
||||||
|
|
||||||
@@ -29,4 +30,9 @@ public interface INotificationBuilder
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="account">Account that needs attention.</param>
|
/// <param name="account">Account that needs attention.</param>
|
||||||
void CreateAttentionRequiredNotification(MailAccount account);
|
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 IsRead { get; } = false;
|
||||||
public bool IsDraft { get; } = false;
|
public bool IsDraft { get; } = false;
|
||||||
public bool HasAttachments { get; } = false;
|
public bool HasAttachments { get; } = false;
|
||||||
|
public bool IsCalendarEvent { get; } = false;
|
||||||
public bool IsFlagged { get; } = false;
|
public bool IsFlagged { get; } = false;
|
||||||
public DateTime CreationDate { get; } = DateTime.Now;
|
public DateTime CreationDate { get; } = DateTime.Now;
|
||||||
public string Base64ContactPicture { get; } = string.Empty;
|
public string Base64ContactPicture { get; } = string.Empty;
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ public partial class MailItemViewModel(MailCopy mailCopy) : ObservableRecipient,
|
|||||||
[NotifyPropertyChangedFor(nameof(PreviewText))]
|
[NotifyPropertyChangedFor(nameof(PreviewText))]
|
||||||
[NotifyPropertyChangedFor(nameof(FromAddress))]
|
[NotifyPropertyChangedFor(nameof(FromAddress))]
|
||||||
[NotifyPropertyChangedFor(nameof(HasAttachments))]
|
[NotifyPropertyChangedFor(nameof(HasAttachments))]
|
||||||
|
[NotifyPropertyChangedFor(nameof(IsCalendarEvent))]
|
||||||
[NotifyPropertyChangedFor(nameof(Importance))]
|
[NotifyPropertyChangedFor(nameof(Importance))]
|
||||||
[NotifyPropertyChangedFor(nameof(ThreadId))]
|
[NotifyPropertyChangedFor(nameof(ThreadId))]
|
||||||
[NotifyPropertyChangedFor(nameof(MessageId))]
|
[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);
|
set => SetProperty(MailCopy.HasAttachments, value, MailCopy, (u, n) => u.HasAttachments = n);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public bool IsCalendarEvent => MailCopy.ItemType == MailItemType.CalendarInvitation;
|
||||||
|
|
||||||
public MailImportance Importance
|
public MailImportance Importance
|
||||||
{
|
{
|
||||||
get => MailCopy.Importance;
|
get => MailCopy.Importance;
|
||||||
@@ -258,6 +261,7 @@ public partial class MailItemViewModel(MailCopy mailCopy) : ObservableRecipient,
|
|||||||
OnPropertyChanged(nameof(PreviewText));
|
OnPropertyChanged(nameof(PreviewText));
|
||||||
OnPropertyChanged(nameof(FromAddress));
|
OnPropertyChanged(nameof(FromAddress));
|
||||||
OnPropertyChanged(nameof(HasAttachments));
|
OnPropertyChanged(nameof(HasAttachments));
|
||||||
|
OnPropertyChanged(nameof(IsCalendarEvent));
|
||||||
OnPropertyChanged(nameof(Importance));
|
OnPropertyChanged(nameof(Importance));
|
||||||
OnPropertyChanged(nameof(ThreadId));
|
OnPropertyChanged(nameof(ThreadId));
|
||||||
OnPropertyChanged(nameof(MessageId));
|
OnPropertyChanged(nameof(MessageId));
|
||||||
|
|||||||
@@ -79,6 +79,11 @@ public partial class ThreadMailItemViewModel : ObservableRecipient, IMailListIte
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public bool HasAttachments => ThreadEmails.Any(e => e.HasAttachments);
|
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>
|
/// <summary>
|
||||||
/// Gets whether any email in this thread is flagged
|
/// Gets whether any email in this thread is flagged
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -167,6 +172,7 @@ public partial class ThreadMailItemViewModel : ObservableRecipient, IMailListIte
|
|||||||
[NotifyPropertyChangedFor(nameof(FromAddress))]
|
[NotifyPropertyChangedFor(nameof(FromAddress))]
|
||||||
[NotifyPropertyChangedFor(nameof(PreviewText))]
|
[NotifyPropertyChangedFor(nameof(PreviewText))]
|
||||||
[NotifyPropertyChangedFor(nameof(HasAttachments))]
|
[NotifyPropertyChangedFor(nameof(HasAttachments))]
|
||||||
|
[NotifyPropertyChangedFor(nameof(IsCalendarEvent))]
|
||||||
[NotifyPropertyChangedFor(nameof(IsFlagged))]
|
[NotifyPropertyChangedFor(nameof(IsFlagged))]
|
||||||
[NotifyPropertyChangedFor(nameof(IsFocused))]
|
[NotifyPropertyChangedFor(nameof(IsFocused))]
|
||||||
[NotifyPropertyChangedFor(nameof(IsRead))]
|
[NotifyPropertyChangedFor(nameof(IsRead))]
|
||||||
@@ -275,6 +281,7 @@ public partial class ThreadMailItemViewModel : ObservableRecipient, IMailListIte
|
|||||||
OnPropertyChanged(nameof(FromAddress));
|
OnPropertyChanged(nameof(FromAddress));
|
||||||
OnPropertyChanged(nameof(PreviewText));
|
OnPropertyChanged(nameof(PreviewText));
|
||||||
OnPropertyChanged(nameof(HasAttachments));
|
OnPropertyChanged(nameof(HasAttachments));
|
||||||
|
OnPropertyChanged(nameof(IsCalendarEvent));
|
||||||
OnPropertyChanged(nameof(IsFlagged));
|
OnPropertyChanged(nameof(IsFlagged));
|
||||||
OnPropertyChanged(nameof(IsFocused));
|
OnPropertyChanged(nameof(IsFocused));
|
||||||
OnPropertyChanged(nameof(IsRead));
|
OnPropertyChanged(nameof(IsRead));
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ using Wino.Core;
|
|||||||
using Wino.Core.Domain;
|
using Wino.Core.Domain;
|
||||||
using Wino.Core.Domain.Enums;
|
using Wino.Core.Domain.Enums;
|
||||||
using Wino.Core.Domain.Interfaces;
|
using Wino.Core.Domain.Interfaces;
|
||||||
|
using Wino.Core.Domain.Models.Calendar;
|
||||||
using Wino.Core.Domain.Models.MailItem;
|
using Wino.Core.Domain.Models.MailItem;
|
||||||
using Wino.Core.Domain.Models.Synchronization;
|
using Wino.Core.Domain.Models.Synchronization;
|
||||||
using Wino.Mail.Services;
|
using Wino.Mail.Services;
|
||||||
@@ -179,6 +180,16 @@ public partial class App : WinoApplication,
|
|||||||
{
|
{
|
||||||
var toastArguments = ToastArguments.Parse(toastArgs.Argument);
|
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).
|
// Check if this is a navigation toast (user clicked the notification).
|
||||||
if (toastArguments.TryGetValue(Constants.ToastActionKey, out MailOperation action) &&
|
if (toastArguments.TryGetValue(Constants.ToastActionKey, out MailOperation action) &&
|
||||||
Guid.TryParse(toastArguments[Constants.ToastMailUniqueIdKey], out Guid mailItemUniqueId))
|
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>
|
/// <summary>
|
||||||
/// Handles toast notification click for navigation.
|
/// Handles toast notification click for navigation.
|
||||||
/// Creates window if not running, sets up navigation parameter.
|
/// Creates window if not running, sets up navigation parameter.
|
||||||
|
|||||||
@@ -7,7 +7,6 @@
|
|||||||
xmlns:helpers="using:Wino.Helpers"
|
xmlns:helpers="using:Wino.Helpers"
|
||||||
HorizontalAlignment="Stretch"
|
HorizontalAlignment="Stretch"
|
||||||
VerticalAlignment="Stretch">
|
VerticalAlignment="Stretch">
|
||||||
|
|
||||||
<Grid x:DefaultBindMode="OneWay">
|
<Grid x:DefaultBindMode="OneWay">
|
||||||
<Grid.RowDefinitions>
|
<Grid.RowDefinitions>
|
||||||
<RowDefinition Height="Auto" />
|
<RowDefinition Height="Auto" />
|
||||||
@@ -23,14 +22,30 @@
|
|||||||
Prefer24HourTimeFormat="{x:Bind Prefer24HourTimeFormat, Mode=OneWay}"
|
Prefer24HourTimeFormat="{x:Bind Prefer24HourTimeFormat, Mode=OneWay}"
|
||||||
ShowPreviewText="False" />
|
ShowPreviewText="False" />
|
||||||
|
|
||||||
<TextBlock
|
<Grid
|
||||||
x:Name="EventDateText"
|
x:Name="EventDateContainer"
|
||||||
Grid.Row="1"
|
Grid.Row="1"
|
||||||
Margin="53,0,12,8"
|
Margin="46,0,12,8">
|
||||||
FontSize="12"
|
<Grid.ColumnDefinitions>
|
||||||
Opacity="0.75"
|
<ColumnDefinition Width="Auto" />
|
||||||
Text="{x:Bind EventDateRangeText, Mode=OneWay}"
|
<ColumnDefinition Width="*" />
|
||||||
TextTrimming="CharacterEllipsis" />
|
</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>
|
</Grid>
|
||||||
|
|
||||||
<VisualStateManager.VisualStateGroups>
|
<VisualStateManager.VisualStateGroups>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using Microsoft.UI.Xaml;
|
|||||||
using Wino.Core.Domain.Interfaces;
|
using Wino.Core.Domain.Interfaces;
|
||||||
using Wino.Core.ViewModels;
|
using Wino.Core.ViewModels;
|
||||||
using Wino.Core.WinUI.Services;
|
using Wino.Core.WinUI.Services;
|
||||||
|
using Wino.Mail.WinUI.Interfaces;
|
||||||
using Wino.Mail.WinUI.Services;
|
using Wino.Mail.WinUI.Services;
|
||||||
using Wino.Services;
|
using Wino.Services;
|
||||||
|
|
||||||
@@ -29,6 +30,7 @@ public static class CoreUWPContainerSetup
|
|||||||
services.AddTransient<IStoreRatingService, StoreRatingService>();
|
services.AddTransient<IStoreRatingService, StoreRatingService>();
|
||||||
services.AddTransient<IKeyPressService, KeyPressService>();
|
services.AddTransient<IKeyPressService, KeyPressService>();
|
||||||
services.AddTransient<INotificationBuilder, NotificationBuilder>();
|
services.AddTransient<INotificationBuilder, NotificationBuilder>();
|
||||||
|
services.AddSingleton<ICalendarReminderServer, CalendarReminderServer>();
|
||||||
services.AddTransient<IClipboardService, ClipboardService>();
|
services.AddTransient<IClipboardService, ClipboardService>();
|
||||||
services.AddTransient<IStartupBehaviorService, StartupBehaviorService>();
|
services.AddTransient<IStartupBehaviorService, StartupBehaviorService>();
|
||||||
services.AddSingleton<IPrintService, PrintService>();
|
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 Serilog;
|
||||||
using Windows.Data.Xml.Dom;
|
using Windows.Data.Xml.Dom;
|
||||||
using Windows.UI.Notifications;
|
using Windows.UI.Notifications;
|
||||||
|
using Wino.Core.Domain.Entities.Calendar;
|
||||||
using Wino.Core.Domain;
|
using Wino.Core.Domain;
|
||||||
using Wino.Core.Domain.Entities.Mail;
|
using Wino.Core.Domain.Entities.Mail;
|
||||||
using Wino.Core.Domain.Entities.Shared;
|
using Wino.Core.Domain.Entities.Shared;
|
||||||
@@ -250,4 +251,38 @@ public class NotificationBuilder : INotificationBuilder
|
|||||||
builder.AddButton(new ToastButton().SetContent(Translator.Buttons_FixAccount));
|
builder.AddButton(new ToastButton().SetContent(Translator.Buttons_FixAccount));
|
||||||
builder.Show();
|
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;
|
||||||
using System.Collections.ObjectModel;
|
using System.Collections.ObjectModel;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
|
using System.Threading.Tasks;
|
||||||
using System.Windows.Input;
|
using System.Windows.Input;
|
||||||
using CommunityToolkit.Mvvm.Input;
|
using CommunityToolkit.Mvvm.Input;
|
||||||
using CommunityToolkit.Mvvm.Messaging;
|
using CommunityToolkit.Mvvm.Messaging;
|
||||||
@@ -34,6 +35,7 @@ public sealed partial class ShellWindow : WindowEx, IWinoShellWindow,
|
|||||||
public ICommand ExitWinoCommand { get; set; }
|
public ICommand ExitWinoCommand { get; set; }
|
||||||
|
|
||||||
public ObservableCollection<SynchronizationActionItem> SyncActionItems { get; } = new();
|
public ObservableCollection<SynchronizationActionItem> SyncActionItems { get; } = new();
|
||||||
|
private bool _calendarReminderServerStartAttempted;
|
||||||
|
|
||||||
public ShellWindow()
|
public ShellWindow()
|
||||||
{
|
{
|
||||||
@@ -155,6 +157,12 @@ public sealed partial class ShellWindow : WindowEx, IWinoShellWindow,
|
|||||||
|
|
||||||
private void MainFrameNavigated(object sender, Microsoft.UI.Xaml.Navigation.NavigationEventArgs e)
|
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
|
// Mail shell has shell content only for mail list page
|
||||||
// Thus, we check if the current content is MailAppShell
|
// Thus, we check if the current content is MailAppShell
|
||||||
|
|
||||||
@@ -164,6 +172,23 @@ public sealed partial class ShellWindow : WindowEx, IWinoShellWindow,
|
|||||||
ShellTitleBar.Content = basePage.ShellContent;
|
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)
|
private void PaneButtonClicked(Microsoft.UI.Xaml.Controls.TitleBar sender, object args)
|
||||||
{
|
{
|
||||||
PreferencesService.IsNavigationPaneOpened = !PreferencesService.IsNavigationPaneOpened;
|
PreferencesService.IsNavigationPaneOpened = !PreferencesService.IsNavigationPaneOpened;
|
||||||
|
|||||||
@@ -29,4 +29,14 @@
|
|||||||
Icon="Attachment" />
|
Icon="Attachment" />
|
||||||
</Viewbox>
|
</Viewbox>
|
||||||
</DataTemplate>
|
</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>
|
</ResourceDictionary>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using CommunityToolkit.Mvvm.Messaging;
|
using CommunityToolkit.Mvvm.Messaging;
|
||||||
using Itenso.TimePeriod;
|
using Itenso.TimePeriod;
|
||||||
@@ -9,6 +10,7 @@ using Serilog;
|
|||||||
using Wino.Core.Domain.Entities.Calendar;
|
using Wino.Core.Domain.Entities.Calendar;
|
||||||
using Wino.Core.Domain.Entities.Shared;
|
using Wino.Core.Domain.Entities.Shared;
|
||||||
using Wino.Core.Domain.Enums;
|
using Wino.Core.Domain.Enums;
|
||||||
|
using Wino.Core.Domain.Extensions;
|
||||||
using Wino.Core.Domain.Interfaces;
|
using Wino.Core.Domain.Interfaces;
|
||||||
using Wino.Core.Domain.Models.Calendar;
|
using Wino.Core.Domain.Models.Calendar;
|
||||||
using Wino.Messaging.Client.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
|
#region Attachments
|
||||||
|
|
||||||
public Task<List<CalendarAttachment>> GetAttachmentsAsync(Guid calendarItemId)
|
public Task<List<CalendarAttachment>> GetAttachmentsAsync(Guid calendarItemId)
|
||||||
@@ -379,4 +435,12 @@ public class CalendarService : BaseDatabaseService, ICalendarService
|
|||||||
}
|
}
|
||||||
|
|
||||||
#endregion
|
#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