An activity interface

This commit is contained in:
Paul Schneider 2026-08-30 23:55:09 +01:00
commit 8d7bb8f6b6
29 changed files with 810 additions and 10 deletions

View file

@ -0,0 +1,113 @@
using System.Net.Http;
using PostIt.ViewModels;
using Yavsc.Abstract.Workflow;
using Yavsc.Api.Client;
namespace PostIt.Tests;
public class ActivitiesPageViewModelTests
{
[Fact]
public async Task ActivityApiClient_uses_business_absolute_paths()
{
var api = new StubActivityApi();
var client = new ActivityApiClient(api, "https://business.example/api/v1/");
await client.GetCatalogAsync("brush", TestContext.Current.CancellationToken);
await client.GetPerformersAsync("brush-pro", TestContext.Current.CancellationToken);
Assert.Equal("https://business.example/api/v1/activity/catalog?parentCode=brush", api.Paths[0]);
Assert.Equal("https://business.example/api/v1/activity/brush-pro/performers", api.Paths[1]);
}
[Fact]
public async Task RefreshAsync_loads_first_activity_then_specialization_performers()
{
var api = new StubActivityApi();
var client = new ActivityApiClient(api, "https://business.example/api/v1/");
var vm = new ActivitiesPageViewModel(client);
await vm.RefreshAsync();
Assert.Equal("brush", vm.SelectedActivity?.Code);
Assert.Single(vm.Specializations);
Assert.Equal("brush", vm.CurrentActivity?.Code);
Assert.Single(vm.Performers);
Assert.Equal("Alice", vm.Performers[0].UserName);
await vm.ShowSpecializationAsync(vm.Specializations[0]);
Assert.Equal("brush-pro", vm.CurrentActivity?.Code);
Assert.Single(vm.Performers);
Assert.Equal("Bob", vm.Performers[0].UserName);
Assert.Contains("brush pro", vm.StatusMessage, StringComparison.OrdinalIgnoreCase);
await vm.ShowSpecializationAsync(null);
Assert.Equal("brush", vm.CurrentActivity?.Code);
Assert.Single(vm.Performers);
Assert.Equal("Alice", vm.Performers[0].UserName);
}
private sealed class StubActivityApi : IYavscApiClient
{
public HttpClient Http { get; } = new();
public List<string> Paths { get; } = new();
public Task<T> CallAsync<T>(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
{
Paths.Add(path);
if (typeof(T) == typeof(List<ActivityBrowseItemDto>))
{
var activities = new List<ActivityBrowseItemDto>
{
new()
{
Code = "brush",
Name = "Brush",
Description = "Coiffure à domicile",
PerformerCount = 1,
Children = new List<ActivityBrowseItemDto>
{
new()
{
Code = "brush-pro",
Name = "Brush Pro",
Description = "Spécialisation premium",
ParentCode = "brush",
PerformerCount = 1,
}
}
}
};
return Task.FromResult((T)(object)activities);
}
if (typeof(T) == typeof(List<ActivityPerformerDto>))
{
var performers = path.EndsWith("brush-pro/performers", StringComparison.Ordinal)
? new List<ActivityPerformerDto>
{
new() { PerformerId = "pro-2", UserName = "Bob", ActivityCode = "brush-pro", ActivityName = "Brush Pro" }
}
: new List<ActivityPerformerDto>
{
new() { PerformerId = "pro-1", UserName = "Alice", ActivityCode = "brush", ActivityName = "Brush" }
};
return Task.FromResult((T)(object)performers);
}
return Task.FromResult(default(T)!);
}
public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
{
Paths.Add(path);
return Task.CompletedTask;
}
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
}

View file

@ -94,6 +94,26 @@ public class SignaturePadControlTests
Assert.NotEqual(first.Strokes, third.Strokes);
}
[Fact]
public void PendingStroke_is_exposed_only_while_capturing()
{
var pad = new SignaturePadControl();
Assert.Empty(pad.PendingStroke);
pad.BeginCaptureForTest();
pad.AppendPointForTest(1_000, 2_000);
pad.AppendPointForTest(3_000, 4_000);
Assert.Equal(new[] { 1_000, 2_000, 3_000, 4_000 }, pad.PendingStroke);
Assert.Equal(new[] { 1_000, 2_000, 3_000, 4_000 }, pad.Strokes);
pad.SealStrokeForTest();
Assert.Empty(pad.PendingStroke);
Assert.Equal(new[] { 2, 1_000, 2_000, 3_000, 4_000 }, pad.Strokes);
}
[Fact]
public void Clear_empties_buffer_and_raises_redraw()
{

View file

@ -212,6 +212,17 @@ public class SignaturePadControl : TemplatedControl
_pendingPoints++;
}
/// <summary>
/// Test hook: mark the control as actively capturing so tests
/// can exercise the live-preview path without synthetic pointer
/// events.
/// </summary>
internal void BeginCaptureForTest()
{
_capturing = true;
_pendingPoints = 0;
}
/// <summary>
/// Test hook: seal the currently-pending stroke with a length
/// prefix. Mirrors what <see cref="OnCaptureReleased"/> does at

View file

@ -23,6 +23,7 @@ public static class ServiceCollectionHelpers
var circleClient = new CircleApiClient(api, settings.BlogsApiUrl);
var blogAclClient = new BlogAclApiClient(api, settings.BlogsApiUrl);
var userSearchClient = new UserSearchClient(api, settings.BlogsApiUrl);
var activityClient = new ActivityApiClient(api, settings.BusinessApiUrl);
var userDirectory = new UserDirectory(userSearchClient);
// Vues
@ -45,6 +46,7 @@ public static class ServiceCollectionHelpers
services.AddSingleton<HomePage>();
services.AddSingleton<SignaturePage>();
services.AddSingleton<CirclesPage>();
services.AddSingleton<ActivitiesPage>();
// ViewModels
services.AddSingleton(settings);
services.AddSingleton<YavscApiClient>(api);
@ -52,10 +54,12 @@ public static class ServiceCollectionHelpers
services.AddSingleton(circleClient);
services.AddSingleton(blogAclClient);
services.AddSingleton(userSearchClient);
services.AddSingleton(activityClient);
services.AddSingleton<IUserDirectory>(userDirectory);
services.AddSingleton<HomePageViewModel>();
services.AddSingleton<SignaturePageViewModel>();
services.AddSingleton<CirclesPageViewModel>();
services.AddSingleton<ActivitiesPageViewModel>();
// Dialogs (modal-light pages): the ViewLocator resolves
// them when a caller pushes a PostAclDialogViewModel or

View file

@ -39,6 +39,7 @@ public class ViewLocator : IDataTemplate
MainViewModel => services.GetRequiredService<MainPage>(),
Settings => services.GetRequiredService<SettingsPage>(),
HomePageViewModel => services.GetRequiredService<HomePage>(),
ActivitiesPageViewModel => services.GetRequiredService<ActivitiesPage>(),
SignaturePageViewModel => services.GetRequiredService<SignaturePage>(),
AddCircleMemberDialogViewModel => services.GetRequiredService<AddCircleMemberDialog>(),
CirclesPageViewModel => services.GetRequiredService<CirclesPage>(),

View file

@ -0,0 +1,219 @@
using System;
using System.Collections.ObjectModel;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Yavsc.Abstract.Workflow;
using Yavsc.Api.Client;
namespace PostIt.ViewModels;
public partial class ActivitiesPageViewModel : ViewModelBase
{
private readonly ActivityApiClient _client;
private bool _syncingSelection;
[ObservableProperty]
public partial ObservableCollection<ActivityBrowseItemDto> Activities { get; set; } = new();
[ObservableProperty]
public partial ActivityBrowseItemDto? SelectedActivity { get; set; }
[ObservableProperty]
public partial ObservableCollection<ActivityBrowseItemDto> Specializations { get; set; } = new();
[ObservableProperty]
public partial ActivityBrowseItemDto? SelectedSpecialization { get; set; }
[ObservableProperty]
public partial ObservableCollection<ActivityPerformerDto> Performers { get; set; } = new();
[ObservableProperty]
public partial bool IsBusy { get; set; }
[ObservableProperty]
public partial string StatusMessage { get; set; } = "Choisissez une activité.";
public ActivityBrowseItemDto? CurrentActivity => SelectedSpecialization ?? SelectedActivity;
public string SelectedActivityLabel => SelectedActivity?.Name ?? "(aucune activité)";
public string CurrentActivityLabel => CurrentActivity?.Name ?? "(aucune)";
public override bool CanNavigateNext
{
get => false;
protected set { _ = value; }
}
public override bool CanNavigatePrevious
{
get => true;
protected set { _ = value; }
}
public ActivitiesPageViewModel(ActivityApiClient client)
{
_client = client ?? throw new ArgumentNullException(nameof(client));
}
partial void OnSelectedActivityChanged(ActivityBrowseItemDto? value)
{
if (_syncingSelection) return;
_ = ShowActivitySafeAsync(value);
}
partial void OnSelectedSpecializationChanged(ActivityBrowseItemDto? value)
{
if (_syncingSelection) return;
_ = ShowSpecializationSafeAsync(value);
}
private async Task ShowActivitySafeAsync(ActivityBrowseItemDto? value)
{
try
{
await ShowActivityAsync(value);
}
catch (HttpRequestException ex) when (ex.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden)
{
StatusMessage = "Accès refusé pour les activités (scope 'api'). Déconnectez puis reconnectez-vous.";
}
catch (Exception ex)
{
StatusMessage = $"Erreur: {ex.Message}";
}
}
private async Task ShowSpecializationSafeAsync(ActivityBrowseItemDto? value)
{
try
{
await ShowSpecializationAsync(value);
}
catch (HttpRequestException ex) when (ex.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden)
{
StatusMessage = "Accès refusé pour les activités (scope 'api'). Déconnectez puis reconnectez-vous.";
}
catch (Exception ex)
{
StatusMessage = $"Erreur: {ex.Message}";
}
}
[RelayCommand]
public async Task RefreshAsync()
{
IsBusy = true;
try
{
var list = await _client.GetCatalogAsync();
Activities = new ObservableCollection<ActivityBrowseItemDto>(list ?? new());
var first = Activities.FirstOrDefault();
await ShowActivityAsync(first);
if (first is null)
{
StatusMessage = "Aucune activité disponible.";
}
}
catch (HttpRequestException ex) when (ex.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden)
{
Activities = new ObservableCollection<ActivityBrowseItemDto>();
Specializations = new ObservableCollection<ActivityBrowseItemDto>();
Performers = new ObservableCollection<ActivityPerformerDto>();
StatusMessage = "Accès refusé pour les activités (scope 'api'). Déconnectez puis reconnectez-vous.";
}
catch (Exception ex)
{
Activities = new ObservableCollection<ActivityBrowseItemDto>();
Specializations = new ObservableCollection<ActivityBrowseItemDto>();
Performers = new ObservableCollection<ActivityPerformerDto>();
StatusMessage = $"Erreur: {ex.Message}";
}
finally
{
IsBusy = false;
}
}
public async Task ShowActivityAsync(ActivityBrowseItemDto? activity)
{
_syncingSelection = true;
try
{
SelectedActivity = activity;
SelectedSpecialization = null;
}
finally
{
_syncingSelection = false;
}
OnPropertyChanged(nameof(CurrentActivity));
OnPropertyChanged(nameof(SelectedActivityLabel));
OnPropertyChanged(nameof(CurrentActivityLabel));
Specializations = new ObservableCollection<ActivityBrowseItemDto>(activity?.Children ?? new());
if (activity is null)
{
Performers = new ObservableCollection<ActivityPerformerDto>();
return;
}
await LoadPerformersAsync(activity);
}
public async Task ShowSpecializationAsync(ActivityBrowseItemDto? specialization)
{
_syncingSelection = true;
try
{
SelectedSpecialization = specialization;
}
finally
{
_syncingSelection = false;
}
OnPropertyChanged(nameof(CurrentActivity));
OnPropertyChanged(nameof(CurrentActivityLabel));
if (specialization is null)
{
if (SelectedActivity is not null)
{
await LoadPerformersAsync(SelectedActivity);
}
return;
}
await LoadPerformersAsync(specialization);
}
private async Task LoadPerformersAsync(ActivityBrowseItemDto activity)
{
IsBusy = true;
try
{
var list = await _client.GetPerformersAsync(activity.Code);
Performers = new ObservableCollection<ActivityPerformerDto>(list ?? new());
StatusMessage = $"{activity.Name} · {Performers.Count} prestataire(s)";
}
catch (HttpRequestException ex) when (ex.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden)
{
Performers = new ObservableCollection<ActivityPerformerDto>();
StatusMessage = "Accès refusé pour les activités (scope 'api'). Déconnectez puis reconnectez-vous.";
}
catch (Exception ex)
{
Performers = new ObservableCollection<ActivityPerformerDto>();
StatusMessage = $"Erreur: {ex.Message}";
}
finally
{
IsBusy = false;
}
}
}

View file

@ -1,4 +1,9 @@
using System;
using System.Threading.Tasks;
using Avalonia;
using CommunityToolkit.Mvvm.Input;
using Microsoft.Extensions.DependencyInjection;
using PostIt.Helpers;
using PostIt.Services;
namespace PostIt.ViewModels;
@ -24,8 +29,25 @@ public class HomePageViewModel : ViewModelBase
Settings = settings;
SessionStatus = sessionStatus;
OpenActivities = new AsyncRelayCommand(OpenActivitiesAsync);
}
public RelayCommand OpenBlogs { get; set; } = new RelayCommand(() => App.PushMainPageAsync());
public IAsyncRelayCommand OpenBlogs { get; } = new AsyncRelayCommand(App.PushMainPageAsync);
public IAsyncRelayCommand OpenActivities { get; }
private async Task OpenActivitiesAsync()
{
var app = (App?)Application.Current;
var vm = app?.ServiceProvider?.GetRequiredService<ActivitiesPageViewModel>();
if (app is null || vm is null)
{
throw new InvalidOperationException("Activities page is not available.");
}
await vm.RefreshAsync();
await app.PushPageAsync(vm);
}
/// <summary>
/// Avalonia designer constructor. Builds a self-contained VM
/// with a freshly-constructed Settings so the XAML preview can

View file

@ -26,7 +26,7 @@ public partial class Settings : ViewModelBase
public partial string BlogsApiUrl { get; set; } = "https://blogs.pschneider.fr/api/v1/";
[ObservableProperty]
public partial string BusinessApiUrl { get; set; } = "https://business.pschneider.fr/api/v1/";
public partial string BusinessApiUrl { get; set; } = "https://api.pschneider.fr/api/v1/";
[ObservableProperty]
public partial string SearchText { get; set; } = string.Empty;
@ -154,7 +154,10 @@ public partial class Settings : ViewModelBase
{
"openid", // OIDC: required for the id_token
"profile", // OIDC: standard profile claims
"offline_access" // OIDC: required to receive a refresh_token
"offline_access", // OIDC: required to receive a refresh_token
"blogs",
"api"
};
/// <summary>
@ -297,8 +300,15 @@ public partial class Settings : ViewModelBase
// → our overridden dispatcher-safe marshaller below.
else lock (_mutationGate)
{
var legacyApiUrl = TryReadLegacyApiUrl(json);
this.Authentication = settings.Authentication;
this.DarkMode = settings.DarkMode;
this.BlogsApiUrl = !string.IsNullOrWhiteSpace(settings.BlogsApiUrl)
? settings.BlogsApiUrl
: legacyApiUrl ?? this.BlogsApiUrl;
this.BusinessApiUrl = !string.IsNullOrWhiteSpace(settings.BusinessApiUrl)
? settings.BusinessApiUrl
: this.BusinessApiUrl;
this.SearchText = settings.SearchText ?? string.Empty;
if (!(settings.Authentication is null))
{
@ -343,6 +353,26 @@ public partial class Settings : ViewModelBase
}
}
private static string? TryReadLegacyApiUrl(string json)
{
try
{
using var doc = JsonDocument.Parse(json);
if (doc.RootElement.TryGetProperty("ApiUrl", out var apiUrl)
&& apiUrl.ValueKind == JsonValueKind.String)
{
return apiUrl.GetString();
}
}
catch
{
// Ignore legacy payload parse errors: normal deserialization
// already reports actionable diagnostics to the caller.
}
return null;
}
private void UseDefaultSettings()
{
this.Authentication = new AuthenticationSettings
@ -353,6 +383,8 @@ public partial class Settings : ViewModelBase
Scopes = AuthenticationSettings.DefaultScopes
};
this.DarkMode = false;
this.BlogsApiUrl = "https://blogs.pschneider.fr/api/v1/";
this.BusinessApiUrl = "https://api.pschneider.fr/api/v1/";
this.SearchText = string.Empty;
}

View file

@ -0,0 +1,79 @@
<ContentPage xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:PostIt.ViewModels"
xmlns:wf="using:Yavsc.Abstract.Workflow"
x:Class="PostIt.Views.ActivitiesPage"
x:DataType="vm:ActivitiesPageViewModel"
Header="Activités">
<Grid RowDefinitions="Auto,*,Auto" Margin="12">
<StackPanel Grid.Row="0" Orientation="Horizontal" Spacing="8">
<Button Content="Rafraîchir" Command="{Binding RefreshCommand}" />
<TextBlock Text="Catalogue des activités" FontWeight="Bold" VerticalAlignment="Center" />
</StackPanel>
<Grid Grid.Row="1" Margin="0,12,0,12" ColumnDefinitions="*,16,*,16,*">
<Grid Grid.Column="0" RowDefinitions="Auto,*">
<TextBlock Grid.Row="0" Text="Activités" FontWeight="Bold" Margin="0,0,0,8" />
<ListBox Grid.Row="1"
ItemsSource="{Binding Activities}"
SelectedItem="{Binding SelectedActivity, Mode=TwoWay}">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="wf:ActivityBrowseItemDto">
<StackPanel Spacing="2" Margin="0,0,0,8">
<TextBlock Text="{Binding Name}" FontWeight="Bold" />
<TextBlock Text="{Binding Description}" TextWrapping="Wrap" FontSize="11" Opacity="0.7" />
<TextBlock Text="{Binding PerformerCount, StringFormat='Prestataires: {0}'}"
FontSize="11" Opacity="0.6" />
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</Grid>
<Grid Grid.Column="2" RowDefinitions="Auto,Auto,*">
<TextBlock Grid.Row="0" Text="Spécialisations" FontWeight="Bold" Margin="0,0,0,8" />
<TextBlock Grid.Row="1"
Text="{Binding SelectedActivityLabel, StringFormat='Pour : {0}'}"
FontSize="11" Opacity="0.7" Margin="0,0,0,8" />
<ListBox Grid.Row="2"
ItemsSource="{Binding Specializations}"
SelectedItem="{Binding SelectedSpecialization, Mode=TwoWay}">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="wf:ActivityBrowseItemDto">
<StackPanel Spacing="2" Margin="0,0,0,8">
<TextBlock Text="{Binding Name}" FontWeight="Bold" />
<TextBlock Text="{Binding Description}" TextWrapping="Wrap" FontSize="11" Opacity="0.7" />
<TextBlock Text="{Binding PerformerCount, StringFormat='Prestataires: {0}'}"
FontSize="11" Opacity="0.6" />
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</Grid>
<Grid Grid.Column="4" RowDefinitions="Auto,Auto,*">
<TextBlock Grid.Row="0" Text="Prestataires" FontWeight="Bold" Margin="0,0,0,8" />
<TextBlock Grid.Row="1"
Text="{Binding CurrentActivityLabel, StringFormat='Activité affichée : {0}'}"
FontSize="11" Opacity="0.7" Margin="0,0,0,8" />
<ListBox Grid.Row="2" ItemsSource="{Binding Performers}">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="wf:ActivityPerformerDto">
<StackPanel Spacing="2" Margin="0,0,0,8">
<TextBlock Text="{Binding UserName}" FontWeight="Bold" />
<TextBlock Text="{Binding WebSite}" FontSize="11" Opacity="0.7" />
<TextBlock Text="{Binding ExtraActivityCount, StringFormat='Autres spécialisations: {0}'}"
FontSize="11" Opacity="0.6" />
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</Grid>
</Grid>
<Grid Grid.Row="2" ColumnDefinitions="*,Auto">
<TextBlock Grid.Column="0" Text="{Binding StatusMessage}" VerticalAlignment="Center" />
<ProgressBar Grid.Column="1" Width="120" IsIndeterminate="True" IsVisible="{Binding IsBusy}" />
</Grid>
</Grid>
</ContentPage>

View file

@ -0,0 +1,17 @@
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
namespace PostIt.Views;
public partial class ActivitiesPage : ContentPage
{
public ActivitiesPage()
{
InitializeComponent();
}
private void InitializeComponent()
{
AvaloniaXamlLoader.Load(this);
}
}

View file

@ -18,5 +18,9 @@
Command="{Binding OpenBlogs}"
HorizontalAlignment="Center"
IsEnabled="{Binding SessionStatus.IsLoggedIn}"/>
<Button Content="Parcourir les activités"
Command="{Binding OpenActivities}"
HorizontalAlignment="Center"
IsEnabled="{Binding SessionStatus.IsLoggedIn}"/>
</StackPanel>
</ContentPage>

View file

@ -62,9 +62,16 @@ public partial class SignaturePage : ContentPage
var h = PadFrame.Bounds.Height;
if (w <= 0 || h <= 0) return;
var pending = _control.PendingStroke;
var strokes = _control.Strokes;
int sealedCount = strokes.Count - pending.Count;
if (sealedCount < 0)
{
sealedCount = 0;
}
int i = 0;
while (i < strokes.Count)
while (i < sealedCount)
{
int k = strokes[i];
if (k <= 0) break;
@ -88,7 +95,6 @@ public partial class SignaturePage : ContentPage
InkLayer.Children.Add(poly);
}
var pending = _control.PendingStroke;
if (pending.Count > 0)
{
var poly = new Polyline