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`. * [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. * [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] 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`. * [PostIt] Alignement de la navigation blogs: renommage `PushMainPageAsync` -> `PushBlogsPageAsync` et ajustement de `HomePageViewModel`.
### Fixed ### 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 ## [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]); 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] [Fact]
public async Task RefreshAsync_loads_first_activity_then_specialization_performers() 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 userSearchClient = new UserSearchClient(api, settings.BlogsApiUrl);
var activityClient = new ActivityApiClient( var activityClient = new ActivityApiClient(
api, api,
settings.ApiUrl, () => settings.ApiUrl,
settings.Authentication?.Authority); () => settings.Authentication?.Authority);
var billingClient = new BillingApiClient(api, settings.ApiUrl); var billingClient = new BillingApiClient(api, () => settings.ApiUrl);
var userDirectory = new UserDirectory(userSearchClient); var userDirectory = new UserDirectory(userSearchClient);
// Vues // Vues

View file

@ -61,16 +61,11 @@
Click="ChooseAvatar_Click" Click="ChooseAvatar_Click"
Margin="0,0,8,0"/> Margin="0,0,8,0"/>
<!-- Sauver: bound to the Save RelayCommand on the Settings <!-- Sauver: bind to the RelayCommand-generated property
VM. The source generator emits an ICommand property whose (SaveCommand). IsEnabled tracks IsDirty so the button
name matches the source method exactly (no "Command" auto-disables when there's nothing to persist. -->
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. -->
<Button Content="Sauver" <Button Content="Sauver"
Command="{Binding Save}" Command="{Binding SaveCommand}"
IsEnabled="{Binding IsDirty}"/> IsEnabled="{Binding IsDirty}"/>
</StackPanel> </StackPanel>
</StackPanel> </StackPanel>

View file

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

View file

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

View file

@ -19,19 +19,29 @@ public sealed class ActivityApiClient
private const string PathPrefix = "activity"; private const string PathPrefix = "activity";
private readonly IYavscApiClient _api; private readonly IYavscApiClient _api;
private readonly Uri _baseAddress; private readonly Func<string> _businessBaseAddress;
private readonly Uri _avatarBaseAddress; private readonly Func<string?> _avatarBaseAddress;
public ActivityApiClient(IYavscApiClient api, string businessBaseAddress, string? avatarBaseAddress = null) 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)); _api = api ?? throw new ArgumentNullException(nameof(api));
if (string.IsNullOrEmpty(businessBaseAddress)) _businessBaseAddress = businessBaseAddress ?? throw new ArgumentNullException(nameof(businessBaseAddress));
throw new ArgumentException("Base address is required.", nameof(businessBaseAddress)); _avatarBaseAddress = avatarBaseAddress ?? (() => null);
_baseAddress = new Uri(businessBaseAddress, UriKind.Absolute); // Validate initial values early to fail fast on invalid setup.
_avatarBaseAddress = string.IsNullOrWhiteSpace(avatarBaseAddress) _ = ResolveBusinessBaseAddress();
? _baseAddress _ = ResolveAvatarBaseAddress();
: new Uri(avatarBaseAddress, UriKind.Absolute);
} }
public Task<List<ActivityInfo>> GetCatalogAsync( public Task<List<ActivityInfo>> GetCatalogAsync(
@ -65,7 +75,7 @@ public sealed class ActivityApiClient
public string BuildAvatarXsUrl(string? userName) public string BuildAvatarXsUrl(string? userName)
{ {
var siteRoot = new Uri(_avatarBaseAddress, "/"); var siteRoot = new Uri(ResolveAvatarBaseAddress(), "/");
if (string.IsNullOrWhiteSpace(userName)) if (string.IsNullOrWhiteSpace(userName))
{ {
@ -75,5 +85,22 @@ public sealed class ActivityApiClient
return new Uri(siteRoot, $"avatars/{Uri.EscapeDataString(userName)}.xs.png").ToString(); 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 const string PathPrefix = "billing";
private readonly IYavscApiClient _api; private readonly IYavscApiClient _api;
private readonly Uri _baseAddress; private readonly Func<string> _businessBaseAddress;
public BillingApiClient(IYavscApiClient api, 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)); _api = api ?? throw new ArgumentNullException(nameof(api));
if (string.IsNullOrWhiteSpace(businessBaseAddress)) _businessBaseAddress = businessBaseAddress ?? throw new ArgumentNullException(nameof(businessBaseAddress));
throw new ArgumentException("Base address is required.", 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) public Task CreateAsync(string billingCode, object payload, CancellationToken ct = default)
@ -121,7 +126,16 @@ public sealed class BillingApiClient
ct: ct); 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) private static BillingQueryDetailsDto MapRdv(RdvQueryResponse dto, string billingCode)
{ {