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.
Replaces the empty ContactService.Desktop stub with a real
implementation backed by UserSearchClient. Closes the loop
between the server-side /api/user-search endpoint (b3056f1c),
the client wrapper (6e7e0414), and the platform abstraction.
IContactService gains:
- SearchAsync(string query, CancellationToken): on desktop,
hits /api/user-search and appends results to an in-memory
cache. On mobile, throws PlatformNotSupportedException —
mobile providers use the device-local address book
(GetDeviceContactsAsync) and don't talk to a network search.
- Contacts (ObservableCollection<ContactDto>): live view of
the cache; UI binds directly to it. Mobile populates it
inside GetDeviceContactsAsync (eager load); desktop populates
it via SearchAsync (lazy, on-demand).
ContactDto shape changes:
- Emails (IReadOnlyList<string>) -> Email (string?). The
/api/user-search endpoint returns one email per user. The
use case ('invite / add to a circle') only needs one.
- Mobile provider flattens its per-contact email list down
to the first non-empty entry (a small functional loss that
matches the wire shape).
App.axaml.cs constructs a ContactService from the
UserSearchClient singleton and registers it as
IContactService so future ViewModels can take the interface
by constructor injection.
Build + 51/51 tests green. The mobile provider is still
gated by #if ANDROID || IOS and not exercised by the
Desktop test target — runtime behaviour on Android will
need a smoke test on device when PostIt.Android lands.
Splits the single ContactService class (which threw
PlatformNotSupportedException on non-Android/iOS targets) into a
platform-conditional structure:
- IContactService + ContactDto: shared abstraction in
src/PostIt/PostIt/Services/IContactService.cs. ViewModels depend
on this; concrete providers map their native shapes to ContactDto.
- ContactService.Mobile.cs: MAUI Essentials implementation, compiled
only when ANDROID or IOS is defined. Wraps
Contacts.Default.GetAllAsync() with permission handling and a
NotImplementedInReferenceAssemblyException safety net.
- ContactService.Desktop.cs: stub returning an empty list, compiled
when neither ANDROID nor IOS is defined. Replaces the
'throw PlatformNotSupportedException' path so desktop targets
(PostIt.Desktop, PostIt.Browser) build and run cleanly.
The Microsoft.Maui.Essentials portable facade is referenced from
PostIt.csproj, but it only becomes functional when the host
application project (PostIt.Android, future PostIt.iOS) also
references the platform-specific implementation.
No tests added: per AGENTS.md, a 'stub returns empty list' test on
PostIt.Tests (net10.0 desktop target) would be cosmetic and not
detect the real failure mode. Android-side tests require a working
PostIt.Android project, which doesn't exist yet.
Future providers (Google Contacts API, Exchange, CardDAV) plug in
as additional IContactService implementations selected by DI
configuration.