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(