refactor(postit): split IContactService from IUserDirectory

IContactService used to be the catch-all for "people you can reach
from PostIt": on mobile it read the device-local address book, on
desktop it queried the central /api/user-search endpoint and merged
both worlds into a single ContactDto (a flat Email field, an
ObservableCollection cache, a SearchAsync method). Two unrelated
flows under the same name, with a wire shape (Email) silently
flattening the mobile provider's multi-email list.

Split into two interfaces, each with a single responsibility:

- IContactService: device-local address book only. Mobile provider
  reads MAUI Essentials Contacts.Default and carries the full email
  list per contact. Desktop provider is an honest stub returning an
  empty list — the desktop has no local address book, and inviting
  external people from desktop is a separate flow (manual email
  entry + invitation endpoint) that doesn't belong here.

- IUserDirectory: central Yavsc user directory, the only consumer
  of /api/user-search. Both Desktop and Mobile providers delegate
  to UserSearchClient; the platform split exists so future
  platform-specific sources (offline cache, directory-scoped
  providers) can plug in without disturbing consumers.

ContactDto restores IReadOnlyList<string> Emails (the flat Email
from d0e0f4c1 was a regression that matched the wire shape of
/api/user-search at the cost of the mobile provider's per-contact
list). UserSummary is a separate platform-neutral record that
mirrors the server's UserSearchResultDto without leaking transport
concerns.

App.axaml.cs registers both interfaces as singletons.

Build + 51/51 PostIt.Tests green. No UI consumer yet — these
interfaces are still plomberie; the ViewModel that joins them for
the "add to a circle" / "invite someone" flows is a follow-up.
This commit is contained in:
Paul Schneider 2026-08-18 13:22:54 +01:00
commit 04a31709a2
Signed by: notazof
GPG key ID: 1DD5D838E5343B06
7 changed files with 259 additions and 128 deletions

View file

@ -1,7 +1,6 @@
#if ANDROID || IOS
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Maui.ApplicationModel.Communication;
@ -11,22 +10,23 @@ using Microsoft.Maui.Devices;
namespace PostIt.Services;
/// <summary>
/// Mobile implementation backed by MAUI Essentials Contacts.Default.
/// Mobile implementation backed by MAUI Essentials
/// <c>Contacts.Default</c>.
///
/// Compiled only for ANDROID and IOS. On desktop targets, see
/// ContactService.Desktop.cs (the stub that wins at compile time).
/// <para>Compiled only for ANDROID and IOS. On desktop targets,
/// see <c>ContactService.Desktop.cs</c> (the stub that wins at
/// compile time).</para>
///
/// Note: at runtime, this class throws
/// NotImplementedInReferenceAssemblyException unless the host
/// application project also references the platform-specific
/// Microsoft.Maui.Essentials implementation (typically the
/// PostIt.Android project). On iOS the same is required via
/// PostIt.iOS. On desktop the stub is used and this file is excluded.
/// <para>Note: at runtime, this class throws
/// <c>NotImplementedInReferenceAssemblyException</c> unless
/// the host application project also references the
/// platform-specific Microsoft.Maui.Essentials implementation
/// (typically <c>PostIt.Android</c>). On iOS the same is
/// required via <c>PostIt.iOS</c>. On desktop the stub is used
/// and this file is excluded.</para>
/// </summary>
public sealed class ContactService : IContactService
{
public ObservableCollection<ContactDto> Contacts { get; } = new();
public async Task<IReadOnlyList<ContactDto>> GetDeviceContactsAsync(CancellationToken ct = default)
{
if (DeviceInfo.Current.Platform == DevicePlatform.Unknown)
@ -41,18 +41,24 @@ public sealed class ContactService : IContactService
var contacts = await Contacts.Default.GetAllAsync();
if (contacts is null) return Array.Empty<ContactDto>();
// Flatten the per-contact email list down to one
// primary email. The platform-neutral ContactDto only
// carries one; the use case ("invite / add to a
// circle") only needs one. The first non-empty entry
// wins.
Contacts.Clear();
// Carry the per-contact email list as-is. A real
// device contact can carry several addresses (home /
// work / other); the UI use case ("invite / add to a
// circle") can then decide which address to use, or
// let the user pick. The platform-neutral ContactDto
// shape is intentionally richer than the Yavsc
// directory's single-Email shape — the two flows
// answer different questions.
var result = new List<ContactDto>(contacts.Count);
foreach (var c in contacts)
{
var email = FlattenPrimaryEmail(c.Emails);
Contacts.Add(new ContactDto(c.Id, c.DisplayName ?? string.Empty, email));
var emails = ExtractEmails(c.Emails);
result.Add(new ContactDto(
c.Id,
c.DisplayName ?? string.Empty,
emails));
}
return Contacts.ToArray();
return result;
}
catch (Exception ex)
{
@ -61,21 +67,16 @@ public sealed class ContactService : IContactService
}
}
public Task SearchAsync(string query, CancellationToken ct = default)
=> throw new PlatformNotSupportedException(
"SearchAsync is not supported on mobile — use GetDeviceContactsAsync " +
"to load the local address book. The network search lives on the " +
"desktop service, which queries the central user-search endpoint.");
private static string? FlattenPrimaryEmail(IEnumerable<EmailAddress>? emails)
private static IReadOnlyList<string> ExtractEmails(IEnumerable<EmailAddress>? emails)
{
if (emails is null) return null;
if (emails is null) return Array.Empty<string>();
var list = new List<string>();
foreach (var e in emails)
{
if (!string.IsNullOrEmpty(e.EmailAddress))
return e.EmailAddress;
list.Add(e.EmailAddress);
}
return null;
return list;
}
}
#endif
#endif