yavsc/doc/architecture/postit.md
Paul Schneider 120bae6f5c 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.
2026-07-09 21:54:06 +01:00

13 KiB

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 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.

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 :

public Control Build(object? data) => data switch
{
    MainPageViewModel   => _services.GetRequiredService<MainPage>(),
    Settings            => _services.GetRequiredService<SettingsPage>(),
    HomePageViewModel   => _services.GetRequiredService<HomePage>(),
    SignaturePageViewModel => _services.GetRequiredService<SignaturePage>(),
    null                => new TextBlock { Text = "No view for <null>" },
    _                   => 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 ViewModelBaseMatch 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 :

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<Settings>() 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, 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