Demo contacts page.

This commit is contained in:
Burak Kaan Köse
2025-10-29 19:35:04 +01:00
parent 3db1fd0dde
commit b0ac6e4e55
25 changed files with 970 additions and 16 deletions
+11
View File
@@ -38,6 +38,8 @@
- Place ViewModels in appropriate projects (Wino.Mail.ViewModels, Wino.Core.ViewModels, etc.) - Place ViewModels in appropriate projects (Wino.Mail.ViewModels, Wino.Core.ViewModels, etc.)
- Create abstract page classes in Views/Abstract folders - Create abstract page classes in Views/Abstract folders
- Follow the existing naming conventions - 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 ### Error Handling
- Always wrap async operations in try-catch blocks - Always wrap async operations in try-catch blocks
@@ -56,6 +58,15 @@
- Use PersonPicture controls for contact avatars - Use PersonPicture controls for contact avatars
- Support multiple selection where appropriate - 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 ## Code Style
- Use var where type is obvious - Use var where type is obvious
- Use string interpolation over string.Format where simple - Use string interpolation over string.Format where simple
@@ -36,6 +36,12 @@ public class AccountContact : IEquatable<AccountContact>
/// </summary> /// </summary>
public bool IsRootContact { get; set; } public bool IsRootContact { get; set; }
/// <summary>
/// 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.
/// </summary>
public bool IsOverridden { get; set; } = false;
public override bool Equals(object obj) public override bool Equals(object obj)
{ {
return Equals(obj as AccountContact); return Equals(obj as AccountContact);
+1 -1
View File
@@ -9,6 +9,7 @@ public enum WinoPage
IdlePage, IdlePage,
ComposePage, ComposePage,
SettingsPage, SettingsPage,
ContactsPage,
MailRenderingPage, MailRenderingPage,
WelcomePage, WelcomePage,
AccountDetailsPage, AccountDetailsPage,
@@ -27,7 +28,6 @@ public enum WinoPage
AliasManagementPage, AliasManagementPage,
EditAccountDetailsPage, EditAccountDetailsPage,
KeyboardShortcutsPage, KeyboardShortcutsPage,
// Calendar
CalendarPage, CalendarPage,
CalendarSettingsPage, CalendarSettingsPage,
EventDetailsPage EventDetailsPage
@@ -1,4 +1,5 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using MimeKit; using MimeKit;
using Wino.Core.Domain.Entities.Shared; using Wino.Core.Domain.Entities.Shared;
@@ -11,4 +12,11 @@ public interface IContactService
Task<AccountContact> GetAddressInformationByAddressAsync(string address); Task<AccountContact> GetAddressInformationByAddressAsync(string address);
Task SaveAddressInformationAsync(MimeMessage message); Task SaveAddressInformationAsync(MimeMessage message);
Task<AccountContact> CreateNewContactAsync(string address, string displayName); Task<AccountContact> CreateNewContactAsync(string address, string displayName);
// New methods for ContactsPage
Task<List<AccountContact>> GetAllContactsAsync();
Task<List<AccountContact>> SearchContactsAsync(string searchQuery);
Task<AccountContact> UpdateContactAsync(AccountContact contact);
Task DeleteContactAsync(string address);
Task DeleteContactsAsync(IEnumerable<string> addresses);
} }
@@ -59,4 +59,11 @@ public interface IMailDialogService : IDialogServiceBase
#pragma warning disable CS8625 // Cannot convert null literal to non-nullable reference type. #pragma warning disable CS8625 // Cannot convert null literal to non-nullable reference type.
Task<KeyboardShortcutDialogResult> ShowKeyboardShortcutDialogAsync(KeyboardShortcut existingShortcut = null); Task<KeyboardShortcutDialogResult> ShowKeyboardShortcutDialogAsync(KeyboardShortcut existingShortcut = null);
#pragma warning restore CS8625 #pragma warning restore CS8625
/// <summary>
/// Presents a dialog to the user for contact creation/modification.
/// </summary>
/// <param name="contact">Existing contact to edit, or null for new contact.</param>
/// <returns>Contact information. Null if canceled.</returns>
Task<AccountContact> ShowEditContactDialogAsync(AccountContact contact = null);
} }
@@ -0,0 +1,3 @@
namespace Wino.Core.Domain.MenuItems;
public class ContactsMenuItem : MenuItemBase { }
@@ -723,7 +723,39 @@
"WinoUpgradeRemainingAccountsMessage": "{0} out of {1} free accounts used.", "WinoUpgradeRemainingAccountsMessage": "{0} out of {1} free accounts used.",
"Yesterday": "Yesterday", "Yesterday": "Yesterday",
"SettingsAppPreferences_EmailSyncInterval_Title": "Email sync interval", "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"
} }
@@ -16,18 +16,18 @@ public class MailOperationViewModel
{ {
return Operation switch return Operation switch
{ {
MailOperation.Archive => "Archive", MailOperation.Archive => Translator.MailOperation_Archive,
MailOperation.UnArchive => "Unarchive", MailOperation.UnArchive => Translator.MailOperation_Unarchive,
MailOperation.SoftDelete => "Delete", MailOperation.SoftDelete => Translator.MailOperation_Delete,
MailOperation.Move => "Move", MailOperation.Move => Translator.MailOperation_Move,
MailOperation.MoveToJunk => "Move to Junk", MailOperation.MoveToJunk => Translator.MailOperation_MoveJunk,
MailOperation.SetFlag => "Set Flag", MailOperation.SetFlag => Translator.MailOperation_SetFlag,
MailOperation.ClearFlag => "Clear Flag", MailOperation.ClearFlag => Translator.MailOperation_ClearFlag,
MailOperation.MarkAsRead => "Mark as Read", MailOperation.MarkAsRead => Translator.MailOperation_MarkAsRead,
MailOperation.MarkAsUnread => "Mark as Unread", MailOperation.MarkAsUnread => Translator.MailOperation_MarkAsUnread,
MailOperation.Reply => "Reply", MailOperation.Reply => Translator.MailOperation_Reply,
MailOperation.ReplyAll => "Reply All", MailOperation.ReplyAll => Translator.MailOperation_ReplyAll,
MailOperation.Forward => "Forward", MailOperation.Forward => Translator.MailOperation_Forward,
_ => Operation.ToString() _ => Operation.ToString()
}; };
} }
+22
View File
@@ -1,5 +1,6 @@
using System; using System;
using System.Globalization; using System.Globalization;
using System.IO;
using System.Linq; using System.Linq;
using CommunityToolkit.WinUI.Helpers; using CommunityToolkit.WinUI.Helpers;
using Microsoft.UI; 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 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 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 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) public static InfoBarSeverity InfoBarSeverityConverter(InfoBarMessageType messageType)
{ {
return messageType switch return messageType switch
@@ -7,6 +7,7 @@ namespace Wino.Core.WinUI.Selectors;
public partial class NavigationMenuTemplateSelector : DataTemplateSelector public partial class NavigationMenuTemplateSelector : DataTemplateSelector
{ {
public DataTemplate MenuItemTemplate { get; set; } public DataTemplate MenuItemTemplate { get; set; }
public DataTemplate ContactsMenuItemTemplate { get; set; }
public DataTemplate AccountManagementTemplate { get; set; } public DataTemplate AccountManagementTemplate { get; set; }
public DataTemplate ClickableAccountMenuTemplate { get; set; } public DataTemplate ClickableAccountMenuTemplate { get; set; }
public DataTemplate MergedAccountTemplate { get; set; } public DataTemplate MergedAccountTemplate { get; set; }
@@ -27,6 +28,8 @@ public partial class NavigationMenuTemplateSelector : DataTemplateSelector
{ {
if (item is NewMailMenuItem) if (item is NewMailMenuItem)
return NewMailTemplate; return NewMailTemplate;
else if (item is ContactsMenuItem)
return ContactsMenuItemTemplate;
else if (item is SettingsItem) else if (item is SettingsItem)
return SettingsItemTemplate; return SettingsItemTemplate;
else if (item is SeperatorItem) else if (item is SeperatorItem)
@@ -19,6 +19,7 @@ using Wino.Core.WinUI.Dialogs;
using Wino.Core.WinUI.Extensions; using Wino.Core.WinUI.Extensions;
using Wino.Dialogs; using Wino.Dialogs;
using Wino.Messaging.Client.Shell; using Wino.Messaging.Client.Shell;
using WinRT.Interop;
namespace Wino.Core.WinUI.Services; namespace Wino.Core.WinUI.Services;
@@ -52,6 +53,9 @@ public class DialogServiceBase : IDialogServiceBase
picker.FileTypeFilter.Add("*"); picker.FileTypeFilter.Add("*");
nint windowHandle = WindowNative.GetWindowHandle(WinoApplication.MainWindow);
InitializeWithWindow.Initialize(picker, windowHandle);
var folder = await picker.PickSingleFolderAsync(); var folder = await picker.PickSingleFolderAsync();
if (folder == null) return string.Empty; if (folder == null) return string.Empty;
@@ -89,6 +93,9 @@ public class DialogServiceBase : IDialogServiceBase
picker.FileTypeFilter.Add(filter.ToString()); picker.FileTypeFilter.Add(filter.ToString());
} }
nint windowHandle = WindowNative.GetWindowHandle(WinoApplication.MainWindow);
InitializeWithWindow.Initialize(picker, windowHandle);
var files = await picker.PickMultipleFilesAsync(); var files = await picker.PickMultipleFilesAsync();
if (files == null) return returnList; if (files == null) return returnList;
@@ -115,6 +122,9 @@ public class DialogServiceBase : IDialogServiceBase
picker.FileTypeFilter.Add(filter.ToString()); picker.FileTypeFilter.Add(filter.ToString());
} }
nint windowHandle = WindowNative.GetWindowHandle(WinoApplication.MainWindow);
InitializeWithWindow.Initialize(picker, windowHandle);
var file = await picker.PickSingleFileAsync(); var file = await picker.PickSingleFileAsync();
if (file == null) return null; if (file == null) return null;
@@ -259,6 +269,9 @@ public class DialogServiceBase : IDialogServiceBase
picker.FileTypeFilter.Add("*"); picker.FileTypeFilter.Add("*");
nint windowHandle = WindowNative.GetWindowHandle(WinoApplication.MainWindow);
InitializeWithWindow.Initialize(picker, windowHandle);
var pickedFolder = await picker.PickSingleFolderAsync(); var pickedFolder = await picker.PickSingleFolderAsync();
if (pickedFolder != null) if (pickedFolder != null)
File diff suppressed because one or more lines are too long
@@ -54,6 +54,7 @@ public partial class AppShellViewModel : MailBaseViewModel,
private readonly SettingsItem SettingsItem = new SettingsItem(); private readonly SettingsItem SettingsItem = new SettingsItem();
private readonly ManageAccountsMenuItem ManageAccountsMenuItem = new ManageAccountsMenuItem(); private readonly ManageAccountsMenuItem ManageAccountsMenuItem = new ManageAccountsMenuItem();
private readonly ContactsMenuItem ContactsMenuItem = new ContactsMenuItem();
public IMenuItem CreateMailMenuItem = new NewMailMenuItem(); public IMenuItem CreateMailMenuItem = new NewMailMenuItem();
@@ -150,6 +151,7 @@ public partial class AppShellViewModel : MailBaseViewModel,
FooterItems.Clear(); FooterItems.Clear();
FooterItems.Add(ContactsMenuItem);
FooterItems.Add(ManageAccountsMenuItem); FooterItems.Add(ManageAccountsMenuItem);
FooterItems.Add(SettingsItem); FooterItems.Add(SettingsItem);
}); });
@@ -591,6 +593,10 @@ public partial class AppShellViewModel : MailBaseViewModel,
{ {
NavigationService.Navigate(WinoPage.ManageAccountsPage, parameter, NavigationReferenceFrame.ShellFrame, NavigationTransitionType.None); 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) else if (clickedMenuItem is IAccountMenuItem clickedAccountMenuItem)
{ {
// Changing loaded account. // Changing loaded account.
@@ -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<AccountContact> _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<AccountContact> Contacts { get; } = new();
public ObservableCollection<AccountContact> 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<AccountContact> 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<AccountContact> 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);
}
}
+1
View File
@@ -59,6 +59,7 @@ public partial class App : WinoApplication, IRecipient<NewMailSynchronizationReq
services.AddTransient(typeof(LanguageTimePageViewModel)); services.AddTransient(typeof(LanguageTimePageViewModel));
services.AddTransient(typeof(AppPreferencesPageViewModel)); services.AddTransient(typeof(AppPreferencesPageViewModel));
services.AddTransient(typeof(AliasManagementPageViewModel)); services.AddTransient(typeof(AliasManagementPageViewModel));
services.AddTransient(typeof(ContactsPageViewModel));
} }
#endregion #endregion
+1
View File
@@ -340,6 +340,7 @@
x:Key="NavigationMenuTemplateSelector" x:Key="NavigationMenuTemplateSelector"
AccountManagementTemplate="{StaticResource ManageAccountsTemplate}" AccountManagementTemplate="{StaticResource ManageAccountsTemplate}"
ClickableAccountMenuTemplate="{StaticResource ClickableAccountMenuTemplate}" ClickableAccountMenuTemplate="{StaticResource ClickableAccountMenuTemplate}"
ContactsMenuItemTemplate="{StaticResource ContactsTemplate}"
FixAuthenticationIssueTemplate="{StaticResource FixAuthenticationIssueTemplate}" FixAuthenticationIssueTemplate="{StaticResource FixAuthenticationIssueTemplate}"
FixMissingFolderConfigTemplate="{StaticResource FixMissingFolderConfig}" FixMissingFolderConfigTemplate="{StaticResource FixMissingFolderConfig}"
FolderMenuTemplate="{StaticResource FolderMenuTemplate}" FolderMenuTemplate="{StaticResource FolderMenuTemplate}"
@@ -0,0 +1,98 @@
<ContentDialog
x:Class="Wino.Dialogs.ContactEditDialog"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:domain="using:Wino.Core.Domain"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
Title="Edit Contact"
HorizontalContentAlignment="Stretch"
DefaultButton="Primary"
IsPrimaryButtonEnabled="True"
PrimaryButtonClick="SaveClicked"
PrimaryButtonText="Save"
SecondaryButtonClick="CancelClicked"
SecondaryButtonText="Cancel"
Style="{StaticResource WinoDialogStyle}"
mc:Ignorable="d">
<ContentDialog.Resources>
<x:Double x:Key="ContentDialogMaxWidth">400</x:Double>
</ContentDialog.Resources>
<StackPanel Spacing="16">
<!-- Contact Name -->
<TextBox
x:Name="ContactNameTextBox"
Header="Name"
PlaceholderText="Contact name"
TextChanged="ValidateInput" />
<!-- Email Address -->
<TextBox
x:Name="EmailAddressTextBox"
Header="Email Address"
PlaceholderText="contact@example.com"
TextChanged="ValidateInput" />
<!-- Contact Photo -->
<StackPanel>
<TextBlock
Margin="0,0,0,8"
FontWeight="SemiBold"
Text="Photo" />
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<PersonPicture
x:Name="ContactPhotoPersonPicture"
Grid.Column="0"
Width="64"
Height="64"
Margin="0,0,16,0" />
<StackPanel
Grid.Column="1"
VerticalAlignment="Center"
Spacing="8">
<Button
x:Name="ChoosePhotoButton"
Click="ChoosePhotoClicked"
Content="Choose Photo" />
<Button
x:Name="RemovePhotoButton"
Click="RemovePhotoClicked"
Content="Remove Photo" />
</StackPanel>
</Grid>
</StackPanel>
<!-- Contact Status Info -->
<Border
x:Name="RootContactInfoBorder"
Padding="12,8"
Background="{ThemeResource AccentFillColorDefaultBrush}"
CornerRadius="4"
Visibility="Collapsed">
<TextBlock
Foreground="{ThemeResource TextOnAccentFillColorPrimaryBrush}"
Text="This is a root contact and cannot be deleted."
TextWrapping="Wrap" />
</Border>
<Border
x:Name="OverriddenContactInfoBorder"
Padding="12,8"
Background="{ThemeResource SystemFillColorCautionBrush}"
CornerRadius="4"
Visibility="Collapsed">
<TextBlock
Foreground="{ThemeResource TextFillColorPrimaryBrush}"
Text="This contact has been manually modified."
TextWrapping="Wrap" />
</Border>
</StackPanel>
</ContentDialog>
@@ -0,0 +1,97 @@
using System;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Wino.Core.Domain.Entities.Shared;
using Wino.Core.Domain.Interfaces;
namespace Wino.Dialogs;
public sealed partial class ContactEditDialog : ContentDialog
{
private AccountContact _contact;
private IDialogServiceBase? _dialogService;
public AccountContact Contact => _contact;
public ContactEditDialog(AccountContact? contact = null, IDialogServiceBase? dialogService = null)
{
InitializeComponent();
_contact = contact ?? new AccountContact();
_dialogService = dialogService;
LoadContactData();
ValidateInput();
}
private void LoadContactData()
{
if (_contact != null)
{
ContactNameTextBox.Text = _contact.Name ?? string.Empty;
EmailAddressTextBox.Text = _contact.Address ?? string.Empty;
// Show info badges
if (_contact.IsRootContact)
{
RootContactInfoBorder.Visibility = Visibility.Visible;
}
if (_contact.IsOverridden)
{
OverriddenContactInfoBorder.Visibility = Visibility.Visible;
}
}
}
private void ChoosePhotoClicked(object sender, RoutedEventArgs e)
{
// TODO: Implement photo picker
}
private void RemovePhotoClicked(object sender, RoutedEventArgs e)
{
ContactPhotoPersonPicture.ProfilePicture = null;
RemovePhotoButton.Visibility = Visibility.Collapsed;
}
private void ValidateInput(object? sender = null, TextChangedEventArgs? e = null)
{
var hasName = !string.IsNullOrWhiteSpace(ContactNameTextBox.Text);
var hasEmail = !string.IsNullOrWhiteSpace(EmailAddressTextBox.Text);
var isValidEmail = hasEmail && IsValidEmail(EmailAddressTextBox.Text);
IsPrimaryButtonEnabled = hasName && isValidEmail;
}
private bool IsValidEmail(string email)
{
try
{
var addr = new System.Net.Mail.MailAddress(email);
return addr.Address == email;
}
catch
{
return false;
}
}
private void SaveClicked(ContentDialog sender, ContentDialogButtonClickEventArgs args)
{
// Update contact data
_contact.Name = ContactNameTextBox.Text?.Trim();
_contact.Address = EmailAddressTextBox.Text?.Trim();
// Mark as overridden if this was a user edit
if (!string.IsNullOrEmpty(_contact.Address))
{
_contact.IsOverridden = true;
}
}
private void CancelClicked(ContentDialog sender, ContentDialogButtonClickEventArgs args)
{
// Nothing to do, dialog will close
}
}
+17
View File
@@ -209,4 +209,21 @@ public class DialogService : DialogServiceBase, IMailDialogService
return dialog.Result; return dialog.Result;
} }
public async Task<Core.Domain.Entities.Shared.AccountContact?> ShowEditContactDialogAsync(Core.Domain.Entities.Shared.AccountContact? contact = null)
{
var dialog = new ContactEditDialog(contact, this)
{
RequestedTheme = ThemeService.RootTheme.ToWindowsElementTheme()
};
var result = await HandleDialogPresentationAsync(dialog);
if (result == ContentDialogResult.Primary)
{
return dialog.Contact;
}
return null;
}
} }
@@ -34,7 +34,7 @@ public class NavigationService : NavigationServiceBase, INavigationService
_statePersistanceService = statePersistanceService; _statePersistanceService = statePersistanceService;
} }
public Type GetPageType(WinoPage winoPage) public Type? GetPageType(WinoPage winoPage)
{ {
return winoPage switch return winoPage switch
{ {
@@ -60,6 +60,7 @@ public class NavigationService : NavigationServiceBase, INavigationService
WinoPage.LanguageTimePage => typeof(LanguageTimePage), WinoPage.LanguageTimePage => typeof(LanguageTimePage),
WinoPage.EditAccountDetailsPage => typeof(EditAccountDetailsPage), WinoPage.EditAccountDetailsPage => typeof(EditAccountDetailsPage),
WinoPage.KeyboardShortcutsPage => typeof(KeyboardShortcutsPage), WinoPage.KeyboardShortcutsPage => typeof(KeyboardShortcutsPage),
WinoPage.ContactsPage => typeof(ContactsPage),
_ => null, _ => null,
}; };
} }
@@ -0,0 +1,6 @@
using Wino.Core.WinUI;
using Wino.Mail.ViewModels;
namespace Wino.Views.Abstract;
public abstract class ContactsPageAbstract : BasePage<ContactsPageViewModel> { }
@@ -0,0 +1,241 @@
<abstract:ContactsPageAbstract
x:Class="Wino.Views.Settings.ContactsPage"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:abstract="using:Wino.Views.Abstract"
xmlns:controls="using:Wino.Controls"
xmlns:controls1="using:CommunityToolkit.WinUI.Controls"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:domain="using:Wino.Core.Domain"
xmlns:entities="using:Wino.Core.Domain.Entities.Shared"
xmlns:helpers="using:Wino.Helpers"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:muxc="using:Microsoft.UI.Xaml.Controls"
x:Name="root"
mc:Ignorable="d">
<Page.Resources>
<DataTemplate x:Key="ContactTemplate" x:DataType="entities:AccountContact">
<Grid Margin="0,4" Padding="16,12">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<!-- Contact Picture -->
<PersonPicture
Grid.Column="0"
Width="48"
Height="48"
Margin="0,0,16,0"
DisplayName="{x:Bind Name}"
ProfilePicture="{x:Bind helpers:XamlHelpers.Base64ToBitmapImage(Base64ContactPicture), Mode=OneWay}" />
<!-- Contact Info -->
<StackPanel Grid.Column="1" VerticalAlignment="Center">
<TextBlock
FontWeight="SemiBold"
Text="{x:Bind Name}"
TextTrimming="CharacterEllipsis" />
<TextBlock
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Style="{StaticResource CaptionTextBlockStyle}"
Text="{x:Bind Address}"
TextTrimming="CharacterEllipsis" />
<StackPanel Orientation="Horizontal" Spacing="8">
<Border
Padding="4,2"
Background="{ThemeResource AccentFillColorDefaultBrush}"
CornerRadius="2"
Visibility="{x:Bind IsRootContact, Mode=OneWay}">
<TextBlock
FontSize="10"
Foreground="{ThemeResource TextOnAccentFillColorPrimaryBrush}"
Text="{x:Bind domain:Translator.ContactStatus_Account, Mode=OneTime}" />
</Border>
<Border
Padding="4,2"
CornerRadius="2"
Visibility="{x:Bind IsOverridden, Mode=OneWay}">
<TextBlock FontSize="10" Text="{x:Bind domain:Translator.ContactStatus_Modified, Mode=OneTime}" />
</Border>
</StackPanel>
</StackPanel>
<!-- Actions -->
<StackPanel
Grid.Column="2"
Orientation="Horizontal"
Spacing="8">
<Button
Command="{Binding ViewModel.EditContactCommand, ElementName=root}"
CommandParameter="{Binding}"
Style="{StaticResource SubtleButtonStyle}"
ToolTipService.ToolTip="{x:Bind domain:Translator.ContactAction_Edit, Mode=OneTime}">
<FontIcon FontSize="16" Glyph="&#xE70F;" />
</Button>
<Button
Command="{Binding ViewModel.PickContactPhotoCommand, ElementName=root}"
CommandParameter="{Binding}"
Style="{StaticResource SubtleButtonStyle}"
ToolTipService.ToolTip="{x:Bind domain:Translator.ContactAction_ChangePhoto, Mode=OneTime}">
<FontIcon FontSize="16" Glyph="&#xE91B;" />
</Button>
<Button
Command="{Binding ViewModel.DeleteContactCommand, ElementName=root}"
CommandParameter="{Binding}"
IsEnabled="{x:Bind helpers:XamlHelpers.ReverseBoolConverter(IsRootContact), Mode=OneWay}"
Style="{StaticResource SubtleButtonStyle}"
ToolTipService.ToolTip="{x:Bind domain:Translator.ContactAction_Delete, Mode=OneTime}">
<FontIcon FontSize="16" Glyph="&#xE74D;" />
</Button>
</StackPanel>
</Grid>
</DataTemplate>
</Page.Resources>
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<!-- Header -->
<Grid Grid.Row="0" Padding="24,16">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0">
<TextBlock
FontSize="28"
FontWeight="SemiBold"
Text="{x:Bind domain:Translator.ContactsPage_Title, Mode=OneTime}" />
<TextBlock Foreground="{ThemeResource TextFillColorSecondaryBrush}" Text="{x:Bind domain:Translator.ContactsPage_Subtitle, Mode=OneTime}" />
</StackPanel>
<StackPanel
Grid.Column="1"
Orientation="Horizontal"
Spacing="8">
<Button Command="{x:Bind ViewModel.AddContactCommand}" Style="{StaticResource AccentButtonStyle}">
<StackPanel Orientation="Horizontal" Spacing="8">
<FontIcon FontSize="16" Glyph="&#xE710;" />
<TextBlock Text="{x:Bind domain:Translator.ContactAction_Add, Mode=OneTime}" />
</StackPanel>
</Button>
<Button Command="{x:Bind ViewModel.ToggleSelectionCommand}" Style="{StaticResource DefaultButtonStyle}">
<StackPanel Orientation="Horizontal" Spacing="8">
<FontIcon FontSize="16" Glyph="&#xE762;" />
<TextBlock Text="{x:Bind helpers:XamlHelpers.BoolToSelectionModeText(ViewModel.IsSelectionMode), Mode=OneWay}" />
</StackPanel>
</Button>
</StackPanel>
</Grid>
<!-- Search and Selection Bar -->
<Grid
Grid.Row="2"
Padding="24,0,24,16"
Visibility="{x:Bind ViewModel.IsSelectionMode, Mode=OneWay}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<StackPanel Orientation="Horizontal" Spacing="16">
<TextBlock
VerticalAlignment="Center"
Text="{x:Bind ViewModel.SelectedContactsCount, Mode=OneWay}"
TextWrapping="Wrap">
<Run Text="{x:Bind ViewModel.SelectedContactsCount, Mode=OneWay}" />
<Run Text="{x:Bind domain:Translator.ContactSelection_Selected, Mode=OneTime}" />
</TextBlock>
</StackPanel>
<StackPanel
Grid.Column="1"
Orientation="Horizontal"
Spacing="8">
<Button Command="{x:Bind ViewModel.SelectAllContactsCommand}" Style="{StaticResource SubtleButtonStyle}">
<TextBlock Text="{x:Bind domain:Translator.ContactSelection_SelectAll, Mode=OneTime}" />
</Button>
<Button Command="{x:Bind ViewModel.ClearSelectionCommand}" Style="{StaticResource SubtleButtonStyle}">
<TextBlock Text="{x:Bind domain:Translator.ContactSelection_Clear, Mode=OneTime}" />
</Button>
<Button
Command="{x:Bind ViewModel.DeleteSelectedContactsCommand}"
IsEnabled="{x:Bind helpers:XamlHelpers.CountToBooleanConverter(ViewModel.SelectedContactsCount), Mode=OneWay}"
Style="{StaticResource SubtleButtonStyle}">
<StackPanel Orientation="Horizontal" Spacing="8">
<FontIcon FontSize="16" Glyph="&#xE74D;" />
<TextBlock Text="{x:Bind domain:Translator.ContactAction_Delete, Mode=OneTime}" />
</StackPanel>
</Button>
</StackPanel>
</Grid>
<!-- Search Box -->
<AutoSuggestBox
Grid.Row="1"
Margin="24,0,24,16"
PlaceholderText="{x:Bind domain:Translator.ContactsPage_SearchPlaceholder, Mode=OneTime}"
QueryIcon="Find"
Text="{x:Bind ViewModel.SearchQuery, Mode=TwoWay}"
Visibility="{x:Bind ViewModel.IsSelectionMode, Mode=OneWay}" />
<!-- Content -->
<Grid Grid.Row="3" Padding="24,0">
<!-- Loading Indicator -->
<ProgressRing
Width="48"
Height="48"
HorizontalAlignment="Center"
VerticalAlignment="Center"
IsActive="{x:Bind ViewModel.IsLoading, Mode=OneWay}"
Visibility="{x:Bind ViewModel.IsLoading, Mode=OneWay}" />
<!-- Contacts List -->
<ListView
ItemTemplate="{StaticResource ContactTemplate}"
ItemsSource="{x:Bind ViewModel.Contacts, Mode=OneWay}"
SelectionMode="{x:Bind helpers:XamlHelpers.BoolToSelectionMode(ViewModel.IsSelectionMode), Mode=OneWay}">
<ListView.ItemContainerTransitions>
<TransitionCollection>
<AddDeleteThemeTransition />
<ContentThemeTransition />
<ReorderThemeTransition />
<EntranceThemeTransition IsStaggeringEnabled="True" />
</TransitionCollection>
</ListView.ItemContainerTransitions>
</ListView>
<!-- Empty State -->
<StackPanel
HorizontalAlignment="Center"
VerticalAlignment="Center"
Spacing="16"
Visibility="{x:Bind helpers:XamlHelpers.CountToVisibilityConverter(ViewModel.SelectedContactsCount), Mode=OneWay}">
<FontIcon
FontSize="48"
Foreground="{ThemeResource TextFillColorTertiaryBrush}"
Glyph="&#xE716;" />
<TextBlock
HorizontalAlignment="Center"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Text="{x:Bind domain:Translator.ContactsPage_EmptyState, Mode=OneTime}"
TextAlignment="Center" />
<Button Command="{x:Bind ViewModel.AddContactCommand}" Style="{StaticResource AccentButtonStyle}">
<StackPanel Orientation="Horizontal" Spacing="8">
<FontIcon FontSize="16" Glyph="&#xE710;" />
<TextBlock Text="{x:Bind domain:Translator.ContactsPage_AddFirstContact, Mode=OneTime}" />
</StackPanel>
</Button>
</StackPanel>
</Grid>
</Grid>
</abstract:ContactsPageAbstract>
@@ -0,0 +1,11 @@
using Wino.Views.Abstract;
namespace Wino.Views.Settings;
public sealed partial class ContactsPage : ContactsPageAbstract
{
public ContactsPage()
{
this.InitializeComponent();
}
}
+6
View File
@@ -68,6 +68,7 @@
<ItemGroup> <ItemGroup>
<None Remove="Controls\ListView\WinoListViewStyles.xaml" /> <None Remove="Controls\ListView\WinoListViewStyles.xaml" />
<None Remove="ShellWindow.xaml" /> <None Remove="ShellWindow.xaml" />
<None Remove="Views\Settings\ContactsPage.xaml" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
@@ -124,6 +125,11 @@
<ProjectReference Include="..\Wino.Mail.ViewModels\Wino.Mail.ViewModels.csproj" /> <ProjectReference Include="..\Wino.Mail.ViewModels\Wino.Mail.ViewModels.csproj" />
<ProjectReference Include="..\Wino.Services\Wino.Services.csproj" /> <ProjectReference Include="..\Wino.Services\Wino.Services.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<Page Update="Views\Settings\ContactsPage.xaml">
<Generator>MSBuild:Compile</Generator>
</Page>
</ItemGroup>
<ItemGroup> <ItemGroup>
<Page Update="Controls\ListView\WinoListViewStyles.xaml"> <Page Update="Controls\ListView\WinoListViewStyles.xaml">
<Generator>MSBuild:Compile</Generator> <Generator>MSBuild:Compile</Generator>
+48 -1
View File
@@ -59,7 +59,7 @@ public class ContactService : BaseDatabaseService, IContactService
{ {
await Connection.InsertAsync(info).ConfigureAwait(false); 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); await Connection.InsertOrReplaceAsync(info).ConfigureAwait(false);
} }
@@ -70,4 +70,51 @@ public class ContactService : BaseDatabaseService, IContactService
} }
} }
} }
public Task<List<AccountContact>> GetAllContactsAsync()
{
return Connection.Table<AccountContact>().ToListAsync();
}
public Task<List<AccountContact>> 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<AccountContact>(rawLikeQuery);
}
public async Task<AccountContact> 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<string> addresses)
{
foreach (var address in addresses)
{
await DeleteContactAsync(address).ConfigureAwait(false);
}
}
} }