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