From e94cce451f62f672e35d009b42b96ec043c44c96 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Burak=20Kaan=20K=C3=B6se?= Date: Sat, 7 Mar 2026 01:46:07 +0100 Subject: [PATCH] Event compose implementation. --- .../CalendarAccountSettingsPageViewModel.cs | 13 - .../CalendarAppShellViewModel.cs | 77 +- .../CalendarEventComposePageViewModel.cs | 153 ++- .../CalendarSettingsPageViewModel.cs | 123 ++- .../Wino.Calendar.ViewModels.csproj | 3 +- .../Enums/NewEventButtonBehavior.cs | 7 + ...CalendarEventComposeValidationException.cs | 10 + .../Interfaces/ICalendarService.cs | 1 + .../Interfaces/IPreferencesService.cs | 10 + .../Translations/en_US/resources.json | 4 + .../CalendarEventComposeResultValidator.cs | 73 ++ ...alendarEventComposeResultValidatorTests.cs | 166 +++ .../AccountDetailsPageViewModel.cs | 99 +- .../Dialogs/SingleCalendarPickerDialog.xaml | 14 +- Wino.Mail.WinUI/MailAppShell.xaml | 1 + .../Services/PreferencesService.cs | 12 + .../Styles/WebViewEditorControl.xaml | 2 +- .../Views/Account/AccountDetailsPage.xaml | 84 +- .../Views/Account/AccountDetailsPage.xaml.cs | 18 +- .../Calendar/CalendarAccountSettingsPage.xaml | 20 +- .../Views/Calendar/CalendarAppShell.xaml | 28 +- .../Calendar/CalendarEventComposePage.xaml | 954 +++++++++--------- .../Calendar/CalendarEventComposePage.xaml.cs | 8 + .../Views/Calendar/CalendarSettingsPage.xaml | 40 +- .../Views/Calendar/EventDetailsPage.xaml | 9 +- Wino.Services/CalendarService.cs | 30 + 26 files changed, 1285 insertions(+), 674 deletions(-) create mode 100644 Wino.Core.Domain/Enums/NewEventButtonBehavior.cs create mode 100644 Wino.Core.Domain/Exceptions/CalendarEventComposeValidationException.cs create mode 100644 Wino.Core.Domain/Validation/CalendarEventComposeResultValidator.cs create mode 100644 Wino.Core.Tests/Services/CalendarEventComposeResultValidatorTests.cs 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 40989bb8..ed992c09 100644 --- a/Wino.Calendar.ViewModels/CalendarAppShellViewModel.cs +++ b/Wino.Calendar.ViewModels/CalendarAppShellViewModel.cs @@ -9,6 +9,7 @@ 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; @@ -122,12 +123,14 @@ public partial class CalendarAppShellViewModel : CalendarBaseViewModel, if (mode == NavigationMode.Back) { await InitializeAccountCalendarsAsync(); + ValidateConfiguredNewEventCalendar(); return; } UpdateDateNavigationHeaderItems(); await InitializeAccountCalendarsAsync(); + ValidateConfiguredNewEventCalendar(); await ShowWhatIsNewIfNeededAsync(); @@ -311,25 +314,31 @@ public partial class CalendarAppShellViewModel : CalendarBaseViewModel, [RelayCommand] private async Task NewEventAsync() { - var availableGroups = AccountCalendarStateService.GroupedAccountCalendars - .Where(group => group.AccountCalendars.Count > 0) - .Select(group => new CalendarPickerAccountGroup - { - Account = group.Account, - Calendars = group.AccountCalendars.Select(calendar => calendar.AccountCalendar).ToList() - }) - .ToList(); + var pickedCalendar = TryResolveConfiguredNewEventCalendar(); - if (availableGroups.Count == 0) + if (pickedCalendar == null) { - _dialogService.InfoBarMessage( - Translator.CalendarEventCompose_NoCalendarsTitle, - Translator.CalendarEventCompose_NoCalendarsMessage, - InfoBarMessageType.Warning); - return; + 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); } - var pickedCalendar = await _dialogService.ShowSingleCalendarPickerDialogAsync(availableGroups); if (pickedCalendar == null) return; @@ -472,7 +481,43 @@ 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() { diff --git a/Wino.Calendar.ViewModels/CalendarEventComposePageViewModel.cs b/Wino.Calendar.ViewModels/CalendarEventComposePageViewModel.cs index e04fb31b..49ac9760 100644 --- a/Wino.Calendar.ViewModels/CalendarEventComposePageViewModel.cs +++ b/Wino.Calendar.ViewModels/CalendarEventComposePageViewModel.cs @@ -6,18 +6,20 @@ using System.ComponentModel; using System.Globalization; using System.IO; using System.Linq; -using System.Net.Mail; using System.Threading.Tasks; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; +using 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; @@ -31,10 +33,12 @@ public partial class CalendarEventComposePageViewModel : CalendarBaseViewModel 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; } = []; @@ -97,6 +101,8 @@ public partial class CalendarEventComposePageViewModel : CalendarBaseViewModel 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, @@ -169,11 +175,13 @@ public partial class CalendarEventComposePageViewModel : CalendarBaseViewModel partial void OnSelectedCalendarChanged(AccountCalendarViewModel value) { - if (value == null || SelectedShowAsOption != null) + 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) @@ -263,36 +271,24 @@ public partial class CalendarEventComposePageViewModel : CalendarBaseViewModel [RelayCommand] private async Task CreateAsync() { - if (!await ValidateAsync()) - return; - var uniqueAttendees = Attendees .GroupBy(attendee => attendee.Email, StringComparer.OrdinalIgnoreCase) .Select(group => group.First()) .ToList(); - var htmlNotes = GetHtmlNotesAsync == null ? string.Empty : await GetHtmlNotesAsync(); - var effectiveStart = GetEffectiveStartDateTime(); - var effectiveEnd = GetEffectiveEndDateTime(); + var createdResult = await BuildResultAsync(uniqueAttendees); - LastCreatedResult = new CalendarEventComposeResult + try { - CalendarId = SelectedCalendar!.Id, - AccountId = SelectedCalendar.Account.Id, - Title = Title.Trim(), - Location = Location?.Trim() ?? string.Empty, - HtmlNotes = htmlNotes, - StartDate = effectiveStart, - EndDate = effectiveEnd, - IsAllDay = IsAllDay, - TimeZoneId = TimeZoneInfo.Local.Id, - ShowAs = SelectedShowAsOption?.ShowAs ?? SelectedCalendar.DefaultShowAs, - SelectedReminders = BuildSelectedReminders(), - Attendees = BuildAttendees(uniqueAttendees), - Attachments = Attachments.Select(attachment => attachment.ToDraftModel()).ToList(), - Recurrence = BuildRecurrenceRule(), - RecurrenceSummary = RecurrenceSummary - }; + _composeResultValidator.Validate(createdResult); + } + catch (CalendarEventComposeValidationException ex) + { + ShowValidationMessage(ex.Message); + return; + } + + LastCreatedResult = createdResult; _navigationService.GoBack(); } @@ -307,7 +303,7 @@ public partial class CalendarEventComposePageViewModel : CalendarBaseViewModel public async Task GetAttendeeAsync(string tokenText) { - if (!IsValidEmailAddress(tokenText)) + if (!EmailValidator.Validate(tokenText)) return null; var existing = Attendees.Any(attendee => attendee.Email.Equals(tokenText, StringComparison.OrdinalIgnoreCase)); @@ -359,26 +355,38 @@ public partial class CalendarEventComposePageViewModel : CalendarBaseViewModel 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(); - foreach (var calendar in calendars) + accountCalendars.AddRange(viewModels); + + if (viewModels.Count > 0) { - accountCalendars.Add(new AccountCalendarViewModel(account, calendar)); + 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); + } }); } @@ -424,68 +432,35 @@ public partial class CalendarEventComposePageViewModel : CalendarBaseViewModel AllDayEndDate = new DateTimeOffset((isAllDay ? endDate.Date : startDate.Date.AddDays(1))); } - private async Task ValidateAsync() + private async Task BuildResultAsync(List uniqueAttendees) { - if (SelectedCalendar == null) - { - ShowValidationMessage(Translator.CalendarEventCompose_ValidationMissingCalendar); - return false; - } - - if (string.IsNullOrWhiteSpace(Title)) - { - ShowValidationMessage(Translator.CalendarEventCompose_ValidationMissingTitle); - return false; - } - - if (IsAllDay) - { - if (AllDayEndDate.Date <= StartDate.Date) - { - ShowValidationMessage(Translator.CalendarEventCompose_ValidationInvalidAllDayRange); - return false; - } - } - else if (GetEffectiveEndDateTime() <= GetEffectiveStartDateTime()) - { - ShowValidationMessage(Translator.CalendarEventCompose_ValidationInvalidTimeRange); - return false; - } - if (RecurrenceEndDate.HasValue && RecurrenceEndDate.Value.Date < StartDate.Date) { - ShowValidationMessage(Translator.CalendarEventCompose_ValidationInvalidRecurrenceEnd); - return false; + throw new CalendarEventComposeValidationException(Translator.CalendarEventCompose_ValidationInvalidRecurrenceEnd); } - var missingAttachments = Attachments - .Where(attachment => !File.Exists(attachment.FilePath)) - .Select(attachment => attachment.FileName) - .ToList(); + var htmlNotes = GetHtmlNotesAsync == null ? string.Empty : await GetHtmlNotesAsync(); + var effectiveStart = GetEffectiveStartDateTime(); + var effectiveEnd = GetEffectiveEndDateTime(); - if (missingAttachments.Count > 0) + return new CalendarEventComposeResult { - ShowValidationMessage(string.Format(Translator.CalendarEventCompose_ValidationMissingAttachment, string.Join(", ", missingAttachments))); - return false; - } - - var normalizedAttendees = Attendees - .Where(attendee => !string.IsNullOrWhiteSpace(attendee.Email)) - .Select(attendee => attendee.Email.Trim()) - .ToList(); - - if (normalizedAttendees.Any(address => !IsValidEmailAddress(address))) - { - ShowValidationMessage(Translator.CalendarEventCompose_ValidationInvalidAttendee); - return false; - } - - if (GetHtmlNotesAsync != null) - { - await GetHtmlNotesAsync(); - } - - return true; + 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() @@ -676,18 +651,6 @@ public partial class CalendarEventComposePageViewModel : CalendarBaseViewModel OnPropertyChanged(nameof(HasAttachments)); } - private static bool IsValidEmailAddress(string address) - { - try - { - var parsedAddress = new MailAddress(address); - return parsedAddress.Address.Equals(address, StringComparison.OrdinalIgnoreCase); - } - catch - { - return false; - } - } } public partial class CalendarComposeFrequencyOption : ObservableObject 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/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/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/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/Translations/en_US/resources.json b/Wino.Core.Domain/Translations/en_US/resources.json index 77c7c8cb..70a2e718 100644 --- a/Wino.Core.Domain/Translations/en_US/resources.json +++ b/Wino.Core.Domain/Translations/en_US/resources.json @@ -1049,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/Dialogs/SingleCalendarPickerDialog.xaml b/Wino.Mail.WinUI/Dialogs/SingleCalendarPickerDialog.xaml index 840f1819..62f2d4b8 100644 --- a/Wino.Mail.WinUI/Dialogs/SingleCalendarPickerDialog.xaml +++ b/Wino.Mail.WinUI/Dialogs/SingleCalendarPickerDialog.xaml @@ -28,12 +28,12 @@ - + - + - + @@ -41,10 +41,10 @@ IsItemClickEnabled="True" ItemClick="CalendarClicked" ItemContainerStyle="{StaticResource CalendarPickerListItemStyle}" - ItemsSource="{Binding Calendars}" + ItemsSource="{x:Bind Calendars}" SelectionMode="None"> - + @@ -55,12 +55,12 @@ Width="14" Height="14" VerticalAlignment="Center" - Fill="{ThemeResource AccentFillColorDefaultBrush}" /> + Fill="{x:Bind helpers:XamlHelpers.GetSolidColorBrushFromHex(BackgroundColorHex), Mode=OneWay}" /> + Text="{x:Bind Name}" /> 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 @@ 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/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 63aedf55..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}"> @@ -156,13 +156,24 @@ @@ -279,7 +290,10 @@ - + diff --git a/Wino.Mail.WinUI/Views/Calendar/CalendarEventComposePage.xaml b/Wino.Mail.WinUI/Views/Calendar/CalendarEventComposePage.xaml index e6189a49..8157ed93 100644 --- a/Wino.Mail.WinUI/Views/Calendar/CalendarEventComposePage.xaml +++ b/Wino.Mail.WinUI/Views/Calendar/CalendarEventComposePage.xaml @@ -10,6 +10,7 @@ xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:data="using:Wino.Calendar.ViewModels.Data" xmlns:domain="using:Wino.Core.Domain" + xmlns:helpers="using:Wino.Helpers" xmlns:mailControls="using:Wino.Mail.Controls" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:shared="using:Wino.Core.Domain.Entities.Shared" @@ -24,510 +25,561 @@ TargetType="Button"> - + - - - - - + - - + + - - + + - + - + - + CornerRadius="{StaticResource ControlCornerRadius}"> + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + - - - - - + + + + + + + + + + + + + - - - - - - - - - - - + + + - + + + + + + - + + - - - - - - - + + + - + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + Text="—" /> - - - - - + + - + + - - - + + - - - - - - + + - + + + + + + + - + + + + + + + + + + + + + + + - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - + - - - - - - - - - + + + + + + + - + + + + - - - - + + + + + + + + + + + + - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/Wino.Mail.WinUI/Views/Calendar/CalendarEventComposePage.xaml.cs b/Wino.Mail.WinUI/Views/Calendar/CalendarEventComposePage.xaml.cs index b39535d5..d4d3ab0e 100644 --- a/Wino.Mail.WinUI/Views/Calendar/CalendarEventComposePage.xaml.cs +++ b/Wino.Mail.WinUI/Views/Calendar/CalendarEventComposePage.xaml.cs @@ -137,6 +137,14 @@ public sealed partial class CalendarEventComposePage : CalendarEventComposePageA } } + 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; 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(