From 120bae6f5c38a820c8a9159ef83e18813e61e94a Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Thu, 9 Jul 2026 21:54:06 +0100 Subject: [PATCH 1/2] doc(arch): PostIt topology + up-to-date project list Two long-standing gaps in the architecture documentation are filled in this commit: 1. doc/architecture/postit.md is new. It covers everything the existing postit-oidc.md does not: the one-codebase / three-frontends topology (PostIt lib + PostIt.Desktop + PostIt.Android + PostIt.Browser), the custom ViewLocator that resolves ViewModel -> View through the DI provider (and why we don't use the Avalonia.Mvvm default), the composition root in App.OnFrameworkInitializationCompleted with the full DI registration table, the navigation flow driven by SessionStatusViewModel events, the ViewModel lifetime conventions (singleton vs transient), the [RelayCommand] XAML binding conventions (referenced to AGENTS.md for the canonical version), and the per-page DataContext / role table. The Settings-singleton invariant is called out as a guard rail, and the SettingsPage anti-empilement invariant is documented as the TODO the code still owes us. 2. doc/architecture/decoupage-organisation.md is brought up to date. Its project table listed 7 .csproj; the repo has 14 (the four PostIt projects, the tests satellites, the cli tool). The table is extended, the ASCII diagram picks up the PostIt block, and an Outils et tests section lists the test / CLI satellites that were missing. doc/README.md is updated to index the new postit.md. No code changes in this commit, no behaviour change. --- doc/README.md | 1 + doc/architecture/decoupage-organisation.md | 22 +- doc/architecture/postit.md | 245 +++++++++++++++++++++ 3 files changed, 267 insertions(+), 1 deletion(-) create mode 100644 doc/architecture/postit.md diff --git a/doc/README.md b/doc/README.md index 4afdf585..b189bb27 100644 --- a/doc/README.md +++ b/doc/README.md @@ -15,6 +15,7 @@ La racine de l'architecture est [Architecture.md](Architecture.md). | [architecture/dictionnaires-metier.md](architecture/dictionnaires-metier.md) | Dictionnaires métier, héritage en arbre, cycle de vie d'un terme | | [architecture/offres-frontmatter.md](architecture/offres-frontmatter.md) | Offre fournisseur, ClasseFormulaire, ClasseDevis, parsing frontmatter | | [architecture/postit-oidc.md](architecture/postit-oidc.md) | Client desktop PostIt, custom URI scheme, silent refresh | +| [architecture/postit.md](architecture/postit.md) | PostIt — topologie des projets, ViewLocator custo, navigation, DI, conventions de binding | | [architecture/decoupage-organisation.md](architecture/decoupage-organisation.md) | Découpage des projets .NET (Abstract, Server, Org, Api, Blogs, Web, Org.Tests) | ## Roadmap & design exploration diff --git a/doc/architecture/decoupage-organisation.md b/doc/architecture/decoupage-organisation.md index 9d4a0e4a..52e6ea97 100644 --- a/doc/architecture/decoupage-organisation.md +++ b/doc/architecture/decoupage-organisation.md @@ -31,7 +31,19 @@ └────────────────┘ Clients externes : - - PostIt : client desktop Avalonia (cf. postit-oidc.md). + - PostIt (Avalonia, code-base unique multi-cible) : + · PostIt — lib partagée (pages, VM, services) + · PostIt.Desktop — front-end Linux/Windows + · PostIt.Android — front-end APK + · PostIt.Browser — front-end WASM + Cf. postit.md et postit-oidc.md. + +Outils et tests : + - cli — outillage CLI + - Yavsc.Tests.Shared — helpers de tests partagés + - Yavsc.Org.Tests — tests du front web + - Yavsc.Blogs.Tests — tests du backend blogs + - PostIt.Tests — tests du client PostIt ``` ## Par projet @@ -44,6 +56,14 @@ Clients externes : | `Yavsc.Api` | ASP.NET Web | API REST JSON principale consommée par les clients externes (PostIt, …). JwtBearer auth. | | `Yavsc.Blogs` | ASP.NET Web | **Backend API headless** dédié aux blogs (uniquement `*ApiController` + services + modèles — aucune vue Razor). Destiné à être déployé sur un sous-domaine en production, séparé du front web hébergé par `Yavsc.Org`. | | `Yavsc.Org.Tests` | Test (xUnit) | Tests d'isolation du front web (`Yavsc.Org`) — fakes, controller tests. | +| `Yavsc.Blogs.Tests`| Test (xUnit) | Tests d'isolation du backend blogs (`Yavsc.Blogs`). | +| `Yavsc.Tests.Shared` | Library | Helpers de tests partagés (fixtures, fakes, builders) entre les projets de tests. | +| `PostIt` | Library | Code-base partagée du client PostIt (Avalonia) : pages, ViewModels, services, `ViewLocator` custo. Multi-cible — produit PostIt.Desktop / PostIt.Android / PostIt.Browser. | +| `PostIt.Desktop` | Avalonia.Desktop | Front-end Desktop Linux/Windows : `Program.Main`, `Platform.CreateBrowser` (CustomSchemeBrowser), custom URI scheme `postit://`. | +| `PostIt.Android` | Avalonia.Android | Front-end Android : `MainActivity` SingleTask, Chrome Custom Tabs, scheme `android://postit-signin`. | +| `PostIt.Browser` | Avalonia.Browser | Front-end WASM : pas de process distinct, IBrowser N/A. | +| `PostIt.Tests` | Test (xUnit) | Tests du client PostIt : settings, scopes Bearer, OIDC stub (`OidcStubAuthority`). | +| `cli` | exe / tool | Outillage CLI (build, packaging, génération de clés). | ## Pourquoi ce découpage diff --git a/doc/architecture/postit.md b/doc/architecture/postit.md new file mode 100644 index 00000000..6fa3c132 --- /dev/null +++ b/doc/architecture/postit.md @@ -0,0 +1,245 @@ +# PostIt — Topologie, navigation, DI + +> **Récapitulatif** : PostIt est le client Avalonia du projet +> Yavsc. C'est un code-base unique (`src/PostIt/PostIt/PostIt.csproj`) +> **multi-cible** vers trois front-ends distincts +> (`PostIt.Desktop`, `Postit.Android`, `PostIt.Browser`). Cette +> fiche couvre la topologie des projets, le DI, le `ViewLocator` +> custo et la navigation — c'est-à-dire tout ce que la fiche +> [postit-oidc.md](postit-oidc.md) ne détaille pas déjà (l'OIDC, +> le flow d'auth, la persistance des tokens). Détail dans cette +> page, racine de l'architecture : [Architecture.md](../Architecture.md). + +## Surface : un code-base, trois front-ends + +``` + ┌────────────────────────┐ + │ PostIt (lib) │ + │ src/PostIt/PostIt/ │ + │ Pages, ViewModels, │ + │ Services, ViewLocator │ + │ (aucun rendu natif) │ + └──────┬───┬─────┬───────┘ + │ │ │ + ┌───────────────┘ │ └────────────────┐ + │ │ │ + ┌──────────▼────────┐ ┌────────▼─────────┐ ┌──────────▼────────┐ + │ PostIt.Desktop │ │ PostIt.Android │ │ PostIt.Browser │ + │ Avalonia.Desktop │ │ Avalonia.Android │ │ Avalonia.Browser │ + │ Linux/Windows │ │ APK │ │ WASM │ + │ + custom scheme │ │ + Chrome Custom │ │ (no native proc) │ + │ postit:// │ │ Tabs │ │ │ + │ + IBrowser custo │ │ + IBrowser custo │ │ │ + └───────────────────┘ └──────────────────┘ └───────────────────┘ +``` + +Le code partagé vit dans `PostIt/`. Chaque front-end est un +**projet Satellite SDK** Avalonia qui ne contient que le +`Program.Main`, le `Platform.CreateBrowser`, et les manifestes +spécifiques (IntentFilter Android, `app.manifest` Desktop). +Toute la logique (VM, services, navigation, settings, OIDC) est +dans le code-base partagé. + +## ViewLocator custo + +Le `ViewLocator` (cf. `src/PostIt/PostIt/ViewLocator.cs`) est un +`IDataTemplate` Avalonia **explicitement câblé sur le +`IServiceProvider`** : + +```csharp +public Control Build(object? data) => data switch +{ + MainPageViewModel => _services.GetRequiredService(), + Settings => _services.GetRequiredService(), + HomePageViewModel => _services.GetRequiredService(), + SignaturePageViewModel => _services.GetRequiredService(), + null => new TextBlock { Text = "No view for " }, + _ => new TextBlock { Text = $"No view for {data.GetType().Name}" } +}; +public bool Match(object? data) => data is ViewModelBase; +``` + +**Pourquoi un custo, et pas le `ViewLocatorBase` par défaut +d'Avalonia.Mvvm ?** Pour deux raisons : + +1. **Sortie du `Activator.CreateInstance`** — les pages + PostIt sont enregistrées dans le DI et peuvent avoir des + dépendances (par construction, aujourd'hui aucune, mais + l'extension future est ouverte). Le `ViewLocatorBase` + historique fait `new View()`, ce qui rend impossible + l'injection et complique les tests. +2. **Filtrage par `ViewModelBase`** — `Match` n'accepte que les + types dérivés de `ViewModelBase`. Toute tentative d'afficher + un objet métier (par ex. un DTO de l'API Yavsc) tombe sur le + `TextBlock` "No view for X", pas sur un crash Avalonia. + +Le `ViewLocator` est ajouté aux `DataTemplates` de l'app dans +`App.OnFrameworkInitializationCompleted` : + +```csharp +DataTemplates.Clear(); +DataTemplates.Add(new ViewLocator(provider)); +``` + +**Conséquence pratique** : pour qu'une nouvelle page soit +affichée par un `ContentControl` qui binde un ViewModel, il +faut *deux* enregistrements : la page en `AddTransient` (ou +`AddSingleton`) dans le DI, **et** une case dans le `switch` +de `ViewLocator.Build`. Si l'un manque, l'app affiche +"No view for X" sans crash. + +## Composition root (`App.axaml.cs`) + +`App.OnFrameworkInitializationCompleted` est le seul endroit où +le DI est construit. Ordre, dans cet ordre : + +1. `new Settings()` + `settings.Load()` — lit + `~/.config/PostIt/postit-settings.json` (ou le fallback + embarqué dans `PostIt.dll`). +2. `new TokenStore(...)` + `new YavscApiClient(settings, tokenStore)`. +3. `new ServiceCollection()` + enregistrements en bloc. +4. `services.BuildServiceProvider()`. +5. `Settings.BindToServiceProvider(provider)` — pose le + singleton statique pour les helpers hors-DI + (`Settings.GetCurrent()`, `Settings.RequireCurrent()`). +6. `DataTemplates.Add(new ViewLocator(provider))`. +7. Branche `IClassicDesktopStyleApplicationLifetime` / + `ISingleViewApplicationLifetime` (Browser/Android). + +### Enregistrements DI + +| Service | Lifetime | Pourquoi | +|-------------------------------|------------|-------------------------------------------------------------------------------------------| +| `Settings` | **Singleton** | État partagé (`Loaded`, `IsDirty`, `Authentication`) — doit être unique. | +| `YavscApiClient` | Singleton | Porte le `TokenStore` et le cache de tokens ; un seul par process. | +| `BlogApiClient` | Singleton | Mapper stateless, partagé. | +| `MainPage` / `SettingsPage` / `HomePage` / `SignaturePage` | Transient | Résolution à la demande par le `ViewLocator`. | +| `MainPageViewModel` / `HomePageViewModel` / `SignaturePageViewModel` | Transient | VM reconstruites à chaque navigation ; pas d'état partagé à conserver. | +| `SessionStatusViewModel` + `SessionStatusBanner` | Singleton + Transient | Le VM est un singleton (survit à la navigation), le bandeau est transient (réinstancié quand la fenêtre le recrée). | + +> **Invariant** : `Settings` est **uniquement** un singleton. Un +> `AddTransient()` supplémentaire (qui réécrase le +> singleton dans le container) ferait que chaque push de +> `SettingsPage` crée une instance vide, casse les bindings +> Authority/ClientId, et perd toute édition. Si tu dois toucher +> à cette table, *ne pas* ajouter de registration pour +> `Settings` ailleurs que la ligne `AddSingleton(settings)`. + +## Navigation + +Le host de navigation est un `NavigationPage x:Name="NavRoot"` +posé sur `MainWindow.axaml`. La pile est gérée par les +événements du `SessionStatusViewModel` : + +| Événement | Effet | +|---------------------------------|------------------------------------------------------------------------| +| `LoginSucceeded` | `PushAsync(MainPage)` au-dessus de `HomePage`. | +| `LogoutCompleted` | `PopToRootAsync()` (revient à `HomePage`). | +| `OpenSettingsRequested` | `PushAsync(SettingsPage)` au-dessus de la page courante. | + +### Garde anti-empilement + +Le handler `OpenSettingsRequested` doit garantir qu'une seule +`SettingsPage` est au sommet de la pile à un instant donné. +Sans garde, plusieurs clics sur **Paramètres** empilent +plusieurs instances (chacune résolue en `Transient`), et +l'utilisateur doit appuyer N fois sur **Retour** pour sortir. + +L'invariant à implémenter dans le handler : + +> Si la page du sommet de `NavRoot.NavigationStack` est déjà +> une `SettingsPage`, ne pas empiler une nouvelle instance +> (no-op silencieux). Sinon, `PushAsync` une nouvelle instance +> comme aujourd'hui. + +Le détail d'implémentation (lecture de la pile, gestion des +cas "SettingsPage est plus bas dans la pile") reste à coder +quand l'UI le demandera. + +## ViewModels et invariants d'état + +- `Settings` est un objet-modèle exposé comme `DataContext` + des pages. Il n'hérite pas de `ViewModelBase` (c'est un + POCO `[ObservableProperty]`-généré par + `CommunityToolkit.Mvvm`). Le fait qu'il soit utilisé comme + DataContext est un raccourci de composition acceptable ici, + pas un pattern à généraliser. + +- `SessionStatusViewModel` est le seul VM avec une durée de vie + **process-entière** (singleton). Il survit à toutes les + navigations, expose `HasValidSession` en continu, et porte + les trois événements qui pilotent la navigation + (`LoginSucceeded`, `LogoutCompleted`, + `OpenSettingsRequested`). + +- `MainPageViewModel` / `HomePageViewModel` / + `SignaturePageViewModel` sont `Transient` — une nouvelle + instance est créée à chaque push, l'ancienne est libérée + quand la page est dépilée. Pas d'état partagé entre + occurrences ; pour passer une donnée d'une page à l'autre, + on passe par un singleton (souvent `YavscApiClient` ou + `Settings`). + +## Bindings XAML : conventions de nommage + +Pour les `[RelayCommand]` (cf. `CommunityToolkit.Mvvm`), le +binding XAML reprend **le nom exact de la méthode, sans +suffixe** : + +| Méthode C# | Binding XAML | +|-----------------------|-----------------------------| +| `Save()` | `{Binding Save}` | +| `SaveAsync()` | `{Binding SaveAsync}` | +| `LoginCommand()` | `{Binding LoginCommand}` (nom littéral, *pas* de suffixe ajouté) | +| `Clear()` | `{Binding Clear}` | +| `CaptureAsync()` | `{Binding CaptureAsync}` | + +**JAMAIS** `SaveCommand`, `SaveCmd`, `DoSave`, etc. Le source +generator `[RelayCommand]` émet une propriété `ICommand` du +même nom que la méthode. Un binding qui pointe vers une +propriété inexistante casse l'app au moment du câblage (le +bouton ne se câble pas, et selon la version ça peut faire +planter l'init de la page). + +Référence canonique : `AGENTS.md`, section +"Avalonia + CommunityToolkit.Mvvm : conventions de binding +pour `[RelayCommand]`". + +## Pages et leurs rôles + +| Page | DataContext | Rôle | +|----------------------------|--------------------------|-----------------------------------------------------------------------| +| `MainWindow` | `HomePageViewModel` (initial) | Host de la `NavigationPage`. | +| `SessionStatusBanner` | `SessionStatusViewModel` | Bandeau persistant en haut de la fenêtre, visible sur toutes les pages. Boutons Login / Logout / Paramètres. | +| `HomePage` | `HomePageViewModel` | Page d'accueil publique. | +| `MainPage` | `MainPageViewModel` | Éditeur de post de blog (après login). | +| `SignaturePage` | `SignaturePageViewModel` | Capture de signature (estimateur). | +| `SettingsPage` | `Settings` | Édition de Authority / ClientId / Scopes / URLs API / Dark mode. Sauver via `Save` (RelayCommand). | + +## Conséquences pratiques + +- **Ajouter une page** : créer la View + le ViewModel + + enregistrer les deux dans le DI **et** dans le `switch` de + `ViewLocator.Build`. Oublier le `ViewLocator` est silencieux + (juste un TextBlock "No view for X"), pas une exception. +- **Ajouter un événement global de navigation** (par ex. + "Push après payment success") : passer par un événement sur + un VM singleton (cf. `SessionStatusViewModel.OpenSettingsRequested`), + pas par une référence à `MainWindow` depuis le VM. Garder + les VMs découplés du `IClassicDesktopStyleApplicationLifetime`. +- **Modifier l'OIDC** : la fiche à lire est + [postit-oidc.md](postit-oidc.md), pas celle-ci. Cette fiche + ne ré-explique ni le flow, ni le pipe, ni le custom scheme. +- **Modifier les `Settings`** : ne pas casser le singleton + (cf. invariant ci-dessus). Toute propriété présentationnelle + ajoutée (par ex. `ScopeListText`) doit porter `[JsonIgnore]` + pour ne pas polluer le format sur disque. + +## Voir aussi + +- [Architecture.md](../Architecture.md) — racine. +- [postit-oidc.md](postit-oidc.md) — flow OIDC, custom scheme, + silent refresh, persistance des tokens. +- [decoupage-organisation.md](decoupage-organisation.md) — + place de `PostIt` dans le découpage global des projets + .NET du repo. From 1733dababbb6d0d3071b1248865f6d92ed40dac5 Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Thu, 9 Jul 2026 22:00:00 +0100 Subject: [PATCH 2/2] postIt: SettingsPage is a singleton, navigation is idempotent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two related changes that close the loop on the SettingsPage push semantics. 1. The SettingsPage used to be registered as Transient. Each click on the Paramètres button resolved a fresh instance, re-bound it to the Settings singleton, and pushed it onto the navigation stack. Repeated clicks accumulated stacked instances, each fully bound, and the user had to tap Back N times to leave. The fix is to register the page as a Singleton in the DI container. There is now one and only one SettingsPage ContentPage for the lifetime of the app: - its DataContext is wired once, at composition time (just after the ViewLocator is added to DataTemplates), not on every push; - the OpenSettingsRequested handler is a pure navigation concern, with no DI resolution and no rebinding; - the in-memory Settings state is preserved across visits (any in-flight edit stays in the same instance). 2. The OpenSettingsRequested handler is guarded so that if the SettingsPage is already at the top of NavigationStack, the push is a no-op. NavigationPage.PushAsync does not deduplicate; without the guard, calling it twice with the same instance pushes it a second time, and the user has to tap Back twice to leave. The guard is a reference comparison on NavigationStack[Count - 1] against the singleton instance, which is correct precisely because the page is a singleton. doc/architecture/postit.md is updated to match: the DI table reflects the new lifetime, and the 'Garde anti-empilement' section is rewritten from 'to be implemented' to the actual implementation, including the rationale for reference comparison and the cross-dependency between the singleton lifetime and the guard. The Settings-singleton invariant (in the same doc) is unchanged: Settings is still a singleton, and adding a transient override would still be the bug it always was. The new SettingsPage singleton sits alongside it cleanly. Build: 0 errors. Tests: 3/3 SettingsLoadTests green. --- doc/architecture/postit.md | 40 ++++++++++++++++---------- src/PostIt/PostIt/App.axaml.cs | 52 ++++++++++++++++++++++++++++------ 2 files changed, 68 insertions(+), 24 deletions(-) diff --git a/doc/architecture/postit.md b/doc/architecture/postit.md index 6fa3c132..77d0a252 100644 --- a/doc/architecture/postit.md +++ b/doc/architecture/postit.md @@ -113,7 +113,8 @@ le DI est construit. Ordre, dans cet ordre : | `Settings` | **Singleton** | État partagé (`Loaded`, `IsDirty`, `Authentication`) — doit être unique. | | `YavscApiClient` | Singleton | Porte le `TokenStore` et le cache de tokens ; un seul par process. | | `BlogApiClient` | Singleton | Mapper stateless, partagé. | -| `MainPage` / `SettingsPage` / `HomePage` / `SignaturePage` | Transient | Résolution à la demande par le `ViewLocator`. | +| `SettingsPage` | **Singleton** | Une seule instance pour la vie de l'app : le `DataContext` est câblé une fois au boot, le push est idempotent (cf. section *Garde anti-empilement* ci-dessous). | +| `MainPage` / `HomePage` / `SignaturePage` | Transient | Résolution à la demande par le `ViewLocator`. | | `MainPageViewModel` / `HomePageViewModel` / `SignaturePageViewModel` | Transient | VM reconstruites à chaque navigation ; pas d'état partagé à conserver. | | `SessionStatusViewModel` + `SessionStatusBanner` | Singleton + Transient | Le VM est un singleton (survit à la navigation), le bandeau est transient (réinstancié quand la fenêtre le recrée). | @@ -139,22 +140,31 @@ posé sur `MainWindow.axaml`. La pile est gérée par les ### Garde anti-empilement -Le handler `OpenSettingsRequested` doit garantir qu'une seule -`SettingsPage` est au sommet de la pile à un instant donné. -Sans garde, plusieurs clics sur **Paramètres** empilent -plusieurs instances (chacune résolue en `Transient`), et -l'utilisateur doit appuyer N fois sur **Retour** pour sortir. +`NavigationPage.PushAsync` n'est pas idempotent : pousser deux +fois la même instance l'empile deux fois, et l'utilisateur doit +taper **Retour** N fois pour sortir. Le handler +`OpenSettingsRequested` est gardé pour bloquer ce cas : -L'invariant à implémenter dans le handler : +```csharp +var settingsPage = provider.GetRequiredService(); +var stack = w.NavRoot.NavigationStack; +if (stack.Count > 0 && ReferenceEquals(stack[stack.Count - 1], settingsPage)) +{ + return; // déjà au sommet, no-op silencieux +} +_ = w.NavRoot.PushAsync(settingsPage); +``` -> Si la page du sommet de `NavRoot.NavigationStack` est déjà -> une `SettingsPage`, ne pas empiler une nouvelle instance -> (no-op silencieux). Sinon, `PushAsync` une nouvelle instance -> comme aujourd'hui. - -Le détail d'implémentation (lecture de la pile, gestion des -cas "SettingsPage est plus bas dans la pile") reste à coder -quand l'UI le demandera. +La comparaison est par référence, pas par type : on ne veut +empêcher qu'un push de *cette* instance particulière, pas +celui d'une éventuelle autre `SettingsPage` (il n'en existe +qu'une, mais l'invariant est plus clair comme ça). La garde +repose sur le fait que `SettingsPage` est un singleton ; si on +repassait en `Transient`, `ReferenceEquals` resterait correct +mais la pertinence de la garde s'évaporerait (chaque push +apporterait une nouvelle instance et l'anti-empilement +reposerait sur l'invariant « la même est déjà au sommet », +qui ne tiendrait plus). ## ViewModels et invariants d'état diff --git a/src/PostIt/PostIt/App.axaml.cs b/src/PostIt/PostIt/App.axaml.cs index 84312064..a73096d2 100644 --- a/src/PostIt/PostIt/App.axaml.cs +++ b/src/PostIt/PostIt/App.axaml.cs @@ -60,7 +60,18 @@ public partial class App : Application // Vues services.AddTransient(); - services.AddTransient(); + // SettingsPage is a singleton: there must be one and only one + // instance of the settings UI for the lifetime of the app. + // This guarantees that (a) the bindings always reflect the + // current in-memory Settings state, (b) the page already has + // its DataContext wired up at composition-root time (see + // below), and (c) the OpenSettingsRequested handler is a + // pure push with a no-op-if-already-on-top guard, never a + // re-resolution from DI. Transient would let the user + // accumulate stale SettingsPage instances on the navigation + // stack, each bound to a fresh SettingsViewModel and missing + // any in-flight edits. + services.AddSingleton(); services.AddTransient(); services.AddTransient(); @@ -93,6 +104,17 @@ public partial class App : Application DataTemplates.Clear(); DataTemplates.Add(new ViewLocator(provider)); + // Wire the Settings singleton onto the SettingsPage singleton + // once, at composition time. The page is registered as a + // singleton (see above) precisely so this binding is stable + // for the lifetime of the app: every push to / pop from the + // navigation stack finds the same ContentPage with the same + // DataContext, and the TwoWay bindings inside the page keep + // mutating the same in-memory Settings instance that the rest + // of the app reads (OidcClientOptions construction, etc.). + provider.GetRequiredService().DataContext = + provider.GetRequiredService(); + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) { var homePage = provider.GetRequiredService(); @@ -130,18 +152,30 @@ public partial class App : Application }; // When the user clicks the "Paramètres" button on the - // session banner, push the SettingsPage on top of the - // current navigation stack. Resolved from DI so the - // ViewLocator + service-locator dance stays out of the - // VM, and bound to the same Settings singleton the rest - // of the app is using (the one we Load()'d at startup). - // Two-way bindings on the page mutate that singleton - // in place; callers re-read on next access. + // session banner, push the SettingsPage singleton on top + // of the current navigation stack. The DataContext is + // already wired at composition time (see the + // provider.GetRequiredService().DataContext + // assignment above), so this handler is a pure + // navigation concern. + // + // Anti-empilement guard: if the SettingsPage is already + // at the top of the stack, do nothing. NavigationPage's + // PushAsync does not deduplicate; calling it twice with + // the same instance would push it a second time and the + // user would have to tap Back twice to leave. Reference + // comparison is correct here because SettingsPage is a + // singleton — there is exactly one instance to compare + // against. sessionStatus.OpenSettingsRequested += () => { var w = (MainWindow)((IClassicDesktopStyleApplicationLifetime)ApplicationLifetime!).MainWindow!; var settingsPage = provider.GetRequiredService(); - settingsPage.DataContext = provider.GetRequiredService(); + var stack = w.NavRoot.NavigationStack; + if (stack.Count > 0 && ReferenceEquals(stack[stack.Count - 1], settingsPage)) + { + return; + } _ = w.NavRoot.PushAsync(settingsPage); };