Add snooze support for calendar reminders (toast UI, service, DB) (#825)
* Filter reminder snooze options by default reminder * Some updates. * Fixing empty welcome page issue and attendee loading. * Icon system for notifications and snooze options etc.
@@ -130,5 +130,6 @@ private string searchQuery = string.Empty;
|
|||||||
- Wrap async operations in try-catch
|
- Wrap async operations in try-catch
|
||||||
- Log errors via IWinoLogger
|
- Log errors via IWinoLogger
|
||||||
- In ViewModels, update all UI-bound properties/collections via `ExecuteUIThread(...)` (especially after awaited calls and any use of `ConfigureAwait(false)`).
|
- In ViewModels, update all UI-bound properties/collections via `ExecuteUIThread(...)` (especially after awaited calls and any use of `ConfigureAwait(false)`).
|
||||||
|
- In `EventDetailsPageViewModel.LoadAttendeesAsync`, never mutate `CurrentEvent.Attendees` outside `ExecuteUIThread(...)`.
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -50,6 +50,12 @@ public partial class CalendarSettingsPageViewModel : CalendarBaseViewModel
|
|||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
public partial int SelectedDefaultReminderIndex { get; set; }
|
public partial int SelectedDefaultReminderIndex { get; set; }
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
public partial List<string> SnoozeOptions { get; set; } = [];
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
public partial int SelectedDefaultSnoozeIndex { get; set; }
|
||||||
|
|
||||||
public IPreferencesService PreferencesService { get; }
|
public IPreferencesService PreferencesService { get; }
|
||||||
private readonly ICalendarService _calendarService;
|
private readonly ICalendarService _calendarService;
|
||||||
private readonly IAccountService _accountService;
|
private readonly IAccountService _accountService;
|
||||||
@@ -108,6 +114,15 @@ public partial class CalendarSettingsPageViewModel : CalendarBaseViewModel
|
|||||||
SelectedDefaultReminderIndex = index >= 0 ? index + 1 : 0;
|
SelectedDefaultReminderIndex = index >= 0 ? index + 1 : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var supportedSnoozeMinutes = CalendarReminderSnoozeOptions.GetSupportedSnoozeMinutes().ToArray();
|
||||||
|
foreach (var snoozeMinutes in supportedSnoozeMinutes)
|
||||||
|
{
|
||||||
|
SnoozeOptions.Add(string.Format(Translator.CalendarReminder_SnoozeMinutesOption, snoozeMinutes));
|
||||||
|
}
|
||||||
|
|
||||||
|
var selectedSnoozeIndex = Array.IndexOf(supportedSnoozeMinutes, preferencesService.DefaultSnoozeDurationInMinutes);
|
||||||
|
SelectedDefaultSnoozeIndex = selectedSnoozeIndex >= 0 ? selectedSnoozeIndex : 0;
|
||||||
|
|
||||||
_isLoaded = true;
|
_isLoaded = true;
|
||||||
|
|
||||||
// Load accounts with calendar support
|
// Load accounts with calendar support
|
||||||
@@ -147,6 +162,7 @@ public partial class CalendarSettingsPageViewModel : CalendarBaseViewModel
|
|||||||
partial void OnWorkingDayStartIndexChanged(int value) => SaveSettings();
|
partial void OnWorkingDayStartIndexChanged(int value) => SaveSettings();
|
||||||
partial void OnWorkingDayEndIndexChanged(int value) => SaveSettings();
|
partial void OnWorkingDayEndIndexChanged(int value) => SaveSettings();
|
||||||
partial void OnSelectedDefaultReminderIndexChanged(int value) => SaveSettings();
|
partial void OnSelectedDefaultReminderIndexChanged(int value) => SaveSettings();
|
||||||
|
partial void OnSelectedDefaultSnoozeIndexChanged(int value) => SaveSettings();
|
||||||
|
|
||||||
public void SaveSettings()
|
public void SaveSettings()
|
||||||
{
|
{
|
||||||
@@ -205,6 +221,13 @@ public partial class CalendarSettingsPageViewModel : CalendarBaseViewModel
|
|||||||
PreferencesService.DefaultReminderDurationInSeconds = minutes * 60;
|
PreferencesService.DefaultReminderDurationInSeconds = minutes * 60;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var supportedSnoozeMinutes = CalendarReminderSnoozeOptions.GetSupportedSnoozeMinutes();
|
||||||
|
if (supportedSnoozeMinutes.Count > 0)
|
||||||
|
{
|
||||||
|
var selectedIndex = Math.Clamp(SelectedDefaultSnoozeIndex, 0, supportedSnoozeMinutes.Count - 1);
|
||||||
|
PreferencesService.DefaultSnoozeDurationInMinutes = supportedSnoozeMinutes[selectedIndex];
|
||||||
|
}
|
||||||
|
|
||||||
Messenger.Send(new CalendarSettingsUpdatedMessage());
|
Messenger.Send(new CalendarSettingsUpdatedMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ public partial class EventDetailsPageViewModel : CalendarBaseViewModel
|
|||||||
private readonly IWinoRequestDelegator _winoRequestDelegator;
|
private readonly IWinoRequestDelegator _winoRequestDelegator;
|
||||||
private readonly INavigationService _navigationService;
|
private readonly INavigationService _navigationService;
|
||||||
private readonly IUnderlyingThemeService _underlyingThemeService;
|
private readonly IUnderlyingThemeService _underlyingThemeService;
|
||||||
|
private readonly INotificationBuilder _notificationBuilder;
|
||||||
private readonly IContactService _contactService;
|
private readonly IContactService _contactService;
|
||||||
|
|
||||||
public CalendarSettings CurrentSettings { get; }
|
public CalendarSettings CurrentSettings { get; }
|
||||||
@@ -144,6 +145,7 @@ public partial class EventDetailsPageViewModel : CalendarBaseViewModel
|
|||||||
IMailDialogService dialogService,
|
IMailDialogService dialogService,
|
||||||
IWinoRequestDelegator winoRequestDelegator,
|
IWinoRequestDelegator winoRequestDelegator,
|
||||||
INavigationService navigationService,
|
INavigationService navigationService,
|
||||||
|
INotificationBuilder notificationBuilder,
|
||||||
IUnderlyingThemeService underlyingThemeService,
|
IUnderlyingThemeService underlyingThemeService,
|
||||||
IContactService contactService)
|
IContactService contactService)
|
||||||
{
|
{
|
||||||
@@ -154,6 +156,7 @@ public partial class EventDetailsPageViewModel : CalendarBaseViewModel
|
|||||||
_winoRequestDelegator = winoRequestDelegator;
|
_winoRequestDelegator = winoRequestDelegator;
|
||||||
_navigationService = navigationService;
|
_navigationService = navigationService;
|
||||||
_underlyingThemeService = underlyingThemeService;
|
_underlyingThemeService = underlyingThemeService;
|
||||||
|
_notificationBuilder = notificationBuilder;
|
||||||
_contactService = contactService;
|
_contactService = contactService;
|
||||||
|
|
||||||
CurrentSettings = _preferencesService.GetCurrentCalendarSettings();
|
CurrentSettings = _preferencesService.GetCurrentCalendarSettings();
|
||||||
@@ -259,8 +262,6 @@ public partial class EventDetailsPageViewModel : CalendarBaseViewModel
|
|||||||
|
|
||||||
private async Task LoadAttendeesAsync(Guid calendarItemId, CalendarItem calendarItem)
|
private async Task LoadAttendeesAsync(Guid calendarItemId, CalendarItem calendarItem)
|
||||||
{
|
{
|
||||||
CurrentEvent.Attendees.Clear();
|
|
||||||
|
|
||||||
var attendees = await _calendarService.GetAttendeesAsync(calendarItemId);
|
var attendees = await _calendarService.GetAttendeesAsync(calendarItemId);
|
||||||
|
|
||||||
// Resolve contacts for all attendees in a single batch DB query.
|
// Resolve contacts for all attendees in a single batch DB query.
|
||||||
@@ -285,10 +286,12 @@ public partial class EventDetailsPageViewModel : CalendarBaseViewModel
|
|||||||
var organizer = attendees.FirstOrDefault(a => a.IsOrganizer);
|
var organizer = attendees.FirstOrDefault(a => a.IsOrganizer);
|
||||||
var nonOrganizerAttendees = attendees.Where(a => !a.IsOrganizer).ToList();
|
var nonOrganizerAttendees = attendees.Where(a => !a.IsOrganizer).ToList();
|
||||||
|
|
||||||
|
var attendeesForUi = new List<CalendarEventAttendee>();
|
||||||
|
|
||||||
// If the organizer is in the list, add them first
|
// If the organizer is in the list, add them first
|
||||||
if (organizer != null)
|
if (organizer != null)
|
||||||
{
|
{
|
||||||
CurrentEvent.Attendees.Add(organizer);
|
attendeesForUi.Add(organizer);
|
||||||
}
|
}
|
||||||
else if (!string.IsNullOrEmpty(calendarItem.OrganizerEmail))
|
else if (!string.IsNullOrEmpty(calendarItem.OrganizerEmail))
|
||||||
{
|
{
|
||||||
@@ -306,14 +309,27 @@ public partial class EventDetailsPageViewModel : CalendarBaseViewModel
|
|||||||
if (contactLookup.TryGetValue(calendarItem.OrganizerEmail, out var organizerContact))
|
if (contactLookup.TryGetValue(calendarItem.OrganizerEmail, out var organizerContact))
|
||||||
organizerAttendee.ResolvedContact = organizerContact;
|
organizerAttendee.ResolvedContact = organizerContact;
|
||||||
|
|
||||||
CurrentEvent.Attendees.Add(organizerAttendee);
|
attendeesForUi.Add(organizerAttendee);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add all other attendees after the organizer
|
// Add all other attendees after the organizer
|
||||||
foreach (var item in nonOrganizerAttendees)
|
foreach (var item in nonOrganizerAttendees)
|
||||||
{
|
{
|
||||||
CurrentEvent.Attendees.Add(item);
|
attendeesForUi.Add(item);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await ExecuteUIThread(() =>
|
||||||
|
{
|
||||||
|
if (CurrentEvent == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
CurrentEvent.Attendees.Clear();
|
||||||
|
|
||||||
|
foreach (var attendee in attendeesForUi)
|
||||||
|
{
|
||||||
|
CurrentEvent.Attendees.Add(attendee);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task LoadAttachmentsAsync(Guid calendarItemId)
|
private async Task LoadAttachmentsAsync(Guid calendarItemId)
|
||||||
@@ -491,6 +507,24 @@ public partial class EventDetailsPageViewModel : CalendarBaseViewModel
|
|||||||
return _nativeAppService.LaunchUriAsync(new Uri(CurrentEvent.CalendarItem.HtmlLink));
|
return _nativeAppService.LaunchUriAsync(new Uri(CurrentEvent.CalendarItem.HtmlLink));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private Task CreateTestNotificationAsync()
|
||||||
|
{
|
||||||
|
if (CurrentEvent?.CalendarItem == null)
|
||||||
|
return Task.CompletedTask;
|
||||||
|
|
||||||
|
var reminderDurationInSeconds = Reminders?
|
||||||
|
.Where(x => x.DurationInSeconds > 0)
|
||||||
|
.OrderByDescending(x => x.DurationInSeconds)
|
||||||
|
.Select(x => x.DurationInSeconds)
|
||||||
|
.FirstOrDefault() ?? 0;
|
||||||
|
|
||||||
|
if (reminderDurationInSeconds <= 0)
|
||||||
|
reminderDurationInSeconds = Math.Max(_preferencesService.DefaultReminderDurationInSeconds, 30 * 60);
|
||||||
|
|
||||||
|
return _notificationBuilder.CreateCalendarReminderNotificationAsync(CurrentEvent.CalendarItem, reminderDurationInSeconds);
|
||||||
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private void ToggleRsvpPanel()
|
private void ToggleRsvpPanel()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
|
||||||
|
namespace Wino.Core.Domain;
|
||||||
|
|
||||||
|
public static class CalendarReminderSnoozeOptions
|
||||||
|
{
|
||||||
|
private static readonly int[] SupportedSnoozeMinutes = [5, 10, 15, 30];
|
||||||
|
|
||||||
|
public static IReadOnlyList<int> GetSupportedSnoozeMinutes()
|
||||||
|
=> SupportedSnoozeMinutes;
|
||||||
|
|
||||||
|
public static IReadOnlyList<int> GetAllowedSnoozeMinutes(long reminderDurationInSeconds, long defaultReminderDurationInSeconds)
|
||||||
|
{
|
||||||
|
var reminderMinutes = (int)Math.Max(0, reminderDurationInSeconds / 60);
|
||||||
|
|
||||||
|
if (reminderMinutes <= 0)
|
||||||
|
return [];
|
||||||
|
|
||||||
|
var maxSnoozeMinutes = reminderMinutes;
|
||||||
|
var defaultReminderMinutes = (int)Math.Max(0, defaultReminderDurationInSeconds / 60);
|
||||||
|
|
||||||
|
if (defaultReminderMinutes > 0)
|
||||||
|
maxSnoozeMinutes = Math.Min(maxSnoozeMinutes, defaultReminderMinutes);
|
||||||
|
|
||||||
|
return SupportedSnoozeMinutes.Where(minutes => minutes <= maxSnoozeMinutes).ToArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,8 @@ public static class Constants
|
|||||||
public const string ToastCalendarItemIdKey = nameof(ToastCalendarItemIdKey);
|
public const string ToastCalendarItemIdKey = nameof(ToastCalendarItemIdKey);
|
||||||
public const string ToastCalendarActionKey = nameof(ToastCalendarActionKey);
|
public const string ToastCalendarActionKey = nameof(ToastCalendarActionKey);
|
||||||
public const string ToastCalendarNavigateAction = nameof(ToastCalendarNavigateAction);
|
public const string ToastCalendarNavigateAction = nameof(ToastCalendarNavigateAction);
|
||||||
|
public const string ToastCalendarSnoozeAction = nameof(ToastCalendarSnoozeAction);
|
||||||
|
public const string ToastCalendarSnoozeDurationInputId = nameof(ToastCalendarSnoozeDurationInputId);
|
||||||
public const string ToastModeKey = nameof(ToastModeKey);
|
public const string ToastModeKey = nameof(ToastModeKey);
|
||||||
public const string ToastModeMail = nameof(ToastModeMail);
|
public const string ToastModeMail = nameof(ToastModeMail);
|
||||||
public const string ToastModeCalendar = nameof(ToastModeCalendar);
|
public const string ToastModeCalendar = nameof(ToastModeCalendar);
|
||||||
|
|||||||
@@ -138,6 +138,7 @@ public class CalendarItem : ICalendarItem
|
|||||||
// TODO
|
// TODO
|
||||||
public string CustomEventColorHex { get; set; }
|
public string CustomEventColorHex { get; set; }
|
||||||
public string HtmlLink { get; set; }
|
public string HtmlLink { get; set; }
|
||||||
|
public DateTime? SnoozedUntil { get; set; }
|
||||||
public CalendarItemStatus Status { get; set; }
|
public CalendarItemStatus Status { get; set; }
|
||||||
public CalendarItemVisibility Visibility { get; set; }
|
public CalendarItemVisibility Visibility { get; set; }
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ public interface ICalendarService
|
|||||||
Task UpdateCalendarItemAsync(CalendarItem calendarItem, List<CalendarEventAttendee> attendees);
|
Task UpdateCalendarItemAsync(CalendarItem calendarItem, List<CalendarEventAttendee> attendees);
|
||||||
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);
|
||||||
|
Task SnoozeCalendarItemAsync(Guid calendarItemId, DateTime snoozedUntilLocal);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Checks due reminder windows and returns reminder notifications that should trigger now.
|
/// Checks due reminder windows and returns reminder notifications that should trigger now.
|
||||||
|
|||||||
@@ -222,6 +222,11 @@ public interface IPreferencesService : INotifyPropertyChanged
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
long DefaultReminderDurationInSeconds { get; set; }
|
long DefaultReminderDurationInSeconds { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Setting: Default snooze duration in minutes for calendar reminder notifications.
|
||||||
|
/// </summary>
|
||||||
|
int DefaultSnoozeDurationInMinutes { get; set; }
|
||||||
|
|
||||||
CalendarSettings GetCurrentCalendarSettings();
|
CalendarSettings GetCurrentCalendarSettings();
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|||||||
@@ -133,6 +133,14 @@
|
|||||||
"CalendarEventDetails_People": "People",
|
"CalendarEventDetails_People": "People",
|
||||||
"CalendarEventDetails_ReadOnlyEvent": "Read-only event",
|
"CalendarEventDetails_ReadOnlyEvent": "Read-only event",
|
||||||
"CalendarEventDetails_Reminder": "Reminder",
|
"CalendarEventDetails_Reminder": "Reminder",
|
||||||
|
"CalendarReminder_StartedHoursAgo": "Started {0} hours ago",
|
||||||
|
"CalendarReminder_StartedMinutesAgo": "Started {0} minutes ago",
|
||||||
|
"CalendarReminder_StartedNow": "Started just now",
|
||||||
|
"CalendarReminder_StartingNow": "Starting now",
|
||||||
|
"CalendarReminder_StartsInHours": "Starts in {0} hours",
|
||||||
|
"CalendarReminder_StartsInMinutes": "Starts in {0} minutes",
|
||||||
|
"CalendarReminder_SnoozeAction": "Snooze",
|
||||||
|
"CalendarReminder_SnoozeMinutesOption": "{0} minutes",
|
||||||
"CalendarEventDetails_ShowAs": "Show as",
|
"CalendarEventDetails_ShowAs": "Show as",
|
||||||
"CalendarShowAs_Free": "Free",
|
"CalendarShowAs_Free": "Free",
|
||||||
"CalendarShowAs_Tentative": "Tentative",
|
"CalendarShowAs_Tentative": "Tentative",
|
||||||
@@ -646,6 +654,8 @@
|
|||||||
"SettingsAvailableThemes_Title": "Available Themes",
|
"SettingsAvailableThemes_Title": "Available Themes",
|
||||||
"SettingsCalendarSettings_Description": "Change first day of week, hour cell height and more...",
|
"SettingsCalendarSettings_Description": "Change first day of week, hour cell height and more...",
|
||||||
"SettingsCalendarSettings_Title": "Calendar Settings",
|
"SettingsCalendarSettings_Title": "Calendar Settings",
|
||||||
|
"CalendarSettings_DefaultSnoozeDuration_Header": "Default snooze duration",
|
||||||
|
"CalendarSettings_DefaultSnoozeDuration_Description": "Set a default snooze duration for calendar reminder notifications.",
|
||||||
"SettingsComposer_Title": "Composer",
|
"SettingsComposer_Title": "Composer",
|
||||||
"SettingsComposerFont_Title": "Default Composer Font",
|
"SettingsComposerFont_Title": "Default Composer Font",
|
||||||
"SettingsComposerFontFamily_Description": "Change the default font family and font size for composing mails.",
|
"SettingsComposerFontFamily_Description": "Change the default font family and font size for composing mails.",
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
using FluentAssertions;
|
||||||
|
using Wino.Core.Domain;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Wino.Core.Tests;
|
||||||
|
|
||||||
|
public class CalendarReminderSnoozeOptionsTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void GetAllowedSnoozeMinutes_WhenDefaultIs15AndReminderIs15_Excludes30()
|
||||||
|
{
|
||||||
|
var options = CalendarReminderSnoozeOptions.GetAllowedSnoozeMinutes(
|
||||||
|
reminderDurationInSeconds: 15 * 60,
|
||||||
|
defaultReminderDurationInSeconds: 15 * 60);
|
||||||
|
|
||||||
|
options.Should().Equal(5, 10, 15);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetAllowedSnoozeMinutes_WhenReminderIs5AndDefaultIs15_DoesNotPassEventStart()
|
||||||
|
{
|
||||||
|
var options = CalendarReminderSnoozeOptions.GetAllowedSnoozeMinutes(
|
||||||
|
reminderDurationInSeconds: 5 * 60,
|
||||||
|
defaultReminderDurationInSeconds: 15 * 60);
|
||||||
|
|
||||||
|
options.Should().Equal(5);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetAllowedSnoozeMinutes_WhenDefaultReminderIsNone_UsesReminderDurationOnly()
|
||||||
|
{
|
||||||
|
var options = CalendarReminderSnoozeOptions.GetAllowedSnoozeMinutes(
|
||||||
|
reminderDurationInSeconds: 30 * 60,
|
||||||
|
defaultReminderDurationInSeconds: 0);
|
||||||
|
|
||||||
|
options.Should().Equal(5, 10, 15, 30);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetAllowedSnoozeMinutes_WhenReminderIsUnderFiveMinutes_ReturnsNoOptions()
|
||||||
|
{
|
||||||
|
var options = CalendarReminderSnoozeOptions.GetAllowedSnoozeMinutes(
|
||||||
|
reminderDurationInSeconds: 60,
|
||||||
|
defaultReminderDurationInSeconds: 15 * 60);
|
||||||
|
|
||||||
|
options.Should().BeEmpty();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -68,8 +68,8 @@ public class CalendarReminderServiceTests : IAsyncLifetime
|
|||||||
due.Should().HaveCount(1);
|
due.Should().HaveCount(1);
|
||||||
due[0].CalendarItem.Id.Should().Be(calendarItem.Id);
|
due[0].CalendarItem.Id.Should().Be(calendarItem.Id);
|
||||||
due[0].ReminderDurationInSeconds.Should().Be(5 * 60);
|
due[0].ReminderDurationInSeconds.Should().Be(5 * 60);
|
||||||
due[0].ReminderKey.Should().Be($"{calendarItem.Id:N}:{5 * 60}");
|
due[0].ReminderKey.Should().StartWith($"{calendarItem.Id:N}:{5 * 60}:");
|
||||||
sentReminderKeys.Should().Contain($"{calendarItem.Id:N}:{5 * 60}");
|
sentReminderKeys.Should().ContainSingle(k => k.StartsWith($"{calendarItem.Id:N}:{5 * 60}:"));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -108,7 +108,7 @@ public class CalendarReminderServiceTests : IAsyncLifetime
|
|||||||
|
|
||||||
firstRun.Should().HaveCount(1);
|
firstRun.Should().HaveCount(1);
|
||||||
secondRun.Should().BeEmpty();
|
secondRun.Should().BeEmpty();
|
||||||
sentReminderKeys.Should().Contain($"{calendarItem.Id:N}:{5 * 60}");
|
sentReminderKeys.Should().ContainSingle(k => k.StartsWith($"{calendarItem.Id:N}:{5 * 60}:"));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -189,6 +189,35 @@ public class CalendarReminderServiceTests : IAsyncLifetime
|
|||||||
due.Should().BeEmpty();
|
due.Should().BeEmpty();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CheckAndNotifyAsync_WhenItemIsSnoozed_TriggersAtSnoozedTime()
|
||||||
|
{
|
||||||
|
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);
|
||||||
|
|
||||||
|
await _calendarService.SnoozeCalendarItemAsync(calendarItem.Id, nowLocal.AddMinutes(10));
|
||||||
|
|
||||||
|
HashSet<string> sentReminderKeys = [];
|
||||||
|
|
||||||
|
var dueAtOriginalTrigger = await _calendarService.CheckAndNotifyAsync(lastCheckLocal, nowLocal, sentReminderKeys);
|
||||||
|
dueAtOriginalTrigger.Should().BeEmpty();
|
||||||
|
|
||||||
|
var snoozeTriggerWindowStart = nowLocal.AddMinutes(10).AddSeconds(-30);
|
||||||
|
var snoozeTriggerWindowEnd = nowLocal.AddMinutes(10);
|
||||||
|
|
||||||
|
var dueAtSnoozeTime = await _calendarService.CheckAndNotifyAsync(snoozeTriggerWindowStart, snoozeTriggerWindowEnd, sentReminderKeys);
|
||||||
|
|
||||||
|
dueAtSnoozeTime.Should().HaveCount(1);
|
||||||
|
dueAtSnoozeTime[0].CalendarItem.Id.Should().Be(calendarItem.Id);
|
||||||
|
dueAtSnoozeTime[0].ReminderKey.Should().StartWith($"{calendarItem.Id:N}:{5 * 60}:");
|
||||||
|
}
|
||||||
|
|
||||||
private async Task<CalendarItem> CreateCalendarItemWithReminderAsync(
|
private async Task<CalendarItem> CreateCalendarItemWithReminderAsync(
|
||||||
DateTime startDate,
|
DateTime startDate,
|
||||||
long reminderDurationInSeconds,
|
long reminderDurationInSeconds,
|
||||||
|
|||||||
@@ -192,6 +192,8 @@ public partial class App : WinoApplication,
|
|||||||
|
|
||||||
private void AppNotificationInvoked(AppNotificationManager sender, AppNotificationActivatedEventArgs args)
|
private void AppNotificationInvoked(AppNotificationManager sender, AppNotificationActivatedEventArgs args)
|
||||||
{
|
{
|
||||||
|
// AppNotification callbacks are not guaranteed to run on the UI thread.
|
||||||
|
// Marshal toast handling to the window dispatcher before touching window APIs.
|
||||||
if (MainWindow?.DispatcherQueue?.TryEnqueue(() => _ = HandleToastActivationAsync(args)) == true)
|
if (MainWindow?.DispatcherQueue?.TryEnqueue(() => _ = HandleToastActivationAsync(args)) == true)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
@@ -224,14 +226,22 @@ public partial class App : WinoApplication,
|
|||||||
|
|
||||||
// Check calendar reminder toast activation first.
|
// Check calendar reminder toast activation first.
|
||||||
if (toastArguments.TryGetValue(Constants.ToastCalendarActionKey, out string calendarAction) &&
|
if (toastArguments.TryGetValue(Constants.ToastCalendarActionKey, out string calendarAction) &&
|
||||||
calendarAction == Constants.ToastCalendarNavigateAction &&
|
|
||||||
toastArguments.TryGetValue(Constants.ToastCalendarItemIdKey, out string calendarItemIdString) &&
|
toastArguments.TryGetValue(Constants.ToastCalendarItemIdKey, out string calendarItemIdString) &&
|
||||||
Guid.TryParse(calendarItemIdString, out Guid calendarItemId))
|
Guid.TryParse(calendarItemIdString, out Guid calendarItemId))
|
||||||
|
{
|
||||||
|
if (calendarAction == Constants.ToastCalendarNavigateAction)
|
||||||
{
|
{
|
||||||
await HandleCalendarToastNavigationAsync(calendarItemId);
|
await HandleCalendarToastNavigationAsync(calendarItemId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (calendarAction == Constants.ToastCalendarSnoozeAction)
|
||||||
|
{
|
||||||
|
await HandleCalendarToastSnoozeAsync(toastArgs, 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))
|
||||||
@@ -275,6 +285,33 @@ public partial class App : WinoApplication,
|
|||||||
navigationService.Navigate(WinoPage.EventDetailsPage, target);
|
navigationService.Navigate(WinoPage.EventDetailsPage, target);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task HandleCalendarToastSnoozeAsync(AppNotificationActivatedEventArgs toastArgs, Guid calendarItemId)
|
||||||
|
{
|
||||||
|
if (!TryGetSnoozeDurationMinutes(toastArgs, out var snoozeDurationMinutes))
|
||||||
|
return;
|
||||||
|
|
||||||
|
var calendarService = Services.GetRequiredService<ICalendarService>();
|
||||||
|
var snoozedUntilLocal = DateTime.Now.AddMinutes(snoozeDurationMinutes);
|
||||||
|
|
||||||
|
await calendarService.SnoozeCalendarItemAsync(calendarItemId, snoozedUntilLocal).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryGetSnoozeDurationMinutes(AppNotificationActivatedEventArgs toastArgs, out int snoozeDurationMinutes)
|
||||||
|
{
|
||||||
|
snoozeDurationMinutes = 0;
|
||||||
|
|
||||||
|
if (toastArgs.UserInput == null ||
|
||||||
|
!toastArgs.UserInput.TryGetValue(Constants.ToastCalendarSnoozeDurationInputId, out var selectedValue) ||
|
||||||
|
selectedValue == null)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var selectedText = selectedValue.ToString();
|
||||||
|
|
||||||
|
return int.TryParse(selectedText, out snoozeDurationMinutes) && snoozeDurationMinutes > 0;
|
||||||
|
}
|
||||||
|
|
||||||
/// <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.
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 459 B |
|
After Width: | Height: | Size: 545 B |
|
After Width: | Height: | Size: 669 B |
|
After Width: | Height: | Size: 887 B |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 434 B |
|
After Width: | Height: | Size: 507 B |
|
After Width: | Height: | Size: 615 B |
|
After Width: | Height: | Size: 817 B |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 351 B |
|
After Width: | Height: | Size: 445 B |
|
After Width: | Height: | Size: 495 B |
|
After Width: | Height: | Size: 685 B |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 323 B |
|
After Width: | Height: | Size: 417 B |
|
After Width: | Height: | Size: 459 B |
|
After Width: | Height: | Size: 636 B |
|
After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 180 B |
|
After Width: | Height: | Size: 234 B |
|
After Width: | Height: | Size: 230 B |
|
After Width: | Height: | Size: 283 B |
|
After Width: | Height: | Size: 521 B |
|
After Width: | Height: | Size: 174 B |
|
After Width: | Height: | Size: 226 B |
|
After Width: | Height: | Size: 229 B |
|
After Width: | Height: | Size: 275 B |
|
After Width: | Height: | Size: 509 B |
|
After Width: | Height: | Size: 279 B |
|
After Width: | Height: | Size: 359 B |
|
After Width: | Height: | Size: 383 B |
|
After Width: | Height: | Size: 371 B |
|
After Width: | Height: | Size: 805 B |
|
After Width: | Height: | Size: 272 B |
|
After Width: | Height: | Size: 324 B |
|
After Width: | Height: | Size: 362 B |
|
After Width: | Height: | Size: 328 B |
|
After Width: | Height: | Size: 706 B |
|
After Width: | Height: | Size: 303 B |
|
After Width: | Height: | Size: 426 B |
|
After Width: | Height: | Size: 496 B |
|
After Width: | Height: | Size: 541 B |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 289 B |
|
After Width: | Height: | Size: 408 B |
|
After Width: | Height: | Size: 465 B |
|
After Width: | Height: | Size: 527 B |
|
After Width: | Height: | Size: 989 B |
|
After Width: | Height: | Size: 336 B |
|
After Width: | Height: | Size: 418 B |
|
After Width: | Height: | Size: 458 B |
|
After Width: | Height: | Size: 595 B |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 308 B |
|
After Width: | Height: | Size: 382 B |
|
After Width: | Height: | Size: 447 B |
|
After Width: | Height: | Size: 574 B |
|
After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 31 KiB |
@@ -38,6 +38,8 @@ public sealed partial class MailAppShell : MailAppShellAbstract,
|
|||||||
IRecipient<CreateNewMailWithMultipleAccountsRequested>,
|
IRecipient<CreateNewMailWithMultipleAccountsRequested>,
|
||||||
IRecipient<InfoBarMessageRequested>
|
IRecipient<InfoBarMessageRequested>
|
||||||
{
|
{
|
||||||
|
public Frame GetShellFrame() => InnerShellFrame;
|
||||||
|
|
||||||
[GeneratedDependencyProperty]
|
[GeneratedDependencyProperty]
|
||||||
public partial UIElement? TopShellContent { get; set; }
|
public partial UIElement? TopShellContent { get; set; }
|
||||||
|
|
||||||
|
|||||||
@@ -129,6 +129,19 @@ public class NavigationService : NavigationServiceBase, INavigationService
|
|||||||
|
|
||||||
if (frameType == NavigationReferenceFrame.ShellFrame) return shellWindow.GetMainFrame();
|
if (frameType == NavigationReferenceFrame.ShellFrame) return shellWindow.GetMainFrame();
|
||||||
|
|
||||||
|
if (frameType == NavigationReferenceFrame.InnerShellFrame)
|
||||||
|
{
|
||||||
|
if (mainFrame.Content is MailAppShell mailAppShell)
|
||||||
|
{
|
||||||
|
return mailAppShell.GetShellFrame();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mainFrame.Content is CalendarAppShell calendarAppShell)
|
||||||
|
{
|
||||||
|
return calendarAppShell.GetShellFrame();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var contentRoot = mainFrame.Content as UIElement;
|
var contentRoot = mainFrame.Content as UIElement;
|
||||||
if (contentRoot == null) return mainFrame;
|
if (contentRoot == null) return mainFrame;
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using CommunityToolkit.Mvvm.Messaging;
|
using CommunityToolkit.Mvvm.Messaging;
|
||||||
using Microsoft.Toolkit.Uwp.Notifications;
|
using Microsoft.Toolkit.Uwp.Notifications;
|
||||||
|
using Microsoft.UI.Xaml;
|
||||||
using Serilog;
|
using Serilog;
|
||||||
using Windows.Data.Xml.Dom;
|
using Windows.Data.Xml.Dom;
|
||||||
using Windows.UI.Notifications;
|
using Windows.UI.Notifications;
|
||||||
@@ -12,6 +14,7 @@ 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;
|
||||||
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.Messaging.UI;
|
using Wino.Messaging.UI;
|
||||||
|
|
||||||
@@ -20,21 +23,29 @@ namespace Wino.Mail.WinUI.Services;
|
|||||||
public class NotificationBuilder : INotificationBuilder
|
public class NotificationBuilder : INotificationBuilder
|
||||||
{
|
{
|
||||||
private const string MailApplicationId = "App";
|
private const string MailApplicationId = "App";
|
||||||
|
private const string NotificationIconRootUri = "ms-appx:///Assets/NotificationIcons/";
|
||||||
|
private static readonly int[] SupportedIconScales = [100, 125, 150, 200, 400];
|
||||||
|
|
||||||
private readonly IAccountService _accountService;
|
private readonly IAccountService _accountService;
|
||||||
private readonly IFolderService _folderService;
|
private readonly IFolderService _folderService;
|
||||||
private readonly IMailService _mailService;
|
private readonly IMailService _mailService;
|
||||||
private readonly IThumbnailService _thumbnailService;
|
private readonly IThumbnailService _thumbnailService;
|
||||||
|
private readonly IPreferencesService _preferencesService;
|
||||||
|
private readonly IUnderlyingThemeService _underlyingThemeService;
|
||||||
|
|
||||||
public NotificationBuilder(IAccountService accountService,
|
public NotificationBuilder(IAccountService accountService,
|
||||||
IFolderService folderService,
|
IFolderService folderService,
|
||||||
IMailService mailService,
|
IMailService mailService,
|
||||||
IThumbnailService thumbnailService)
|
IThumbnailService thumbnailService,
|
||||||
|
IPreferencesService preferencesService,
|
||||||
|
IUnderlyingThemeService underlyingThemeService)
|
||||||
{
|
{
|
||||||
_accountService = accountService;
|
_accountService = accountService;
|
||||||
_folderService = folderService;
|
_folderService = folderService;
|
||||||
_mailService = mailService;
|
_mailService = mailService;
|
||||||
_thumbnailService = thumbnailService;
|
_thumbnailService = thumbnailService;
|
||||||
|
_preferencesService = preferencesService;
|
||||||
|
_underlyingThemeService = underlyingThemeService;
|
||||||
|
|
||||||
WeakReferenceMessenger.Default.Register<MailReadStatusChanged>(this, (r, msg) =>
|
WeakReferenceMessenger.Default.Register<MailReadStatusChanged>(this, (r, msg) =>
|
||||||
{
|
{
|
||||||
@@ -156,12 +167,12 @@ public class NotificationBuilder : INotificationBuilder
|
|||||||
private ToastButton GetDismissButton()
|
private ToastButton GetDismissButton()
|
||||||
=> new ToastButton()
|
=> new ToastButton()
|
||||||
.SetDismissActivation()
|
.SetDismissActivation()
|
||||||
.SetImageUri(new Uri("ms-appx:///Assets/NotificationIcons/dismiss.png"));
|
.SetImageUri(GetNotificationIconUri("dismiss"));
|
||||||
|
|
||||||
private static ToastButton GetArchiveButton(Guid mailUniqueId)
|
private ToastButton GetArchiveButton(Guid mailUniqueId)
|
||||||
=> new ToastButton()
|
=> new ToastButton()
|
||||||
.SetContent(Translator.MailOperation_Archive)
|
.SetContent(Translator.MailOperation_Archive)
|
||||||
.SetImageUri(new Uri("ms-appx:///Assets/NotificationIcons/archive.png"))
|
.SetImageUri(GetNotificationIconUri("mail-archive"))
|
||||||
.AddArgument(Constants.ToastMailUniqueIdKey, mailUniqueId.ToString())
|
.AddArgument(Constants.ToastMailUniqueIdKey, mailUniqueId.ToString())
|
||||||
.AddArgument(Constants.ToastActionKey, MailOperation.Archive)
|
.AddArgument(Constants.ToastActionKey, MailOperation.Archive)
|
||||||
.AddArgument(Constants.ToastModeKey, Constants.ToastModeMail)
|
.AddArgument(Constants.ToastModeKey, Constants.ToastModeMail)
|
||||||
@@ -170,16 +181,16 @@ public class NotificationBuilder : INotificationBuilder
|
|||||||
private ToastButton GetDeleteButton(Guid mailUniqueId)
|
private ToastButton GetDeleteButton(Guid mailUniqueId)
|
||||||
=> new ToastButton()
|
=> new ToastButton()
|
||||||
.SetContent(Translator.MailOperation_Delete)
|
.SetContent(Translator.MailOperation_Delete)
|
||||||
.SetImageUri(new Uri("ms-appx:///Assets/NotificationIcons/delete.png"))
|
.SetImageUri(GetNotificationIconUri("mail-delete"))
|
||||||
.AddArgument(Constants.ToastMailUniqueIdKey, mailUniqueId.ToString())
|
.AddArgument(Constants.ToastMailUniqueIdKey, mailUniqueId.ToString())
|
||||||
.AddArgument(Constants.ToastActionKey, MailOperation.SoftDelete)
|
.AddArgument(Constants.ToastActionKey, MailOperation.SoftDelete)
|
||||||
.AddArgument(Constants.ToastModeKey, Constants.ToastModeMail)
|
.AddArgument(Constants.ToastModeKey, Constants.ToastModeMail)
|
||||||
.SetBackgroundActivation();
|
.SetBackgroundActivation();
|
||||||
|
|
||||||
private static ToastButton GetMarkAsReadButton(Guid mailUniqueId)
|
private ToastButton GetMarkAsReadButton(Guid mailUniqueId)
|
||||||
=> new ToastButton()
|
=> new ToastButton()
|
||||||
.SetContent(Translator.MailOperation_MarkAsRead)
|
.SetContent(Translator.MailOperation_MarkAsRead)
|
||||||
.SetImageUri(new System.Uri("ms-appx:///Assets/NotificationIcons/markread.png"))
|
.SetImageUri(GetNotificationIconUri("mail-markread"))
|
||||||
.AddArgument(Constants.ToastMailUniqueIdKey, mailUniqueId.ToString())
|
.AddArgument(Constants.ToastMailUniqueIdKey, mailUniqueId.ToString())
|
||||||
.AddArgument(Constants.ToastActionKey, MailOperation.MarkAsRead)
|
.AddArgument(Constants.ToastActionKey, MailOperation.MarkAsRead)
|
||||||
.AddArgument(Constants.ToastModeKey, Constants.ToastModeMail)
|
.AddArgument(Constants.ToastModeKey, Constants.ToastModeMail)
|
||||||
@@ -290,11 +301,9 @@ public class NotificationBuilder : INotificationBuilder
|
|||||||
var builder = new ToastContentBuilder();
|
var builder = new ToastContentBuilder();
|
||||||
builder.SetToastScenario(ToastScenario.Reminder);
|
builder.SetToastScenario(ToastScenario.Reminder);
|
||||||
|
|
||||||
var localStart = calendarItem.LocalStartDate;
|
var localStart = calendarItem.GetLocalStartDate();
|
||||||
var reminderMinutes = (int)Math.Max(0, reminderDurationInSeconds / 60);
|
var nowLocal = DateTime.Now;
|
||||||
var reminderContext = reminderMinutes > 0
|
var reminderContext = GetCalendarReminderContext(localStart, nowLocal);
|
||||||
? $"Starts in {reminderMinutes} minute{(reminderMinutes == 1 ? string.Empty : "s")}"
|
|
||||||
: "Starting now";
|
|
||||||
|
|
||||||
builder.AddText(calendarItem.Title);
|
builder.AddText(calendarItem.Title);
|
||||||
builder.AddText($"{reminderContext} - {localStart:g}");
|
builder.AddText($"{reminderContext} - {localStart:g}");
|
||||||
@@ -305,7 +314,54 @@ public class NotificationBuilder : INotificationBuilder
|
|||||||
builder.AddArgument(Constants.ToastCalendarActionKey, Constants.ToastCalendarNavigateAction);
|
builder.AddArgument(Constants.ToastCalendarActionKey, Constants.ToastCalendarNavigateAction);
|
||||||
builder.AddArgument(Constants.ToastCalendarItemIdKey, calendarItem.Id.ToString());
|
builder.AddArgument(Constants.ToastCalendarItemIdKey, calendarItem.Id.ToString());
|
||||||
builder.AddArgument(Constants.ToastModeKey, Constants.ToastModeCalendar);
|
builder.AddArgument(Constants.ToastModeKey, Constants.ToastModeCalendar);
|
||||||
builder.AddButton(GetDismissButton());
|
|
||||||
|
var allowedSnoozeMinutes = CalendarReminderSnoozeOptions.GetAllowedSnoozeMinutes(
|
||||||
|
reminderDurationInSeconds,
|
||||||
|
_preferencesService.DefaultReminderDurationInSeconds);
|
||||||
|
|
||||||
|
if (allowedSnoozeMinutes.Count > 0)
|
||||||
|
{
|
||||||
|
var preferredSnoozeMinutes = _preferencesService.DefaultSnoozeDurationInMinutes;
|
||||||
|
var defaultSnoozeMinutes = allowedSnoozeMinutes.Contains(preferredSnoozeMinutes)
|
||||||
|
? preferredSnoozeMinutes
|
||||||
|
: allowedSnoozeMinutes[0];
|
||||||
|
|
||||||
|
var selectionBox = new ToastSelectionBox(Constants.ToastCalendarSnoozeDurationInputId)
|
||||||
|
{
|
||||||
|
DefaultSelectionBoxItemId = defaultSnoozeMinutes.ToString()
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach (var snoozeMinutes in allowedSnoozeMinutes)
|
||||||
|
{
|
||||||
|
selectionBox.Items.Add(new ToastSelectionBoxItem(
|
||||||
|
snoozeMinutes.ToString(),
|
||||||
|
string.Format(Translator.CalendarReminder_SnoozeMinutesOption, snoozeMinutes)));
|
||||||
|
}
|
||||||
|
|
||||||
|
builder.AddToastInput(selectionBox);
|
||||||
|
var snoozeButton = new ToastButton()
|
||||||
|
.SetContent(Translator.CalendarReminder_SnoozeAction)
|
||||||
|
.SetImageUri(GetNotificationIconUri("calendar-snooze"))
|
||||||
|
.SetBackgroundActivation();
|
||||||
|
|
||||||
|
builder.AddButton(snoozeButton)
|
||||||
|
.AddArgument(Constants.ToastCalendarActionKey, Constants.ToastCalendarSnoozeAction)
|
||||||
|
.AddArgument(Constants.ToastCalendarItemIdKey, calendarItem.Id.ToString())
|
||||||
|
.AddArgument(Constants.ToastModeKey, Constants.ToastModeCalendar);
|
||||||
|
}
|
||||||
|
|
||||||
|
builder.AddButton(new ToastButton()
|
||||||
|
.SetDismissActivation()
|
||||||
|
.SetImageUri(GetNotificationIconUri("dismiss")));
|
||||||
|
|
||||||
|
if (Uri.TryCreate(calendarItem.HtmlLink, UriKind.Absolute, out var joinUri))
|
||||||
|
{
|
||||||
|
builder.AddButton(new ToastButton()
|
||||||
|
.SetContent(Translator.CalendarEventDetails_JoinOnline)
|
||||||
|
.SetImageUri(GetNotificationIconUri("calendar-join"))
|
||||||
|
.SetProtocolActivation(joinUri));
|
||||||
|
}
|
||||||
|
|
||||||
builder.AddAudio(new ToastAudio()
|
builder.AddAudio(new ToastAudio()
|
||||||
{
|
{
|
||||||
Src = new Uri("ms-winsoundevent:Notification.Reminder")
|
Src = new Uri("ms-winsoundevent:Notification.Reminder")
|
||||||
@@ -317,6 +373,36 @@ public class NotificationBuilder : INotificationBuilder
|
|||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static string GetCalendarReminderContext(DateTime localStart, DateTime nowLocal)
|
||||||
|
{
|
||||||
|
var delta = localStart - nowLocal;
|
||||||
|
var absDelta = delta.Duration();
|
||||||
|
|
||||||
|
if (absDelta < TimeSpan.FromMinutes(1))
|
||||||
|
return delta.TotalSeconds >= 0 ? Translator.CalendarReminder_StartingNow : Translator.CalendarReminder_StartedNow;
|
||||||
|
|
||||||
|
if (delta.TotalSeconds > 0)
|
||||||
|
{
|
||||||
|
if (delta.TotalHours >= 1)
|
||||||
|
{
|
||||||
|
var hours = Math.Max(1, (int)Math.Floor(delta.TotalHours));
|
||||||
|
return string.Format(Translator.CalendarReminder_StartsInHours, hours);
|
||||||
|
}
|
||||||
|
|
||||||
|
var minutes = Math.Max(1, (int)Math.Floor(delta.TotalMinutes));
|
||||||
|
return string.Format(Translator.CalendarReminder_StartsInMinutes, minutes);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (absDelta.TotalHours >= 1)
|
||||||
|
{
|
||||||
|
var hoursAgo = Math.Max(1, (int)Math.Floor(absDelta.TotalHours));
|
||||||
|
return string.Format(Translator.CalendarReminder_StartedHoursAgo, hoursAgo);
|
||||||
|
}
|
||||||
|
|
||||||
|
var minutesAgo = Math.Max(1, (int)Math.Floor(absDelta.TotalMinutes));
|
||||||
|
return string.Format(Translator.CalendarReminder_StartedMinutesAgo, minutesAgo);
|
||||||
|
}
|
||||||
|
|
||||||
private static void ShowToast(ToastContentBuilder builder, string? tag = null)
|
private static void ShowToast(ToastContentBuilder builder, string? tag = null)
|
||||||
{
|
{
|
||||||
var toastNotification = new ToastNotification(builder.GetToastContent().GetXml());
|
var toastNotification = new ToastNotification(builder.GetToastContent().GetXml());
|
||||||
@@ -329,4 +415,25 @@ public class NotificationBuilder : INotificationBuilder
|
|||||||
var notifier = ToastNotificationManager.CreateToastNotifier();
|
var notifier = ToastNotificationManager.CreateToastNotifier();
|
||||||
notifier.Show(toastNotification);
|
notifier.Show(toastNotification);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private Uri GetNotificationIconUri(string iconName)
|
||||||
|
{
|
||||||
|
var theme = _underlyingThemeService.IsUnderlyingThemeDark() ? "dark" : "light";
|
||||||
|
var scale = GetClosestAvailableScale();
|
||||||
|
return new($"{NotificationIconRootUri}{iconName}.theme-{theme}.scale-{scale}.png");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int GetClosestAvailableScale()
|
||||||
|
{
|
||||||
|
var rasterScale = 1.0;
|
||||||
|
|
||||||
|
if (WinoApplication.MainWindow?.Content is FrameworkElement rootElement &&
|
||||||
|
rootElement.XamlRoot != null)
|
||||||
|
{
|
||||||
|
rasterScale = rootElement.XamlRoot.RasterizationScale;
|
||||||
|
}
|
||||||
|
|
||||||
|
var requestedScale = (int)Math.Round(rasterScale * 100);
|
||||||
|
return SupportedIconScales.OrderBy(s => Math.Abs(s - requestedScale)).First();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -284,6 +284,12 @@ public class PreferencesService(IConfigurationService configurationService) : Ob
|
|||||||
set => SaveProperty(propertyName: nameof(DefaultReminderDurationInSeconds), value);
|
set => SaveProperty(propertyName: nameof(DefaultReminderDurationInSeconds), value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public int DefaultSnoozeDurationInMinutes
|
||||||
|
{
|
||||||
|
get => _configurationService.Get(nameof(DefaultSnoozeDurationInMinutes), 5);
|
||||||
|
set => SaveProperty(propertyName: nameof(DefaultSnoozeDurationInMinutes), value);
|
||||||
|
}
|
||||||
|
|
||||||
public int EmailSyncIntervalMinutes
|
public int EmailSyncIntervalMinutes
|
||||||
{
|
{
|
||||||
get => _configurationService.Get(nameof(EmailSyncIntervalMinutes), 3);
|
get => _configurationService.Get(nameof(EmailSyncIntervalMinutes), 3);
|
||||||
|
|||||||
@@ -235,6 +235,17 @@
|
|||||||
<ComboBox ItemsSource="{x:Bind ViewModel.ReminderOptions, Mode=OneWay}" SelectedIndex="{x:Bind ViewModel.SelectedDefaultReminderIndex, Mode=TwoWay}" />
|
<ComboBox ItemsSource="{x:Bind ViewModel.ReminderOptions, Mode=OneWay}" SelectedIndex="{x:Bind ViewModel.SelectedDefaultReminderIndex, Mode=TwoWay}" />
|
||||||
</controls:SettingsCard.Content>
|
</controls:SettingsCard.Content>
|
||||||
</controls:SettingsCard>
|
</controls:SettingsCard>
|
||||||
|
|
||||||
|
<controls:SettingsCard
|
||||||
|
Description="{x:Bind domain:Translator.CalendarSettings_DefaultSnoozeDuration_Description}"
|
||||||
|
Header="{x:Bind domain:Translator.CalendarSettings_DefaultSnoozeDuration_Header}">
|
||||||
|
<controls:SettingsCard.HeaderIcon>
|
||||||
|
<PathIcon Data="F1 M 10 1.25 C 10.456706 1.25 10.889486 1.337565 11.298339 1.512695 C 11.707192 1.687826 12.072591 1.927409 12.394531 2.231445 C 12.716471 2.535482 12.97656 2.892253 13.173828 3.301758 C 13.371096 3.711263 13.481771 4.146484 13.505859 4.607422 L 13.505859 5 C 13.969401 5.028646 14.386068 5.16276 14.755859 5.402344 C 15.125651 5.641927 15.414713 5.947917 15.623047 6.320312 C 15.83138 6.692709 15.9349 7.096355 15.933594 7.53125 L 15.933594 8.75 L 16.25 8.75 C 16.822917 8.75 17.317057 8.95638 17.732422 9.369141 C 18.147787 9.781901 18.357422 10.273437 18.359375 10.84375 L 18.359375 16.40625 C 18.359375 16.979167 18.153971 17.473308 17.743164 17.888672 C 17.332357 18.304037 16.839844 18.511719 16.265625 18.511719 L 3.734375 18.511719 C 3.164062 18.511719 2.671549 18.304037 2.256836 17.888672 C 1.842122 17.473308 1.634114 16.979167 1.630859 16.40625 L 1.630859 10.84375 C 1.630859 10.273437 1.837565 9.781901 2.250977 9.369141 C 2.664388 8.95638 3.156901 8.75 3.728516 8.75 L 4.0625 8.75 L 4.0625 7.53125 C 4.0625 7.09375 4.166341 6.689453 4.374023 6.314453 C 4.581706 5.939453 4.86914 5.632486 5.236328 5.393555 C 5.603516 5.154623 6.019532 5.021485 6.484375 4.994141 L 6.484375 4.607422 C 6.506511 4.148438 6.617838 3.714518 6.818359 3.305664 C 7.01888 2.896811 7.282877 2.539063 7.610352 2.232422 C 7.937826 1.92578 8.308268 1.685222 8.721679 1.510742 C 9.135091 1.336264 9.56575 1.249024 10.013672 1.249024 Z M 10.013672 2.5 C 9.441406 2.5 8.947916 2.706381 8.533203 3.119141 C 8.118489 3.531901 7.911459 4.023437 7.912109 4.59375 L 7.912109 6.25 L 12.089844 6.25 L 12.089844 4.59375 C 12.089844 4.023438 11.882161 3.531902 11.466797 3.119141 C 11.051433 2.706382 10.557292 2.5 9.984375 2.5 Z M 5.3125 7.53125 L 5.3125 8.75 L 14.6875 8.75 L 14.6875 7.53125 C 14.6875 7.303385 14.605144 7.107747 14.440429 6.944336 C 14.275714 6.780925 14.080729 6.69987 13.855469 6.703125 L 6.142578 6.703125 C 5.914713 6.703125 5.71875 6.785482 5.552734 6.950195 C 5.386719 7.114909 5.30339 7.310547 5.300781 7.537109 Z M 16.25 10 L 3.75 10 C 3.540365 10 3.361328 10.071615 3.212891 10.214844 C 3.064453 10.358073 2.989258 10.534505 2.986328 10.744141 L 2.986328 16.40625 C 2.986328 16.618489 3.058595 16.797526 3.203125 16.943359 C 3.347656 17.089193 3.526693 17.161458 3.740234 17.15625 L 16.25 17.15625 C 16.458333 17.15625 16.636067 17.082683 16.783203 16.935547 C 16.930339 16.788411 17.003906 16.609375 17.003906 16.398437 L 17.003906 10.742187 C 17.003906 10.536459 16.932942 10.359049 16.791016 10.209961 C 16.64909 10.060873 16.469401 9.986328 16.257812 9.986328 Z M 10 11.5625 C 10.227865 11.5625 10.423502 11.644856 10.586914 11.809571 C 10.750325 11.974285 10.83138 12.16862 10.830078 12.393555 L 10.830078 13.427734 L 11.855469 13.427734 C 12.080404 13.427734 12.273437 13.509115 12.434571 13.671875 C 12.595704 13.834636 12.675781 14.026693 12.675781 14.248047 C 12.675781 14.46224 12.596354 14.653321 12.4375 14.813477 C 12.278646 14.973633 12.089192 15.053711 11.869141 15.053711 L 10.830078 15.053711 L 10.830078 16.074219 C 10.830078 16.296224 10.748372 16.486981 10.584961 16.646484 C 10.42155 16.805989 10.229818 16.885742 10.005859 16.885742 C 9.781901 16.885742 9.590495 16.806315 9.431641 16.647461 C 9.272786 16.488607 9.193359 16.296875 9.193359 16.072266 L 9.193359 15.053711 L 8.15625 15.053711 C 7.931314 15.053711 7.738934 14.972331 7.579102 14.80957 C 7.419269 14.646809 7.339192 14.454753 7.338867 14.233399 C 7.338867 14.008464 7.419921 13.815755 7.582031 13.655274 C 7.744141 13.494793 7.935872 13.414551 8.157227 13.414551 L 9.193359 13.414551 L 9.193359 12.394531 C 9.193359 12.166667 9.275715 11.971029 9.440429 11.807618 C 9.605144 11.644206 9.799479 11.562825 10.023437 11.5625 Z" />
|
||||||
|
</controls:SettingsCard.HeaderIcon>
|
||||||
|
<controls:SettingsCard.Content>
|
||||||
|
<ComboBox ItemsSource="{x:Bind ViewModel.SnoozeOptions, Mode=OneWay}" SelectedIndex="{x:Bind ViewModel.SelectedDefaultSnoozeIndex, Mode=TwoWay}" />
|
||||||
|
</controls:SettingsCard.Content>
|
||||||
|
</controls:SettingsCard>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
<VisualStateManager.VisualStateGroups>
|
<VisualStateManager.VisualStateGroups>
|
||||||
<VisualStateGroup x:Name="ClockIdentifierStates">
|
<VisualStateGroup x:Name="ClockIdentifierStates">
|
||||||
|
|||||||
@@ -207,6 +207,23 @@
|
|||||||
</Button.Flyout>
|
</Button.Flyout>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
<Border
|
||||||
|
Width="1"
|
||||||
|
Height="24"
|
||||||
|
Margin="4,0"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Background="{ThemeResource DividerStrokeColorDefaultBrush}" />
|
||||||
|
|
||||||
|
<!-- Test Notification -->
|
||||||
|
<Button
|
||||||
|
Command="{x:Bind ViewModel.CreateTestNotificationCommand}"
|
||||||
|
Style="{StaticResource TransparentActionButtonStyle}">
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||||
|
<coreControls:WinoFontIcon FontSize="16" Icon="Reminder" />
|
||||||
|
<TextBlock VerticalAlignment="Center" Text="Test notification" />
|
||||||
|
</StackPanel>
|
||||||
|
</Button>
|
||||||
|
|
||||||
<!-- Edit Series -->
|
<!-- Edit Series -->
|
||||||
<Border
|
<Border
|
||||||
Width="1"
|
Width="1"
|
||||||
|
|||||||
@@ -98,6 +98,66 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<None Remove="Assets\CalendarWide310x150Logo.png" />
|
<None Remove="Assets\CalendarWide310x150Logo.png" />
|
||||||
|
<None Remove="Assets\NotificationIcons\calendar-join.theme-dark.scale-100.png" />
|
||||||
|
<None Remove="Assets\NotificationIcons\calendar-join.theme-dark.scale-125.png" />
|
||||||
|
<None Remove="Assets\NotificationIcons\calendar-join.theme-dark.scale-150.png" />
|
||||||
|
<None Remove="Assets\NotificationIcons\calendar-join.theme-dark.scale-200.png" />
|
||||||
|
<None Remove="Assets\NotificationIcons\calendar-join.theme-dark.scale-400.png" />
|
||||||
|
<None Remove="Assets\NotificationIcons\calendar-join.theme-light.scale-100.png" />
|
||||||
|
<None Remove="Assets\NotificationIcons\calendar-join.theme-light.scale-125.png" />
|
||||||
|
<None Remove="Assets\NotificationIcons\calendar-join.theme-light.scale-150.png" />
|
||||||
|
<None Remove="Assets\NotificationIcons\calendar-join.theme-light.scale-200.png" />
|
||||||
|
<None Remove="Assets\NotificationIcons\calendar-join.theme-light.scale-400.png" />
|
||||||
|
<None Remove="Assets\NotificationIcons\calendar-snooze.theme-dark.scale-100.png" />
|
||||||
|
<None Remove="Assets\NotificationIcons\calendar-snooze.theme-dark.scale-125.png" />
|
||||||
|
<None Remove="Assets\NotificationIcons\calendar-snooze.theme-dark.scale-150.png" />
|
||||||
|
<None Remove="Assets\NotificationIcons\calendar-snooze.theme-dark.scale-200.png" />
|
||||||
|
<None Remove="Assets\NotificationIcons\calendar-snooze.theme-dark.scale-400.png" />
|
||||||
|
<None Remove="Assets\NotificationIcons\calendar-snooze.theme-light.scale-100.png" />
|
||||||
|
<None Remove="Assets\NotificationIcons\calendar-snooze.theme-light.scale-125.png" />
|
||||||
|
<None Remove="Assets\NotificationIcons\calendar-snooze.theme-light.scale-150.png" />
|
||||||
|
<None Remove="Assets\NotificationIcons\calendar-snooze.theme-light.scale-200.png" />
|
||||||
|
<None Remove="Assets\NotificationIcons\calendar-snooze.theme-light.scale-400.png" />
|
||||||
|
<None Remove="Assets\NotificationIcons\dismiss.theme-dark.scale-100.png" />
|
||||||
|
<None Remove="Assets\NotificationIcons\dismiss.theme-dark.scale-125.png" />
|
||||||
|
<None Remove="Assets\NotificationIcons\dismiss.theme-dark.scale-150.png" />
|
||||||
|
<None Remove="Assets\NotificationIcons\dismiss.theme-dark.scale-200.png" />
|
||||||
|
<None Remove="Assets\NotificationIcons\dismiss.theme-dark.scale-400.png" />
|
||||||
|
<None Remove="Assets\NotificationIcons\dismiss.theme-light.scale-100.png" />
|
||||||
|
<None Remove="Assets\NotificationIcons\dismiss.theme-light.scale-125.png" />
|
||||||
|
<None Remove="Assets\NotificationIcons\dismiss.theme-light.scale-150.png" />
|
||||||
|
<None Remove="Assets\NotificationIcons\dismiss.theme-light.scale-200.png" />
|
||||||
|
<None Remove="Assets\NotificationIcons\dismiss.theme-light.scale-400.png" />
|
||||||
|
<None Remove="Assets\NotificationIcons\mail-archive.theme-dark.scale-100.png" />
|
||||||
|
<None Remove="Assets\NotificationIcons\mail-archive.theme-dark.scale-125.png" />
|
||||||
|
<None Remove="Assets\NotificationIcons\mail-archive.theme-dark.scale-150.png" />
|
||||||
|
<None Remove="Assets\NotificationIcons\mail-archive.theme-dark.scale-200.png" />
|
||||||
|
<None Remove="Assets\NotificationIcons\mail-archive.theme-dark.scale-400.png" />
|
||||||
|
<None Remove="Assets\NotificationIcons\mail-archive.theme-light.scale-100.png" />
|
||||||
|
<None Remove="Assets\NotificationIcons\mail-archive.theme-light.scale-125.png" />
|
||||||
|
<None Remove="Assets\NotificationIcons\mail-archive.theme-light.scale-150.png" />
|
||||||
|
<None Remove="Assets\NotificationIcons\mail-archive.theme-light.scale-200.png" />
|
||||||
|
<None Remove="Assets\NotificationIcons\mail-archive.theme-light.scale-400.png" />
|
||||||
|
<None Remove="Assets\NotificationIcons\mail-delete.theme-dark.scale-100.png" />
|
||||||
|
<None Remove="Assets\NotificationIcons\mail-delete.theme-dark.scale-125.png" />
|
||||||
|
<None Remove="Assets\NotificationIcons\mail-delete.theme-dark.scale-150.png" />
|
||||||
|
<None Remove="Assets\NotificationIcons\mail-delete.theme-dark.scale-200.png" />
|
||||||
|
<None Remove="Assets\NotificationIcons\mail-delete.theme-dark.scale-400.png" />
|
||||||
|
<None Remove="Assets\NotificationIcons\mail-delete.theme-light.scale-100.png" />
|
||||||
|
<None Remove="Assets\NotificationIcons\mail-delete.theme-light.scale-125.png" />
|
||||||
|
<None Remove="Assets\NotificationIcons\mail-delete.theme-light.scale-150.png" />
|
||||||
|
<None Remove="Assets\NotificationIcons\mail-delete.theme-light.scale-200.png" />
|
||||||
|
<None Remove="Assets\NotificationIcons\mail-delete.theme-light.scale-400.png" />
|
||||||
|
<None Remove="Assets\NotificationIcons\mail-markread.theme-dark.scale-100.png" />
|
||||||
|
<None Remove="Assets\NotificationIcons\mail-markread.theme-dark.scale-125.png" />
|
||||||
|
<None Remove="Assets\NotificationIcons\mail-markread.theme-dark.scale-150.png" />
|
||||||
|
<None Remove="Assets\NotificationIcons\mail-markread.theme-dark.scale-200.png" />
|
||||||
|
<None Remove="Assets\NotificationIcons\mail-markread.theme-dark.scale-400.png" />
|
||||||
|
<None Remove="Assets\NotificationIcons\mail-markread.theme-light.scale-100.png" />
|
||||||
|
<None Remove="Assets\NotificationIcons\mail-markread.theme-light.scale-125.png" />
|
||||||
|
<None Remove="Assets\NotificationIcons\mail-markread.theme-light.scale-150.png" />
|
||||||
|
<None Remove="Assets\NotificationIcons\mail-markread.theme-light.scale-200.png" />
|
||||||
|
<None Remove="Assets\NotificationIcons\mail-markread.theme-light.scale-400.png" />
|
||||||
<None Remove="BackgroundImages\Acrylic.jpg" />
|
<None Remove="BackgroundImages\Acrylic.jpg" />
|
||||||
<None Remove="BackgroundImages\Clouds.jpg" />
|
<None Remove="BackgroundImages\Clouds.jpg" />
|
||||||
<None Remove="BackgroundImages\Forest.jpg" />
|
<None Remove="BackgroundImages\Forest.jpg" />
|
||||||
|
|||||||
@@ -336,6 +336,12 @@ public class CalendarService : BaseDatabaseService, ICalendarService
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Task SnoozeCalendarItemAsync(Guid calendarItemId, DateTime snoozedUntilLocal)
|
||||||
|
=> Connection.ExecuteAsync(
|
||||||
|
$"UPDATE {nameof(CalendarItem)} SET {nameof(CalendarItem.SnoozedUntil)} = ? WHERE {nameof(CalendarItem.Id)} = ?",
|
||||||
|
snoozedUntilLocal,
|
||||||
|
calendarItemId);
|
||||||
|
|
||||||
public async Task<List<CalendarReminderNotificationRequest>> CheckAndNotifyAsync(DateTime lastCheckLocal, DateTime nowLocal, ISet<string> sentReminderKeys, CancellationToken cancellationToken = default)
|
public async Task<List<CalendarReminderNotificationRequest>> CheckAndNotifyAsync(DateTime lastCheckLocal, DateTime nowLocal, ISet<string> sentReminderKeys, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
if (sentReminderKeys == null)
|
if (sentReminderKeys == null)
|
||||||
@@ -347,7 +353,8 @@ public class CalendarService : BaseDatabaseService, ICalendarService
|
|||||||
c.Id AS CalendarItemId,
|
c.Id AS CalendarItemId,
|
||||||
c.StartDate,
|
c.StartDate,
|
||||||
c.StartTimeZone,
|
c.StartTimeZone,
|
||||||
r.DurationInSeconds AS ReminderDurationInSeconds
|
r.DurationInSeconds AS ReminderDurationInSeconds,
|
||||||
|
c.SnoozedUntil
|
||||||
FROM CalendarItem c
|
FROM CalendarItem c
|
||||||
INNER JOIN Reminder r ON r.CalendarItemId = c.Id
|
INNER JOIN Reminder r ON r.CalendarItemId = c.Id
|
||||||
INNER JOIN AccountCalendar ac ON ac.Id = c.CalendarId
|
INNER JOIN AccountCalendar ac ON ac.Id = c.CalendarId
|
||||||
@@ -367,11 +374,14 @@ public class CalendarService : BaseDatabaseService, ICalendarService
|
|||||||
|
|
||||||
var eventStartLocal = candidate.StartDate.ToLocalTimeFromTimeZone(candidate.StartTimeZone);
|
var eventStartLocal = candidate.StartDate.ToLocalTimeFromTimeZone(candidate.StartTimeZone);
|
||||||
var triggerTimeLocal = eventStartLocal.AddSeconds(-candidate.ReminderDurationInSeconds);
|
var triggerTimeLocal = eventStartLocal.AddSeconds(-candidate.ReminderDurationInSeconds);
|
||||||
|
var effectiveTriggerTimeLocal = candidate.SnoozedUntil.HasValue
|
||||||
|
? MaxDateTime(triggerTimeLocal, candidate.SnoozedUntil.Value)
|
||||||
|
: triggerTimeLocal;
|
||||||
|
|
||||||
if (triggerTimeLocal <= lastCheckLocal || triggerTimeLocal > nowLocal)
|
if (effectiveTriggerTimeLocal <= lastCheckLocal || effectiveTriggerTimeLocal > nowLocal)
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
var reminderKey = $"{candidate.CalendarItemId:N}:{candidate.ReminderDurationInSeconds}";
|
var reminderKey = $"{candidate.CalendarItemId:N}:{candidate.ReminderDurationInSeconds}:{effectiveTriggerTimeLocal.Ticks}";
|
||||||
if (!sentReminderKeys.Add(reminderKey))
|
if (!sentReminderKeys.Add(reminderKey))
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
@@ -438,11 +448,15 @@ public class CalendarService : BaseDatabaseService, ICalendarService
|
|||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
|
private static DateTime MaxDateTime(DateTime first, DateTime second)
|
||||||
|
=> first >= second ? first : second;
|
||||||
|
|
||||||
private sealed class CalendarReminderCandidate
|
private sealed class CalendarReminderCandidate
|
||||||
{
|
{
|
||||||
public Guid CalendarItemId { get; set; }
|
public Guid CalendarItemId { get; set; }
|
||||||
public DateTime StartDate { get; set; }
|
public DateTime StartDate { get; set; }
|
||||||
public string StartTimeZone { get; set; }
|
public string StartTimeZone { get; set; }
|
||||||
public long ReminderDurationInSeconds { get; set; }
|
public long ReminderDurationInSeconds { get; set; }
|
||||||
|
public DateTime? SnoozedUntil { get; set; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -120,6 +120,15 @@ public class DatabaseService : IDatabaseService
|
|||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var calendarItemColumns = await Connection.GetTableInfoAsync(nameof(CalendarItem)).ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (!calendarItemColumns.Any(c => c.Name == nameof(CalendarItem.SnoozedUntil)))
|
||||||
|
{
|
||||||
|
await Connection
|
||||||
|
.ExecuteAsync($"ALTER TABLE {nameof(CalendarItem)} ADD COLUMN {nameof(CalendarItem.SnoozedUntil)} TEXT NULL")
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
var contactColumns = await Connection.GetTableInfoAsync(nameof(AccountContact)).ConfigureAwait(false);
|
var contactColumns = await Connection.GetTableInfoAsync(nameof(AccountContact)).ConfigureAwait(false);
|
||||||
|
|
||||||
if (!contactColumns.Any(c => c.Name == nameof(AccountContact.ContactPictureFileId)))
|
if (!contactColumns.Any(c => c.Name == nameof(AccountContact.ContactPictureFileId)))
|
||||||
|
|||||||