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:
parent
29a66a8c30
commit
04a31709a2
7 changed files with 259 additions and 128 deletions
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue