From 12a71ada6a8a0c3d9924e7756fc1bc17737b85fd Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Wed, 19 Aug 2026 17:17:22 +0100 Subject: [PATCH] doc: align navigation docs with VM-first pattern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two recent commits (3fbbafc4, 0065de70) replaced the OpenSettingsRequested event + CurrentViewModel assignment with App.PushPageAsync(vm): the VM resolves the target ViewModel through DI, App resolves the Control through the ViewLocator, guards against double-push, and pushes via NavRoot. The docs were still describing the pre-refactor world. Update three places: - CONTRIBUTING.md — the "Navigation (PostIt)" rule now describes App.PushPageAsync as the single channel and shows the canonical OpenSettings command as the example. - doc/architecture/postit.md — the Navigation section distinguishes VM-first navigation (App.PushPageAsync) from lifecycle signals (LoginSucceeded, LogoutCompleted) and drops the obsolete OpenSettingsRequested row. - src/PostIt/PostIt/App.axaml.cs — refresh the SettingsPage singleton justification: point (c) now describes the anti-empilement guard inside PushPageAsync, not the OpenSettingsRequested handler that no longer exists. No production behaviour change — doc only (and the inline comment that referenced a removed event). --- CONTRIBUTING.md | 50 +++++++++++++++++------- doc/architecture/postit.md | 69 +++++++++++++++++++++++----------- src/PostIt/PostIt/App.axaml.cs | 12 +++--- 3 files changed, 91 insertions(+), 40 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a2c9aeb1..847b2954 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -64,23 +64,47 @@ Quelques règles non capturées par `.editorconfig` : - Préférer les types BCL (`int`, `string`) aux types framework (`Int32`, `String`). - Préférer les expressions de pattern matching aux casts explicites. -- **Navigation (PostIt)** : la navigation est contrôlée par - `src/PostIt/PostIt/ViewLocator.cs`. Pour ouvrir un écran, - on affecte le ViewModel cible à la propriété `CurrentViewModel` - du `MainPageViewModel` (qui binde l'`IContentControl.Content` - de la page hôte). Tant que la vue correspondante est supportée - par le `ViewLocator`, ce dernier décide de l'instance de - `Control` à pousser en navigation, et il l'obtient de la DI - (`_services.GetRequiredService()`). On n'instancie - jamais une `View` à la main depuis un ViewModel, on ne - récupère jamais une `View` depuis la DI directement dans un - ViewModel. Exemple canonique : +- **Navigation (PostIt)** : la navigation est centralisée dans + `App.PushPageAsync(ViewModelBase vm)` (`src/PostIt/PostIt/App.axaml.cs`). + Pour ouvrir un écran, un ViewModel (généralement dans une + commande `[RelayCommand]`) appelle + `await ((App)App.Current!).PushPageAsync(targetVm).ConfigureAwait(true);`. + `PushPageAsync` résout la `Control` correspondante via le + `ViewLocator` (un `IDataTemplate` enregistré dans + `Application.DataTemplates` au boot), l'identifie comme + `Page`, lui assigne le VM comme `DataContext`, et appelle + `NavRoot.PushAsync(page)`. Une garde anti-empilement + compare par référence la nouvelle page au sommet courant + de la stack pour éviter un push doublon. + + Pour qu'une nouvelle page soit navigable, il faut *deux* + enregistrements : la page dans le DI (`AddTransient` + ou `AddSingleton`) **et** une case dans le `switch` + de `ViewLocator.Build`. Si l'un manque, l'app affiche + "No view for X" sans crash. + + Règles : + + - On n'instancie jamais une `View` à la main depuis un + ViewModel, on ne récupère jamais une `View` depuis la DI + directement dans un ViewModel. + - Le ViewModel qui déclenche la nav ne pousse pas lui-même + la page ; il appelle `App.PushPageAsync(vm)` et laisse + `App` orchestrer le `PushAsync` physique. + - Le ViewModel qui déclenche la nav ne capture pas de + référence à `MainWindow` ou `NavigationPage`. Il passe + par `App.Current` (l'app Avalonia est un singleton). + + Exemple canonique (depuis `MainPageViewModel`) : ```csharp [RelayCommand] - internal void OpenSettings() + internal async Task OpenSettings() { - CurrentViewModel = SettingsModel; + var settingsVm = ((App)App.Current!).ServiceProvider + .GetRequiredService(); + await ((App)App.Current!).PushPageAsync(settingsVm) + .ConfigureAwait(true); } ``` diff --git a/doc/architecture/postit.md b/doc/architecture/postit.md index 77d0a252..70f3f2fd 100644 --- a/doc/architecture/postit.md +++ b/doc/architecture/postit.md @@ -129,30 +129,51 @@ le DI est construit. Ordre, dans cet ordre : ## 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` : +posé sur `MainWindow.axaml`. La pile est gérée par deux +mécanismes distincts : -| Événement | Effet | -|---------------------------------|------------------------------------------------------------------------| -| `LoginSucceeded` | `PushAsync(MainPage)` au-dessus de `HomePage`. | -| `LogoutCompleted` | `PopToRootAsync()` (revient à `HomePage`). | -| `OpenSettingsRequested` | `PushAsync(SettingsPage)` au-dessus de la page courante. | +1. **Nav utilisateur (VM-first)** : un ViewModel (souvent dans + une commande `[RelayCommand]`) appelle + `await ((App)App.Current!).PushPageAsync(targetVm).ConfigureAwait(true);`. + `App.PushPageAsync` (`src/PostIt/PostIt/App.axaml.cs`) + résout la `Control` correspondante via le `ViewLocator` + enregistré dans `Application.DataTemplates`, l'identifie + comme `Page`, lui assigne le VM comme `DataContext`, et + appelle `NavRoot.PushAsync(page)`. C'est le seul chemin + pour les boutons de la toolbar, les `OpenSettings` / + `OpenCircles` / `ManageAcl` / `OpenSignatureDev`, et + toute autre nav déclenchée par un ViewModel. + +2. **Signaux de cycle de vie** : le `SessionStatusViewModel` + lève des événements consommés dans + `App.OnFrameworkInitializationCompleted` pour orchestrer + la nav de boot : + + | Événement | Effet | + |---------------------|------------------------------------------------------------------| + | `LoginSucceeded` | `PushAsync(MainPage)` au-dessus de `HomePage` (post-login). | + | `LogoutCompleted` | `PopToRootAsync()` (revient à `HomePage`). | + + Ces events ne sont **pas** un canal de nav utilisateur ; ils + portent une transition d'état applicatif (authentification + établie / perdue) et c'est `App` qui choisit d'en faire une + transition de pile. ### Garde anti-empilement `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 : +taper **Retour** N fois pour sortir. La garde est implémentée +dans `App.PushPageAsync` (et consommée par tous les chemins +de nav utilisateur) : ```csharp -var settingsPage = provider.GetRequiredService(); -var stack = w.NavRoot.NavigationStack; -if (stack.Count > 0 && ReferenceEquals(stack[stack.Count - 1], settingsPage)) +var stack = window.NavRoot.NavigationStack; +if (stack.Count > 0 && ReferenceEquals(stack[stack.Count - 1], page)) { - return; // déjà au sommet, no-op silencieux + return Task.CompletedTask; // déjà au sommet, no-op silencieux } -_ = w.NavRoot.PushAsync(settingsPage); +return window.NavRoot.PushAsync(page); ``` La comparaison est par référence, pas par type : on ne veut @@ -178,9 +199,11 @@ qui ne tiendrait plus). - `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`). + les événements de cycle de vie consommés par `App` pour + orchestrer la nav de boot (`LoginSucceeded`, + `LogoutCompleted`). La nav utilisateur déclenchée par + l'utilisateur passe par `App.PushPageAsync(vm)`, pas par + un événement du `SessionStatusViewModel`. - `MainPageViewModel` / `HomePageViewModel` / `SignaturePageViewModel` sont `Transient` — une nouvelle @@ -233,10 +256,14 @@ pour `[RelayCommand]`". `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`. + "Push après payment success") : ne pas capturer `MainWindow` + ni `NavigationPage` depuis le VM. La nav passe par + `App.PushPageAsync(vm)` dans tous les cas : soit le VM + appelle la méthode directement depuis une commande + (`[RelayCommand]`), soit un handler abonné à un événement + d'un singleton (cf. `SessionStatusViewModel`) l'appelle. + 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. diff --git a/src/PostIt/PostIt/App.axaml.cs b/src/PostIt/PostIt/App.axaml.cs index ef35c875..894256fa 100644 --- a/src/PostIt/PostIt/App.axaml.cs +++ b/src/PostIt/PostIt/App.axaml.cs @@ -165,12 +165,12 @@ public partial class App : Application // 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. + // below), and (c) PushPageAsync's anti-empilement guard sees + // the same instance across pushes, so a second Settings tap + // is a no-op rather than re-pushing the page. 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();