Files
Wino-Mail/Wino.Mail.ViewModels/ContactsPageViewModel.cs
T
2026-03-12 14:55:07 +01:00

490 lines
16 KiB
C#

using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Collections.Specialized;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using CommunityToolkit.Mvvm.Messaging;
using Wino.Core.Domain;
using Wino.Core.Domain.Entities.Shared;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Navigation;
using Wino.Mail.ViewModels.Data;
using Wino.Messaging.Client.Contacts;
namespace Wino.Mail.ViewModels;
public partial class ContactsPageViewModel : MailBaseViewModel,
IRecipient<NewContactRequested>
{
private const int ContactPageSize = 50;
private readonly IContactService _contactService;
private readonly IMailDialogService _dialogService;
private readonly IContactPictureFileService _contactPictureFileService;
private CancellationTokenSource _searchDebounceCancellationTokenSource;
private int _currentOffset = 0;
private int _currentQueryVersion = 0;
[ObservableProperty]
public partial string SearchQuery { get; set; } = string.Empty;
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(LoadMoreContactsCommand))]
[NotifyPropertyChangedFor(nameof(IsEmpty))]
public partial bool IsLoading { get; set; } = false;
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(LoadMoreContactsCommand))]
public partial bool IsLoadingMore { get; set; } = false;
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(LoadMoreContactsCommand))]
public partial bool HasMoreContacts { get; set; } = false;
[ObservableProperty]
public partial bool IsSelectionMode { get; set; } = false;
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(DeleteSelectedContactsCommand))]
public partial int SelectedContactsCount { get; set; } = 0;
[ObservableProperty]
public partial int TotalContactsCount { get; set; } = 0;
public bool IsEmpty => !IsLoading && Contacts.Count == 0;
public bool CanLoadMoreContacts => HasMoreContacts && !IsLoading && !IsLoadingMore;
public bool CanDeleteSelectedContacts => SelectedContactsCount > 0;
public ObservableCollection<AccountContactViewModel> Contacts { get; } = new();
public ObservableCollection<AccountContactViewModel> SelectedContacts { get; } = new();
public ContactsPageViewModel(IContactService contactService, IMailDialogService dialogService, IContactPictureFileService contactPictureFileService)
{
_contactService = contactService;
_dialogService = dialogService;
_contactPictureFileService = contactPictureFileService;
Contacts.CollectionChanged += ContactsCollectionChanged;
}
public override async void OnNavigatedTo(NavigationMode mode, object parameters)
{
base.OnNavigatedTo(mode, parameters);
SelectedContacts.CollectionChanged -= SelectedContactsChanged;
SelectedContacts.CollectionChanged += SelectedContactsChanged;
await ReloadContactsAsync();
}
public override void OnNavigatedFrom(NavigationMode mode, object parameters)
{
base.OnNavigatedFrom(mode, parameters);
SelectedContacts.CollectionChanged -= SelectedContactsChanged;
_searchDebounceCancellationTokenSource?.Cancel();
_searchDebounceCancellationTokenSource?.Dispose();
_searchDebounceCancellationTokenSource = null;
}
private async void SelectedContactsChanged(object sender, NotifyCollectionChangedEventArgs e)
=> await ExecuteUIThread(() => { SelectedContactsCount = SelectedContacts.Count; });
private async void ContactsCollectionChanged(object sender, NotifyCollectionChangedEventArgs e)
=> await ExecuteUIThread(() => { OnPropertyChanged(nameof(IsEmpty)); });
void IRecipient<NewContactRequested>.Receive(NewContactRequested message)
=> _ = AddContactAsync();
[RelayCommand]
private async Task ReloadContactsAsync()
{
var queryVersion = ++_currentQueryVersion;
_currentOffset = 0;
await ExecuteUIThread(() =>
{
HasMoreContacts = false;
Contacts.Clear();
SelectedContacts.Clear();
});
await LoadContactsPageAsync(queryVersion, reset: true);
}
[RelayCommand(CanExecute = nameof(CanLoadMoreContacts))]
private async Task LoadMoreContactsAsync()
{
await LoadContactsPageAsync(_currentQueryVersion, reset: false);
}
private async Task LoadContactsPageAsync(int queryVersion, bool reset)
{
if (IsLoading || IsLoadingMore)
return;
await ExecuteUIThread(() =>
{
if (reset)
IsLoading = true;
else
IsLoadingMore = true;
});
try
{
var searchQuery = string.IsNullOrWhiteSpace(SearchQuery) ? null : SearchQuery.Trim();
var page = await _contactService.GetContactsPageAsync(
_currentOffset,
ContactPageSize,
searchQuery,
excludeRootContacts: true).ConfigureAwait(false);
if (queryVersion != _currentQueryVersion)
return;
await ExecuteUIThread(() =>
{
if (reset)
{
Contacts.Clear();
}
foreach (var contact in page.Contacts)
{
Contacts.Add(new AccountContactViewModel(contact));
}
TotalContactsCount = page.TotalCount;
HasMoreContacts = page.HasMore;
_currentOffset = Contacts.Count;
});
}
catch (Exception ex)
{
if (queryVersion != _currentQueryVersion)
return;
_dialogService.InfoBarMessage(
Translator.ContactInfoBar_ErrorTitle,
string.Format(Translator.ContactInfoBar_FailedToLoadContacts, ex.Message),
InfoBarMessageType.Error);
}
finally
{
if (queryVersion == _currentQueryVersion)
{
await ExecuteUIThread(() =>
{
if (reset)
IsLoading = false;
else
IsLoadingMore = false;
});
}
}
}
[RelayCommand]
private async Task AddContactAsync()
{
var result = await _dialogService.ShowEditContactDialogAsync(null);
if (result == null) return;
try
{
var newContact = await _contactService.CreateNewContactAsync(result.Address, result.Name);
if (result.ContactPictureFileId.HasValue)
{
newContact.ContactPictureFileId = result.ContactPictureFileId;
await _contactService.UpdateContactAsync(newContact);
}
await ReloadContactsAsync();
_dialogService.InfoBarMessage(
Translator.ContactInfoBar_SuccessTitle,
Translator.ContactInfoBar_ContactAdded,
InfoBarMessageType.Success);
}
catch (Exception ex)
{
_dialogService.InfoBarMessage(
Translator.ContactInfoBar_ErrorTitle,
string.Format(Translator.ContactInfoBar_FailedToAddContact, ex.Message),
InfoBarMessageType.Error);
}
}
protected override void RegisterRecipients()
{
base.RegisterRecipients();
Messenger.Register<NewContactRequested>(this);
}
protected override void UnregisterRecipients()
{
base.UnregisterRecipients();
Messenger.Unregister<NewContactRequested>(this);
}
[RelayCommand]
private async Task EditContactAsync(AccountContactViewModel contactViewModel)
{
var contact = contactViewModel?.SourceContact;
if (contact == null) return;
var result = await _dialogService.ShowEditContactDialogAsync(contact);
if (result == null) return;
try
{
contact.Name = result.Name;
contact.ContactPictureFileId = result.ContactPictureFileId;
contact.IsOverridden = result.IsOverridden;
await _contactService.UpdateContactAsync(contact);
await ReloadContactsAsync();
_dialogService.InfoBarMessage(
Translator.ContactInfoBar_SuccessTitle,
Translator.ContactInfoBar_ContactUpdated,
InfoBarMessageType.Success);
}
catch (Exception ex)
{
_dialogService.InfoBarMessage(
Translator.ContactInfoBar_ErrorTitle,
string.Format(Translator.ContactInfoBar_FailedToUpdateContact, ex.Message),
InfoBarMessageType.Error);
}
}
[RelayCommand]
private async Task DeleteContactAsync(AccountContactViewModel contactViewModel)
{
var contact = contactViewModel?.SourceContact;
if (contact == null || contact.IsRootContact)
{
_dialogService.InfoBarMessage(
Translator.ContactInfoBar_WarningTitle,
Translator.ContactInfoBar_CannotDeleteRoot,
InfoBarMessageType.Warning);
return;
}
var confirmed = await _dialogService.ShowConfirmationDialogAsync(
string.Format(Translator.ContactConfirmDialog_DeleteMessage, contact.Name ?? contact.Address),
Translator.ContactConfirmDialog_DeleteTitle,
Translator.ContactConfirmDialog_DeleteButton);
if (confirmed)
{
await DeleteContactsInternalAsync(new[] { contact });
}
}
[RelayCommand(CanExecute = nameof(CanDeleteSelectedContacts))]
private async Task DeleteSelectedContactsAsync()
{
if (SelectedContacts.Count == 0) return;
var deletableContacts = SelectedContacts
.Select(c => c?.SourceContact)
.Where(c => c != null && !c.IsRootContact)
.GroupBy(c => c.Address, StringComparer.OrdinalIgnoreCase)
.Select(g => g.First())
.ToList();
if (deletableContacts.Count == 0)
{
_dialogService.InfoBarMessage(
Translator.ContactInfoBar_WarningTitle,
Translator.ContactInfoBar_CannotDeleteRoot,
InfoBarMessageType.Warning);
return;
}
var confirmed = await _dialogService.ShowConfirmationDialogAsync(
string.Format(Translator.ContactConfirmDialog_DeleteMultipleMessage, deletableContacts.Count),
Translator.ContactConfirmDialog_DeleteTitle,
Translator.ContactConfirmDialog_DeleteButton);
if (confirmed)
{
await DeleteContactsInternalAsync(deletableContacts);
}
}
private async Task DeleteContactsInternalAsync(IEnumerable<AccountContact> contactsToDelete)
{
try
{
var addresses = contactsToDelete
.Select(c => c.Address)
.Where(a => !string.IsNullOrWhiteSpace(a))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
if (addresses.Count == 0) return;
await _contactService.DeleteContactsAsync(addresses);
await ReloadContactsAsync();
_dialogService.InfoBarMessage(
Translator.ContactInfoBar_SuccessTitle,
Translator.ContactInfoBar_ContactsDeleted,
InfoBarMessageType.Success);
}
catch (Exception ex)
{
_dialogService.InfoBarMessage(
Translator.ContactInfoBar_ErrorTitle,
string.Format(Translator.ContactInfoBar_FailedToDeleteContacts, ex.Message),
InfoBarMessageType.Error);
}
}
[RelayCommand]
private async Task ToggleSelection()
{
await ExecuteUIThread(() =>
{
IsSelectionMode = !IsSelectionMode;
if (!IsSelectionMode)
{
SelectedContacts.Clear();
}
});
}
[RelayCommand]
private async Task SelectAllContacts()
{
await ExecuteUIThread(() =>
{
SelectedContacts.Clear();
foreach (var contact in Contacts)
{
SelectedContacts.Add(contact);
}
});
}
[RelayCommand]
private async Task ClearSelection()
{
await ExecuteUIThread(() => { SelectedContacts.Clear(); });
}
[RelayCommand]
private async Task PickContactPhotoAsync(AccountContactViewModel contactViewModel)
{
var contact = contactViewModel?.SourceContact;
if (contact == null) return;
try
{
var files = await _dialogService.PickFilesAsync(".png", ".jpg", ".jpeg");
if (files?.Any() == true)
{
var file = files.First();
if (contact.ContactPictureFileId.HasValue)
await _contactPictureFileService.DeleteContactPictureAsync(contact.ContactPictureFileId.Value);
contact.ContactPictureFileId = await _contactPictureFileService
.SaveContactPictureAsync(file.Data)
.ConfigureAwait(false);
await _contactService.UpdateContactAsync(contact);
await RefreshContactInUiAsync(contact);
_dialogService.InfoBarMessage(
Translator.ContactInfoBar_SuccessTitle,
Translator.ContactInfoBar_ContactPhotoUpdated,
InfoBarMessageType.Success);
}
}
catch (Exception ex)
{
_dialogService.InfoBarMessage(
Translator.ContactInfoBar_ErrorTitle,
string.Format(Translator.ContactInfoBar_FailedToUpdatePhoto, ex.Message),
InfoBarMessageType.Error);
}
}
private async Task RefreshContactInUiAsync(AccountContact contact)
{
if (contact == null || string.IsNullOrWhiteSpace(contact.Address))
return;
await ExecuteUIThread(() =>
{
ReplaceContactByAddress(Contacts, contact);
ReplaceContactByAddress(SelectedContacts, contact);
});
}
private static void ReplaceContactByAddress(ObservableCollection<AccountContactViewModel> source, AccountContact updatedContact)
{
var index = source
.Select((item, i) => new { item, i })
.FirstOrDefault(x => string.Equals(x.item.Address, updatedContact.Address, StringComparison.OrdinalIgnoreCase))
?.i ?? -1;
if (index < 0) return;
source[index] = new AccountContactViewModel(CloneContact(updatedContact));
}
private static AccountContact CloneContact(AccountContact contact)
=> new()
{
Address = contact.Address,
Name = contact.Name,
ContactPictureFileId = contact.ContactPictureFileId,
IsRootContact = contact.IsRootContact,
IsOverridden = contact.IsOverridden
};
partial void OnSearchQueryChanged(string value)
{
DebounceSearchAndReload();
}
private async void DebounceSearchAndReload()
{
_searchDebounceCancellationTokenSource?.Cancel();
_searchDebounceCancellationTokenSource?.Dispose();
_searchDebounceCancellationTokenSource = new CancellationTokenSource();
try
{
await Task.Delay(250, _searchDebounceCancellationTokenSource.Token);
await ReloadContactsAsync();
}
catch (OperationCanceledException)
{
// Ignore stale search input.
}
}
}