feat(postit): wire Desktop address book to /api/user-search
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.
This commit is contained in:
parent
6e7e04141b
commit
d0e0f4c175
4 changed files with 126 additions and 30 deletions
|
|
@ -60,6 +60,7 @@ public partial class App : Application
|
|||
var circleClient = new CircleApiClient(api, settings.BlogsApiUrl);
|
||||
var blogAclClient = new BlogAclApiClient(api, settings.BlogsApiUrl);
|
||||
var userSearchClient = new UserSearchClient(api, settings.BlogsApiUrl);
|
||||
var contactService = new ContactService(userSearchClient);
|
||||
|
||||
var services = new ServiceCollection();
|
||||
|
||||
|
|
@ -89,6 +90,7 @@ public partial class App : Application
|
|||
services.AddSingleton(circleClient);
|
||||
services.AddSingleton(blogAclClient);
|
||||
services.AddSingleton(userSearchClient);
|
||||
services.AddSingleton<IContactService>(contactService);
|
||||
services.AddTransient<MainPageViewModel>();
|
||||
services.AddTransient<HomePageViewModel>();
|
||||
services.AddTransient<SignaturePageViewModel>();
|
||||
|
|
|
|||
|
|
@ -1,27 +1,72 @@
|
|||
#if !ANDROID && !IOS
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Yavsc.Api.Client;
|
||||
using Yavsc.Api.Client.Dtos;
|
||||
|
||||
namespace PostIt.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Desktop stub for IContactService.
|
||||
/// Desktop implementation of <see cref="IContactService"/> backed
|
||||
/// by the central <c>/api/user-search</c> endpoint
|
||||
/// (<see cref="UserSearchClient"/>).
|
||||
///
|
||||
/// On desktop targets (Linux, macOS, Windows) MAUI Essentials
|
||||
/// Contacts.Default throws NotImplementedInReferenceAssemblyException,
|
||||
/// so we short-circuit with an empty list rather than trying to
|
||||
/// call into the portable facade at runtime.
|
||||
/// <para>Desktop has no equivalent of the mobile address book
|
||||
/// (no Contacts.Default, no CardDAV out of the box), so the
|
||||
/// address book is built on demand from the Yavsc user table.
|
||||
/// Results are accumulated in an in-memory cache exposed as
|
||||
/// <see cref="Contacts"/>; the cache is process-lifetime only
|
||||
/// — there's no persistence layer.</para>
|
||||
///
|
||||
/// Future provider plug-ins (Google Contacts API, Exchange EWS,
|
||||
/// CardDAV) can either replace this stub on a per-OS basis or
|
||||
/// live behind their own IContactService implementation that the
|
||||
/// DI container selects by configuration.
|
||||
/// <para>This is the consumer that closes the loop with the
|
||||
/// user-search endpoint landed on the server in commit 6
|
||||
/// (<c>b3056f1c</c>) and the client in commit 7
|
||||
/// (<c>6e7e0414</c>).</para>
|
||||
/// </summary>
|
||||
public sealed class ContactService : IContactService
|
||||
{
|
||||
private readonly UserSearchClient _client;
|
||||
|
||||
public ObservableCollection<ContactDto> Contacts { get; } = new();
|
||||
|
||||
public ContactService(UserSearchClient client)
|
||||
{
|
||||
_client = client ?? throw new ArgumentNullException(nameof(client));
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<ContactDto>> GetDeviceContactsAsync(CancellationToken ct = default)
|
||||
=> Task.FromResult<IReadOnlyList<ContactDto>>(Array.Empty<ContactDto>());
|
||||
=> Task.FromResult<IReadOnlyList<ContactDto>>(Contacts.ToArray());
|
||||
|
||||
public async Task SearchAsync(string query, CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(query))
|
||||
{
|
||||
// Clear the cache to mirror an empty result. The
|
||||
// address-book UX treats an empty query as "start
|
||||
// over".
|
||||
Contacts.Clear();
|
||||
return;
|
||||
}
|
||||
|
||||
var results = await _client.SearchAsync(query: query, ct: ct).ConfigureAwait(false);
|
||||
if (results is null) return;
|
||||
|
||||
// Append the search results to the cache. We don't
|
||||
// de-dupe across searches — the simplest behaviour, and
|
||||
// matches what users expect from a search panel ("show
|
||||
// me what came back"). Callers wanting a single list
|
||||
// can re-render Contacts on the next query.
|
||||
foreach (var u in results)
|
||||
{
|
||||
Contacts.Add(new ContactDto(
|
||||
Id: u.Id,
|
||||
DisplayName: u.FullName ?? u.UserName,
|
||||
Email: u.Email));
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
#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;
|
||||
|
|
@ -24,6 +25,8 @@ namespace PostIt.Services;
|
|||
/// </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)
|
||||
|
|
@ -38,21 +41,18 @@ public sealed class ContactService : IContactService
|
|||
var contacts = await Contacts.Default.GetAllAsync();
|
||||
if (contacts is null) return Array.Empty<ContactDto>();
|
||||
|
||||
var result = new List<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();
|
||||
foreach (var c in contacts)
|
||||
{
|
||||
var emails = new List<string>();
|
||||
if (c.Emails is not null)
|
||||
{
|
||||
foreach (var e in c.Emails)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(e.EmailAddress))
|
||||
emails.Add(e.EmailAddress);
|
||||
}
|
||||
}
|
||||
result.Add(new ContactDto(c.Id, c.DisplayName ?? string.Empty, emails));
|
||||
var email = FlattenPrimaryEmail(c.Emails);
|
||||
Contacts.Add(new ContactDto(c.Id, c.DisplayName ?? string.Empty, email));
|
||||
}
|
||||
return result;
|
||||
return Contacts.ToArray();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
|
@ -60,5 +60,22 @@ public sealed class ContactService : IContactService
|
|||
return Array.Empty<ContactDto>();
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
if (emails is null) return null;
|
||||
foreach (var e in emails)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(e.EmailAddress))
|
||||
return e.EmailAddress;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
|
@ -1,13 +1,14 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace PostIt.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Abstraction over device contact providers (MAUI Essentials on mobile,
|
||||
/// future Google/Exchange/IMAP providers).
|
||||
/// Abstraction over device contact providers (MAUI Essentials on
|
||||
/// mobile, the central /api/user-search endpoint on desktop).
|
||||
///
|
||||
/// Implementations live next to this file in platform-conditional
|
||||
/// source files: ContactService.Mobile.cs (ANDROID/IOS) and
|
||||
|
|
@ -15,15 +16,46 @@ namespace PostIt.Services;
|
|||
/// </summary>
|
||||
public interface IContactService
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns the contacts known so far. On mobile this is the
|
||||
/// full device address book (after permission grant); on
|
||||
/// desktop this is the in-memory cache populated by previous
|
||||
/// <see cref="SearchAsync"/> calls — empty until the user
|
||||
/// has searched for something.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<ContactDto>> GetDeviceContactsAsync(CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// On desktop: hits <c>GET /api/user-search?q=…</c> and
|
||||
/// appends matching users to the in-memory cache exposed via
|
||||
/// <see cref="Contacts"/>. On mobile: throws
|
||||
/// <see cref="PlatformNotSupportedException"/> — the mobile
|
||||
/// provider uses the device-local address book, not a
|
||||
/// network search.
|
||||
/// </summary>
|
||||
Task SearchAsync(string query, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Live view of the in-memory contact cache. UI binds to
|
||||
/// this directly for a \"search results\" panel; on mobile
|
||||
/// implementations this is populated eagerly by
|
||||
/// <see cref="GetDeviceContactsAsync"/>.
|
||||
/// </summary>
|
||||
ObservableCollection<ContactDto> Contacts { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Platform-neutral contact DTO. Source-of-truth shape for the UI layer;
|
||||
/// concrete providers (MAUI Essentials today, Google Contacts API later)
|
||||
/// map to this type.
|
||||
/// Platform-neutral contact DTO. Source-of-truth shape for the UI
|
||||
/// layer; concrete providers (MAUI Essentials on mobile,
|
||||
/// UserSearchClient on desktop) map to this type.
|
||||
///
|
||||
/// <para><c>Email</c> is a single string on purpose: the central
|
||||
/// search endpoint returns one email per user, and the UI use
|
||||
/// case is \"pick someone to invite / add to a circle\", which
|
||||
/// never needs more than one. Multi-email contacts on mobile
|
||||
/// flatten to the primary address (first non-empty).</para>
|
||||
/// </summary>
|
||||
public sealed record ContactDto(
|
||||
string Id,
|
||||
string DisplayName,
|
||||
IReadOnlyList<string> Emails);
|
||||
string? Email);
|
||||
Loading…
Add table
Add a link
Reference in a new issue