diff --git a/Directory.Packages.props b/Directory.Packages.props
index 8c993a36..d6aeace2 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -33,7 +33,7 @@
-
+
@@ -74,4 +74,4 @@
-
+
\ No newline at end of file
diff --git a/Wino.Calendar.ViewModels/CalendarAppShellViewModel.cs b/Wino.Calendar.ViewModels/CalendarAppShellViewModel.cs
index 42eaaaa1..40989bb8 100644
--- a/Wino.Calendar.ViewModels/CalendarAppShellViewModel.cs
+++ b/Wino.Calendar.ViewModels/CalendarAppShellViewModel.cs
@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Globalization;
+using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
@@ -9,6 +10,7 @@ using CommunityToolkit.Mvvm.Messaging;
using Serilog;
using Wino.Calendar.ViewModels.Data;
using Wino.Calendar.ViewModels.Interfaces;
+using Wino.Core.Domain;
using Wino.Core.Domain.Collections;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Extensions;
@@ -73,7 +75,7 @@ public partial class CalendarAppShellViewModel : CalendarBaseViewModel,
ICalendarService calendarService,
IAccountCalendarStateService accountCalendarStateService,
INavigationService navigationService,
- IDialogServiceBase dialogService,
+ IMailDialogService dialogService,
IUpdateManager updateManager)
{
_accountService = accountService;
@@ -290,7 +292,7 @@ public partial class CalendarAppShellViewModel : CalendarBaseViewModel,
private DateTime? _navigationDate;
private readonly IAccountService _accountService;
private readonly ICalendarService _calendarService;
- private readonly IDialogServiceBase _dialogService;
+ private readonly IMailDialogService _dialogService;
private readonly IUpdateManager _updateManager;
#region Commands
@@ -306,6 +308,41 @@ public partial class CalendarAppShellViewModel : CalendarBaseViewModel,
[RelayCommand]
public void ManageAccounts() => NavigationService.Navigate(WinoPage.AccountManagementPage);
+ [RelayCommand]
+ private async Task NewEventAsync()
+ {
+ var availableGroups = AccountCalendarStateService.GroupedAccountCalendars
+ .Where(group => group.AccountCalendars.Count > 0)
+ .Select(group => new CalendarPickerAccountGroup
+ {
+ Account = group.Account,
+ Calendars = group.AccountCalendars.Select(calendar => calendar.AccountCalendar).ToList()
+ })
+ .ToList();
+
+ if (availableGroups.Count == 0)
+ {
+ _dialogService.InfoBarMessage(
+ Translator.CalendarEventCompose_NoCalendarsTitle,
+ Translator.CalendarEventCompose_NoCalendarsMessage,
+ InfoBarMessageType.Warning);
+ return;
+ }
+
+ var pickedCalendar = await _dialogService.ShowSingleCalendarPickerDialogAsync(availableGroups);
+ if (pickedCalendar == null)
+ return;
+
+ var (startDate, endDate) = GetDefaultComposeDateRange();
+
+ NavigationService.Navigate(WinoPage.CalendarEventComposePage, new CalendarEventComposeNavigationArgs
+ {
+ SelectedCalendarId = pickedCalendar.Id,
+ StartDate = startDate,
+ EndDate = endDate
+ });
+ }
+
[RelayCommand]
@@ -436,4 +473,20 @@ public partial class CalendarAppShellViewModel : CalendarBaseViewModel,
public async void Receive(AccountRemovedMessage message)
=> await InitializeAccountCalendarsAsync();
+
+ private static (DateTime StartDate, DateTime EndDate) GetDefaultComposeDateRange()
+ {
+ var localNow = DateTime.Now;
+ var roundedMinutes = localNow.Minute switch
+ {
+ < 30 => 30,
+ 30 when localNow.Second == 0 && localNow.Millisecond == 0 => 30,
+ _ => 60
+ };
+
+ var startDate = new DateTime(localNow.Year, localNow.Month, localNow.Day, localNow.Hour, 0, 0);
+ startDate = roundedMinutes == 60 ? startDate.AddHours(1) : startDate.AddMinutes(roundedMinutes);
+
+ return (startDate, startDate.AddMinutes(30));
+ }
}
diff --git a/Wino.Calendar.ViewModels/CalendarEventComposePageViewModel.cs b/Wino.Calendar.ViewModels/CalendarEventComposePageViewModel.cs
new file mode 100644
index 00000000..e04fb31b
--- /dev/null
+++ b/Wino.Calendar.ViewModels/CalendarEventComposePageViewModel.cs
@@ -0,0 +1,746 @@
+using System;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.Collections.Specialized;
+using System.ComponentModel;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using System.Net.Mail;
+using System.Threading.Tasks;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using Wino.Calendar.ViewModels.Data;
+using Wino.Core.Domain;
+using Wino.Core.Domain.Entities.Calendar;
+using Wino.Core.Domain.Entities.Shared;
+using Wino.Core.Domain.Enums;
+using Wino.Core.Domain.Interfaces;
+using Wino.Core.Domain.Models.Calendar;
+using Wino.Core.Domain.Models.Navigation;
+using Wino.Core.ViewModels;
+
+namespace Wino.Calendar.ViewModels;
+
+public partial class CalendarEventComposePageViewModel : CalendarBaseViewModel
+{
+ private readonly IAccountService _accountService;
+ private readonly ICalendarService _calendarService;
+ private readonly INavigationService _navigationService;
+ private readonly IMailDialogService _dialogService;
+ private readonly IContactService _contactService;
+ private readonly IPreferencesService _preferencesService;
+ private readonly IUnderlyingThemeService _underlyingThemeService;
+
+ public Func> GetHtmlNotesAsync { get; set; }
+
+ public ObservableCollection AvailableCalendars { get; } = [];
+ public ObservableCollection Attendees { get; } = [];
+ public ObservableCollection Attachments { get; } = [];
+ public ObservableCollection ShowAsOptions { get; } = [];
+ public ObservableCollection ReminderOptions { get; } = [];
+ public ObservableCollection RecurrenceIntervalOptions { get; } = [];
+ public ObservableCollection RecurrenceFrequencyOptions { get; } = [];
+ public ObservableCollection WeekdayOptions { get; } = [];
+
+ [ObservableProperty]
+ public partial AccountCalendarViewModel SelectedCalendar { get; set; }
+
+ [ObservableProperty]
+ public partial string Title { get; set; } = string.Empty;
+
+ [ObservableProperty]
+ public partial string Location { get; set; } = string.Empty;
+
+ [ObservableProperty]
+ public partial bool IsAllDay { get; set; }
+
+ [ObservableProperty]
+ public partial DateTimeOffset StartDate { get; set; }
+
+ [ObservableProperty]
+ public partial TimeSpan StartTime { get; set; }
+
+ [ObservableProperty]
+ public partial TimeSpan EndTime { get; set; }
+
+ [ObservableProperty]
+ public partial DateTimeOffset AllDayEndDate { get; set; }
+
+ [ObservableProperty]
+ public partial bool IsRecurring { get; set; }
+
+ [ObservableProperty]
+ public partial int SelectedRecurrenceInterval { get; set; } = 1;
+
+ [ObservableProperty]
+ public partial CalendarComposeFrequencyOption SelectedRecurrenceFrequencyOption { get; set; }
+
+ [ObservableProperty]
+ public partial DateTimeOffset? RecurrenceEndDate { get; set; }
+
+ [ObservableProperty]
+ public partial string RecurrenceSummary { get; set; } = string.Empty;
+
+ [ObservableProperty]
+ public partial ReminderOption SelectedReminderOption { get; set; }
+
+ [ObservableProperty]
+ public partial ShowAsOption SelectedShowAsOption { get; set; }
+
+ [ObservableProperty]
+ public partial bool IsDarkWebviewRenderer { get; set; }
+
+ [ObservableProperty]
+ public partial CalendarEventComposeResult LastCreatedResult { get; set; }
+
+ public CalendarSettings CurrentSettings { get; }
+ public string TimePickerClockIdentifier => CurrentSettings.DayHeaderDisplayType == DayHeaderDisplayType.TwentyFourHour ? "24HourClock" : "12HourClock";
+ public bool HasAttachments => Attachments.Count > 0;
+
+ public CalendarEventComposePageViewModel(IAccountService accountService,
+ ICalendarService calendarService,
+ INavigationService navigationService,
+ IMailDialogService dialogService,
+ IContactService contactService,
+ IPreferencesService preferencesService,
+ IUnderlyingThemeService underlyingThemeService)
+ {
+ _accountService = accountService;
+ _calendarService = calendarService;
+ _navigationService = navigationService;
+ _dialogService = dialogService;
+ _contactService = contactService;
+ _preferencesService = preferencesService;
+ _underlyingThemeService = underlyingThemeService;
+
+ CurrentSettings = _preferencesService.GetCurrentCalendarSettings();
+ IsDarkWebviewRenderer = _underlyingThemeService.IsUnderlyingThemeDark();
+
+ Attachments.CollectionChanged += AttachmentsCollectionChanged;
+
+ ShowAsOptions.Add(new ShowAsOption(CalendarItemShowAs.Free));
+ ShowAsOptions.Add(new ShowAsOption(CalendarItemShowAs.Tentative));
+ ShowAsOptions.Add(new ShowAsOption(CalendarItemShowAs.Busy));
+ ShowAsOptions.Add(new ShowAsOption(CalendarItemShowAs.OutOfOffice));
+ ShowAsOptions.Add(new ShowAsOption(CalendarItemShowAs.WorkingElsewhere));
+
+ foreach (var reminderMinutes in _calendarService.GetPredefinedReminderMinutes().OrderByDescending(x => x))
+ {
+ ReminderOptions.Add(new ReminderOption(reminderMinutes));
+ }
+
+ foreach (var interval in Enumerable.Range(1, 99))
+ {
+ RecurrenceIntervalOptions.Add(interval);
+ }
+
+ RecurrenceFrequencyOptions.Add(new CalendarComposeFrequencyOption(CalendarItemRecurrenceFrequency.Daily, Translator.CalendarEventCompose_FrequencyDay));
+ RecurrenceFrequencyOptions.Add(new CalendarComposeFrequencyOption(CalendarItemRecurrenceFrequency.Weekly, Translator.CalendarEventCompose_FrequencyWeek));
+ RecurrenceFrequencyOptions.Add(new CalendarComposeFrequencyOption(CalendarItemRecurrenceFrequency.Monthly, Translator.CalendarEventCompose_FrequencyMonth));
+ RecurrenceFrequencyOptions.Add(new CalendarComposeFrequencyOption(CalendarItemRecurrenceFrequency.Yearly, Translator.CalendarEventCompose_FrequencyYear));
+ SelectedRecurrenceFrequencyOption = RecurrenceFrequencyOptions.FirstOrDefault();
+
+ WeekdayOptions.Add(CreateWeekdayOption(DayOfWeek.Monday, "MO", Translator.CalendarEventCompose_Weekday_Monday));
+ WeekdayOptions.Add(CreateWeekdayOption(DayOfWeek.Tuesday, "TU", Translator.CalendarEventCompose_Weekday_Tuesday));
+ WeekdayOptions.Add(CreateWeekdayOption(DayOfWeek.Wednesday, "WE", Translator.CalendarEventCompose_Weekday_Wednesday));
+ WeekdayOptions.Add(CreateWeekdayOption(DayOfWeek.Thursday, "TH", Translator.CalendarEventCompose_Weekday_Thursday));
+ WeekdayOptions.Add(CreateWeekdayOption(DayOfWeek.Friday, "FR", Translator.CalendarEventCompose_Weekday_Friday));
+ WeekdayOptions.Add(CreateWeekdayOption(DayOfWeek.Saturday, "SA", Translator.CalendarEventCompose_Weekday_Saturday));
+ WeekdayOptions.Add(CreateWeekdayOption(DayOfWeek.Sunday, "SU", Translator.CalendarEventCompose_Weekday_Sunday));
+
+ SelectedReminderOption = GetDefaultReminderOption();
+ SelectedShowAsOption = ShowAsOptions.FirstOrDefault(option => option.ShowAs == CalendarItemShowAs.Busy);
+
+ var (defaultStart, defaultEnd) = GetDefaultComposeDateRange();
+ ApplyDateRange(defaultStart, defaultEnd, false);
+ }
+
+ public override async void OnNavigatedTo(NavigationMode mode, object parameters)
+ {
+ base.OnNavigatedTo(mode, parameters);
+
+ await LoadAvailableCalendarsAsync();
+
+ var args = parameters as CalendarEventComposeNavigationArgs;
+ ApplyNavigationArgs(args);
+ UpdateRecurrenceSummary();
+ }
+
+ partial void OnSelectedCalendarChanged(AccountCalendarViewModel value)
+ {
+ if (value == null || SelectedShowAsOption != null)
+ return;
+
+ SelectedShowAsOption = ShowAsOptions.FirstOrDefault(option => option.ShowAs == value.DefaultShowAs)
+ ?? ShowAsOptions.FirstOrDefault();
+ }
+
+ partial void OnIsAllDayChanged(bool value)
+ {
+ if (value)
+ {
+ if (AllDayEndDate.Date <= StartDate.Date)
+ {
+ AllDayEndDate = StartDate.AddDays(1);
+ }
+ }
+
+ UpdateRecurrenceSummary();
+ }
+
+ partial void OnStartDateChanged(DateTimeOffset value)
+ {
+ if (IsAllDay && AllDayEndDate.Date <= value.Date)
+ {
+ AllDayEndDate = value.AddDays(1);
+ }
+
+ if (IsRecurring && WeekdayOptions.All(option => !option.IsSelected))
+ {
+ SelectSingleWeekday(value.DayOfWeek);
+ }
+
+ UpdateRecurrenceSummary();
+ }
+
+ partial void OnStartTimeChanged(TimeSpan value) => UpdateRecurrenceSummary();
+ partial void OnEndTimeChanged(TimeSpan value) => UpdateRecurrenceSummary();
+ partial void OnAllDayEndDateChanged(DateTimeOffset value) => UpdateRecurrenceSummary();
+ partial void OnIsRecurringChanged(bool value)
+ {
+ if (value && WeekdayOptions.All(option => !option.IsSelected))
+ {
+ SelectSingleWeekday(StartDate.DayOfWeek);
+ }
+
+ UpdateRecurrenceSummary();
+ }
+ partial void OnSelectedRecurrenceIntervalChanged(int value) => UpdateRecurrenceSummary();
+ partial void OnSelectedRecurrenceFrequencyOptionChanged(CalendarComposeFrequencyOption value) => UpdateRecurrenceSummary();
+ partial void OnRecurrenceEndDateChanged(DateTimeOffset? value) => UpdateRecurrenceSummary();
+
+ [RelayCommand]
+ private async Task AddAttachmentsAsync()
+ {
+ var pickedFiles = await _dialogService.PickFilesMetadataAsync("*");
+ if (pickedFiles.Count == 0)
+ return;
+
+ await ExecuteUIThread(() =>
+ {
+ foreach (var file in pickedFiles)
+ {
+ if (Attachments.Any(existing => existing.FilePath.Equals(file.FullFilePath, StringComparison.OrdinalIgnoreCase)))
+ continue;
+
+ Attachments.Add(new CalendarComposeAttachmentViewModel(file.FileName, file.FullFilePath, file.FileExtension, file.Size));
+ }
+ });
+ }
+
+ [RelayCommand]
+ private void RemoveAttachment(CalendarComposeAttachmentViewModel attachment)
+ {
+ if (attachment == null)
+ return;
+
+ Attachments.Remove(attachment);
+ }
+
+ [RelayCommand]
+ private void ClearRecurrenceEndDate()
+ {
+ RecurrenceEndDate = null;
+ }
+
+ [RelayCommand]
+ private void Cancel()
+ {
+ _navigationService.GoBack();
+ }
+
+ [RelayCommand]
+ private async Task CreateAsync()
+ {
+ if (!await ValidateAsync())
+ return;
+
+ var uniqueAttendees = Attendees
+ .GroupBy(attendee => attendee.Email, StringComparer.OrdinalIgnoreCase)
+ .Select(group => group.First())
+ .ToList();
+
+ var htmlNotes = GetHtmlNotesAsync == null ? string.Empty : await GetHtmlNotesAsync();
+ var effectiveStart = GetEffectiveStartDateTime();
+ var effectiveEnd = GetEffectiveEndDateTime();
+
+ LastCreatedResult = new CalendarEventComposeResult
+ {
+ CalendarId = SelectedCalendar!.Id,
+ AccountId = SelectedCalendar.Account.Id,
+ Title = Title.Trim(),
+ Location = Location?.Trim() ?? string.Empty,
+ HtmlNotes = htmlNotes,
+ StartDate = effectiveStart,
+ EndDate = effectiveEnd,
+ IsAllDay = IsAllDay,
+ TimeZoneId = TimeZoneInfo.Local.Id,
+ ShowAs = SelectedShowAsOption?.ShowAs ?? SelectedCalendar.DefaultShowAs,
+ SelectedReminders = BuildSelectedReminders(),
+ Attendees = BuildAttendees(uniqueAttendees),
+ Attachments = Attachments.Select(attachment => attachment.ToDraftModel()).ToList(),
+ Recurrence = BuildRecurrenceRule(),
+ RecurrenceSummary = RecurrenceSummary
+ };
+
+ _navigationService.GoBack();
+ }
+
+ public async Task> SearchContactsAsync(string queryText)
+ {
+ if (string.IsNullOrWhiteSpace(queryText) || queryText.Length < 2)
+ return [];
+
+ return await _contactService.GetAddressInformationAsync(queryText).ConfigureAwait(false);
+ }
+
+ public async Task GetAttendeeAsync(string tokenText)
+ {
+ if (!IsValidEmailAddress(tokenText))
+ return null;
+
+ var existing = Attendees.Any(attendee => attendee.Email.Equals(tokenText, StringComparison.OrdinalIgnoreCase));
+ if (existing)
+ return null;
+
+ var info = await _contactService.GetAddressInformationByAddressAsync(tokenText).ConfigureAwait(false);
+ if (info != null)
+ {
+ return CalendarComposeAttendeeViewModel.FromContact(info);
+ }
+
+ return new CalendarComposeAttendeeViewModel(string.Empty, tokenText);
+ }
+
+ public void AddAttendee(CalendarComposeAttendeeViewModel attendee)
+ {
+ if (Attendees.Any(existing => existing.Email.Equals(attendee.Email, StringComparison.OrdinalIgnoreCase)))
+ return;
+
+ Attendees.Add(attendee);
+ }
+
+ [RelayCommand]
+ private void RemoveAttendee(CalendarComposeAttendeeViewModel attendee)
+ {
+ if (attendee == null)
+ return;
+
+ Attendees.Remove(attendee);
+ }
+
+ public void NotifyAddressExists()
+ {
+ _dialogService.InfoBarMessage(
+ Translator.Info_ContactExistsTitle,
+ Translator.Info_ContactExistsMessage,
+ InfoBarMessageType.Warning);
+ }
+
+ public void NotifyInvalidEmail(string address)
+ {
+ _dialogService.InfoBarMessage(
+ Translator.Info_InvalidAddressTitle,
+ string.Format(Translator.Info_InvalidAddressMessage, address),
+ InfoBarMessageType.Warning);
+ }
+
+ private async Task LoadAvailableCalendarsAsync()
+ {
+ var accountCalendars = new List();
+ var accounts = await _accountService.GetAccountsAsync().ConfigureAwait(false);
+
+ foreach (var account in accounts)
+ {
+ var calendars = await _calendarService.GetAccountCalendarsAsync(account.Id).ConfigureAwait(false);
+
+ foreach (var calendar in calendars)
+ {
+ accountCalendars.Add(new AccountCalendarViewModel(account, calendar));
+ }
+ }
+
+ await ExecuteUIThread(() =>
+ {
+ AvailableCalendars.Clear();
+
+ foreach (var calendar in accountCalendars.OrderBy(calendar => calendar.Account.Name).ThenBy(calendar => calendar.Name))
+ {
+ AvailableCalendars.Add(calendar);
+ }
+ });
+ }
+
+ private void ApplyNavigationArgs(CalendarEventComposeNavigationArgs args)
+ {
+ var (defaultStart, defaultEnd) = GetDefaultComposeDateRange();
+ var startDate = args?.StartDate != default ? args!.StartDate : defaultStart;
+ var endDate = args?.EndDate != default ? args!.EndDate : defaultEnd;
+ var isAllDay = args?.IsAllDay ?? false;
+
+ Title = args?.Title ?? string.Empty;
+ Location = args?.Location ?? string.Empty;
+
+ ApplyDateRange(startDate, endDate, isAllDay);
+
+ SelectedCalendar = ResolveSelectedCalendar(args?.SelectedCalendarId);
+ if (SelectedCalendar != null)
+ {
+ SelectedShowAsOption = ShowAsOptions.FirstOrDefault(option => option.ShowAs == SelectedCalendar.DefaultShowAs)
+ ?? SelectedShowAsOption
+ ?? ShowAsOptions.FirstOrDefault();
+ }
+ }
+
+ private AccountCalendarViewModel ResolveSelectedCalendar(Guid? selectedCalendarId)
+ {
+ if (selectedCalendarId.HasValue)
+ {
+ var selectedCalendar = AvailableCalendars.FirstOrDefault(calendar => calendar.Id == selectedCalendarId.Value);
+ if (selectedCalendar != null)
+ return selectedCalendar;
+ }
+
+ return AvailableCalendars.FirstOrDefault(calendar => calendar.IsPrimary) ?? AvailableCalendars.FirstOrDefault();
+ }
+
+ private void ApplyDateRange(DateTime startDate, DateTime endDate, bool isAllDay)
+ {
+ IsAllDay = isAllDay;
+ StartDate = new DateTimeOffset(startDate.Date);
+ StartTime = startDate.TimeOfDay;
+ EndTime = endDate.TimeOfDay;
+ AllDayEndDate = new DateTimeOffset((isAllDay ? endDate.Date : startDate.Date.AddDays(1)));
+ }
+
+ private async Task ValidateAsync()
+ {
+ if (SelectedCalendar == null)
+ {
+ ShowValidationMessage(Translator.CalendarEventCompose_ValidationMissingCalendar);
+ return false;
+ }
+
+ if (string.IsNullOrWhiteSpace(Title))
+ {
+ ShowValidationMessage(Translator.CalendarEventCompose_ValidationMissingTitle);
+ return false;
+ }
+
+ if (IsAllDay)
+ {
+ if (AllDayEndDate.Date <= StartDate.Date)
+ {
+ ShowValidationMessage(Translator.CalendarEventCompose_ValidationInvalidAllDayRange);
+ return false;
+ }
+ }
+ else if (GetEffectiveEndDateTime() <= GetEffectiveStartDateTime())
+ {
+ ShowValidationMessage(Translator.CalendarEventCompose_ValidationInvalidTimeRange);
+ return false;
+ }
+
+ if (RecurrenceEndDate.HasValue && RecurrenceEndDate.Value.Date < StartDate.Date)
+ {
+ ShowValidationMessage(Translator.CalendarEventCompose_ValidationInvalidRecurrenceEnd);
+ return false;
+ }
+
+ var missingAttachments = Attachments
+ .Where(attachment => !File.Exists(attachment.FilePath))
+ .Select(attachment => attachment.FileName)
+ .ToList();
+
+ if (missingAttachments.Count > 0)
+ {
+ ShowValidationMessage(string.Format(Translator.CalendarEventCompose_ValidationMissingAttachment, string.Join(", ", missingAttachments)));
+ return false;
+ }
+
+ var normalizedAttendees = Attendees
+ .Where(attendee => !string.IsNullOrWhiteSpace(attendee.Email))
+ .Select(attendee => attendee.Email.Trim())
+ .ToList();
+
+ if (normalizedAttendees.Any(address => !IsValidEmailAddress(address)))
+ {
+ ShowValidationMessage(Translator.CalendarEventCompose_ValidationInvalidAttendee);
+ return false;
+ }
+
+ if (GetHtmlNotesAsync != null)
+ {
+ await GetHtmlNotesAsync();
+ }
+
+ return true;
+ }
+
+ private List BuildSelectedReminders()
+ {
+ if (SelectedReminderOption == null)
+ return [];
+
+ return
+ [
+ new Reminder
+ {
+ Id = Guid.NewGuid(),
+ CalendarItemId = Guid.Empty,
+ DurationInSeconds = SelectedReminderOption.Minutes * 60L,
+ ReminderType = CalendarItemReminderType.Popup
+ }
+ ];
+ }
+
+ private static List BuildAttendees(IEnumerable attendees)
+ {
+ return attendees
+ .Select(attendee => new CalendarEventAttendee
+ {
+ Id = Guid.NewGuid(),
+ CalendarItemId = Guid.Empty,
+ Name = attendee.HasDistinctDisplayName ? attendee.DisplayName : string.Empty,
+ Email = attendee.Email,
+ AttendenceStatus = AttendeeStatus.NeedsAction,
+ IsOrganizer = false,
+ ResolvedContact = attendee.ResolvedContact
+ })
+ .ToList();
+ }
+
+ private ReminderOption GetDefaultReminderOption()
+ {
+ var reminderMinutes = Math.Max(1, _preferencesService.DefaultReminderDurationInSeconds / 60);
+ return ReminderOptions.FirstOrDefault(option => option.Minutes == reminderMinutes)
+ ?? ReminderOptions.FirstOrDefault();
+ }
+
+ private void UpdateRecurrenceSummary()
+ {
+ var effectiveStart = GetEffectiveStartDateTime();
+ var effectiveEnd = GetEffectiveEndDateTime();
+ var timeSummary = IsAllDay
+ ? Translator.CalendarItemAllDay
+ : string.Format(
+ CultureInfo.CurrentCulture,
+ Translator.CalendarEventCompose_TimeRangeSummary,
+ effectiveStart.ToString(CurrentSettings.DayHeaderDisplayType == DayHeaderDisplayType.TwentyFourHour ? "HH:mm" : "h:mm tt", CultureInfo.CurrentCulture),
+ effectiveEnd.ToString(CurrentSettings.DayHeaderDisplayType == DayHeaderDisplayType.TwentyFourHour ? "HH:mm" : "h:mm tt", CultureInfo.CurrentCulture));
+
+ if (!IsRecurring)
+ {
+ RecurrenceSummary = string.Format(
+ CultureInfo.CurrentCulture,
+ Translator.CalendarEventCompose_SingleOccurrenceSummary,
+ effectiveStart.ToString("dddd yyyy-MM-dd", CultureInfo.CurrentCulture),
+ timeSummary);
+ return;
+ }
+
+ var frequencyLabel = SelectedRecurrenceFrequencyOption?.PluralLabel(SelectedRecurrenceInterval)
+ ?? Translator.CalendarEventCompose_FrequencyWeekPlural;
+
+ var selectedDays = WeekdayOptions
+ .Where(option => option.IsSelected)
+ .Select(option => option.FullDayName)
+ .ToList();
+
+ var weekdaySummary = selectedDays.Count == 0
+ ? string.Empty
+ : string.Format(
+ CultureInfo.CurrentCulture,
+ Translator.CalendarEventCompose_WeekdaySummary,
+ string.Join(", ", selectedDays));
+
+ var untilSummary = RecurrenceEndDate.HasValue
+ ? string.Format(
+ CultureInfo.CurrentCulture,
+ Translator.CalendarEventCompose_UntilSummary,
+ RecurrenceEndDate.Value.ToString("ddd yyyy-MM-dd", CultureInfo.CurrentCulture))
+ : string.Empty;
+
+ RecurrenceSummary = string.Format(
+ CultureInfo.CurrentCulture,
+ Translator.CalendarEventCompose_RecurringSummary,
+ SelectedRecurrenceInterval,
+ frequencyLabel,
+ weekdaySummary,
+ timeSummary,
+ effectiveStart.ToString("dddd yyyy-MM-dd", CultureInfo.CurrentCulture),
+ untilSummary).Trim();
+ }
+
+ private string BuildRecurrenceRule()
+ {
+ if (!IsRecurring || SelectedRecurrenceFrequencyOption == null)
+ return string.Empty;
+
+ var parts = new List
+ {
+ $"FREQ={SelectedRecurrenceFrequencyOption.Frequency.ToString().ToUpperInvariant()}",
+ $"INTERVAL={SelectedRecurrenceInterval}"
+ };
+
+ var selectedDays = WeekdayOptions
+ .Where(option => option.IsSelected)
+ .Select(option => option.RuleValue)
+ .ToList();
+
+ if (selectedDays.Count > 0)
+ {
+ parts.Add($"BYDAY={string.Join(",", selectedDays)}");
+ }
+
+ if (RecurrenceEndDate.HasValue)
+ {
+ var untilValue = IsAllDay
+ ? RecurrenceEndDate.Value.ToString("yyyyMMdd", CultureInfo.InvariantCulture)
+ : RecurrenceEndDate.Value.Date.AddDays(1).AddSeconds(-1).ToString("yyyyMMdd'T'HHmmss", CultureInfo.InvariantCulture);
+
+ parts.Add($"UNTIL={untilValue}");
+ }
+
+ return $"RRULE:{string.Join(";", parts)}";
+ }
+
+ private DateTime GetEffectiveStartDateTime()
+ => StartDate.Date.Add(IsAllDay ? TimeSpan.Zero : StartTime);
+
+ private DateTime GetEffectiveEndDateTime()
+ => IsAllDay
+ ? AllDayEndDate.Date
+ : StartDate.Date.Add(EndTime);
+
+ private static (DateTime StartDate, DateTime EndDate) GetDefaultComposeDateRange()
+ {
+ var localNow = DateTime.Now;
+ var roundedMinutes = localNow.Minute switch
+ {
+ < 30 => 30,
+ 30 when localNow.Second == 0 && localNow.Millisecond == 0 => 30,
+ _ => 60
+ };
+
+ var startDate = new DateTime(localNow.Year, localNow.Month, localNow.Day, localNow.Hour, 0, 0);
+ startDate = roundedMinutes == 60 ? startDate.AddHours(1) : startDate.AddMinutes(roundedMinutes);
+
+ return (startDate, startDate.AddMinutes(30));
+ }
+
+ private CalendarComposeWeekdayOption CreateWeekdayOption(DayOfWeek dayOfWeek, string ruleValue, string label)
+ {
+ var option = new CalendarComposeWeekdayOption(dayOfWeek, ruleValue, label);
+ option.PropertyChanged += WeekdayOptionPropertyChanged;
+ return option;
+ }
+
+ private void WeekdayOptionPropertyChanged(object sender, PropertyChangedEventArgs e)
+ {
+ if (e.PropertyName == nameof(CalendarComposeWeekdayOption.IsSelected))
+ {
+ UpdateRecurrenceSummary();
+ }
+ }
+
+ private void SelectSingleWeekday(DayOfWeek dayOfWeek)
+ {
+ foreach (var option in WeekdayOptions)
+ {
+ option.IsSelected = option.DayOfWeek == dayOfWeek;
+ }
+ }
+
+ private void ShowValidationMessage(string message)
+ {
+ _dialogService.InfoBarMessage(
+ Translator.CalendarEventCompose_ValidationTitle,
+ message,
+ InfoBarMessageType.Warning);
+ }
+
+ private void AttachmentsCollectionChanged(object sender, NotifyCollectionChangedEventArgs e)
+ {
+ OnPropertyChanged(nameof(HasAttachments));
+ }
+
+ private static bool IsValidEmailAddress(string address)
+ {
+ try
+ {
+ var parsedAddress = new MailAddress(address);
+ return parsedAddress.Address.Equals(address, StringComparison.OrdinalIgnoreCase);
+ }
+ catch
+ {
+ return false;
+ }
+ }
+}
+
+public partial class CalendarComposeFrequencyOption : ObservableObject
+{
+ public CalendarItemRecurrenceFrequency Frequency { get; }
+ public string DisplayText { get; }
+
+ public CalendarComposeFrequencyOption(CalendarItemRecurrenceFrequency frequency, string displayText)
+ {
+ Frequency = frequency;
+ DisplayText = displayText;
+ }
+
+ public string PluralLabel(int interval)
+ {
+ if (interval == 1)
+ return DisplayText;
+
+ return Frequency switch
+ {
+ CalendarItemRecurrenceFrequency.Daily => Translator.CalendarEventCompose_FrequencyDayPlural,
+ CalendarItemRecurrenceFrequency.Weekly => Translator.CalendarEventCompose_FrequencyWeekPlural,
+ CalendarItemRecurrenceFrequency.Monthly => Translator.CalendarEventCompose_FrequencyMonthPlural,
+ CalendarItemRecurrenceFrequency.Yearly => Translator.CalendarEventCompose_FrequencyYearPlural,
+ _ => DisplayText
+ };
+ }
+}
+
+public partial class CalendarComposeWeekdayOption : ObservableObject
+{
+ public DayOfWeek DayOfWeek { get; }
+ public string RuleValue { get; }
+ public string Label { get; }
+ public string FullDayName => DayOfWeek switch
+ {
+ DayOfWeek.Monday => CultureInfo.CurrentCulture.DateTimeFormat.DayNames[1],
+ DayOfWeek.Tuesday => CultureInfo.CurrentCulture.DateTimeFormat.DayNames[2],
+ DayOfWeek.Wednesday => CultureInfo.CurrentCulture.DateTimeFormat.DayNames[3],
+ DayOfWeek.Thursday => CultureInfo.CurrentCulture.DateTimeFormat.DayNames[4],
+ DayOfWeek.Friday => CultureInfo.CurrentCulture.DateTimeFormat.DayNames[5],
+ DayOfWeek.Saturday => CultureInfo.CurrentCulture.DateTimeFormat.DayNames[6],
+ DayOfWeek.Sunday => CultureInfo.CurrentCulture.DateTimeFormat.DayNames[0],
+ _ => string.Empty
+ };
+
+ [ObservableProperty]
+ public partial bool IsSelected { get; set; }
+
+ public CalendarComposeWeekdayOption(DayOfWeek dayOfWeek, string ruleValue, string label)
+ {
+ DayOfWeek = dayOfWeek;
+ RuleValue = ruleValue;
+ Label = label;
+ }
+}
diff --git a/Wino.Calendar.ViewModels/CalendarPageViewModel.cs b/Wino.Calendar.ViewModels/CalendarPageViewModel.cs
index b067c279..1ffd35df 100644
--- a/Wino.Calendar.ViewModels/CalendarPageViewModel.cs
+++ b/Wino.Calendar.ViewModels/CalendarPageViewModel.cs
@@ -314,7 +314,44 @@ public partial class CalendarPageViewModel : CalendarBaseViewModel,
[RelayCommand]
private void MoreDetails()
{
- // TODO: Navigate to advanced event creation page with existing parameters.
+ if (SelectedQuickEventDate == null)
+ return;
+
+ var startDate = SelectedQuickEventDate.Value.Date.AddHours(9);
+ var endDate = startDate.AddMinutes(30);
+
+ if (!IsAllDay)
+ {
+ var selectedStartTime = CurrentSettings.GetTimeSpan(SelectedStartTimeString);
+ var selectedEndTime = CurrentSettings.GetTimeSpan(SelectedEndTimeString);
+
+ if (selectedStartTime.HasValue)
+ {
+ startDate = SelectedQuickEventDate.Value.Date.Add(selectedStartTime.Value);
+ }
+
+ if (selectedEndTime.HasValue)
+ {
+ endDate = SelectedQuickEventDate.Value.Date.Add(selectedEndTime.Value);
+ }
+ }
+ else
+ {
+ startDate = SelectedQuickEventDate.Value.Date;
+ endDate = SelectedQuickEventDate.Value.Date.AddDays(1);
+ }
+
+ IsQuickEventDialogOpen = false;
+
+ _navigationService.Navigate(WinoPage.CalendarEventComposePage, new CalendarEventComposeNavigationArgs
+ {
+ SelectedCalendarId = SelectedQuickEventAccountCalendar?.Id,
+ Title = EventName ?? string.Empty,
+ Location = Location ?? string.Empty,
+ IsAllDay = IsAllDay,
+ StartDate = startDate,
+ EndDate = endDate
+ });
}
public void SelectQuickEventTimeRange(TimeSpan startTime, TimeSpan endTime)
diff --git a/Wino.Calendar.ViewModels/Data/CalendarComposeAttachmentViewModel.cs b/Wino.Calendar.ViewModels/Data/CalendarComposeAttachmentViewModel.cs
new file mode 100644
index 00000000..34aece53
--- /dev/null
+++ b/Wino.Calendar.ViewModels/Data/CalendarComposeAttachmentViewModel.cs
@@ -0,0 +1,57 @@
+using System;
+using Wino.Core.Domain.Enums;
+using Wino.Core.Domain.Models.Calendar;
+using Wino.Core.Extensions;
+
+namespace Wino.Calendar.ViewModels.Data;
+
+public class CalendarComposeAttachmentViewModel
+{
+ public Guid Id { get; } = Guid.NewGuid();
+ public string FileName { get; }
+ public string FilePath { get; }
+ public string FileExtension { get; }
+ public long Size { get; }
+ public string ReadableSize => Size.GetBytesReadable();
+ public MailAttachmentType AttachmentType { get; }
+
+ public CalendarComposeAttachmentViewModel(string fileName, string filePath, string fileExtension, long size)
+ {
+ FileName = fileName;
+ FilePath = filePath;
+ FileExtension = fileExtension;
+ Size = size;
+ AttachmentType = GetAttachmentType(fileExtension);
+ }
+
+ public CalendarEventComposeAttachmentDraft ToDraftModel()
+ {
+ return new CalendarEventComposeAttachmentDraft
+ {
+ Id = Id,
+ FileName = FileName,
+ FilePath = FilePath,
+ FileExtension = FileExtension,
+ Size = Size
+ };
+ }
+
+ private static MailAttachmentType GetAttachmentType(string extension)
+ {
+ if (string.IsNullOrWhiteSpace(extension))
+ return MailAttachmentType.None;
+
+ return extension.ToLowerInvariant() switch
+ {
+ ".exe" => MailAttachmentType.Executable,
+ ".rar" => MailAttachmentType.RarArchive,
+ ".zip" => MailAttachmentType.Archive,
+ ".ogg" or ".mp3" or ".wav" or ".aac" or ".alac" => MailAttachmentType.Audio,
+ ".mp4" or ".wmv" or ".avi" or ".flv" => MailAttachmentType.Video,
+ ".pdf" => MailAttachmentType.PDF,
+ ".htm" or ".html" => MailAttachmentType.HTML,
+ ".png" or ".jpg" or ".jpeg" or ".gif" or ".jiff" => MailAttachmentType.Image,
+ _ => MailAttachmentType.Other
+ };
+ }
+}
diff --git a/Wino.Calendar.ViewModels/Data/CalendarComposeAttendeeViewModel.cs b/Wino.Calendar.ViewModels/Data/CalendarComposeAttendeeViewModel.cs
new file mode 100644
index 00000000..a0cec4f4
--- /dev/null
+++ b/Wino.Calendar.ViewModels/Data/CalendarComposeAttendeeViewModel.cs
@@ -0,0 +1,21 @@
+using Wino.Core.Domain.Entities.Shared;
+
+namespace Wino.Calendar.ViewModels.Data;
+
+public class CalendarComposeAttendeeViewModel
+{
+ public string DisplayName { get; }
+ public string Email { get; }
+ public AccountContact ResolvedContact { get; }
+ public bool HasDistinctDisplayName => !string.IsNullOrWhiteSpace(DisplayName) && !DisplayName.Equals(Email, System.StringComparison.OrdinalIgnoreCase);
+
+ public CalendarComposeAttendeeViewModel(string displayName, string email, AccountContact resolvedContact = null)
+ {
+ DisplayName = string.IsNullOrWhiteSpace(displayName) ? email : displayName;
+ Email = email;
+ ResolvedContact = resolvedContact;
+ }
+
+ public static CalendarComposeAttendeeViewModel FromContact(AccountContact contact)
+ => new(contact.Name, contact.Address, contact);
+}
diff --git a/Wino.Core.Domain/Enums/WinoPage.cs b/Wino.Core.Domain/Enums/WinoPage.cs
index 43637436..cee8e7b5 100644
--- a/Wino.Core.Domain/Enums/WinoPage.cs
+++ b/Wino.Core.Domain/Enums/WinoPage.cs
@@ -33,6 +33,7 @@ public enum WinoPage
CalendarSettingsPage,
CalendarAccountSettingsPage,
EventDetailsPage,
+ CalendarEventComposePage,
SignatureAndEncryptionPage,
StoragePage,
WelcomePageV2,
diff --git a/Wino.Core.Domain/Interfaces/IDialogServiceBase.cs b/Wino.Core.Domain/Interfaces/IDialogServiceBase.cs
index 11a9f6f3..18f59bab 100644
--- a/Wino.Core.Domain/Interfaces/IDialogServiceBase.cs
+++ b/Wino.Core.Domain/Interfaces/IDialogServiceBase.cs
@@ -29,6 +29,7 @@ public interface IDialogServiceBase
Task ShowAccountProviderSelectionDialogAsync(List availableProviders);
IAccountCreationDialog GetAccountCreationDialog(AccountCreationDialogResult accountCreationDialogResult);
Task> PickFilesAsync(params object[] typeFilters);
+ Task> PickFilesMetadataAsync(params object[] typeFilters);
Task PickFilePathAsync(string saveFileName);
Task ShowPrintDialogAsync(WebView2PrintSettingsModel initialSettings = null);
diff --git a/Wino.Core.Domain/Interfaces/IMailDialogService.cs b/Wino.Core.Domain/Interfaces/IMailDialogService.cs
index 1b475eae..807a473b 100644
--- a/Wino.Core.Domain/Interfaces/IMailDialogService.cs
+++ b/Wino.Core.Domain/Interfaces/IMailDialogService.cs
@@ -2,10 +2,12 @@
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Threading.Tasks;
+using Wino.Core.Domain.Entities.Calendar;
using Wino.Core.Domain.Entities.Mail;
using Wino.Core.Domain.Entities.Shared;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Models;
+using Wino.Core.Domain.Models.Calendar;
using Wino.Core.Domain.Models.Folders;
namespace Wino.Core.Domain.Interfaces;
@@ -18,6 +20,7 @@ public interface IMailDialogService : IDialogServiceBase
// Custom dialogs
Task ShowMoveMailFolderDialogAsync(List availableFolders);
Task ShowAccountPickerDialogAsync(List availableAccounts);
+ Task ShowSingleCalendarPickerDialogAsync(List availableCalendarGroups);
///
/// Displays a dialog to the user for reordering accounts.
diff --git a/Wino.Core.Domain/Models/Calendar/CalendarEventComposeAttachmentDraft.cs b/Wino.Core.Domain/Models/Calendar/CalendarEventComposeAttachmentDraft.cs
new file mode 100644
index 00000000..44b17035
--- /dev/null
+++ b/Wino.Core.Domain/Models/Calendar/CalendarEventComposeAttachmentDraft.cs
@@ -0,0 +1,12 @@
+using System;
+
+namespace Wino.Core.Domain.Models.Calendar;
+
+public class CalendarEventComposeAttachmentDraft
+{
+ public Guid Id { get; set; }
+ public string FileName { get; set; } = string.Empty;
+ public string FilePath { get; set; } = string.Empty;
+ public string FileExtension { get; set; } = string.Empty;
+ public long Size { get; set; }
+}
diff --git a/Wino.Core.Domain/Models/Calendar/CalendarEventComposeNavigationArgs.cs b/Wino.Core.Domain/Models/Calendar/CalendarEventComposeNavigationArgs.cs
new file mode 100644
index 00000000..a2522ce6
--- /dev/null
+++ b/Wino.Core.Domain/Models/Calendar/CalendarEventComposeNavigationArgs.cs
@@ -0,0 +1,13 @@
+using System;
+
+namespace Wino.Core.Domain.Models.Calendar;
+
+public class CalendarEventComposeNavigationArgs
+{
+ public Guid? SelectedCalendarId { get; set; }
+ public string Title { get; set; } = string.Empty;
+ public string Location { get; set; } = string.Empty;
+ public bool IsAllDay { get; set; }
+ public DateTime StartDate { get; set; }
+ public DateTime EndDate { get; set; }
+}
diff --git a/Wino.Core.Domain/Models/Calendar/CalendarEventComposeResult.cs b/Wino.Core.Domain/Models/Calendar/CalendarEventComposeResult.cs
new file mode 100644
index 00000000..2be440ac
--- /dev/null
+++ b/Wino.Core.Domain/Models/Calendar/CalendarEventComposeResult.cs
@@ -0,0 +1,25 @@
+using System;
+using System.Collections.Generic;
+using Wino.Core.Domain.Entities.Calendar;
+using Wino.Core.Domain.Enums;
+
+namespace Wino.Core.Domain.Models.Calendar;
+
+public class CalendarEventComposeResult
+{
+ public Guid CalendarId { get; set; }
+ public Guid AccountId { get; set; }
+ public string Title { get; set; } = string.Empty;
+ public string Location { get; set; } = string.Empty;
+ public string HtmlNotes { get; set; } = string.Empty;
+ public DateTime StartDate { get; set; }
+ public DateTime EndDate { get; set; }
+ public bool IsAllDay { get; set; }
+ public string TimeZoneId { get; set; } = string.Empty;
+ public CalendarItemShowAs ShowAs { get; set; }
+ public List SelectedReminders { get; set; } = [];
+ public List Attendees { get; set; } = [];
+ public List Attachments { get; set; } = [];
+ public string Recurrence { get; set; } = string.Empty;
+ public string RecurrenceSummary { get; set; } = string.Empty;
+}
diff --git a/Wino.Core.Domain/Models/Calendar/CalendarPickerAccountGroup.cs b/Wino.Core.Domain/Models/Calendar/CalendarPickerAccountGroup.cs
new file mode 100644
index 00000000..62c2295c
--- /dev/null
+++ b/Wino.Core.Domain/Models/Calendar/CalendarPickerAccountGroup.cs
@@ -0,0 +1,11 @@
+using System.Collections.Generic;
+using Wino.Core.Domain.Entities.Calendar;
+using Wino.Core.Domain.Entities.Shared;
+
+namespace Wino.Core.Domain.Models.Calendar;
+
+public class CalendarPickerAccountGroup
+{
+ public MailAccount Account { get; set; } = null!;
+ public List Calendars { get; set; } = [];
+}
diff --git a/Wino.Core.Domain/Models/Common/PickedFileMetadata.cs b/Wino.Core.Domain/Models/Common/PickedFileMetadata.cs
new file mode 100644
index 00000000..57c0722c
--- /dev/null
+++ b/Wino.Core.Domain/Models/Common/PickedFileMetadata.cs
@@ -0,0 +1,9 @@
+using System.IO;
+
+namespace Wino.Core.Domain.Models.Common;
+
+public record PickedFileMetadata(string FullFilePath, long Size)
+{
+ public string FileName => Path.GetFileName(FullFilePath);
+ public string FileExtension => Path.GetExtension(FullFilePath)?.ToLowerInvariant() ?? string.Empty;
+}
diff --git a/Wino.Core.Domain/Translations/en_US/resources.json b/Wino.Core.Domain/Translations/en_US/resources.json
index 70ce1ef6..77c7c8cb 100644
--- a/Wino.Core.Domain/Translations/en_US/resources.json
+++ b/Wino.Core.Domain/Translations/en_US/resources.json
@@ -124,6 +124,56 @@
"CalendarAttendeeStatus_NeedsAction": "Needs Action",
"CalendarAttendeeStatus_Tentative": "Tentative",
"CalendarEventDetails_Attachments": "Attachments",
+ "CalendarEventCompose_AddAttachment": "Add attachment",
+ "CalendarEventCompose_AllDay": "All Day",
+ "CalendarEventCompose_EndDate": "End date",
+ "CalendarEventCompose_EndTime": "End time",
+ "CalendarEventCompose_Every": "every",
+ "CalendarEventCompose_ForWeekdays": "for",
+ "CalendarEventCompose_FrequencyDay": "day",
+ "CalendarEventCompose_FrequencyDayPlural": "days",
+ "CalendarEventCompose_FrequencyMonth": "month",
+ "CalendarEventCompose_FrequencyMonthPlural": "months",
+ "CalendarEventCompose_FrequencyWeek": "week",
+ "CalendarEventCompose_FrequencyWeekPlural": "weeks",
+ "CalendarEventCompose_FrequencyYear": "year",
+ "CalendarEventCompose_FrequencyYearPlural": "years",
+ "CalendarEventCompose_Location": "Location",
+ "CalendarEventCompose_LocationPlaceholder": "Add a location",
+ "CalendarEventCompose_NewEventButton": "New Event",
+ "CalendarEventCompose_NoCalendarsMessage": "There are no calendars available for event creation yet.",
+ "CalendarEventCompose_NoCalendarsTitle": "No calendars available",
+ "CalendarEventCompose_NoEndDate": "No end date",
+ "CalendarEventCompose_Notes": "Notes",
+ "CalendarEventCompose_PickCalendarTitle": "Pick a calendar",
+ "CalendarEventCompose_Recurring": "Recurring",
+ "CalendarEventCompose_RecurringSummary": "Occurs every {0} {1}{2} {3} effective {4}{5}",
+ "CalendarEventCompose_RepeatEvery": "Repeat every",
+ "CalendarEventCompose_SelectCalendar": "Select calendar",
+ "CalendarEventCompose_SingleOccurrenceSummary": "Occurs on {0} {1}",
+ "CalendarEventCompose_StartDate": "Start date",
+ "CalendarEventCompose_StartTime": "Start time",
+ "CalendarEventCompose_TimeRangeSummary": "from {0} to {1}",
+ "CalendarEventCompose_Title": "Event title",
+ "CalendarEventCompose_TitlePlaceholder": "Add a title",
+ "CalendarEventCompose_Until": "until",
+ "CalendarEventCompose_UntilSummary": " until {0}",
+ "CalendarEventCompose_ValidationInvalidAllDayRange": "The all-day end date must be after the start date.",
+ "CalendarEventCompose_ValidationInvalidAttendee": "One or more attendees have an invalid email address.",
+ "CalendarEventCompose_ValidationInvalidRecurrenceEnd": "The recurrence end date must be on or after the event start date.",
+ "CalendarEventCompose_ValidationInvalidTimeRange": "The end time must be later than the start time.",
+ "CalendarEventCompose_ValidationMissingAttachment": "One or more attachments are no longer available: {0}",
+ "CalendarEventCompose_ValidationMissingCalendar": "Select a calendar before creating the event.",
+ "CalendarEventCompose_ValidationMissingTitle": "Enter an event title before creating the event.",
+ "CalendarEventCompose_ValidationTitle": "Event validation failed",
+ "CalendarEventCompose_WeekdaySummary": " on {0}",
+ "CalendarEventCompose_Weekday_Friday": "F",
+ "CalendarEventCompose_Weekday_Monday": "M",
+ "CalendarEventCompose_Weekday_Saturday": "S",
+ "CalendarEventCompose_Weekday_Sunday": "S",
+ "CalendarEventCompose_Weekday_Thursday": "T",
+ "CalendarEventCompose_Weekday_Tuesday": "T",
+ "CalendarEventCompose_Weekday_Wednesday": "W",
"CalendarEventDetails_Details": "Details",
"CalendarEventDetails_EditSeries": "Edit Series",
"CalendarEventDetails_Editing": "Editing",
diff --git a/Wino.Mail.WinUI/App.xaml.cs b/Wino.Mail.WinUI/App.xaml.cs
index e013840f..85ad8868 100644
--- a/Wino.Mail.WinUI/App.xaml.cs
+++ b/Wino.Mail.WinUI/App.xaml.cs
@@ -171,6 +171,7 @@ public partial class App : WinoApplication,
services.AddTransient(typeof(CalendarSettingsPageViewModel));
services.AddTransient(typeof(CalendarAccountSettingsPageViewModel));
services.AddTransient(typeof(EventDetailsPageViewModel));
+ services.AddTransient(typeof(CalendarEventComposePageViewModel));
}
#endregion
diff --git a/Wino.Mail.WinUI/Controls/CalendarMailItemDisplayInformationControl.xaml.cs b/Wino.Mail.WinUI/Controls/CalendarMailItemDisplayInformationControl.xaml.cs
index 1d7f8eb3..a8bd6042 100644
--- a/Wino.Mail.WinUI/Controls/CalendarMailItemDisplayInformationControl.xaml.cs
+++ b/Wino.Mail.WinUI/Controls/CalendarMailItemDisplayInformationControl.xaml.cs
@@ -14,7 +14,6 @@ using Wino.Core.Domain;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.MailItem;
-using Wino.Helpers;
using Wino.Mail.ViewModels.Data;
using Wino.Mail.WinUI;
@@ -176,7 +175,7 @@ public sealed partial class CalendarMailItemDisplayInformationControl : UserCont
}
using var stream = new MemoryStream();
- calendarMimePart.Content.DecodeTo(stream);
+ calendarMimePart.Content?.DecodeTo(stream);
var contentBytes = stream.ToArray();
if (contentBytes.Length == 0)
diff --git a/Wino.Mail.WinUI/Controls/ImagePreviewControl.cs b/Wino.Mail.WinUI/Controls/ImagePreviewControl.cs
index 3f46b383..8e65b616 100644
--- a/Wino.Mail.WinUI/Controls/ImagePreviewControl.cs
+++ b/Wino.Mail.WinUI/Controls/ImagePreviewControl.cs
@@ -9,6 +9,7 @@ using Microsoft.Extensions.DependencyInjection;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Media.Imaging;
+using Wino.Core.Domain.Entities.Shared;
using Wino.Core.Domain.Interfaces;
using Wino.Mail.WinUI;
@@ -30,6 +31,15 @@ public sealed partial class ImagePreviewControl : PersonPicture
[GeneratedDependencyProperty]
public partial IMailItemDisplayInformation? MailItemInformation { get; set; }
+ [GeneratedDependencyProperty]
+ public partial AccountContact? PreviewContact { get; set; }
+
+ [GeneratedDependencyProperty]
+ public partial string? Address { get; set; }
+
+ [GeneratedDependencyProperty]
+ public partial string? DisplayNameOverride { get; set; }
+
private readonly IThumbnailService? _thumbnailService;
private readonly IPreferencesService? _preferencesService;
private readonly IContactPictureFileService? _contactPictureFileService;
@@ -73,6 +83,11 @@ public sealed partial class ImagePreviewControl : PersonPicture
RequestRefresh();
}
+
+ partial void OnPreviewContactPropertyChanged(DependencyPropertyChangedEventArgs e) => RequestRefresh();
+ partial void OnAddressPropertyChanged(DependencyPropertyChangedEventArgs e) => RequestRefresh();
+ partial void OnDisplayNameOverridePropertyChanged(DependencyPropertyChangedEventArgs e) => RequestRefresh();
+
private void OnLoaded(object sender, RoutedEventArgs e)
{
RequestRefresh();
@@ -262,7 +277,7 @@ public sealed partial class ImagePreviewControl : PersonPicture
var address = ResolveAddress();
var displayName = ResolveDisplayName(address);
var base64Picture = ResolveBase64Picture();
- var contactPictureFileId = MailItemInformation?.SenderContact?.ContactPictureFileId;
+ var contactPictureFileId = PreviewContact?.ContactPictureFileId ?? MailItemInformation?.SenderContact?.ContactPictureFileId;
return new RefreshSnapshot(displayName, address, contactPictureFileId, base64Picture);
}).ConfigureAwait(false);
@@ -270,6 +285,12 @@ public sealed partial class ImagePreviewControl : PersonPicture
private string ResolveAddress()
{
+ if (!string.IsNullOrWhiteSpace(PreviewContact?.Address))
+ return PreviewContact.Address.Trim();
+
+ if (!string.IsNullOrWhiteSpace(Address))
+ return Address.Trim();
+
if (MailItemInformation == null)
return string.Empty;
@@ -285,6 +306,12 @@ public sealed partial class ImagePreviewControl : PersonPicture
private string ResolveDisplayName(string resolvedAddress)
{
+ if (!string.IsNullOrWhiteSpace(PreviewContact?.Name))
+ return PreviewContact.Name.Trim();
+
+ if (!string.IsNullOrWhiteSpace(DisplayNameOverride))
+ return DisplayNameOverride.Trim();
+
var contactName = MailItemInformation?.SenderContact?.Name;
if (!string.IsNullOrWhiteSpace(contactName))
return contactName.Trim();
@@ -297,6 +324,9 @@ public sealed partial class ImagePreviewControl : PersonPicture
private string ResolveBase64Picture()
{
+ if (!string.IsNullOrWhiteSpace(PreviewContact?.Base64ContactPicture))
+ return PreviewContact.Base64ContactPicture;
+
if (!string.IsNullOrWhiteSpace(MailItemInformation?.SenderContact?.Base64ContactPicture))
return MailItemInformation.SenderContact.Base64ContactPicture;
diff --git a/Wino.Mail.WinUI/Dialogs/SingleCalendarPickerDialog.xaml b/Wino.Mail.WinUI/Dialogs/SingleCalendarPickerDialog.xaml
new file mode 100644
index 00000000..840f1819
--- /dev/null
+++ b/Wino.Mail.WinUI/Dialogs/SingleCalendarPickerDialog.xaml
@@ -0,0 +1,73 @@
+
+
+
+ 420
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Wino.Mail.WinUI/Dialogs/SingleCalendarPickerDialog.xaml.cs b/Wino.Mail.WinUI/Dialogs/SingleCalendarPickerDialog.xaml.cs
new file mode 100644
index 00000000..bf4b5641
--- /dev/null
+++ b/Wino.Mail.WinUI/Dialogs/SingleCalendarPickerDialog.xaml.cs
@@ -0,0 +1,26 @@
+using System.Collections.Generic;
+using Microsoft.UI.Xaml.Controls;
+using Wino.Core.Domain.Entities.Calendar;
+using Wino.Core.Domain.Models.Calendar;
+
+namespace Wino.Dialogs;
+
+public sealed partial class SingleCalendarPickerDialog : ContentDialog
+{
+ public AccountCalendar? PickedCalendar { get; private set; }
+
+ public List AvailableGroups { get; } = [];
+
+ public SingleCalendarPickerDialog(List availableGroups)
+ {
+ AvailableGroups = availableGroups;
+
+ InitializeComponent();
+ }
+
+ private void CalendarClicked(object sender, ItemClickEventArgs e)
+ {
+ PickedCalendar = e.ClickedItem as AccountCalendar;
+ Hide();
+ }
+}
diff --git a/Wino.Mail.WinUI/Services/DialogService.cs b/Wino.Mail.WinUI/Services/DialogService.cs
index 0ffa1681..793354d1 100644
--- a/Wino.Mail.WinUI/Services/DialogService.cs
+++ b/Wino.Mail.WinUI/Services/DialogService.cs
@@ -6,12 +6,14 @@ using CommunityToolkit.Mvvm.Messaging;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Wino.Core.Domain;
+using Wino.Core.Domain.Entities.Calendar;
using Wino.Core.Domain.Entities.Mail;
using Wino.Core.Domain.Entities.Shared;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models;
using Wino.Core.Domain.Models.Accounts;
+using Wino.Core.Domain.Models.Calendar;
using Wino.Core.Domain.Models.Folders;
using Wino.Core.Domain.Models.Synchronization;
using Wino.Mail.WinUI.Extensions;
@@ -122,6 +124,18 @@ public class DialogService : DialogServiceBase, IMailDialogService
return accountPicker.PickedAccount ?? null!;
}
+ public async Task ShowSingleCalendarPickerDialogAsync(List availableCalendarGroups)
+ {
+ var calendarPicker = new SingleCalendarPickerDialog(availableCalendarGroups)
+ {
+ RequestedTheme = ThemeService.RootTheme.ToWindowsElementTheme()
+ };
+
+ await HandleDialogPresentationAsync(calendarPicker);
+
+ return calendarPicker.PickedCalendar ?? null!;
+ }
+
public async Task ShowSignatureEditorDialog(AccountSignature? signatureModel = null)
{
SignatureEditorDialog signatureEditorDialog;
diff --git a/Wino.Mail.WinUI/Services/DialogServiceBase.cs b/Wino.Mail.WinUI/Services/DialogServiceBase.cs
index bd5ecffc..418e7776 100644
--- a/Wino.Mail.WinUI/Services/DialogServiceBase.cs
+++ b/Wino.Mail.WinUI/Services/DialogServiceBase.cs
@@ -120,6 +120,40 @@ public class DialogServiceBase : IDialogServiceBase
return returnList;
}
+ public async Task> PickFilesMetadataAsync(params object[] typeFilters)
+ {
+ var returnList = new List();
+ var picker = new FileOpenPicker
+ {
+ ViewMode = PickerViewMode.Thumbnail,
+ SuggestedStartLocation = PickerLocationId.Desktop
+ };
+
+ foreach (var filter in typeFilters)
+ {
+ picker.FileTypeFilter.Add(filter.ToString());
+ }
+
+ var mainWindow = WinoApplication.MainWindow;
+ if (mainWindow == null) return returnList;
+
+ nint windowHandle = WindowNative.GetWindowHandle(mainWindow);
+ InitializeWithWindow.Initialize(picker, windowHandle);
+
+ var files = await picker.PickMultipleFilesAsync();
+ if (files == null) return returnList;
+
+ foreach (var file in files)
+ {
+ StorageApplicationPermissions.FutureAccessList.Add(file);
+
+ var basicProperties = await file.GetBasicPropertiesAsync();
+ returnList.Add(new PickedFileMetadata(file.Path, (long)basicProperties.Size));
+ }
+
+ return returnList;
+ }
+
private async Task PickFileAsync(params object[] typeFilters)
{
var picker = new FileOpenPicker
diff --git a/Wino.Mail.WinUI/Services/NavigationService.cs b/Wino.Mail.WinUI/Services/NavigationService.cs
index c42727a2..bcea463c 100644
--- a/Wino.Mail.WinUI/Services/NavigationService.cs
+++ b/Wino.Mail.WinUI/Services/NavigationService.cs
@@ -55,7 +55,8 @@ public class NavigationService : NavigationServiceBase, INavigationService
private static readonly WinoPage[] CalendarOnlyPages =
[
WinoPage.CalendarPage,
- WinoPage.EventDetailsPage
+ WinoPage.EventDetailsPage,
+ WinoPage.CalendarEventComposePage
];
public NavigationService(IStatePersistanceService statePersistanceService, IDispatcher dispatcher, IWinoWindowManager windowManager)
@@ -126,6 +127,7 @@ public class NavigationService : NavigationServiceBase, INavigationService
WinoPage.SpecialImapCredentialsPage => typeof(SpecialImapCredentialsPage),
WinoPage.CalendarPage => typeof(CalendarPage),
WinoPage.EventDetailsPage => typeof(EventDetailsPage),
+ WinoPage.CalendarEventComposePage => typeof(CalendarEventComposePage),
WinoPage.CalendarSettingsPage => typeof(CalendarSettingsPage),
WinoPage.CalendarAccountSettingsPage => typeof(CalendarAccountSettingsPage),
_ => null,
@@ -248,7 +250,7 @@ public class NavigationService : NavigationServiceBase, INavigationService
}
_statePersistanceService.IsReadingMail = _renderingPageTypes.Contains(page);
- _statePersistanceService.IsEventDetailsVisible = page == WinoPage.EventDetailsPage;
+ _statePersistanceService.IsEventDetailsVisible = page == WinoPage.EventDetailsPage || page == WinoPage.CalendarEventComposePage;
Frame? innerShellFrame = GetCoreFrameInternal(NavigationReferenceFrame.InnerShellFrame);
if (innerShellFrame == null && frame == NavigationReferenceFrame.ShellFrame)
diff --git a/Wino.Mail.WinUI/Views/Abstract/CalendarEventComposePageAbstract.cs b/Wino.Mail.WinUI/Views/Abstract/CalendarEventComposePageAbstract.cs
new file mode 100644
index 00000000..07392be8
--- /dev/null
+++ b/Wino.Mail.WinUI/Views/Abstract/CalendarEventComposePageAbstract.cs
@@ -0,0 +1,5 @@
+using Wino.Calendar.ViewModels;
+
+namespace Wino.Mail.WinUI.Views.Abstract;
+
+public abstract class CalendarEventComposePageAbstract : BasePage { }
diff --git a/Wino.Mail.WinUI/Views/Calendar/CalendarAppShell.xaml b/Wino.Mail.WinUI/Views/Calendar/CalendarAppShell.xaml
index 432712fe..63aedf55 100644
--- a/Wino.Mail.WinUI/Views/Calendar/CalendarAppShell.xaml
+++ b/Wino.Mail.WinUI/Views/Calendar/CalendarAppShell.xaml
@@ -149,14 +149,26 @@
+
+
+
@@ -261,7 +273,7 @@
diff --git a/Wino.Mail.WinUI/Views/Calendar/CalendarEventComposePage.xaml b/Wino.Mail.WinUI/Views/Calendar/CalendarEventComposePage.xaml
new file mode 100644
index 00000000..e6189a49
--- /dev/null
+++ b/Wino.Mail.WinUI/Views/Calendar/CalendarEventComposePage.xaml
@@ -0,0 +1,533 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Wino.Mail.WinUI/Views/Calendar/CalendarEventComposePage.xaml.cs b/Wino.Mail.WinUI/Views/Calendar/CalendarEventComposePage.xaml.cs
new file mode 100644
index 00000000..b39535d5
--- /dev/null
+++ b/Wino.Mail.WinUI/Views/Calendar/CalendarEventComposePage.xaml.cs
@@ -0,0 +1,157 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Reactive.Linq;
+using System.Threading;
+using CommunityToolkit.Mvvm.Messaging;
+using CommunityToolkit.WinUI.Controls;
+using EmailValidation;
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Controls;
+using Microsoft.UI.Xaml.Navigation;
+using Windows.Foundation;
+using Wino.Messaging.Client.Shell;
+using Wino.Calendar.ViewModels.Data;
+using Wino.Mail.WinUI.Views.Abstract;
+
+namespace Wino.Calendar.Views;
+
+public sealed partial class CalendarEventComposePage : CalendarEventComposePageAbstract,
+ IRecipient
+{
+ private readonly List _disposables = [];
+
+ public CalendarEventComposePage()
+ {
+ InitializeComponent();
+ }
+
+ protected override async void OnNavigatedTo(NavigationEventArgs e)
+ {
+ base.OnNavigatedTo(e);
+
+ _disposables.Add(GetSuggestionBoxDisposable(AttendeeBox));
+ _disposables.Add(NotesEditor);
+
+ ViewModel.GetHtmlNotesAsync = async () => await NotesEditor.GetHtmlBodyAsync() ?? string.Empty;
+ await NotesEditor.RenderHtmlAsync(" ");
+ }
+
+ protected override void OnNavigatingFrom(NavigatingCancelEventArgs e)
+ {
+ base.OnNavigatingFrom(e);
+
+ foreach (var disposable in _disposables)
+ {
+ disposable.Dispose();
+ }
+
+ _disposables.Clear();
+ }
+
+ private IDisposable GetSuggestionBoxDisposable(TokenizingTextBox box)
+ {
+ return Observable.FromEventPattern, AutoSuggestBoxTextChangedEventArgs>(
+ handler => box.TextChanged += handler,
+ handler => box.TextChanged -= handler)
+ .Throttle(TimeSpan.FromMilliseconds(120))
+ .ObserveOn(SynchronizationContext.Current!)
+ .Subscribe(async eventPattern =>
+ {
+ if (eventPattern.EventArgs.Reason != AutoSuggestionBoxTextChangeReason.UserInput)
+ return;
+
+ if (eventPattern.Sender is not AutoSuggestBox senderBox || senderBox.Text.Length < 2)
+ return;
+
+ var addresses = await ViewModel.SearchContactsAsync(senderBox.Text).ConfigureAwait(false);
+ await ViewModel.ExecuteUIThread(() => senderBox.ItemsSource = addresses);
+ });
+ }
+
+ private async void TokenItemAdding(TokenizingTextBox sender, TokenItemAddingEventArgs args)
+ {
+ if (!EmailValidator.Validate(args.TokenText))
+ {
+ args.Cancel = true;
+ ViewModel.NotifyInvalidEmail(args.TokenText);
+ return;
+ }
+
+ var deferral = args.GetDeferral();
+
+ try
+ {
+ var attendee = await ViewModel.GetAttendeeAsync(args.TokenText);
+ if (attendee == null)
+ {
+ args.Cancel = true;
+ ViewModel.NotifyAddressExists();
+ return;
+ }
+
+ args.Item = attendee;
+ }
+ finally
+ {
+ deferral.Complete();
+ }
+ }
+
+ private async void AddressBoxLostFocus(object sender, RoutedEventArgs e)
+ {
+ if (sender is not TokenizingTextBox tokenizingTextBox)
+ return;
+
+ if (tokenizingTextBox.Items.LastOrDefault() is not ITokenStringContainer info)
+ return;
+
+ var currentText = info.Text;
+ if (string.IsNullOrWhiteSpace(currentText) || !EmailValidator.Validate(currentText))
+ return;
+
+ var attendee = await ViewModel.GetAttendeeAsync(currentText);
+ if (attendee == null)
+ {
+ tokenizingTextBox.Text = string.Empty;
+ return;
+ }
+
+ ViewModel.AddAttendee(attendee);
+ tokenizingTextBox.Text = string.Empty;
+ }
+
+ private void RemoveAttendeeClicked(object sender, RoutedEventArgs e)
+ {
+ if (sender is Button { Tag: CalendarComposeAttendeeViewModel attendee })
+ {
+ ViewModel.RemoveAttendeeCommand.Execute(attendee);
+ }
+ }
+
+ private void RemoveAttachmentClicked(object sender, RoutedEventArgs e)
+ {
+ if (sender is Button { Tag: CalendarComposeAttachmentViewModel attachment })
+ {
+ ViewModel.RemoveAttachmentCommand.Execute(attachment);
+ }
+ }
+
+ public void Receive(ApplicationThemeChanged message)
+ {
+ ViewModel.IsDarkWebviewRenderer = message.IsUnderlyingThemeDark;
+ NotesEditor.IsEditorDarkMode = message.IsUnderlyingThemeDark;
+ }
+
+ protected override void RegisterRecipients()
+ {
+ base.RegisterRecipients();
+ WeakReferenceMessenger.Default.Register(this);
+ }
+
+ protected override void UnregisterRecipients()
+ {
+ base.UnregisterRecipients();
+ WeakReferenceMessenger.Default.Unregister(this);
+ }
+}