UI visuals for mail calendar items, calendar reminders.

This commit is contained in:
Burak Kaan Köse
2026-02-11 01:49:29 +01:00
parent 870a5e2bf6
commit 52ee5f1d8a
21 changed files with 639 additions and 77 deletions
+36
View File
@@ -15,6 +15,7 @@ using Wino.Core;
using Wino.Core.Domain;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Calendar;
using Wino.Core.Domain.Models.MailItem;
using Wino.Core.Domain.Models.Synchronization;
using Wino.Mail.Services;
@@ -179,6 +180,16 @@ public partial class App : WinoApplication,
{
var toastArguments = ToastArguments.Parse(toastArgs.Argument);
// Check calendar reminder toast activation first.
if (toastArguments.TryGetValue(Constants.ToastCalendarActionKey, out string calendarAction) &&
calendarAction == Constants.ToastCalendarNavigateAction &&
toastArguments.TryGetValue(Constants.ToastCalendarItemIdKey, out string calendarItemIdString) &&
Guid.TryParse(calendarItemIdString, out Guid calendarItemId))
{
await HandleCalendarToastNavigationAsync(calendarItemId);
return;
}
// Check if this is a navigation toast (user clicked the notification).
if (toastArguments.TryGetValue(Constants.ToastActionKey, out MailOperation action) &&
Guid.TryParse(toastArguments[Constants.ToastMailUniqueIdKey], out Guid mailItemUniqueId))
@@ -198,6 +209,31 @@ public partial class App : WinoApplication,
}
}
private async Task HandleCalendarToastNavigationAsync(Guid calendarItemId)
{
var calendarService = Services.GetRequiredService<ICalendarService>();
var navigationService = Services.GetRequiredService<INavigationService>();
var calendarItem = await calendarService.GetCalendarItemAsync(calendarItemId).ConfigureAwait(false);
if (calendarItem == null)
return;
var target = new CalendarItemTarget(calendarItem, CalendarEventTargetType.Single);
if (!IsAppRunning())
{
await CreateAndActivateWindow(null!);
}
else
{
MainWindow.BringToFront();
MainWindow.Activate();
}
navigationService.ChangeApplicationMode(Core.Domain.Enums.WinoApplicationMode.Calendar);
navigationService.Navigate(WinoPage.EventDetailsPage, target);
}
/// <summary>
/// Handles toast notification click for navigation.
/// Creates window if not running, sets up navigation parameter.
@@ -7,7 +7,6 @@
xmlns:helpers="using:Wino.Helpers"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch">
<Grid x:DefaultBindMode="OneWay">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
@@ -23,14 +22,30 @@
Prefer24HourTimeFormat="{x:Bind Prefer24HourTimeFormat, Mode=OneWay}"
ShowPreviewText="False" />
<TextBlock
x:Name="EventDateText"
<Grid
x:Name="EventDateContainer"
Grid.Row="1"
Margin="53,0,12,8"
FontSize="12"
Opacity="0.75"
Text="{x:Bind EventDateRangeText, Mode=OneWay}"
TextTrimming="CharacterEllipsis" />
Margin="46,0,12,8">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<ContentPresenter
x:Name="CalendarInvitationContent"
Grid.Column="0"
Margin="0,0,4,0"
VerticalAlignment="Center"
x:Load="{x:Bind MailItem.IsCalendarEvent, Mode=OneWay}"
ContentTemplate="{StaticResource CalendarInvitationSymbolControlTemplate}" />
<TextBlock
x:Name="EventDateText"
Grid.Column="1"
Margin="4,0,2,0"
FontSize="12"
Opacity="0.75"
Text="{x:Bind EventDateRangeText, Mode=OneWay}"
TextTrimming="CharacterEllipsis" />
</Grid>
</Grid>
<VisualStateManager.VisualStateGroups>
+2
View File
@@ -3,6 +3,7 @@ using Microsoft.UI.Xaml;
using Wino.Core.Domain.Interfaces;
using Wino.Core.ViewModels;
using Wino.Core.WinUI.Services;
using Wino.Mail.WinUI.Interfaces;
using Wino.Mail.WinUI.Services;
using Wino.Services;
@@ -29,6 +30,7 @@ public static class CoreUWPContainerSetup
services.AddTransient<IStoreRatingService, StoreRatingService>();
services.AddTransient<IKeyPressService, KeyPressService>();
services.AddTransient<INotificationBuilder, NotificationBuilder>();
services.AddSingleton<ICalendarReminderServer, CalendarReminderServer>();
services.AddTransient<IClipboardService, ClipboardService>();
services.AddTransient<IStartupBehaviorService, StartupBehaviorService>();
services.AddSingleton<IPrintService, PrintService>();
@@ -0,0 +1,8 @@
using System.Threading.Tasks;
namespace Wino.Mail.WinUI.Interfaces;
public interface ICalendarReminderServer
{
Task StartAsync();
}
@@ -0,0 +1,107 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Serilog;
using Wino.Core.Domain.Interfaces;
using Wino.Mail.WinUI.Interfaces;
namespace Wino.Mail.WinUI.Services;
public class CalendarReminderServer : ICalendarReminderServer
{
private static readonly TimeSpan PollingInterval = TimeSpan.FromSeconds(30);
private readonly ICalendarService _calendarService;
private readonly IAccountService _accountService;
private readonly INotificationBuilder _notificationBuilder;
private readonly ILogger _logger = Log.ForContext<CalendarReminderServer>();
private readonly SemaphoreSlim _startLock = new(1, 1);
private readonly HashSet<string> _sentReminderKeys = [];
private Task? _loopTask;
private CancellationTokenSource? _loopCts;
private DateTime _lastCheckLocal = DateTime.MinValue;
public CalendarReminderServer(ICalendarService calendarService, IAccountService accountService, INotificationBuilder notificationBuilder)
{
_calendarService = calendarService;
_accountService = accountService;
_notificationBuilder = notificationBuilder;
}
public async Task StartAsync()
{
await _startLock.WaitAsync().ConfigureAwait(false);
try
{
if (_loopTask != null)
return;
var accounts = await _accountService.GetAccountsAsync().ConfigureAwait(false);
var hasCalendarAccess = accounts.Exists(a => a.IsCalendarAccessGranted);
if (!hasCalendarAccess)
{
_logger.Information("Calendar reminder server will not start because no account has calendar access.");
return;
}
_lastCheckLocal = DateTime.Now.AddSeconds(-30);
_loopCts = new CancellationTokenSource();
_loopTask = RunLoopAsync(_loopCts.Token);
_logger.Information("Calendar reminder server started.");
}
finally
{
_startLock.Release();
}
}
private async Task RunLoopAsync(CancellationToken cancellationToken)
{
using var timer = new PeriodicTimer(PollingInterval);
try
{
while (await timer.WaitForNextTickAsync(cancellationToken).ConfigureAwait(false))
{
await ExecuteTickAsync(cancellationToken).ConfigureAwait(false);
}
}
catch (OperationCanceledException)
{
// no-op
}
catch (Exception ex)
{
_logger.Error(ex, "Calendar reminder server loop terminated unexpectedly.");
}
}
private async Task ExecuteTickAsync(CancellationToken cancellationToken)
{
var nowLocal = DateTime.Now;
if (_lastCheckLocal == DateTime.MinValue)
_lastCheckLocal = nowLocal.AddSeconds(-PollingInterval.TotalSeconds);
var dueNotifications = await _calendarService
.CheckAndNotifyAsync(_lastCheckLocal, nowLocal, _sentReminderKeys, cancellationToken)
.ConfigureAwait(false);
foreach (var reminder in dueNotifications)
{
cancellationToken.ThrowIfCancellationRequested();
await _notificationBuilder
.CreateCalendarReminderNotificationAsync(reminder.CalendarItem, reminder.ReminderDurationInSeconds)
.ConfigureAwait(false);
}
_lastCheckLocal = nowLocal;
}
}
@@ -7,6 +7,7 @@ using Microsoft.Toolkit.Uwp.Notifications;
using Serilog;
using Windows.Data.Xml.Dom;
using Windows.UI.Notifications;
using Wino.Core.Domain.Entities.Calendar;
using Wino.Core.Domain;
using Wino.Core.Domain.Entities.Mail;
using Wino.Core.Domain.Entities.Shared;
@@ -250,4 +251,38 @@ public class NotificationBuilder : INotificationBuilder
builder.AddButton(new ToastButton().SetContent(Translator.Buttons_FixAccount));
builder.Show();
}
public Task CreateCalendarReminderNotificationAsync(CalendarItem calendarItem, long reminderDurationInSeconds)
{
if (calendarItem == null)
return Task.CompletedTask;
var builder = new ToastContentBuilder();
builder.SetToastScenario(ToastScenario.Reminder);
var localStart = calendarItem.LocalStartDate;
var reminderMinutes = (int)Math.Max(0, reminderDurationInSeconds / 60);
var reminderContext = reminderMinutes > 0
? $"Starts in {reminderMinutes} minute{(reminderMinutes == 1 ? string.Empty : "s")}"
: "Starting now";
builder.AddText(calendarItem.Title);
builder.AddText($"{reminderContext} - {localStart:g}");
if (!string.IsNullOrWhiteSpace(calendarItem.Location))
builder.AddText(calendarItem.Location);
builder.AddArgument(Constants.ToastCalendarActionKey, Constants.ToastCalendarNavigateAction);
builder.AddArgument(Constants.ToastCalendarItemIdKey, calendarItem.Id.ToString());
builder.AddButton(GetDismissButton());
builder.AddAudio(new ToastAudio()
{
Src = new Uri("ms-winsoundevent:Notification.Reminder")
});
var tag = $"calendar-reminder-{calendarItem.Id:N}-{reminderDurationInSeconds}";
builder.Show(toast => toast.Tag = tag);
return Task.CompletedTask;
}
}
+25
View File
@@ -1,6 +1,7 @@
using System;
using System.Collections.ObjectModel;
using System.Linq;
using System.Threading.Tasks;
using System.Windows.Input;
using CommunityToolkit.Mvvm.Input;
using CommunityToolkit.Mvvm.Messaging;
@@ -34,6 +35,7 @@ public sealed partial class ShellWindow : WindowEx, IWinoShellWindow,
public ICommand ExitWinoCommand { get; set; }
public ObservableCollection<SynchronizationActionItem> SyncActionItems { get; } = new();
private bool _calendarReminderServerStartAttempted;
public ShellWindow()
{
@@ -155,6 +157,12 @@ public sealed partial class ShellWindow : WindowEx, IWinoShellWindow,
private void MainFrameNavigated(object sender, Microsoft.UI.Xaml.Navigation.NavigationEventArgs e)
{
if (!_calendarReminderServerStartAttempted)
{
_calendarReminderServerStartAttempted = true;
_ = StartCalendarReminderServerAsync();
}
// Mail shell has shell content only for mail list page
// Thus, we check if the current content is MailAppShell
@@ -164,6 +172,23 @@ public sealed partial class ShellWindow : WindowEx, IWinoShellWindow,
ShellTitleBar.Content = basePage.ShellContent;
}
private async Task StartCalendarReminderServerAsync()
{
try
{
var reminderServer = WinoApplication.Current.Services.GetService<ICalendarReminderServer>();
if (reminderServer != null)
{
await reminderServer.StartAsync();
}
}
catch (Exception ex)
{
_calendarReminderServerStartAttempted = false;
Serilog.Log.Error(ex, "Failed to start calendar reminder server.");
}
}
private void PaneButtonClicked(Microsoft.UI.Xaml.Controls.TitleBar sender, object args)
{
PreferencesService.IsNavigationPaneOpened = !PreferencesService.IsNavigationPaneOpened;
@@ -29,4 +29,14 @@
Icon="Attachment" />
</Viewbox>
</DataTemplate>
<!-- Calendar invitation -->
<DataTemplate x:Key="CalendarInvitationSymbolControlTemplate">
<Viewbox
Width="14"
HorizontalAlignment="Center"
VerticalAlignment="Bottom">
<controls:WinoFontIcon Margin="2,0,0,0" Icon="Calendar" />
</Viewbox>
</DataTemplate>
</ResourceDictionary>