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/CalendarAccountSettingsPageViewModel.cs b/Wino.Calendar.ViewModels/CalendarAccountSettingsPageViewModel.cs
index 1fbf23e4..79400c7d 100644
--- a/Wino.Calendar.ViewModels/CalendarAccountSettingsPageViewModel.cs
+++ b/Wino.Calendar.ViewModels/CalendarAccountSettingsPageViewModel.cs
@@ -35,9 +35,6 @@ public partial class CalendarAccountSettingsPageViewModel : CalendarBaseViewMode
[ObservableProperty]
public partial bool IsSyncEnabled { get; set; }
- [ObservableProperty]
- public partial bool IsPrimaryCalendar { get; set; }
-
public ObservableCollection ShowAsOptions { get; } = new ObservableCollection();
[ObservableProperty]
@@ -82,7 +79,6 @@ public partial class CalendarAccountSettingsPageViewModel : CalendarBaseViewMode
// Initialize properties from AccountCalendar
AccountColorHex = AccountCalendar.BackgroundColorHex ?? "#0078D4";
IsSyncEnabled = AccountCalendar.IsSynchronizationEnabled;
- IsPrimaryCalendar = AccountCalendar.IsPrimary;
SelectedDefaultShowAsOption = ShowAsOptions.FirstOrDefault(o => o.ShowAs == AccountCalendar.DefaultShowAs) ?? ShowAsOptions[2];
}
@@ -104,15 +100,6 @@ public partial class CalendarAccountSettingsPageViewModel : CalendarBaseViewMode
}
}
- partial void OnIsPrimaryCalendarChanged(bool value)
- {
- if (AccountCalendar != null)
- {
- AccountCalendar.IsPrimary = value;
- SaveChangesAsync();
- }
- }
-
partial void OnSelectedDefaultShowAsOptionChanged(ShowAsOption value)
{
if (AccountCalendar != null && value != null)
diff --git a/Wino.Calendar.ViewModels/CalendarAppShellViewModel.cs b/Wino.Calendar.ViewModels/CalendarAppShellViewModel.cs
index 42eaaaa1..ed992c09 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;
@@ -8,7 +9,9 @@ using CommunityToolkit.Mvvm.Input;
using CommunityToolkit.Mvvm.Messaging;
using Serilog;
using Wino.Calendar.ViewModels.Data;
+using Wino.Core.Domain.Entities.Calendar;
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 +76,7 @@ public partial class CalendarAppShellViewModel : CalendarBaseViewModel,
ICalendarService calendarService,
IAccountCalendarStateService accountCalendarStateService,
INavigationService navigationService,
- IDialogServiceBase dialogService,
+ IMailDialogService dialogService,
IUpdateManager updateManager)
{
_accountService = accountService;
@@ -120,12 +123,14 @@ public partial class CalendarAppShellViewModel : CalendarBaseViewModel,
if (mode == NavigationMode.Back)
{
await InitializeAccountCalendarsAsync();
+ ValidateConfiguredNewEventCalendar();
return;
}
UpdateDateNavigationHeaderItems();
await InitializeAccountCalendarsAsync();
+ ValidateConfiguredNewEventCalendar();
await ShowWhatIsNewIfNeededAsync();
@@ -290,7 +295,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 +311,47 @@ public partial class CalendarAppShellViewModel : CalendarBaseViewModel,
[RelayCommand]
public void ManageAccounts() => NavigationService.Navigate(WinoPage.AccountManagementPage);
+ [RelayCommand]
+ private async Task NewEventAsync()
+ {
+ var pickedCalendar = TryResolveConfiguredNewEventCalendar();
+
+ if (pickedCalendar == null)
+ {
+ 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;
+ }
+
+ 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]
@@ -435,5 +481,57 @@ public partial class CalendarAppShellViewModel : CalendarBaseViewModel,
}
public async void Receive(AccountRemovedMessage message)
- => await InitializeAccountCalendarsAsync();
+ {
+ await InitializeAccountCalendarsAsync();
+ ValidateConfiguredNewEventCalendar();
+ }
+
+ private AccountCalendar TryResolveConfiguredNewEventCalendar()
+ {
+ ValidateConfiguredNewEventCalendar();
+
+ if (PreferencesService.NewEventButtonBehavior != NewEventButtonBehavior.AlwaysUseSpecificCalendar
+ || !PreferencesService.DefaultNewEventCalendarId.HasValue)
+ {
+ return null;
+ }
+
+ return AccountCalendarStateService.AllCalendars
+ .FirstOrDefault(calendar => calendar.Id == PreferencesService.DefaultNewEventCalendarId.Value)?
+ .AccountCalendar;
+ }
+
+ private void ValidateConfiguredNewEventCalendar()
+ {
+ if (PreferencesService.NewEventButtonBehavior != NewEventButtonBehavior.AlwaysUseSpecificCalendar
+ || !PreferencesService.DefaultNewEventCalendarId.HasValue)
+ {
+ return;
+ }
+
+ var exists = AccountCalendarStateService.AllCalendars
+ .Any(calendar => calendar.Id == PreferencesService.DefaultNewEventCalendarId.Value);
+
+ if (exists)
+ return;
+
+ PreferencesService.NewEventButtonBehavior = NewEventButtonBehavior.AskEachTime;
+ PreferencesService.DefaultNewEventCalendarId = null;
+ }
+
+ 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..49ac9760
--- /dev/null
+++ b/Wino.Calendar.ViewModels/CalendarEventComposePageViewModel.cs
@@ -0,0 +1,709 @@
+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.Threading.Tasks;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using EmailValidation;
+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.Exceptions;
+using Wino.Core.Domain.Interfaces;
+using Wino.Core.Domain.Models.Calendar;
+using Wino.Core.Domain.Models.Navigation;
+using Wino.Core.Domain.Validation;
+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;
+ private readonly CalendarEventComposeResultValidator _composeResultValidator = new();
+
+ public Func> GetHtmlNotesAsync { get; set; }
+
+ public ObservableCollection AvailableCalendars { get; } = [];
+ public ObservableCollection AvailableCalendarGroups { 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 string SelectedCalendarDisplayText => SelectedCalendar?.Name ?? Translator.CalendarEventCompose_SelectCalendar;
+ public string SelectedCalendarAccountText => SelectedCalendar?.Account?.Address ?? string.Empty;
+
+ 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)
+ return;
+
+ SelectedShowAsOption = ShowAsOptions.FirstOrDefault(option => option.ShowAs == value.DefaultShowAs)
+ ?? ShowAsOptions.FirstOrDefault();
+ OnPropertyChanged(nameof(SelectedCalendarDisplayText));
+ OnPropertyChanged(nameof(SelectedCalendarAccountText));
+ }
+
+ 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()
+ {
+ var uniqueAttendees = Attendees
+ .GroupBy(attendee => attendee.Email, StringComparer.OrdinalIgnoreCase)
+ .Select(group => group.First())
+ .ToList();
+
+ var createdResult = await BuildResultAsync(uniqueAttendees);
+
+ try
+ {
+ _composeResultValidator.Validate(createdResult);
+ }
+ catch (CalendarEventComposeValidationException ex)
+ {
+ ShowValidationMessage(ex.Message);
+ return;
+ }
+
+ LastCreatedResult = createdResult;
+
+ _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 (!EmailValidator.Validate(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 groupedCalendars = new List();
+ var accounts = await _accountService.GetAccountsAsync().ConfigureAwait(false);
+
+ foreach (var account in accounts)
+ {
+ var calendars = await _calendarService.GetAccountCalendarsAsync(account.Id).ConfigureAwait(false);
+ var viewModels = calendars
+ .Select(calendar => new AccountCalendarViewModel(account, calendar))
+ .ToList();
+
+ accountCalendars.AddRange(viewModels);
+
+ if (viewModels.Count > 0)
+ {
+ groupedCalendars.Add(new GroupedAccountCalendarViewModel(account, viewModels));
+ }
+ }
+
+ await ExecuteUIThread(() =>
+ {
+ AvailableCalendars.Clear();
+ AvailableCalendarGroups.Clear();
+
+ foreach (var calendar in accountCalendars.OrderBy(calendar => calendar.Account.Name).ThenBy(calendar => calendar.Name))
+ {
+ AvailableCalendars.Add(calendar);
+ }
+
+ foreach (var group in groupedCalendars.OrderBy(group => group.Account.Name))
+ {
+ AvailableCalendarGroups.Add(group);
+ }
+ });
+ }
+
+ 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 BuildResultAsync(List uniqueAttendees)
+ {
+ if (RecurrenceEndDate.HasValue && RecurrenceEndDate.Value.Date < StartDate.Date)
+ {
+ throw new CalendarEventComposeValidationException(Translator.CalendarEventCompose_ValidationInvalidRecurrenceEnd);
+ }
+
+ var htmlNotes = GetHtmlNotesAsync == null ? string.Empty : await GetHtmlNotesAsync();
+ var effectiveStart = GetEffectiveStartDateTime();
+ var effectiveEnd = GetEffectiveEndDateTime();
+
+ return new CalendarEventComposeResult
+ {
+ CalendarId = SelectedCalendar?.Id ?? Guid.Empty,
+ AccountId = SelectedCalendar?.Account.Id ?? Guid.Empty,
+ 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 ?? CalendarItemShowAs.Busy,
+ SelectedReminders = BuildSelectedReminders(),
+ Attendees = BuildAttendees(uniqueAttendees),
+ Attachments = Attachments.Select(attachment => attachment.ToDraftModel()).ToList(),
+ Recurrence = BuildRecurrenceRule(),
+ RecurrenceSummary = RecurrenceSummary
+ };
+ }
+
+ 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));
+ }
+
+}
+
+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/CalendarSettingsPageViewModel.cs b/Wino.Calendar.ViewModels/CalendarSettingsPageViewModel.cs
index d03f2ad4..ebc2b30d 100644
--- a/Wino.Calendar.ViewModels/CalendarSettingsPageViewModel.cs
+++ b/Wino.Calendar.ViewModels/CalendarSettingsPageViewModel.cs
@@ -1,20 +1,17 @@
-using System;
+using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Globalization;
using System.Linq;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
-using CommunityToolkit.Mvvm.Input;
-using CommunityToolkit.Mvvm.Messaging;
+using Wino.Calendar.ViewModels.Data;
using Wino.Core.Domain;
using Wino.Core.Domain.Entities.Shared;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Translations;
using Wino.Core.ViewModels;
-using Wino.Messaging.Client.Calendar;
-using Wino.Messaging.Client.Navigation;
namespace Wino.Calendar.ViewModels;
@@ -56,12 +53,21 @@ public partial class CalendarSettingsPageViewModel : CalendarBaseViewModel
[ObservableProperty]
public partial int SelectedDefaultSnoozeIndex { get; set; }
+ public ObservableCollection Accounts { get; } = [];
+ public ObservableCollection NewEventBehaviorOptions { get; } = [];
+ public ObservableCollection AvailableNewEventCalendars { get; } = [];
+
+ [ObservableProperty]
+ public partial CalendarNewEventBehaviorOption SelectedNewEventBehaviorOption { get; set; }
+
+ [ObservableProperty]
+ public partial AccountCalendarViewModel SelectedNewEventCalendar { get; set; }
+
+ public bool ShouldShowSpecificNewEventCalendar => SelectedNewEventBehaviorOption?.Behavior == NewEventButtonBehavior.AlwaysUseSpecificCalendar;
+
public IPreferencesService PreferencesService { get; }
private readonly ICalendarService _calendarService;
private readonly IAccountService _accountService;
-
- public ObservableCollection Accounts { get; } = new ObservableCollection();
-
private readonly bool _isLoaded = false;
public CalendarSettingsPageViewModel(IPreferencesService preferencesService, ICalendarService calendarService, IAccountService accountService)
@@ -71,10 +77,8 @@ public partial class CalendarSettingsPageViewModel : CalendarBaseViewModel
_accountService = accountService;
var currentLanguageLanguageCode = WinoTranslationDictionary.GetLanguageFileNameRelativePath(preferencesService.CurrentLanguage);
-
var cultureInfo = new CultureInfo(currentLanguageLanguageCode);
- // Populate the day names list
for (var i = 0; i < 7; i++)
{
DayNames.Add(cultureInfo.DateTimeFormat.DayNames[i]);
@@ -89,7 +93,6 @@ public partial class CalendarSettingsPageViewModel : CalendarBaseViewModel
WorkingDayStartIndex = DayNames.IndexOf(cultureInfo.DateTimeFormat.GetDayName(preferencesService.WorkingDayStart));
WorkingDayEndIndex = DayNames.IndexOf(cultureInfo.DateTimeFormat.GetDayName(preferencesService.WorkingDayEnd));
- // Initialize reminder options
var predefinedMinutes = _calendarService.GetPredefinedReminderMinutes();
ReminderOptions.Add("None");
foreach (var minutes in predefinedMinutes)
@@ -102,10 +105,9 @@ public partial class CalendarSettingsPageViewModel : CalendarBaseViewModel
ReminderOptions.Add(displayText);
}
- // Set selected index based on current default reminder setting
if (preferencesService.DefaultReminderDurationInSeconds == 0)
{
- SelectedDefaultReminderIndex = 0; // None
+ SelectedDefaultReminderIndex = 0;
}
else
{
@@ -123,35 +125,47 @@ public partial class CalendarSettingsPageViewModel : CalendarBaseViewModel
var selectedSnoozeIndex = Array.IndexOf(supportedSnoozeMinutes, preferencesService.DefaultSnoozeDurationInMinutes);
SelectedDefaultSnoozeIndex = selectedSnoozeIndex >= 0 ? selectedSnoozeIndex : 0;
+ NewEventBehaviorOptions.Add(new CalendarNewEventBehaviorOption(NewEventButtonBehavior.AskEachTime, Translator.CalendarSettings_NewEventBehavior_AskEachTime));
+ NewEventBehaviorOptions.Add(new CalendarNewEventBehaviorOption(NewEventButtonBehavior.AlwaysUseSpecificCalendar, Translator.CalendarSettings_NewEventBehavior_AlwaysUseSpecificCalendar));
+ SelectedNewEventBehaviorOption = NewEventBehaviorOptions.FirstOrDefault(option => option.Behavior == preferencesService.NewEventButtonBehavior)
+ ?? NewEventBehaviorOptions.First();
+
_isLoaded = true;
- // Load accounts with calendar support
LoadAccountsAsync();
}
private async void LoadAccountsAsync()
{
- var accounts = await _accountService.GetAccountsAsync();
-
+ var accounts = await _accountService.GetAccountsAsync().ConfigureAwait(false);
+ var calendarsByAccount = new List<(MailAccount Account, List Calendars)>();
+
+ foreach (var account in accounts)
+ {
+ var calendars = await _calendarService.GetAccountCalendarsAsync(account.Id).ConfigureAwait(false);
+ calendarsByAccount.Add((account, calendars.Select(calendar => new AccountCalendarViewModel(account, calendar)).ToList()));
+ }
+
await Dispatcher.ExecuteOnUIThread(() =>
{
Accounts.Clear();
+ AvailableNewEventCalendars.Clear();
+
foreach (var account in accounts)
{
Accounts.Add(account);
}
- });
- }
- [RelayCommand]
- private void NavigateToAccountSettings(MailAccount account)
- {
- if (account == null) return;
-
- Messenger.Send(new BreadcrumbNavigationRequested(
- string.Format(Translator.CalendarAccountSettings_Description, account.Name),
- WinoPage.CalendarAccountSettingsPage,
- account.Id));
+ foreach (var accountCalendars in calendarsByAccount)
+ {
+ foreach (var calendar in accountCalendars.Calendars)
+ {
+ AvailableNewEventCalendars.Add(calendar);
+ }
+ }
+
+ ApplyStoredNewEventCalendarPreference();
+ });
}
partial void OnCellHourHeightChanged(double oldValue, double newValue) => SaveSettings();
@@ -163,10 +177,17 @@ public partial class CalendarSettingsPageViewModel : CalendarBaseViewModel
partial void OnWorkingDayEndIndexChanged(int value) => SaveSettings();
partial void OnSelectedDefaultReminderIndexChanged(int value) => SaveSettings();
partial void OnSelectedDefaultSnoozeIndexChanged(int value) => SaveSettings();
+ partial void OnSelectedNewEventBehaviorOptionChanged(CalendarNewEventBehaviorOption value)
+ {
+ OnPropertyChanged(nameof(ShouldShowSpecificNewEventCalendar));
+ SaveSettings();
+ }
+ partial void OnSelectedNewEventCalendarChanged(AccountCalendarViewModel value) => SaveSettings();
public void SaveSettings()
{
- if (!_isLoaded) return;
+ if (!_isLoaded)
+ return;
PreferencesService.FirstDayOfWeek = SelectedFirstDayOfWeekIndex switch
{
@@ -209,10 +230,9 @@ public partial class CalendarSettingsPageViewModel : CalendarBaseViewModel
PreferencesService.WorkingHourEnd = WorkingHourEnd;
PreferencesService.HourHeight = CellHourHeight;
- // Save default reminder setting
if (SelectedDefaultReminderIndex == 0)
{
- PreferencesService.DefaultReminderDurationInSeconds = 0; // None
+ PreferencesService.DefaultReminderDurationInSeconds = 0;
}
else
{
@@ -228,6 +248,47 @@ public partial class CalendarSettingsPageViewModel : CalendarBaseViewModel
PreferencesService.DefaultSnoozeDurationInMinutes = supportedSnoozeMinutes[selectedIndex];
}
- Messenger.Send(new CalendarSettingsUpdatedMessage());
+ var newEventBehavior = SelectedNewEventBehaviorOption?.Behavior ?? NewEventButtonBehavior.AskEachTime;
+ if (newEventBehavior == NewEventButtonBehavior.AlwaysUseSpecificCalendar && SelectedNewEventCalendar != null)
+ {
+ PreferencesService.NewEventButtonBehavior = NewEventButtonBehavior.AlwaysUseSpecificCalendar;
+ PreferencesService.DefaultNewEventCalendarId = SelectedNewEventCalendar.Id;
+ }
+ else
+ {
+ PreferencesService.NewEventButtonBehavior = NewEventButtonBehavior.AskEachTime;
+ PreferencesService.DefaultNewEventCalendarId = null;
+ }
+ }
+
+ private void ApplyStoredNewEventCalendarPreference()
+ {
+ var configuredCalendarId = PreferencesService.DefaultNewEventCalendarId;
+ var configuredCalendar = configuredCalendarId.HasValue
+ ? AvailableNewEventCalendars.FirstOrDefault(calendar => calendar.Id == configuredCalendarId.Value)
+ : null;
+
+ if (PreferencesService.NewEventButtonBehavior == NewEventButtonBehavior.AlwaysUseSpecificCalendar && configuredCalendar == null)
+ {
+ SelectedNewEventBehaviorOption = NewEventBehaviorOptions.First(option => option.Behavior == NewEventButtonBehavior.AskEachTime);
+ SelectedNewEventCalendar = null;
+ return;
+ }
+
+ SelectedNewEventCalendar = configuredCalendar
+ ?? AvailableNewEventCalendars.FirstOrDefault(calendar => calendar.IsPrimary)
+ ?? AvailableNewEventCalendars.FirstOrDefault();
+ }
+}
+
+public sealed class CalendarNewEventBehaviorOption
+{
+ public NewEventButtonBehavior Behavior { get; }
+ public string DisplayText { get; }
+
+ public CalendarNewEventBehaviorOption(NewEventButtonBehavior behavior, string displayText)
+ {
+ Behavior = behavior;
+ DisplayText = displayText;
}
}
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.Calendar.ViewModels/Wino.Calendar.ViewModels.csproj b/Wino.Calendar.ViewModels/Wino.Calendar.ViewModels.csproj
index c1a3c878..b51d389c 100644
--- a/Wino.Calendar.ViewModels/Wino.Calendar.ViewModels.csproj
+++ b/Wino.Calendar.ViewModels/Wino.Calendar.ViewModels.csproj
@@ -10,7 +10,8 @@
true
-
+
+
diff --git a/Wino.Core.Domain/Enums/NewEventButtonBehavior.cs b/Wino.Core.Domain/Enums/NewEventButtonBehavior.cs
new file mode 100644
index 00000000..9ef143e4
--- /dev/null
+++ b/Wino.Core.Domain/Enums/NewEventButtonBehavior.cs
@@ -0,0 +1,7 @@
+namespace Wino.Core.Domain.Enums;
+
+public enum NewEventButtonBehavior
+{
+ AskEachTime = 0,
+ AlwaysUseSpecificCalendar = 1
+}
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/Exceptions/CalendarEventComposeValidationException.cs b/Wino.Core.Domain/Exceptions/CalendarEventComposeValidationException.cs
new file mode 100644
index 00000000..834606cc
--- /dev/null
+++ b/Wino.Core.Domain/Exceptions/CalendarEventComposeValidationException.cs
@@ -0,0 +1,10 @@
+using System;
+
+namespace Wino.Core.Domain.Exceptions;
+
+public sealed class CalendarEventComposeValidationException : Exception
+{
+ public CalendarEventComposeValidationException(string message) : base(message)
+ {
+ }
+}
diff --git a/Wino.Core.Domain/Interfaces/ICalendarService.cs b/Wino.Core.Domain/Interfaces/ICalendarService.cs
index d41f6dc0..8c41925d 100644
--- a/Wino.Core.Domain/Interfaces/ICalendarService.cs
+++ b/Wino.Core.Domain/Interfaces/ICalendarService.cs
@@ -18,6 +18,7 @@ public interface ICalendarService
Task DeleteAccountCalendarAsync(AccountCalendar accountCalendar);
Task InsertAccountCalendarAsync(AccountCalendar accountCalendar);
Task UpdateAccountCalendarAsync(AccountCalendar accountCalendar);
+ Task SetPrimaryCalendarAsync(Guid accountId, Guid accountCalendarId);
Task CreateNewCalendarItemAsync(CalendarItem calendarItem, List attendees);
///
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/Interfaces/IPreferencesService.cs b/Wino.Core.Domain/Interfaces/IPreferencesService.cs
index 8763b686..21701be5 100644
--- a/Wino.Core.Domain/Interfaces/IPreferencesService.cs
+++ b/Wino.Core.Domain/Interfaces/IPreferencesService.cs
@@ -227,6 +227,16 @@ public interface IPreferencesService : INotifyPropertyChanged
///
int DefaultSnoozeDurationInMinutes { get; set; }
+ ///
+ /// Setting: How the New Event button chooses a calendar.
+ ///
+ NewEventButtonBehavior NewEventButtonBehavior { get; set; }
+
+ ///
+ /// Setting: Default calendar used when New Event is configured to always use a specific calendar.
+ ///
+ Guid? DefaultNewEventCalendarId { get; set; }
+
CalendarSettings GetCurrentCalendarSettings();
#endregion
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..70a2e718 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",
@@ -999,6 +1049,10 @@
"CalendarAccountSettings_DefaultShowAsDescription": "Default availability status for new events created with this account",
"CalendarAccountSettings_PrimaryCalendar": "Primary Calendar",
"CalendarAccountSettings_PrimaryCalendarDescription": "Mark this calendar as the primary calendar for the account",
+ "CalendarSettings_NewEventBehavior_Header": "New Event button behavior",
+ "CalendarSettings_NewEventBehavior_Description": "Choose whether the New Event button should ask for a calendar each time or always open a specific calendar.",
+ "CalendarSettings_NewEventBehavior_AskEachTime": "Ask each time.",
+ "CalendarSettings_NewEventBehavior_AlwaysUseSpecificCalendar": "Always use specific calendar.",
"WhatIsNew_GetStartedButton": "Get Started",
"WhatIsNew_ContinueAnywayButton": "Continue anyway",
"WhatIsNew_PreparingForNewVersionButton": "Preparing for new version...",
diff --git a/Wino.Core.Domain/Validation/CalendarEventComposeResultValidator.cs b/Wino.Core.Domain/Validation/CalendarEventComposeResultValidator.cs
new file mode 100644
index 00000000..5e722f6f
--- /dev/null
+++ b/Wino.Core.Domain/Validation/CalendarEventComposeResultValidator.cs
@@ -0,0 +1,73 @@
+using System;
+using System.IO;
+using System.Linq;
+using System.Net.Mail;
+using Wino.Core.Domain.Exceptions;
+using Wino.Core.Domain.Models.Calendar;
+
+namespace Wino.Core.Domain.Validation;
+
+public sealed class CalendarEventComposeResultValidator
+{
+ public void Validate(CalendarEventComposeResult result)
+ {
+ ArgumentNullException.ThrowIfNull(result);
+
+ if (result.CalendarId == Guid.Empty)
+ throw new CalendarEventComposeValidationException(Translator.CalendarEventCompose_ValidationMissingCalendar);
+
+ if (result.AccountId == Guid.Empty)
+ throw new CalendarEventComposeValidationException(Translator.CalendarEventCompose_ValidationMissingCalendar);
+
+ if (string.IsNullOrWhiteSpace(result.Title))
+ throw new CalendarEventComposeValidationException(Translator.CalendarEventCompose_ValidationMissingTitle);
+
+ if (result.EndDate <= result.StartDate)
+ {
+ var message = result.IsAllDay
+ ? Translator.CalendarEventCompose_ValidationInvalidAllDayRange
+ : Translator.CalendarEventCompose_ValidationInvalidTimeRange;
+
+ throw new CalendarEventComposeValidationException(message);
+ }
+
+ var missingAttachments = result.Attachments
+ .Where(attachment => string.IsNullOrWhiteSpace(attachment.FilePath) || !File.Exists(attachment.FilePath))
+ .Select(attachment => attachment.FileName)
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .ToList();
+
+ if (missingAttachments.Count > 0)
+ {
+ throw new CalendarEventComposeValidationException(
+ string.Format(Translator.CalendarEventCompose_ValidationMissingAttachment, string.Join(", ", missingAttachments)));
+ }
+
+ var invalidAttendee = result.Attendees
+ .FirstOrDefault(attendee => string.IsNullOrWhiteSpace(attendee.Email) || !IsValidEmailAddress(attendee.Email.Trim()));
+
+ if (invalidAttendee != null)
+ throw new CalendarEventComposeValidationException(Translator.CalendarEventCompose_ValidationInvalidAttendee);
+
+ var duplicateAttendeeGroups = result.Attendees
+ .Where(attendee => !string.IsNullOrWhiteSpace(attendee.Email))
+ .GroupBy(attendee => attendee.Email.Trim(), StringComparer.OrdinalIgnoreCase)
+ .FirstOrDefault(group => group.Count() > 1);
+
+ if (duplicateAttendeeGroups != null)
+ throw new CalendarEventComposeValidationException(Translator.CalendarEventCompose_ValidationInvalidAttendee);
+ }
+
+ private static bool IsValidEmailAddress(string address)
+ {
+ try
+ {
+ var parsedAddress = new MailAddress(address);
+ return parsedAddress.Address.Equals(address, StringComparison.OrdinalIgnoreCase);
+ }
+ catch
+ {
+ return false;
+ }
+ }
+}
diff --git a/Wino.Core.Tests/Services/CalendarEventComposeResultValidatorTests.cs b/Wino.Core.Tests/Services/CalendarEventComposeResultValidatorTests.cs
new file mode 100644
index 00000000..31a7ad85
--- /dev/null
+++ b/Wino.Core.Tests/Services/CalendarEventComposeResultValidatorTests.cs
@@ -0,0 +1,166 @@
+using FluentAssertions;
+using System.IO;
+using Wino.Core.Domain;
+using Wino.Core.Domain.Entities.Calendar;
+using Wino.Core.Domain.Entities.Shared;
+using Wino.Core.Domain.Exceptions;
+using Wino.Core.Domain.Models.Calendar;
+using Wino.Core.Domain.Validation;
+using Xunit;
+
+namespace Wino.Core.Tests.Services;
+
+public sealed class CalendarEventComposeResultValidatorTests
+{
+ private readonly CalendarEventComposeResultValidator _validator = new();
+
+ [Fact]
+ public void Validate_WhenResultIsValid_DoesNotThrow()
+ {
+ var tempFilePath = Path.GetTempFileName();
+
+ try
+ {
+ var result = CreateValidResult();
+ result.Attachments.Add(new CalendarEventComposeAttachmentDraft
+ {
+ Id = Guid.NewGuid(),
+ FileName = Path.GetFileName(tempFilePath),
+ FilePath = tempFilePath,
+ FileExtension = ".tmp",
+ Size = 12
+ });
+
+ Action act = () => _validator.Validate(result);
+
+ act.Should().NotThrow();
+ }
+ finally
+ {
+ File.Delete(tempFilePath);
+ }
+ }
+
+ [Fact]
+ public void Validate_WhenEndDateIsBeforeStartDate_ThrowsValidationException()
+ {
+ var result = CreateValidResult();
+ result.EndDate = result.StartDate.AddMinutes(-30);
+
+ Action act = () => _validator.Validate(result);
+
+ act.Should()
+ .Throw()
+ .WithMessage(Translator.CalendarEventCompose_ValidationInvalidTimeRange);
+ }
+
+ [Fact]
+ public void Validate_WhenAllDayEndDateMatchesStartDate_ThrowsValidationException()
+ {
+ var result = CreateValidResult();
+ result.IsAllDay = true;
+ result.EndDate = result.StartDate;
+
+ Action act = () => _validator.Validate(result);
+
+ act.Should()
+ .Throw()
+ .WithMessage(Translator.CalendarEventCompose_ValidationInvalidAllDayRange);
+ }
+
+ [Fact]
+ public void Validate_WhenAttachmentDoesNotExist_ThrowsValidationException()
+ {
+ var result = CreateValidResult();
+ result.Attachments.Add(new CalendarEventComposeAttachmentDraft
+ {
+ Id = Guid.NewGuid(),
+ FileName = "missing.txt",
+ FilePath = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid():N}.txt"),
+ FileExtension = ".txt",
+ Size = 42
+ });
+
+ Action act = () => _validator.Validate(result);
+
+ act.Should()
+ .Throw()
+ .WithMessage(string.Format(Translator.CalendarEventCompose_ValidationMissingAttachment, "missing.txt"));
+ }
+
+ [Fact]
+ public void Validate_WhenAttendeeEmailIsInvalid_ThrowsValidationException()
+ {
+ var result = CreateValidResult();
+ result.Attendees.Add(new CalendarEventAttendee
+ {
+ Id = Guid.NewGuid(),
+ CalendarItemId = Guid.Empty,
+ Email = "not-an-email"
+ });
+
+ Action act = () => _validator.Validate(result);
+
+ act.Should()
+ .Throw()
+ .WithMessage(Translator.CalendarEventCompose_ValidationInvalidAttendee);
+ }
+
+ [Fact]
+ public void Validate_WhenAttendeeEmailIsDuplicated_ThrowsValidationException()
+ {
+ var result = CreateValidResult();
+ result.Attendees.Add(new CalendarEventAttendee
+ {
+ Id = Guid.NewGuid(),
+ CalendarItemId = Guid.Empty,
+ Email = "person@example.com"
+ });
+ result.Attendees.Add(new CalendarEventAttendee
+ {
+ Id = Guid.NewGuid(),
+ CalendarItemId = Guid.Empty,
+ Email = "PERSON@example.com"
+ });
+
+ Action act = () => _validator.Validate(result);
+
+ act.Should()
+ .Throw()
+ .WithMessage(Translator.CalendarEventCompose_ValidationInvalidAttendee);
+ }
+
+ [Fact]
+ public void Validate_WhenCalendarIdIsMissing_ThrowsValidationException()
+ {
+ var result = CreateValidResult();
+ result.CalendarId = Guid.Empty;
+
+ Action act = () => _validator.Validate(result);
+
+ act.Should()
+ .Throw()
+ .WithMessage(Translator.CalendarEventCompose_ValidationMissingCalendar);
+ }
+
+ private static CalendarEventComposeResult CreateValidResult()
+ {
+ return new CalendarEventComposeResult
+ {
+ CalendarId = Guid.NewGuid(),
+ AccountId = Guid.NewGuid(),
+ Title = "Design review",
+ StartDate = new DateTime(2026, 3, 7, 13, 30, 0),
+ EndDate = new DateTime(2026, 3, 7, 14, 0, 0),
+ TimeZoneId = TimeZoneInfo.Local.Id,
+ SelectedReminders =
+ [
+ new Reminder
+ {
+ Id = Guid.NewGuid(),
+ DurationInSeconds = 900
+ }
+ ]
+ };
+ }
+}
diff --git a/Wino.Mail.ViewModels/AccountDetailsPageViewModel.cs b/Wino.Mail.ViewModels/AccountDetailsPageViewModel.cs
index 6c24aa07..7f24642e 100644
--- a/Wino.Mail.ViewModels/AccountDetailsPageViewModel.cs
+++ b/Wino.Mail.ViewModels/AccountDetailsPageViewModel.cs
@@ -7,8 +7,8 @@ using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using CommunityToolkit.Mvvm.Messaging;
-using Wino.Core.Domain;
using Wino.Core.Domain.Entities.Calendar;
+using Wino.Core.Domain;
using Wino.Core.Domain.Entities.Shared;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Interfaces;
@@ -33,6 +33,11 @@ public partial class AccountDetailsPageViewModel : MailBaseViewModel
public MailAccount Account { get; set; }
public ObservableCollection CurrentFolders { get; set; } = [];
public ObservableCollection AccountCalendars { get; set; } = [];
+ public ObservableCollection AccountCalendarSettingsItems { get; } = [];
+ public ObservableCollection ShowAsOptions { get; } = [];
+
+ [ObservableProperty]
+ public partial AccountCalendar SelectedPrimaryCalendar { get; set; }
[ObservableProperty]
public partial int SelectedTabIndex { get; set; } = 1; // Default to Mail tab
@@ -70,6 +75,12 @@ public partial class AccountDetailsPageViewModel : MailBaseViewModel
_folderService = folderService;
_calendarService = calendarService;
_statePersistanceService = statePersistanceService;
+
+ ShowAsOptions.Add(new AccountCalendarShowAsOption(CalendarItemShowAs.Free, Translator.CalendarShowAs_Free));
+ ShowAsOptions.Add(new AccountCalendarShowAsOption(CalendarItemShowAs.Tentative, Translator.CalendarShowAs_Tentative));
+ ShowAsOptions.Add(new AccountCalendarShowAsOption(CalendarItemShowAs.Busy, Translator.CalendarShowAs_Busy));
+ ShowAsOptions.Add(new AccountCalendarShowAsOption(CalendarItemShowAs.OutOfOffice, Translator.CalendarShowAs_OutOfOffice));
+ ShowAsOptions.Add(new AccountCalendarShowAsOption(CalendarItemShowAs.WorkingElsewhere, Translator.CalendarShowAs_WorkingElsewhere));
}
[RelayCommand]
@@ -164,21 +175,41 @@ public partial class AccountDetailsPageViewModel : MailBaseViewModel
private async Task LoadAccountCalendarsAsync()
{
var calendars = await _calendarService.GetAccountCalendarsAsync(Account.Id);
-
- AccountCalendars.Clear();
- foreach (var calendar in calendars)
+
+ await ExecuteUIThread(() =>
{
- AccountCalendars.Add(calendar);
- }
+ AccountCalendars.Clear();
+ AccountCalendarSettingsItems.Clear();
+
+ foreach (var calendar in calendars)
+ {
+ AccountCalendars.Add(calendar);
+ AccountCalendarSettingsItems.Add(new AccountCalendarSettingsItemViewModel(calendar, ShowAsOptions));
+ }
+ });
+
+ SelectedPrimaryCalendar = AccountCalendars.FirstOrDefault(calendar => calendar.IsPrimary) ?? AccountCalendars.FirstOrDefault();
}
- [RelayCommand]
- private void CalendarItemClicked(AccountCalendar calendar)
- {
- if (calendar == null) return;
+ public AccountCalendarShowAsOption GetShowAsOption(CalendarItemShowAs showAs)
+ => ShowAsOptions.FirstOrDefault(option => option.ShowAs == showAs) ?? ShowAsOptions.First();
- // Navigate to calendar settings page with breadcrumb
- Messenger.Send(new BreadcrumbNavigationRequested(calendar.Name, WinoPage.CalendarAccountSettingsPage, calendar));
+ public async Task UpdateCalendarSynchronizationAsync(AccountCalendar calendar, bool isEnabled)
+ {
+ if (calendar == null || calendar.IsSynchronizationEnabled == isEnabled)
+ return;
+
+ calendar.IsSynchronizationEnabled = isEnabled;
+ await _calendarService.UpdateAccountCalendarAsync(calendar);
+ }
+
+ public async Task UpdateCalendarDefaultShowAsAsync(AccountCalendar calendar, AccountCalendarShowAsOption option)
+ {
+ if (calendar == null || option == null || calendar.DefaultShowAs == option.ShowAs)
+ return;
+
+ calendar.DefaultShowAs = option.ShowAs;
+ await _calendarService.UpdateAccountCalendarAsync(calendar);
}
protected override async void OnPropertyChanged(PropertyChangedEventArgs e)
@@ -209,6 +240,50 @@ public partial class AccountDetailsPageViewModel : MailBaseViewModel
Account.Preferences.IsTaskbarBadgeEnabled = IsTaskbarBadgeEnabled;
await _accountService.UpdateAccountAsync(Account);
break;
+ case nameof(SelectedPrimaryCalendar) when SelectedPrimaryCalendar != null:
+ foreach (var calendar in AccountCalendars)
+ {
+ calendar.IsPrimary = calendar.Id == SelectedPrimaryCalendar.Id;
+ }
+
+ await _calendarService.SetPrimaryCalendarAsync(Account.Id, SelectedPrimaryCalendar.Id);
+ break;
}
}
}
+
+public sealed class AccountCalendarShowAsOption
+{
+ public CalendarItemShowAs ShowAs { get; }
+ public string DisplayText { get; }
+
+ public AccountCalendarShowAsOption(CalendarItemShowAs showAs, string displayText)
+ {
+ ShowAs = showAs;
+ DisplayText = displayText;
+ }
+}
+
+public partial class AccountCalendarSettingsItemViewModel : ObservableObject
+{
+ public AccountCalendar Calendar { get; }
+ public ObservableCollection ShowAsOptions { get; }
+
+ public string Name => Calendar.Name;
+ public string TimeZone => Calendar.TimeZone;
+ public string BackgroundColorHex => Calendar.BackgroundColorHex;
+
+ [ObservableProperty]
+ public partial bool IsSynchronizationEnabled { get; set; }
+
+ [ObservableProperty]
+ public partial AccountCalendarShowAsOption SelectedShowAsOption { get; set; }
+
+ public AccountCalendarSettingsItemViewModel(AccountCalendar calendar, ObservableCollection showAsOptions)
+ {
+ Calendar = calendar;
+ ShowAsOptions = showAsOptions;
+ IsSynchronizationEnabled = calendar.IsSynchronizationEnabled;
+ SelectedShowAsOption = showAsOptions.FirstOrDefault(option => option.ShowAs == calendar.DefaultShowAs) ?? showAsOptions.FirstOrDefault();
+ }
+}
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 65918995..06cc8a00 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..62f2d4b8
--- /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/MailAppShell.xaml b/Wino.Mail.WinUI/MailAppShell.xaml
index 4eda9d55..bd4bf68d 100644
--- a/Wino.Mail.WinUI/MailAppShell.xaml
+++ b/Wino.Mail.WinUI/MailAppShell.xaml
@@ -157,6 +157,7 @@
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/Services/PreferencesService.cs b/Wino.Mail.WinUI/Services/PreferencesService.cs
index 5c321dd1..50b8a433 100644
--- a/Wino.Mail.WinUI/Services/PreferencesService.cs
+++ b/Wino.Mail.WinUI/Services/PreferencesService.cs
@@ -290,6 +290,18 @@ public class PreferencesService(IConfigurationService configurationService) : Ob
set => SaveProperty(propertyName: nameof(DefaultSnoozeDurationInMinutes), value);
}
+ public NewEventButtonBehavior NewEventButtonBehavior
+ {
+ get => _configurationService.Get(nameof(NewEventButtonBehavior), NewEventButtonBehavior.AskEachTime);
+ set => SetPropertyAndSave(nameof(NewEventButtonBehavior), value);
+ }
+
+ public Guid? DefaultNewEventCalendarId
+ {
+ get => _configurationService.Get(nameof(DefaultNewEventCalendarId), null);
+ set => SetPropertyAndSave(nameof(DefaultNewEventCalendarId), value);
+ }
+
public int EmailSyncIntervalMinutes
{
get => _configurationService.Get(nameof(EmailSyncIntervalMinutes), 3);
diff --git a/Wino.Mail.WinUI/Styles/WebViewEditorControl.xaml b/Wino.Mail.WinUI/Styles/WebViewEditorControl.xaml
index fe7e608e..4e0cb9cf 100644
--- a/Wino.Mail.WinUI/Styles/WebViewEditorControl.xaml
+++ b/Wino.Mail.WinUI/Styles/WebViewEditorControl.xaml
@@ -9,7 +9,7 @@
-
+
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/Account/AccountDetailsPage.xaml b/Wino.Mail.WinUI/Views/Account/AccountDetailsPage.xaml
index 8ce44c18..2e550af7 100644
--- a/Wino.Mail.WinUI/Views/Account/AccountDetailsPage.xaml
+++ b/Wino.Mail.WinUI/Views/Account/AccountDetailsPage.xaml
@@ -14,6 +14,7 @@
xmlns:interactivity="using:Microsoft.Xaml.Interactivity"
xmlns:interfaces="using:Wino.Core.Domain.Interfaces"
xmlns:local="using:Wino.Views"
+ xmlns:mailViewModels="using:Wino.Mail.ViewModels"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:muxc="using:Microsoft.UI.Xaml.Controls"
xmlns:ui="using:CommunityToolkit.WinUI"
@@ -50,7 +51,7 @@
VerticalAlignment="Center"
Checked="SyncFolderToggled"
IsChecked="{x:Bind IsSynchronizationEnabled, Mode=OneWay}"
- Tag="{Binding}"
+ Tag="{x:Bind}"
Unchecked="SyncFolderToggled"
Visibility="{x:Bind IsMoveTarget}" />
@@ -61,7 +62,7 @@
VerticalAlignment="Center"
Checked="UnreadBadgeCheckboxToggled"
IsChecked="{x:Bind ShowUnreadCount, Mode=OneWay}"
- Tag="{Binding}"
+ Tag="{x:Bind}"
Unchecked="UnreadBadgeCheckboxToggled"
Visibility="{x:Bind IsMoveTarget}" />
@@ -296,27 +297,72 @@
Visibility="Collapsed">
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
-
-
+
-
-
-
-
-
+ IsExpanded="False">
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Wino.Mail.WinUI/Views/Account/AccountDetailsPage.xaml.cs b/Wino.Mail.WinUI/Views/Account/AccountDetailsPage.xaml.cs
index 712f782b..b23b96f5 100644
--- a/Wino.Mail.WinUI/Views/Account/AccountDetailsPage.xaml.cs
+++ b/Wino.Mail.WinUI/Views/Account/AccountDetailsPage.xaml.cs
@@ -1,8 +1,8 @@
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Navigation;
-using Wino.Core.Domain.Entities.Calendar;
using Wino.Core.Domain.Models.Folders;
+using Wino.Mail.ViewModels;
using Wino.Views.Abstract;
namespace Wino.Views;
@@ -37,11 +37,21 @@ public sealed partial class AccountDetailsPage : AccountDetailsPageAbstract
}
}
- private void CalendarItemClicked(object sender, RoutedEventArgs e)
+ private async void CalendarSynchronizationToggled(object sender, RoutedEventArgs e)
{
- if (sender is CommunityToolkit.WinUI.Controls.SettingsCard settingsCard && settingsCard.CommandParameter is AccountCalendar calendar)
+ if (sender is ToggleSwitch { Tag: AccountCalendarSettingsItemViewModel calendarItem } toggleSwitch)
{
- ViewModel.CalendarItemClickedCommand?.Execute(calendar);
+ calendarItem.IsSynchronizationEnabled = toggleSwitch.IsOn;
+ await ViewModel.UpdateCalendarSynchronizationAsync(calendarItem.Calendar, toggleSwitch.IsOn);
+ }
+ }
+
+ private async void CalendarShowAsSelectionChanged(object sender, SelectionChangedEventArgs e)
+ {
+ if (sender is ComboBox { Tag: AccountCalendarSettingsItemViewModel calendarItem, SelectedItem: AccountCalendarShowAsOption option })
+ {
+ calendarItem.SelectedShowAsOption = option;
+ await ViewModel.UpdateCalendarDefaultShowAsAsync(calendarItem.Calendar, option);
}
}
diff --git a/Wino.Mail.WinUI/Views/Calendar/CalendarAccountSettingsPage.xaml b/Wino.Mail.WinUI/Views/Calendar/CalendarAccountSettingsPage.xaml
index 91959143..35e029f5 100644
--- a/Wino.Mail.WinUI/Views/Calendar/CalendarAccountSettingsPage.xaml
+++ b/Wino.Mail.WinUI/Views/Calendar/CalendarAccountSettingsPage.xaml
@@ -3,6 +3,7 @@
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:abstract="using:Wino.Mail.WinUI.Views.Abstract"
+ xmlns:calendarViewModels="using:Wino.Calendar.ViewModels"
xmlns:controls="using:CommunityToolkit.WinUI.Controls"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:domain="using:Wino.Core.Domain"
@@ -56,16 +57,6 @@
-
-
-
-
-
-
-
-
+ SelectedItem="{x:Bind ViewModel.SelectedDefaultShowAsOption, Mode=TwoWay}">
+
+
+
+
+
+
diff --git a/Wino.Mail.WinUI/Views/Calendar/CalendarAppShell.xaml b/Wino.Mail.WinUI/Views/Calendar/CalendarAppShell.xaml
index 432712fe..e90f6f88 100644
--- a/Wino.Mail.WinUI/Views/Calendar/CalendarAppShell.xaml
+++ b/Wino.Mail.WinUI/Views/Calendar/CalendarAppShell.xaml
@@ -82,10 +82,10 @@
VerticalAlignment="Center"
HorizontalContentAlignment="Left"
Background="Transparent"
+ DisplayType="{x:Bind ViewModel.StatePersistenceService.CalendarDisplayType, Mode=OneWay}"
FontSize="14"
FontWeight="Normal"
IsHitTestVisible="False"
- DisplayType="{x:Bind ViewModel.StatePersistenceService.CalendarDisplayType, Mode=OneWay}"
ItemsSource="{x:Bind ViewModel.DateNavigationHeaderItems}"
SelectedIndex="{x:Bind ViewModel.SelectedDateNavigationHeaderIndex, Mode=OneWay}">
@@ -149,14 +149,37 @@
+
+
+
@@ -261,13 +284,16 @@
-
+
diff --git a/Wino.Mail.WinUI/Views/Calendar/CalendarEventComposePage.xaml b/Wino.Mail.WinUI/Views/Calendar/CalendarEventComposePage.xaml
new file mode 100644
index 00000000..8157ed93
--- /dev/null
+++ b/Wino.Mail.WinUI/Views/Calendar/CalendarEventComposePage.xaml
@@ -0,0 +1,585 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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..d4d3ab0e
--- /dev/null
+++ b/Wino.Mail.WinUI/Views/Calendar/CalendarEventComposePage.xaml.cs
@@ -0,0 +1,165 @@
+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);
+ }
+ }
+
+ private void ComposeCalendarClicked(object sender, ItemClickEventArgs e)
+ {
+ if (e.ClickedItem is AccountCalendarViewModel calendar)
+ {
+ ViewModel.SelectedCalendar = calendar;
+ }
+ }
+
+ 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);
+ }
+}
diff --git a/Wino.Mail.WinUI/Views/Calendar/CalendarSettingsPage.xaml b/Wino.Mail.WinUI/Views/Calendar/CalendarSettingsPage.xaml
index cfe95323..ed9720e5 100644
--- a/Wino.Mail.WinUI/Views/Calendar/CalendarSettingsPage.xaml
+++ b/Wino.Mail.WinUI/Views/Calendar/CalendarSettingsPage.xaml
@@ -3,9 +3,11 @@
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:abstract="using:Wino.Mail.WinUI.Views.Abstract"
+ xmlns:calendarViewModels="using:Wino.Calendar.ViewModels"
xmlns:controls="using:CommunityToolkit.WinUI.Controls"
xmlns:controls1="using:Microsoft.UI.Xaml.Controls"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
+ xmlns:data="using:Wino.Calendar.ViewModels.Data"
xmlns:domain="using:Wino.Core.Domain"
xmlns:entities="using:Wino.Core.Domain.Entities.Shared"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
@@ -236,9 +238,7 @@
-
+
@@ -246,6 +246,40 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Wino.Mail.WinUI/Views/Calendar/EventDetailsPage.xaml b/Wino.Mail.WinUI/Views/Calendar/EventDetailsPage.xaml
index 86ceaf9b..d90722ba 100644
--- a/Wino.Mail.WinUI/Views/Calendar/EventDetailsPage.xaml
+++ b/Wino.Mail.WinUI/Views/Calendar/EventDetailsPage.xaml
@@ -179,9 +179,14 @@
+ SelectedItem="{x:Bind ViewModel.SelectedShowAsOption, Mode=TwoWay}">
+
+
+
+
+
+
diff --git a/Wino.Services/CalendarService.cs b/Wino.Services/CalendarService.cs
index 2c46ff2c..9a3d0f42 100644
--- a/Wino.Services/CalendarService.cs
+++ b/Wino.Services/CalendarService.cs
@@ -54,11 +54,41 @@ public class CalendarService : BaseDatabaseService, ICalendarService
public async Task UpdateAccountCalendarAsync(AccountCalendar accountCalendar)
{
+ if (accountCalendar.IsPrimary)
+ {
+ await Connection.ExecuteAsync(
+ "UPDATE AccountCalendar SET IsPrimary = 0 WHERE AccountId = ? AND Id != ?",
+ accountCalendar.AccountId,
+ accountCalendar.Id);
+ }
+
await Connection.UpdateAsync(accountCalendar, typeof(AccountCalendar));
WeakReferenceMessenger.Default.Send(new CalendarListUpdated(accountCalendar));
}
+ public async Task SetPrimaryCalendarAsync(Guid accountId, Guid accountCalendarId)
+ {
+ await Connection.RunInTransactionAsync(connection =>
+ {
+ connection.Execute(
+ "UPDATE AccountCalendar SET IsPrimary = 0 WHERE AccountId = ?",
+ accountId);
+
+ connection.Execute(
+ "UPDATE AccountCalendar SET IsPrimary = 1 WHERE AccountId = ? AND Id = ?",
+ accountId,
+ accountCalendarId);
+ });
+
+ var calendars = await GetAccountCalendarsAsync(accountId).ConfigureAwait(false);
+
+ foreach (var calendar in calendars)
+ {
+ WeakReferenceMessenger.Default.Send(new CalendarListUpdated(calendar));
+ }
+ }
+
public async Task DeleteAccountCalendarAsync(AccountCalendar accountCalendar)
{
await Connection.ExecuteAsync(