diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 135e6a2a..0a05ed5f 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -38,6 +38,8 @@ - Place ViewModels in appropriate projects (Wino.Mail.ViewModels, Wino.Core.ViewModels, etc.) - Create abstract page classes in Views/Abstract folders - Follow the existing naming conventions +- **NEVER** edit files in the old Wino.Mail project folder - it's UWP and deprecated +- **ALWAYS** work with WinUI projects (Wino.Mail.WinUI, Wino.Core.WinUI) for UI components ### Error Handling - Always wrap async operations in try-catch blocks @@ -56,6 +58,15 @@ - Use PersonPicture controls for contact avatars - Support multiple selection where appropriate +### UI Data Binding and Converters +- **NEVER** create IValueConverter classes or add them to Converters.xaml +- **NEVER** use BoolToVisibilityConverter - boolean values are automatically converted to Visibility by the WinUI 3 SDK +- **ALWAYS** use XamlHelpers static methods for data transformations when needed +- Use XamlHelpers methods with x:Bind syntax: `{x:Bind helpers:XamlHelpers.ReverseBoolToVisibilityConverter(PropertyName), Mode=OneWay}` +- Available XamlHelpers methods include: ReverseBoolToVisibilityConverter, CountToBooleanConverter, BoolToSelectionMode, Base64ToBitmapImage, etc. +- If a helper method doesn't exist, add it to XamlHelpers.cs instead of creating a converter +- For boolean to Visibility binding, use direct binding: `Visibility="{x:Bind BooleanProperty, Mode=OneWay}"` + ## Code Style - Use var where type is obvious - Use string interpolation over string.Format where simple diff --git a/Wino.Core.Domain/Entities/Shared/AccountContact.cs b/Wino.Core.Domain/Entities/Shared/AccountContact.cs index 49949665..85faa0ed 100644 --- a/Wino.Core.Domain/Entities/Shared/AccountContact.cs +++ b/Wino.Core.Domain/Entities/Shared/AccountContact.cs @@ -36,6 +36,12 @@ public class AccountContact : IEquatable /// public bool IsRootContact { get; set; } + /// + /// When true, indicates that the contact has been manually modified by the user. + /// Contacts with this flag set to true should not be updated during synchronization. + /// + public bool IsOverridden { get; set; } = false; + public override bool Equals(object obj) { return Equals(obj as AccountContact); diff --git a/Wino.Core.Domain/Enums/WinoPage.cs b/Wino.Core.Domain/Enums/WinoPage.cs index 8df1526d..3bef698d 100644 --- a/Wino.Core.Domain/Enums/WinoPage.cs +++ b/Wino.Core.Domain/Enums/WinoPage.cs @@ -9,6 +9,7 @@ public enum WinoPage IdlePage, ComposePage, SettingsPage, + ContactsPage, MailRenderingPage, WelcomePage, AccountDetailsPage, @@ -27,7 +28,6 @@ public enum WinoPage AliasManagementPage, EditAccountDetailsPage, KeyboardShortcutsPage, - // Calendar CalendarPage, CalendarSettingsPage, EventDetailsPage diff --git a/Wino.Core.Domain/Interfaces/IContactService.cs b/Wino.Core.Domain/Interfaces/IContactService.cs index e721ec2a..a10d5a38 100644 --- a/Wino.Core.Domain/Interfaces/IContactService.cs +++ b/Wino.Core.Domain/Interfaces/IContactService.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.Linq; using System.Threading.Tasks; using MimeKit; using Wino.Core.Domain.Entities.Shared; @@ -11,4 +12,11 @@ public interface IContactService Task GetAddressInformationByAddressAsync(string address); Task SaveAddressInformationAsync(MimeMessage message); Task CreateNewContactAsync(string address, string displayName); + + // New methods for ContactsPage + Task> GetAllContactsAsync(); + Task> SearchContactsAsync(string searchQuery); + Task UpdateContactAsync(AccountContact contact); + Task DeleteContactAsync(string address); + Task DeleteContactsAsync(IEnumerable addresses); } diff --git a/Wino.Core.Domain/Interfaces/IMailDialogService.cs b/Wino.Core.Domain/Interfaces/IMailDialogService.cs index 988c75d5..1b475eae 100644 --- a/Wino.Core.Domain/Interfaces/IMailDialogService.cs +++ b/Wino.Core.Domain/Interfaces/IMailDialogService.cs @@ -59,4 +59,11 @@ public interface IMailDialogService : IDialogServiceBase #pragma warning disable CS8625 // Cannot convert null literal to non-nullable reference type. Task ShowKeyboardShortcutDialogAsync(KeyboardShortcut existingShortcut = null); #pragma warning restore CS8625 + + /// + /// Presents a dialog to the user for contact creation/modification. + /// + /// Existing contact to edit, or null for new contact. + /// Contact information. Null if canceled. + Task ShowEditContactDialogAsync(AccountContact contact = null); } diff --git a/Wino.Core.Domain/MenuItems/ContactsMenuItem.cs b/Wino.Core.Domain/MenuItems/ContactsMenuItem.cs new file mode 100644 index 00000000..1fa8c71c --- /dev/null +++ b/Wino.Core.Domain/MenuItems/ContactsMenuItem.cs @@ -0,0 +1,3 @@ +namespace Wino.Core.Domain.MenuItems; + +public class ContactsMenuItem : MenuItemBase { } diff --git a/Wino.Core.Domain/Translations/en_US/resources.json b/Wino.Core.Domain/Translations/en_US/resources.json index 07ea2308..f863df78 100644 --- a/Wino.Core.Domain/Translations/en_US/resources.json +++ b/Wino.Core.Domain/Translations/en_US/resources.json @@ -723,7 +723,39 @@ "WinoUpgradeRemainingAccountsMessage": "{0} out of {1} free accounts used.", "Yesterday": "Yesterday", "SettingsAppPreferences_EmailSyncInterval_Title": "Email sync interval", - "SettingsAppPreferences_EmailSyncInterval_Description": "Automatic email synchronization interval (minutes). This setting will be applied only after restarting Wino Mail." + "SettingsAppPreferences_EmailSyncInterval_Description": "Automatic email synchronization interval (minutes). This setting will be applied only after restarting Wino Mail.", + "ContactsPage_Title": "Contacts", + "ContactsPage_AddContact": "Add Contact", + "ContactsPage_EditContact": "Edit Contact", + "ContactsPage_DeleteContact": "Delete Contact", + "ContactsPage_SearchPlaceholder": "Search contacts...", + "ContactsPage_NoContacts": "No contacts found", + "ContactsPage_ContactsCount": "{0} contacts", + "ContactsPage_SelectedContactsCount": "{0} selected", + "ContactsPage_DeleteSelectedContacts": "Delete Selected", + "ContactEditDialog_Title": "Edit Contact", + "ContactEditDialog_PhotoSection": "Photo", + "ContactEditDialog_ChoosePhoto": "Choose Photo", + "ContactEditDialog_RemovePhoto": "Remove Photo", + "ContactEditDialog_NameHeader": "Name", + "ContactEditDialog_NamePlaceholder": "Contact name", + "ContactEditDialog_EmailHeader": "Email Address", + "ContactEditDialog_EmailPlaceholder": "contact@example.com", + "ContactEditDialog_InfoSection": "Contact Information", + "ContactEditDialog_RootContactInfo": "This is a root contact associated with your accounts and cannot be deleted.", + "ContactEditDialog_OverriddenContactInfo": "This contact has been manually modified and will not be updated during synchronization.", + "ContactsPage_Subtitle": "Manage your email contacts and their information", + "ContactStatus_Account": "Account", + "ContactStatus_Modified": "Modified", + "ContactAction_Edit": "Edit contact", + "ContactAction_ChangePhoto": "Change photo", + "ContactAction_Delete": "Delete contact", + "ContactAction_Add": "Add Contact", + "ContactSelection_Selected": "selected", + "ContactSelection_SelectAll": "Select All", + "ContactSelection_Clear": "Clear Selection", + "ContactsPage_EmptyState": "No contacts to display", + "ContactsPage_AddFirstContact": "Add your first contact" } diff --git a/Wino.Core.ViewModels/Data/MailOperationViewModel.cs b/Wino.Core.ViewModels/Data/MailOperationViewModel.cs index 5b8067bd..bc16f0e7 100644 --- a/Wino.Core.ViewModels/Data/MailOperationViewModel.cs +++ b/Wino.Core.ViewModels/Data/MailOperationViewModel.cs @@ -16,18 +16,18 @@ public class MailOperationViewModel { return Operation switch { - MailOperation.Archive => "Archive", - MailOperation.UnArchive => "Unarchive", - MailOperation.SoftDelete => "Delete", - MailOperation.Move => "Move", - MailOperation.MoveToJunk => "Move to Junk", - MailOperation.SetFlag => "Set Flag", - MailOperation.ClearFlag => "Clear Flag", - MailOperation.MarkAsRead => "Mark as Read", - MailOperation.MarkAsUnread => "Mark as Unread", - MailOperation.Reply => "Reply", - MailOperation.ReplyAll => "Reply All", - MailOperation.Forward => "Forward", + MailOperation.Archive => Translator.MailOperation_Archive, + MailOperation.UnArchive => Translator.MailOperation_Unarchive, + MailOperation.SoftDelete => Translator.MailOperation_Delete, + MailOperation.Move => Translator.MailOperation_Move, + MailOperation.MoveToJunk => Translator.MailOperation_MoveJunk, + MailOperation.SetFlag => Translator.MailOperation_SetFlag, + MailOperation.ClearFlag => Translator.MailOperation_ClearFlag, + MailOperation.MarkAsRead => Translator.MailOperation_MarkAsRead, + MailOperation.MarkAsUnread => Translator.MailOperation_MarkAsUnread, + MailOperation.Reply => Translator.MailOperation_Reply, + MailOperation.ReplyAll => Translator.MailOperation_ReplyAll, + MailOperation.Forward => Translator.MailOperation_Forward, _ => Operation.ToString() }; } diff --git a/Wino.Core.WinUI/Helpers/XamlHelpers.cs b/Wino.Core.WinUI/Helpers/XamlHelpers.cs index fbcf09f8..24730be7 100644 --- a/Wino.Core.WinUI/Helpers/XamlHelpers.cs +++ b/Wino.Core.WinUI/Helpers/XamlHelpers.cs @@ -1,5 +1,6 @@ using System; using System.Globalization; +using System.IO; using System.Linq; using CommunityToolkit.WinUI.Helpers; using Microsoft.UI; @@ -46,6 +47,27 @@ public static class XamlHelpers public static bool ObjectEquals(object obj1, object obj2) => object.Equals(obj1, obj2); public static Visibility CountToVisibilityConverter(int value) => value > 0 ? Visibility.Visible : Visibility.Collapsed; public static Visibility CountToVisibilityConverterWithThreshold(int value, int threshold) => value > threshold ? Visibility.Visible : Visibility.Collapsed; + public static ListViewSelectionMode BoolToSelectionMode(bool isSelectionMode) => isSelectionMode ? ListViewSelectionMode.Multiple : ListViewSelectionMode.None; + public static string BoolToSelectionModeText(bool isSelectionMode) => isSelectionMode ? Translator.Buttons_Cancel : Translator.Buttons_Multiselect; + + public static Microsoft.UI.Xaml.Media.Imaging.BitmapImage Base64ToBitmapImage(string base64String) + { + if (string.IsNullOrEmpty(base64String)) + return null; + + try + { + var imageBytes = Convert.FromBase64String(base64String); + using var stream = new System.IO.MemoryStream(imageBytes); + var bitmap = new Microsoft.UI.Xaml.Media.Imaging.BitmapImage(); + bitmap.SetSource(stream.AsRandomAccessStream()); + return bitmap; + } + catch + { + return null; + } + } public static InfoBarSeverity InfoBarSeverityConverter(InfoBarMessageType messageType) { return messageType switch diff --git a/Wino.Core.WinUI/Selectors/NavigationMenuTemplateSelector.cs b/Wino.Core.WinUI/Selectors/NavigationMenuTemplateSelector.cs index d3259997..d5d8d9f1 100644 --- a/Wino.Core.WinUI/Selectors/NavigationMenuTemplateSelector.cs +++ b/Wino.Core.WinUI/Selectors/NavigationMenuTemplateSelector.cs @@ -7,6 +7,7 @@ namespace Wino.Core.WinUI.Selectors; public partial class NavigationMenuTemplateSelector : DataTemplateSelector { public DataTemplate MenuItemTemplate { get; set; } + public DataTemplate ContactsMenuItemTemplate { get; set; } public DataTemplate AccountManagementTemplate { get; set; } public DataTemplate ClickableAccountMenuTemplate { get; set; } public DataTemplate MergedAccountTemplate { get; set; } @@ -27,6 +28,8 @@ public partial class NavigationMenuTemplateSelector : DataTemplateSelector { if (item is NewMailMenuItem) return NewMailTemplate; + else if (item is ContactsMenuItem) + return ContactsMenuItemTemplate; else if (item is SettingsItem) return SettingsItemTemplate; else if (item is SeperatorItem) diff --git a/Wino.Core.WinUI/Services/DialogServiceBase.cs b/Wino.Core.WinUI/Services/DialogServiceBase.cs index ff834bb7..ca9edf8c 100644 --- a/Wino.Core.WinUI/Services/DialogServiceBase.cs +++ b/Wino.Core.WinUI/Services/DialogServiceBase.cs @@ -19,6 +19,7 @@ using Wino.Core.WinUI.Dialogs; using Wino.Core.WinUI.Extensions; using Wino.Dialogs; using Wino.Messaging.Client.Shell; +using WinRT.Interop; namespace Wino.Core.WinUI.Services; @@ -52,6 +53,9 @@ public class DialogServiceBase : IDialogServiceBase picker.FileTypeFilter.Add("*"); + nint windowHandle = WindowNative.GetWindowHandle(WinoApplication.MainWindow); + InitializeWithWindow.Initialize(picker, windowHandle); + var folder = await picker.PickSingleFolderAsync(); if (folder == null) return string.Empty; @@ -89,6 +93,9 @@ public class DialogServiceBase : IDialogServiceBase picker.FileTypeFilter.Add(filter.ToString()); } + nint windowHandle = WindowNative.GetWindowHandle(WinoApplication.MainWindow); + InitializeWithWindow.Initialize(picker, windowHandle); + var files = await picker.PickMultipleFilesAsync(); if (files == null) return returnList; @@ -115,6 +122,9 @@ public class DialogServiceBase : IDialogServiceBase picker.FileTypeFilter.Add(filter.ToString()); } + nint windowHandle = WindowNative.GetWindowHandle(WinoApplication.MainWindow); + InitializeWithWindow.Initialize(picker, windowHandle); + var file = await picker.PickSingleFileAsync(); if (file == null) return null; @@ -259,6 +269,9 @@ public class DialogServiceBase : IDialogServiceBase picker.FileTypeFilter.Add("*"); + nint windowHandle = WindowNative.GetWindowHandle(WinoApplication.MainWindow); + InitializeWithWindow.Initialize(picker, windowHandle); + var pickedFolder = await picker.PickSingleFolderAsync(); if (pickedFolder != null) diff --git a/Wino.Core.WinUI/Styles/DataTemplates.xaml b/Wino.Core.WinUI/Styles/DataTemplates.xaml index ec1f72a0..4cd4ef3a 100644 --- a/Wino.Core.WinUI/Styles/DataTemplates.xaml +++ b/Wino.Core.WinUI/Styles/DataTemplates.xaml @@ -49,6 +49,17 @@ + + + + + + + + diff --git a/Wino.Mail.ViewModels/AppShellViewModel.cs b/Wino.Mail.ViewModels/AppShellViewModel.cs index 5fc8edb1..c88af020 100644 --- a/Wino.Mail.ViewModels/AppShellViewModel.cs +++ b/Wino.Mail.ViewModels/AppShellViewModel.cs @@ -54,6 +54,7 @@ public partial class AppShellViewModel : MailBaseViewModel, private readonly SettingsItem SettingsItem = new SettingsItem(); private readonly ManageAccountsMenuItem ManageAccountsMenuItem = new ManageAccountsMenuItem(); + private readonly ContactsMenuItem ContactsMenuItem = new ContactsMenuItem(); public IMenuItem CreateMailMenuItem = new NewMailMenuItem(); @@ -150,6 +151,7 @@ public partial class AppShellViewModel : MailBaseViewModel, FooterItems.Clear(); + FooterItems.Add(ContactsMenuItem); FooterItems.Add(ManageAccountsMenuItem); FooterItems.Add(SettingsItem); }); @@ -591,6 +593,10 @@ public partial class AppShellViewModel : MailBaseViewModel, { NavigationService.Navigate(WinoPage.ManageAccountsPage, parameter, NavigationReferenceFrame.ShellFrame, NavigationTransitionType.None); } + else if (clickedMenuItem is ContactsMenuItem) + { + NavigationService.Navigate(WinoPage.ContactsPage, parameter, NavigationReferenceFrame.ShellFrame, NavigationTransitionType.None); + } else if (clickedMenuItem is IAccountMenuItem clickedAccountMenuItem) { // Changing loaded account. diff --git a/Wino.Mail.ViewModels/ContactsPageViewModel.cs b/Wino.Mail.ViewModels/ContactsPageViewModel.cs new file mode 100644 index 00000000..0bd30e75 --- /dev/null +++ b/Wino.Mail.ViewModels/ContactsPageViewModel.cs @@ -0,0 +1,306 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Wino.Core.Domain.Entities.Shared; +using Wino.Core.Domain.Enums; +using Wino.Core.Domain.Interfaces; +using Wino.Core.Domain.Models.Navigation; + +namespace Wino.Mail.ViewModels; + +public partial class ContactsPageViewModel : MailBaseViewModel +{ + private readonly IContactService _contactService; + private readonly IMailDialogService _dialogService; + + private List _allContacts = new(); + + [ObservableProperty] + public partial string SearchQuery { get; set; } = string.Empty; + + [ObservableProperty] + public partial bool IsLoading { get; set; } = false; + + [ObservableProperty] + public partial bool IsSelectionMode { get; set; } = false; + + [ObservableProperty] + public partial int SelectedContactsCount { get; set; } = 0; + + public ObservableCollection Contacts { get; } = new(); + public ObservableCollection SelectedContacts { get; } = new(); + + public ContactsPageViewModel(IContactService contactService, IMailDialogService dialogService) + { + _contactService = contactService; + _dialogService = dialogService; + + + } + + public override async void OnNavigatedTo(NavigationMode mode, object parameters) + { + base.OnNavigatedTo(mode, parameters); + + SelectedContacts.CollectionChanged -= SelectedContactsChanged; + SelectedContacts.CollectionChanged += SelectedContactsChanged; + + await LoadContactsAsync(); + } + + public override void OnNavigatedFrom(NavigationMode mode, object parameters) + { + base.OnNavigatedFrom(mode, parameters); + + SelectedContacts.CollectionChanged -= SelectedContactsChanged; + } + + private void SelectedContactsChanged(object sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e) + => SelectedContactsCount = SelectedContacts.Count; + + [RelayCommand] + private async Task LoadContactsAsync() + { + IsLoading = true; + + try + { + _allContacts = await _contactService.GetAllContactsAsync(); + await FilterContactsAsync(); + } + catch (Exception ex) + { + _dialogService.InfoBarMessage("Error", $"Failed to load contacts: {ex.Message}", InfoBarMessageType.Error); + } + finally + { + IsLoading = false; + } + } + + [RelayCommand] + private async Task SearchContactsAsync() + { + await FilterContactsAsync(); + } + + private async Task FilterContactsAsync() + { + List filteredContacts; + + if (string.IsNullOrWhiteSpace(SearchQuery)) + { + filteredContacts = _allContacts; + } + else + { + filteredContacts = await _contactService.SearchContactsAsync(SearchQuery); + } + + await ExecuteUIThread(() => + { + Contacts.Clear(); + foreach (var contact in filteredContacts.OrderBy(c => c.Name ?? c.Address)) + { + Contacts.Add(contact); + } + }); + } + + [RelayCommand] + private async Task AddContactAsync() + { + var result = await _dialogService.ShowEditContactDialogAsync(null); + + if (result != null) + { + try + { + var newContact = await _contactService.CreateNewContactAsync(result.Address, result.Name); + + if (!string.IsNullOrEmpty(result.Base64ContactPicture)) + { + newContact.Base64ContactPicture = result.Base64ContactPicture; + await _contactService.UpdateContactAsync(newContact); + } + + _allContacts.Add(newContact); + await FilterContactsAsync(); + + _dialogService.InfoBarMessage("Success", "Contact added successfully", InfoBarMessageType.Success); + } + catch (Exception ex) + { + _dialogService.InfoBarMessage("Error", $"Failed to add contact: {ex.Message}", InfoBarMessageType.Error); + } + } + } + + [RelayCommand] + private async Task EditContactAsync(AccountContact contact) + { + if (contact == null) return; + + var result = await _dialogService.ShowEditContactDialogAsync(contact); + + if (result != null) + { + try + { + // Update the contact properties + contact.Name = result.Name; + contact.Base64ContactPicture = result.Base64ContactPicture; + contact.IsOverridden = result.IsOverridden; + + await _contactService.UpdateContactAsync(contact); + + // Update the UI + var index = _allContacts.FindIndex(c => c.Address == contact.Address); + if (index >= 0) + { + _allContacts[index] = contact; + } + + await FilterContactsAsync(); + + _dialogService.InfoBarMessage("Success", "Contact updated successfully", InfoBarMessageType.Success); + } + catch (Exception ex) + { + _dialogService.InfoBarMessage("Error", $"Failed to update contact: {ex.Message}", InfoBarMessageType.Error); + } + } + } + + [RelayCommand] + private async Task DeleteContactAsync(AccountContact contact) + { + if (contact == null || contact.IsRootContact) + { + _dialogService.InfoBarMessage("Cannot Delete", "Root contacts cannot be deleted", InfoBarMessageType.Warning); + return; + } + + var result = await _dialogService.ShowConfirmationDialogAsync( + $"Are you sure you want to delete the contact '{contact.Name ?? contact.Address}'?", + "Delete Contact", + "Delete"); + + if (result) + { + await DeleteContactsInternalAsync(new[] { contact }); + } + } + + [RelayCommand] + private async Task DeleteSelectedContactsAsync() + { + if (SelectedContacts.Count == 0) return; + + var deletableContacts = SelectedContacts.Where(c => !c.IsRootContact).ToList(); + + if (deletableContacts.Count == 0) + { + _dialogService.InfoBarMessage("Cannot Delete", "Root contacts cannot be deleted", InfoBarMessageType.Warning); + return; + } + + var result = await _dialogService.ShowConfirmationDialogAsync( + $"Are you sure you want to delete {deletableContacts.Count} contact(s)?", + "Delete Contacts", + "Delete"); + + if (result) + { + await DeleteContactsInternalAsync(deletableContacts); + } + } + + private async Task DeleteContactsInternalAsync(IEnumerable contactsToDelete) + { + try + { + var addresses = contactsToDelete.Select(c => c.Address); + await _contactService.DeleteContactsAsync(addresses); + + // Update local collections + foreach (var contact in contactsToDelete.ToList()) + { + _allContacts.Remove(contact); + SelectedContacts.Remove(contact); + } + + await FilterContactsAsync(); + + _dialogService.InfoBarMessage("Success", "Contacts deleted successfully", InfoBarMessageType.Success); + } + catch (Exception ex) + { + _dialogService.InfoBarMessage("Error", $"Failed to delete contacts: {ex.Message}", InfoBarMessageType.Error); + } + } + + [RelayCommand] + private void ToggleSelection() + { + IsSelectionMode = !IsSelectionMode; + + if (!IsSelectionMode) + { + SelectedContacts.Clear(); + } + } + + [RelayCommand] + private void SelectAllContacts() + { + SelectedContacts.Clear(); + foreach (var contact in Contacts) + { + SelectedContacts.Add(contact); + } + } + + [RelayCommand] + private void ClearSelection() + { + SelectedContacts.Clear(); + } + + [RelayCommand] + private async Task PickContactPhotoAsync(AccountContact contact) + { + if (contact == null) return; + + try + { + var files = await _dialogService.PickFilesAsync(".png", ".jpg", ".jpeg"); + + if (files?.Any() == true) + { + var file = files.First(); + var base64Image = Convert.ToBase64String(file.Data); + + contact.Base64ContactPicture = base64Image; + await _contactService.UpdateContactAsync(contact); + + await FilterContactsAsync(); + _dialogService.InfoBarMessage("Success", "Contact photo updated successfully", InfoBarMessageType.Success); + } + } + catch (Exception ex) + { + _dialogService.InfoBarMessage("Error", $"Failed to update photo: {ex.Message}", InfoBarMessageType.Error); + } + } + + partial void OnSearchQueryChanged(string value) + { + // Debounce search - implement if needed + SearchContactsCommand.ExecuteAsync(null); + } +} diff --git a/Wino.Mail.WinUI/App.xaml.cs b/Wino.Mail.WinUI/App.xaml.cs index a803db7c..65466623 100644 --- a/Wino.Mail.WinUI/App.xaml.cs +++ b/Wino.Mail.WinUI/App.xaml.cs @@ -59,6 +59,7 @@ public partial class App : WinoApplication, IRecipient + + + 400 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Wino.Mail.WinUI/Views/Settings/ContactsPage.xaml.cs b/Wino.Mail.WinUI/Views/Settings/ContactsPage.xaml.cs new file mode 100644 index 00000000..d7c93337 --- /dev/null +++ b/Wino.Mail.WinUI/Views/Settings/ContactsPage.xaml.cs @@ -0,0 +1,11 @@ +using Wino.Views.Abstract; + +namespace Wino.Views.Settings; + +public sealed partial class ContactsPage : ContactsPageAbstract +{ + public ContactsPage() + { + this.InitializeComponent(); + } +} \ No newline at end of file diff --git a/Wino.Mail.WinUI/Wino.Mail.WinUI.csproj b/Wino.Mail.WinUI/Wino.Mail.WinUI.csproj index c276b633..d27338d1 100644 --- a/Wino.Mail.WinUI/Wino.Mail.WinUI.csproj +++ b/Wino.Mail.WinUI/Wino.Mail.WinUI.csproj @@ -68,6 +68,7 @@ + @@ -124,6 +125,11 @@ + + + MSBuild:Compile + + MSBuild:Compile diff --git a/Wino.Services/ContactService.cs b/Wino.Services/ContactService.cs index 65b7fcd7..1fa6b446 100644 --- a/Wino.Services/ContactService.cs +++ b/Wino.Services/ContactService.cs @@ -59,7 +59,7 @@ public class ContactService : BaseDatabaseService, IContactService { await Connection.InsertAsync(info).ConfigureAwait(false); } - else if (!currentContact.IsRootContact) // Don't update root contacts. They belong to accounts. + else if (!currentContact.IsRootContact && !currentContact.IsOverridden) // Don't update root contacts or overridden contacts. { await Connection.InsertOrReplaceAsync(info).ConfigureAwait(false); } @@ -70,4 +70,51 @@ public class ContactService : BaseDatabaseService, IContactService } } } + + public Task> GetAllContactsAsync() + { + return Connection.Table().ToListAsync(); + } + + public Task> SearchContactsAsync(string searchQuery) + { + if (string.IsNullOrWhiteSpace(searchQuery)) + return GetAllContactsAsync(); + + var query = new Query(nameof(AccountContact)); + query.WhereContains("Address", searchQuery.Trim()); + query.OrWhereContains("Name", searchQuery.Trim()); + + var rawLikeQuery = query.GetRawQuery(); + + return Connection.QueryAsync(rawLikeQuery); + } + + public async Task UpdateContactAsync(AccountContact contact) + { + // Mark the contact as overridden when manually updated + contact.IsOverridden = true; + + await Connection.UpdateAsync(contact).ConfigureAwait(false); + + return contact; + } + + public async Task DeleteContactAsync(string address) + { + var contact = await GetAddressInformationByAddressAsync(address).ConfigureAwait(false); + + if (contact != null && !contact.IsRootContact) + { + await Connection.DeleteAsync(contact).ConfigureAwait(false); + } + } + + public async Task DeleteContactsAsync(IEnumerable addresses) + { + foreach (var address in addresses) + { + await DeleteContactAsync(address).ConfigureAwait(false); + } + } }