feat(acl): server endpoints + client + UI for circle membership

Round-trip out a long-standing hole in the ACL feature: until
this commit a circle on Yavsc was an empty named bucket. You
could create 'Famille' and grant it on a post, but the circle
carried no members, so 'Famille' authorised no one. The MVC
admin controller (Yavsc.Org.Controllers.CircleMembersController)
existed but had no REST counterpart, so PostIt — which only
talks to the Yavsc.Blogs API — had no way to manage membership
at all.

This commit closes that gap end-to-end:

Server (Yavsc.Blogs)
- GET    /api/circle/{id}/members           list members
- POST   /api/circle/{id}/members           add a user (body { userId })
- DELETE /api/circle/{id}/members/{userId}  remove a user
  All three are scoped to caller == circle.OwnerId; non-owned
  circles return 404 (not 403) to avoid leaking existence, in
  line with the rest of the controller.
- Two new DTOs (CircleMemberDto, AddCircleMemberDto) for the
  wire shapes. CircleMemberDto mirrors UserSearchResultDto
  minus Email — membership UI doesn't need contact details.

Tests (Yavsc.Blogs.Tests)
- CircleMembersApiTests: 5 [Fact] covering empty list,
  add+get, duplicate add → 409, remove, and cross-owner 404.
  Test users (alice, bob) are seeded directly through the
  in-memory DbContext — the Blogs fixture doesn't stand up
  UserManager<ApplicationUser>.

Client (Yavsc.Api.Client)
- CircleMemberDto + 3 methods on CircleApiClient:
  GetMembersAsync, AddMemberAsync, RemoveMemberAsync.
  All match the server's contract: 404 flattens to null,
  409 surfaces as an exception (callers can dedupe beforehand
  if they want idempotent behaviour).

UI (PostIt)
- CirclesPageViewModel gains a Members ObservableCollection
  that auto-loads on SelectedCircle change (via the partial
  setter generated by [ObservableProperty]). Commands:
  LoadMembersAsync, OpenAddMember (raises an event the view
  subscribes to), OnAddMemberConfirmedAsync (called by the
  view when the dialog confirms a selection), RemoveMemberAsync.
  409 (already a member) is detected from the exception
  message and surfaced as a friendly status rather than an
  error — a likely race when the same user gets added twice
  through two UI paths.

- CirclesPage layout is now two-pane (circles + editor on the
  left, members of the selected circle on the right). The
  member pane has an 'Ajouter un membre' button that opens
  AddCircleMemberDialog. Code-behind wires the dialog's
  Confirmed event back into the VM via an async lambda
  wrapper (EventHandler<T> wants void, the VM method is
  async Task).

- AddCircleMemberDialog is a ContentPage (light modal, same
  pattern as PostAclDialog). Its ViewModel consumes
  IUserDirectory — the abstraction introduced by 04a31709
  to fix the 'user search should not be IContactService'
  confusion. The dialog raises Confirmed with the picked
  UserSummary; the host (CirclesPage) is responsible for
  calling CircleApiClient.AddMemberAsync.

Out of scope (tracked in MEMORY.md, 2026-08-18):
- i18n: all visible text still hard-coded French.
- XAML accessibility audit of pre-existing pages.
- Avalonia.Headless UI tests of the new navigation flow.

Tests: 51/51 PostIt.Tests green, 20/20 Yavsc.Blogs.Tests
green (was 15; +5 for CircleMembersApiTests), 44/44
Yavsc.Org.Tests green (no regression).
This commit is contained in:
Paul Schneider 2026-08-18 14:10:12 +01:00
commit 5e3d361f88
Signed by: notazof
GPG key ID: 1DD5D838E5343B06
10 changed files with 923 additions and 38 deletions

View file

@ -0,0 +1,118 @@
using System;
using System.Collections.ObjectModel;
using System.Threading;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using PostIt.Services;
using Yavsc.Api.Client;
namespace PostIt.ViewModels;
/// <summary>
/// View model for the "add a Yavsc user to a circle" modal.
///
/// <para>Resolves users through <see cref="IUserDirectory"/>
/// (which delegates to <c>/api/user-search</c>); the caller
/// (CirclesPage) decides whether to add the picked user to
/// the circle by calling
/// <see cref="AddCircleMemberDialogViewModel.AddCommand"/>
/// (which is bound to the dialog's "Ajouter" button).</para>
///
/// <para>The dialog itself doesn't know the target
/// <c>CircleId</c>: that's set by the caller via the
/// constructor and the dialog only triggers
/// <see cref="IUserDirectory.SearchAsync"/> against the
/// <see cref="SearchQuery"/> string. The "Add" command
/// returns the picked <see cref="UserSummary"/> via the
/// <see cref="Confirmed"/> event, and the hosting
/// <c>CirclesPage</c> then calls
/// <see cref="CircleApiClient.AddMemberAsync"/>.</para>
/// </summary>
public partial class AddCircleMemberDialogViewModel : ViewModelBase
{
private readonly IUserDirectory _directory;
[ObservableProperty]
public partial string SearchQuery { get; set; } = string.Empty;
[ObservableProperty]
public partial ObservableCollection<UserSummary> Results { get; set; } = new();
[ObservableProperty]
public partial UserSummary? Selected { get; set; }
[ObservableProperty]
public partial bool IsBusy { get; set; }
[ObservableProperty]
public partial string StatusMessage { get; set; } = string.Empty;
/// <summary>
/// Raised when the user confirms a selection. The hosting
/// <c>CirclesPage</c> subscribes to this event and calls
/// <c>CircleApiClient.AddMemberAsync</c> with the target
/// circle id + the picked user's id. The dialog itself
/// does not know the circle id by design: separation of
/// concerns — the modal is a user picker, not a
/// "circle joiner" form.
/// </summary>
public event EventHandler<UserSummary>? Confirmed;
public AddCircleMemberDialogViewModel(IUserDirectory directory)
{
_directory = directory ?? throw new ArgumentNullException(nameof(directory));
}
public override bool CanNavigateNext { get => throw new NotImplementedException(); protected set => throw new NotImplementedException(); }
public override bool CanNavigatePrevious { get => throw new NotImplementedException(); protected set => throw new NotImplementedException(); }
/// <summary>
/// Search the directory for users matching the current
/// <see cref="SearchQuery"/>. Triggered explicitly via the
/// "Rechercher" button — no debouncing, so the caller
/// stays in control of how often the network is hit.
/// </summary>
[RelayCommand]
public async Task SearchAsync()
{
if (string.IsNullOrWhiteSpace(SearchQuery))
{
Results.Clear();
StatusMessage = "Tapez un nom ou un email";
return;
}
IsBusy = true;
try
{
var hits = await _directory.SearchAsync(SearchQuery, CancellationToken.None).ConfigureAwait(true);
Results = new ObservableCollection<UserSummary>(hits ?? Array.Empty<UserSummary>());
StatusMessage = $"{Results.Count} résultat(s)";
}
catch (Exception ex)
{
StatusMessage = $"Erreur: {ex.Message}";
}
finally
{
IsBusy = false;
}
}
/// <summary>
/// Raise <see cref="Confirmed"/> for the currently selected
/// user. No-op when no selection has been made — keeps the
/// UI from firing an event with a null payload.
/// </summary>
[RelayCommand]
public void Add()
{
if (Selected is null)
{
StatusMessage = "Sélectionnez un utilisateur";
return;
}
Confirmed?.Invoke(this, Selected);
}
}

View file

@ -1,8 +1,10 @@
using System;
using System.Collections.ObjectModel;
using System.Linq;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using PostIt.Services;
using Yavsc.Api.Client;
using Yavsc.Api.Client.Dtos;
@ -11,13 +13,24 @@ namespace PostIt.ViewModels;
/// <summary>
/// View model for the "Mes cercles" page. CRUD on the caller's own
/// circles (the server scopes every endpoint to the caller's uid
/// since the BlogAcl fix on this branch).
/// since the BlogAcl fix on this branch), plus membership
/// management on the currently selected circle.
///
/// <para>The view lists circles in <see cref="Circles"/>, supports
/// create / edit via <see cref="DraftName"/>, and exposes
/// per-item Delete and per-item edit commands. <see cref="IsBusy"/>
/// drives a progress overlay during API calls; <see cref="StatusMessage"/>
/// surfaces success / error feedback in the view footer.</para>
///
/// <para>When the user selects a circle in the list,
/// <see cref="LoadMembersAsync"/> fetches its members into
/// <see cref="Members"/>. The "Add a member" command
/// (<see cref="OpenAddMemberAsync"/>) is a UI event the view
/// raises to open <c>AddCircleMemberDialog</c>; the dialog
/// raises a <c>Confirmed</c> event back, which the page's
/// code-behind forwards here via
/// <see cref="OnAddMemberConfirmedAsync"/>. The "remove"
/// command is per-row and runs inline.</para>
/// </summary>
public partial class CirclesPageViewModel : ViewModelBase
{
@ -37,12 +50,26 @@ public partial class CirclesPageViewModel : ViewModelBase
[ObservableProperty]
public partial bool DraftPublic { get; set; }
/// <summary>Members of the currently selected circle. Empty
/// when no circle is selected or after a refresh that
/// produced an empty list. Updated by
/// <see cref="LoadMembersAsync"/>.</summary>
[ObservableProperty]
public partial ObservableCollection<CircleMemberDto> Members { get; set; } = new();
[ObservableProperty]
public partial bool IsBusy { get; set; }
[ObservableProperty]
public partial string StatusMessage { get; set; } = string.Empty;
/// <summary>
/// Raised when the user wants to add a member to the
/// currently selected circle. The view listens to this
/// event and opens <c>AddCircleMemberDialog</c>.
/// </summary>
public event EventHandler? AddMemberRequested;
public CirclesPageViewModel(CircleApiClient client)
{
_client = client ?? throw new ArgumentNullException(nameof(client));
@ -51,6 +78,24 @@ public partial class CirclesPageViewModel : ViewModelBase
public override bool CanNavigateNext { get => throw new NotImplementedException(); protected set => throw new NotImplementedException(); }
public override bool CanNavigatePrevious { get => throw new NotImplementedException(); protected set => throw new NotImplementedException(); }
/// <summary>
/// Partial property setter: when the selected circle
/// changes, refresh the members list. The setter is
/// invoked by the [ObservableProperty] source generator
/// for both user selections and programmatic resets.
/// </summary>
partial void OnSelectedCircleChanged(CircleDto? value)
{
Members = new ObservableCollection<CircleMemberDto>();
if (value is not null)
{
// Fire-and-forget: load members in the background.
// Errors are routed to StatusMessage inside
// LoadMembersAsync.
_ = LoadMembersAsync(value.Id);
}
}
[RelayCommand]
public async Task RefreshAsync()
{
@ -71,6 +116,33 @@ public partial class CirclesPageViewModel : ViewModelBase
}
}
/// <summary>
/// Load the members of one of the caller's circles. The
/// server scopes the endpoint with a 404 when the circle
/// doesn't belong to the caller (mirroring the rest of the
/// circle API); that case flattens to an empty list here.
/// </summary>
[RelayCommand]
public async Task LoadMembersAsync(long circleId)
{
IsBusy = true;
try
{
var list = await _client.GetMembersAsync(circleId);
Members = new ObservableCollection<CircleMemberDto>(list ?? new());
StatusMessage = $"{Members.Count} membre(s)";
}
catch (Exception ex)
{
StatusMessage = $"Erreur: {ex.Message}";
Members = new ObservableCollection<CircleMemberDto>();
}
finally
{
IsBusy = false;
}
}
[RelayCommand]
public void StartCreate()
{
@ -141,6 +213,12 @@ public partial class CirclesPageViewModel : ViewModelBase
{
await _client.DeleteCircleAsync(circle.Id);
StatusMessage = $"Cercle « {circle.Name} » supprimé";
// If the deleted circle was the selected one,
// clear the selection so the Members view goes
// empty too (the partial setter on
// SelectedCircle will reset Members).
if (SelectedCircle?.Id == circle.Id)
SelectedCircle = null;
await RefreshAsync();
}
catch (Exception ex)
@ -152,4 +230,81 @@ public partial class CirclesPageViewModel : ViewModelBase
IsBusy = false;
}
}
/// <summary>
/// Fire the <see cref="AddMemberRequested"/> event so
/// the view opens <c>AddCircleMemberDialog</c>. The view
/// forwards the dialog's <c>Confirmed</c> event back to
/// <see cref="OnAddMemberConfirmedAsync"/>.
/// </summary>
[RelayCommand]
public void OpenAddMember()
{
if (SelectedCircle is null)
{
StatusMessage = "Sélectionnez d'abord un cercle";
return;
}
AddMemberRequested?.Invoke(this, EventArgs.Empty);
}
/// <summary>
/// Called by the view when the dialog confirms a
/// selection. Adds the picked user to the currently
/// selected circle and refreshes the members list.
/// </summary>
public async Task OnAddMemberConfirmedAsync(object? sender, UserSummary picked)
{
if (SelectedCircle is null || picked is null) return;
IsBusy = true;
try
{
await _client.AddMemberAsync(SelectedCircle.Id, picked.Id);
StatusMessage = $"« {picked.DisplayName} » ajouté au cercle";
await LoadMembersAsync(SelectedCircle.Id);
}
catch (Exception ex)
{
// 409 (already a member) is a likely race — surface
// it as a friendly status, not an error. The
// server returns 409 for "already a member";
// YavscApiClient surfaces that as an exception
// today; future refactors could route 409 into a
// typed result, but for now the message string is
// distinctive enough.
var msg = ex.Message.Contains("409") || ex.Message.Contains("Conflict")
? "Déjà membre du cercle"
: $"Erreur: {ex.Message}";
StatusMessage = msg;
}
finally
{
IsBusy = false;
}
}
/// <summary>
/// Per-row "remove" command. Updates the local
/// collection in place so the UI doesn't flash.
/// </summary>
[RelayCommand]
public async Task RemoveMemberAsync(CircleMemberDto? member)
{
if (member is null || SelectedCircle is null) return;
IsBusy = true;
try
{
await _client.RemoveMemberAsync(SelectedCircle.Id, member.Id);
Members.Remove(member);
StatusMessage = $"« {member.UserName} » retiré du cercle";
}
catch (Exception ex)
{
StatusMessage = $"Erreur: {ex.Message}";
}
finally
{
IsBusy = false;
}
}
}

View file

@ -0,0 +1,57 @@
<ContentPage
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="PostIt.Views.AddCircleMemberDialog"
xmlns:vm="using:PostIt.ViewModels"
xmlns:services="using:PostIt.Services"
x:DataType="vm:AddCircleMemberDialogViewModel"
>
<Grid RowDefinitions="Auto,Auto,*,Auto" Margin="12">
<!-- Search box + button -->
<Grid Grid.Row="0" ColumnDefinitions="*,Auto" Margin="0,0,0,8"
IsEnabled="{Binding !IsBusy}">
<TextBox Grid.Column="0"
Text="{Binding SearchQuery, Mode=TwoWay}"
PlaceholderText="Nom ou email d'un utilisateur Yavsc..."
HorizontalAlignment="Stretch"/>
<Button Grid.Column="1" Content="Rechercher"
Command="{Binding SearchCommand}"
Margin="8,0,0,0"/>
</Grid>
<!-- Selection hint -->
<TextBlock Grid.Row="1"
Text="Sélectionnez un résultat puis cliquez Ajouter."
FontSize="11" Opacity="0.6"
Margin="0,0,0,8"/>
<!-- Search results -->
<ListBox Grid.Row="2"
ItemsSource="{Binding Results}"
SelectedItem="{Binding Selected, Mode=TwoWay}">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="services:UserSummary">
<StackPanel Spacing="2">
<TextBlock Text="{Binding DisplayName}"
FontWeight="Bold"/>
<TextBlock Text="{Binding UserName}"
FontSize="11" Opacity="0.6"/>
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<!-- Action buttons -->
<Grid Grid.Row="3" ColumnDefinitions="*,Auto,Auto" Margin="0,8,0,0">
<TextBlock Grid.Column="0" Text="{Binding StatusMessage}"
VerticalAlignment="Center"/>
<Button Grid.Column="1" Content="Ajouter"
Command="{Binding AddCommand}"
IsEnabled="{Binding Selected, Converter={x:Static ObjectConverters.IsNotNull}}"
Margin="0,0,8,0"/>
<Button Grid.Column="2" Content="Fermer"
Click="OnCloseClicked"/>
</Grid>
</Grid>
</ContentPage>

View file

@ -0,0 +1,54 @@
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
using Avalonia.Interactivity;
using PostIt.Services;
using PostIt.ViewModels;
namespace PostIt.Views;
/// <summary>
/// Modal "add a member to a circle" page. Hosted by
/// <c>CirclesPage</c>; the caller passes the resolved
/// <see cref="IUserDirectory"/> via the constructor.
///
/// <para>The dialog raises <c>Confirmed</c> on its ViewModel
/// when the user picks a result and clicks "Ajouter"; the
/// hosting page subscribes to that event and calls
/// <c>CircleApiClient.AddMemberAsync</c> with the target
/// circle id. The dialog itself does not know the circle id
/// by design.</para>
/// </summary>
public partial class AddCircleMemberDialog : ContentPage
{
public AddCircleMemberDialog()
{
InitializeComponent();
}
public AddCircleMemberDialog(IUserDirectory directory)
{
InitializeComponent();
DataContext = new AddCircleMemberDialogViewModel(directory);
}
private void InitializeComponent()
{
AvaloniaXamlLoader.Load(this);
}
/// <summary>
/// Subscribe a handler to be notified when the user
/// confirms a selection. Returns the underlying VM so
/// the caller can also drive further state (clear the
/// selection, close the dialog, refresh its own list).
/// </summary>
public AddCircleMemberDialogViewModel? ViewModel
=> DataContext as AddCircleMemberDialogViewModel;
private void OnCloseClicked(object? sender, RoutedEventArgs e)
{
// Same light-modal pattern as PostAclDialog: rely on
// the system back gesture or the navigation host's
// "pop" — the ContentPage doesn't own the back stack.
}
}

View file

@ -6,7 +6,7 @@
xmlns:dtos="using:Yavsc.Api.Client.Dtos"
x:DataType="vm:CirclesPageViewModel"
>
<Grid RowDefinitions="Auto,*,Auto,Auto">
<Grid RowDefinitions="Auto,*,Auto">
<!-- Toolbar: refresh + new -->
<StackPanel Grid.Row="0" Orientation="Horizontal" Spacing="8" Margin="12">
@ -16,46 +16,91 @@
Command="{Binding StartCreateCommand}"/>
</StackPanel>
<!-- List of circles -->
<ListBox Grid.Row="1" Margin="12,0,12,12"
ItemsSource="{Binding Circles}"
SelectedItem="{Binding SelectedCircle, Mode=TwoWay}">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="dtos:CircleDto">
<Grid ColumnDefinitions="*,Auto,Auto">
<StackPanel Grid.Column="0" Spacing="2">
<TextBlock Text="{Binding Name}" FontWeight="Bold"/>
<TextBlock Text="{Binding Public, StringFormat='Public : {0}'}"
FontSize="11" Opacity="0.6"/>
</StackPanel>
<Button Grid.Column="1" Content="Éditer"
Command="{Binding $parent[ContentPage].((vm:CirclesPageViewModel)DataContext).StartEditCommand}"
CommandParameter="{Binding}"/>
<Button Grid.Column="2" Content="Supprimer"
Command="{Binding $parent[ContentPage].((vm:CirclesPageViewModel)DataContext).DeleteCommand}"
CommandParameter="{Binding}"/>
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<!-- Two-pane body: circles (left) + members (right) -->
<Grid Grid.Row="1" Margin="12,0,12,12"
ColumnDefinitions="*,16,*"
RowDefinitions="*,Auto">
<!-- Editor -->
<Grid Grid.Row="2" Margin="12" RowDefinitions="Auto,Auto,Auto"
ColumnDefinitions="Auto,*" IsEnabled="{Binding !IsBusy}">
<TextBlock Grid.Row="0" Grid.Column="0" Text="Nom :"
VerticalAlignment="Center" Margin="0,0,8,0"/>
<TextBox Grid.Row="0" Grid.Column="1"
Text="{Binding DraftName, Mode=TwoWay}"/>
<CheckBox Grid.Row="1" Grid.Column="1"
Content="Public"
IsChecked="{Binding DraftPublic, Mode=TwoWay}"/>
<Button Grid.Row="2" Grid.Column="1" Content="Enregistrer"
Command="{Binding SaveCommand}"
HorizontalAlignment="Right" Margin="0,8,0,0"/>
<!-- Left column: list of circles + editor -->
<Grid Grid.Row="0" Grid.Column="0"
RowDefinitions="*,Auto">
<ListBox Grid.Row="0"
ItemsSource="{Binding Circles}"
SelectedItem="{Binding SelectedCircle, Mode=TwoWay}">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="dtos:CircleDto">
<Grid ColumnDefinitions="*,Auto,Auto">
<StackPanel Grid.Column="0" Spacing="2">
<TextBlock Text="{Binding Name}" FontWeight="Bold"/>
<TextBlock Text="{Binding Public, StringFormat='Public : {0}'}"
FontSize="11" Opacity="0.6"/>
</StackPanel>
<Button Grid.Column="1" Content="Éditer"
Command="{Binding $parent[ContentPage].((vm:CirclesPageViewModel)DataContext).StartEditCommand}"
CommandParameter="{Binding}"/>
<Button Grid.Column="2" Content="Supprimer"
Command="{Binding $parent[ContentPage].((vm:CirclesPageViewModel)DataContext).DeleteCommand}"
CommandParameter="{Binding}"/>
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<!-- Editor -->
<Grid Grid.Row="1" Margin="0,12,0,0" RowDefinitions="Auto,Auto,Auto"
ColumnDefinitions="Auto,*" IsEnabled="{Binding !IsBusy}">
<TextBlock Grid.Row="0" Grid.Column="0" Text="Nom :"
VerticalAlignment="Center" Margin="0,0,8,0"/>
<TextBox Grid.Row="0" Grid.Column="1"
Text="{Binding DraftName, Mode=TwoWay}"/>
<CheckBox Grid.Row="1" Grid.Column="1"
Content="Public"
IsChecked="{Binding DraftPublic, Mode=TwoWay}"/>
<Button Grid.Row="2" Grid.Column="1" Content="Enregistrer"
Command="{Binding SaveCommand}"
HorizontalAlignment="Right" Margin="0,8,0,0"/>
</Grid>
</Grid>
<!-- Right column: members of the selected circle -->
<Grid Grid.Row="0" Grid.Column="2"
RowDefinitions="Auto,*,Auto">
<StackPanel Grid.Row="0" Orientation="Horizontal" Spacing="8" Margin="0,0,0,8">
<TextBlock Text="Membres"
FontWeight="Bold"
VerticalAlignment="Center"/>
<Button Content="Ajouter un membre"
Command="{Binding OpenAddMemberCommand}"/>
</StackPanel>
<ListBox Grid.Row="1"
ItemsSource="{Binding Members}">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="dtos:CircleMemberDto">
<Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0" Spacing="2">
<TextBlock Text="{Binding FullName}"
FontWeight="Bold"/>
<TextBlock Text="{Binding UserName}"
FontSize="11" Opacity="0.6"/>
</StackPanel>
<Button Grid.Column="1" Content="Retirer"
Command="{Binding $parent[ContentPage].((vm:CirclesPageViewModel)DataContext).RemoveMemberCommand}"
CommandParameter="{Binding}"/>
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<!-- Empty-state hint -->
<TextBlock Grid.Row="2"
Text="Sélectionnez un cercle pour voir ses membres."
IsVisible="{Binding SelectedCircle, Converter={x:Static ObjectConverters.IsNull}}"
FontSize="11" Opacity="0.6"
Margin="0,8,0,0"/>
</Grid>
</Grid>
<!-- Status bar -->
<Grid Grid.Row="3" ColumnDefinitions="*,Auto" Margin="12,0,12,12">
<Grid Grid.Row="2" ColumnDefinitions="*,Auto" Margin="12,0,12,12">
<TextBlock Grid.Column="0" Text="{Binding StatusMessage}"
VerticalAlignment="Center"/>
<ProgressBar Grid.Column="1" IsIndeterminate="True"

View file

@ -1,14 +1,55 @@
using System;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
using Microsoft.Extensions.DependencyInjection;
using PostIt.Services;
using PostIt.ViewModels;
namespace PostIt.Views;
public partial class CirclesPage : ContentPage
{
private CirclesPageViewModel? _vm;
public CirclesPage()
{
InitializeComponent();
DataContextChanged += OnDataContextChanged;
}
private void OnDataContextChanged(object? sender, EventArgs e)
{
// Unsubscribe from the previous VM to avoid leaking
// handlers across navigation pushes / DataContext resets.
if (_vm is not null)
_vm.AddMemberRequested -= OnAddMemberRequested;
_vm = DataContext as CirclesPageViewModel;
if (_vm is not null)
_vm.AddMemberRequested += OnAddMemberRequested;
}
private void OnAddMemberRequested(object? sender, EventArgs e)
{
var app = Application.Current as App;
var services = app?.ServiceProvider;
if (services is null || _vm is null) return;
// Resolve the directory via DI. The dialog raises its
// own Confirmed event; the VM subscribes via the method
// below — we pass the VM in so the closure can call
// back into it without the dialog needing to know the
// type of its caller. EventHandler<UserSummary> wants a
// void return, so wrap the async VM method in a fire-
// and-forget helper.
var directory = services.GetRequiredService<IUserDirectory>();
var dialog = new AddCircleMemberDialog(directory);
dialog.ViewModel!.Confirmed += async (sender, picked) =>
await _vm.OnAddMemberConfirmedAsync(sender, picked);
if (this.VisualRoot is MainWindow window)
_ = window.NavRoot.PushAsync(dialog);
}
private void InitializeComponent()