fix/inactive-toolbar-buttons #38
3 changed files with 92 additions and 41 deletions
doc: align navigation docs with VM-first pattern
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).
commit
12a71ada6a
|
|
@ -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<TView>()`). 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<TPage>`
|
||||
ou `AddSingleton<TPage>`) **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<Settings>();
|
||||
await ((App)App.Current!).PushPageAsync(settingsVm)
|
||||
.ConfigureAwait(true);
|
||||
}
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -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<SettingsPage>();
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -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<SettingsPage>();
|
||||
services.AddTransient<HomePage>();
|
||||
services.AddTransient<SignaturePage>();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue