postit: persist settings save and apply ApiUrl changes without restart #51

Merged
notazof merged 25 commits from feat/estimate into main 2026-09-06 21:05:58 +01:00
8 changed files with 84 additions and 30 deletions
Showing only changes of commit bfbcaeb400 - Show all commits

postit: persist settings save and apply ApiUrl changes without restart
All checks were successful
Forgejo Release / release (push) Successful in 7m59s
Dotnet build and test / build (pull_request) Successful in 4m27s

Paul Schneider 2026-09-06 14:17:40 +01:00
Signed by: notazof
GPG key ID: 1DD5D838E5343B06

View file

@ -13,12 +13,14 @@
* [PostIt] Les avatars ne sont plus relies en string sur `Image.Source`: ils sont telecharges et lies en `Bitmap`.
* [Yavsc.Api.Client] `ActivityApiClient` accepte une base d'avatar dediee et construit les URLs avatar depuis l'autorite d'identification.
* [PostIt] Les clients Activites/Billing utilisent maintenant `ApiUrl` en lecture dynamique: un changement via Parametres prend effet sans redemarrer l'application (apres sauvegarde et rafraichissement de la page).
* [PostIt] Le header de `MainPage` n'utilise plus `ScrollViewer`; remplacement par une barre de commandes basee sur `WrapPanel`.
* [PostIt] Alignement de la navigation blogs: renommage `PushMainPageAsync` -> `PushBlogsPageAsync` et ajustement de `HomePageViewModel`.
### Fixed
néant
* [PostIt.Android] Correction d'un 404 sur la page Activites au premier lancement: la configuration embarquee pointait `ApiUrl` vers le host Blogs au lieu de l'API metier.
* [PostIt] Correction du bouton Sauver de la page Parametres: binding vers `SaveCommand` pour persister correctement `ApiUrl`/`BlogsApiUrl`.
## [1.0.8-rc11] - unstable

View file

@ -39,6 +39,22 @@ public class ActivitiesPageViewModelTests
Assert.Equal("https://business.example/api/v1/billing/Rdv", api.Paths[3]);
}
[Fact]
public async Task ActivityApiClient_uses_updated_business_base_without_restart()
{
var api = new StubActivityApi();
var baseUrl = "https://business-a.example/api/v1/";
var client = new ActivityApiClient(api, () => baseUrl);
await client.GetCatalogAsync(ct: TestContext.Current.CancellationToken);
baseUrl = "https://business-b.example/api/v1/";
await client.GetUsersAsync("brush", TestContext.Current.CancellationToken);
Assert.Equal("https://business-a.example/api/v1/activity/catalog", api.Paths[0]);
Assert.Equal("https://business-b.example/api/v1/activity/brush/users", api.Paths[1]);
}
[Fact]
public async Task RefreshAsync_loads_first_activity_then_specialization_performers()
{

View file

@ -26,9 +26,9 @@ public static class ServiceCollectionHelpers
var userSearchClient = new UserSearchClient(api, settings.BlogsApiUrl);
var activityClient = new ActivityApiClient(
api,
settings.ApiUrl,
settings.Authentication?.Authority);
var billingClient = new BillingApiClient(api, settings.ApiUrl);
() => settings.ApiUrl,
() => settings.Authentication?.Authority);
var billingClient = new BillingApiClient(api, () => settings.ApiUrl);
var userDirectory = new UserDirectory(userSearchClient);
// Vues

View file

@ -61,16 +61,11 @@
Click="ChooseAvatar_Click"
Margin="0,0,8,0"/>
<!-- Sauver: bound to the Save RelayCommand on the Settings
VM. The source generator emits an ICommand property whose
name matches the source method exactly (no "Command"
suffix is added), so we bind {Binding Save} here. See
AGENTS.md "Avalonia + CommunityToolkit.Mvvm : conventions
de binding pour [RelayCommand]" for the full rationale.
IsEnabled tracks IsDirty so the button auto-disables
when there's nothing to persist. -->
<!-- Sauver: bind to the RelayCommand-generated property
(SaveCommand). IsEnabled tracks IsDirty so the button
auto-disables when there's nothing to persist. -->
<Button Content="Sauver"
Command="{Binding Save}"
Command="{Binding SaveCommand}"
IsEnabled="{Binding IsDirty}"/>
</StackPanel>
</StackPanel>

View file

@ -5,7 +5,7 @@
},
"RedirectUri": "postit://callback",
"DarkMode": false,
"ApiUrl": "https://blogs.pschneider.fr/api/v1/",
"ApiUrl": "https://api.pschneider.fr/api/v1/",
"Scopes": [
"openid",
"profile",

View file

@ -5,7 +5,7 @@
},
"RedirectUri": "postit://callback",
"DarkMode": true,
"ApiUrl": "https://blogs.pschneider.fr/api/v1/",
"ApiUrl": "https://api.pschneider.fr/api/v1/",
"Scopes": [
"openid",
"profile",

View file

@ -19,19 +19,29 @@ public sealed class ActivityApiClient
private const string PathPrefix = "activity";
private readonly IYavscApiClient _api;
private readonly Uri _baseAddress;
private readonly Uri _avatarBaseAddress;
private readonly Func<string> _businessBaseAddress;
private readonly Func<string?> _avatarBaseAddress;
public ActivityApiClient(IYavscApiClient api, string businessBaseAddress, string? avatarBaseAddress = null)
: this(
api,
() => businessBaseAddress,
() => avatarBaseAddress)
{
}
public ActivityApiClient(
IYavscApiClient api,
Func<string> businessBaseAddress,
Func<string?>? avatarBaseAddress = null)
{
_api = api ?? throw new ArgumentNullException(nameof(api));
if (string.IsNullOrEmpty(businessBaseAddress))
throw new ArgumentException("Base address is required.", nameof(businessBaseAddress));
_businessBaseAddress = businessBaseAddress ?? throw new ArgumentNullException(nameof(businessBaseAddress));
_avatarBaseAddress = avatarBaseAddress ?? (() => null);
_baseAddress = new Uri(businessBaseAddress, UriKind.Absolute);
_avatarBaseAddress = string.IsNullOrWhiteSpace(avatarBaseAddress)
? _baseAddress
: new Uri(avatarBaseAddress, UriKind.Absolute);
// Validate initial values early to fail fast on invalid setup.
_ = ResolveBusinessBaseAddress();
_ = ResolveAvatarBaseAddress();
}
public Task<List<ActivityInfo>> GetCatalogAsync(
@ -65,7 +75,7 @@ public sealed class ActivityApiClient
public string BuildAvatarXsUrl(string? userName)
{
var siteRoot = new Uri(_avatarBaseAddress, "/");
var siteRoot = new Uri(ResolveAvatarBaseAddress(), "/");
if (string.IsNullOrWhiteSpace(userName))
{
@ -75,5 +85,22 @@ public sealed class ActivityApiClient
return new Uri(siteRoot, $"avatars/{Uri.EscapeDataString(userName)}.xs.png").ToString();
}
private string Absolute(string relativePath) => new Uri(_baseAddress, relativePath).ToString();
private string Absolute(string relativePath) => new Uri(ResolveBusinessBaseAddress(), relativePath).ToString();
private Uri ResolveBusinessBaseAddress()
{
var raw = _businessBaseAddress();
if (string.IsNullOrWhiteSpace(raw))
throw new InvalidOperationException("Business base address is required.");
return new Uri(raw, UriKind.Absolute);
}
private Uri ResolveAvatarBaseAddress()
{
var raw = _avatarBaseAddress();
return string.IsNullOrWhiteSpace(raw)
? ResolveBusinessBaseAddress()
: new Uri(raw, UriKind.Absolute);
}
}

View file

@ -20,15 +20,20 @@ public sealed class BillingApiClient
private const string PathPrefix = "billing";
private readonly IYavscApiClient _api;
private readonly Uri _baseAddress;
private readonly Func<string> _businessBaseAddress;
public BillingApiClient(IYavscApiClient api, string businessBaseAddress)
: this(api, () => businessBaseAddress)
{
}
public BillingApiClient(IYavscApiClient api, Func<string> businessBaseAddress)
{
_api = api ?? throw new ArgumentNullException(nameof(api));
if (string.IsNullOrWhiteSpace(businessBaseAddress))
throw new ArgumentException("Base address is required.", nameof(businessBaseAddress));
_businessBaseAddress = businessBaseAddress ?? throw new ArgumentNullException(nameof(businessBaseAddress));
_baseAddress = new Uri(businessBaseAddress, UriKind.Absolute);
// Validate initial value early to fail fast on invalid setup.
_ = ResolveBusinessBaseAddress();
}
public Task CreateAsync(string billingCode, object payload, CancellationToken ct = default)
@ -121,7 +126,16 @@ public sealed class BillingApiClient
ct: ct);
}
private string Absolute(string relativePath) => new Uri(_baseAddress, relativePath).ToString();
private string Absolute(string relativePath) => new Uri(ResolveBusinessBaseAddress(), relativePath).ToString();
private Uri ResolveBusinessBaseAddress()
{
var raw = _businessBaseAddress();
if (string.IsNullOrWhiteSpace(raw))
throw new InvalidOperationException("Business base address is required.");
return new Uri(raw, UriKind.Absolute);
}
private static BillingQueryDetailsDto MapRdv(RdvQueryResponse dto, string billingCode)
{