diff --git a/doc/README.md b/doc/README.md index b189bb27..4afdf585 100644 --- a/doc/README.md +++ b/doc/README.md @@ -15,7 +15,6 @@ 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 52e6ea97..9d4a0e4a 100644 --- a/doc/architecture/decoupage-organisation.md +++ b/doc/architecture/decoupage-organisation.md @@ -31,19 +31,7 @@ └────────────────┘ Clients externes : - - 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 + - PostIt : client desktop Avalonia (cf. postit-oidc.md). ``` ## Par projet @@ -56,14 +44,6 @@ Outils et tests : | `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 deleted file mode 100644 index 77d0a252..00000000 --- a/doc/architecture/postit.md +++ /dev/null @@ -1,255 +0,0 @@ -# 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é. | -| `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). | - -> **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 - -`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 : - -```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); -``` - -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 - -- `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. diff --git a/src/PostIt/PostIt/App.axaml.cs b/src/PostIt/PostIt/App.axaml.cs index a73096d2..84312064 100644 --- a/src/PostIt/PostIt/App.axaml.cs +++ b/src/PostIt/PostIt/App.axaml.cs @@ -60,18 +60,7 @@ public partial class App : Application // Vues 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(); services.AddTransient(); @@ -104,17 +93,6 @@ 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(); @@ -152,30 +130,18 @@ public partial class App : Application }; // When the user clicks the "Paramètres" button on the - // 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. + // 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. sessionStatus.OpenSettingsRequested += () => { var w = (MainWindow)((IClassicDesktopStyleApplicationLifetime)ApplicationLifetime!).MainWindow!; var settingsPage = provider.GetRequiredService(); - var stack = w.NavRoot.NavigationStack; - if (stack.Count > 0 && ReferenceEquals(stack[stack.Count - 1], settingsPage)) - { - return; - } + settingsPage.DataContext = provider.GetRequiredService(); _ = w.NavRoot.PushAsync(settingsPage); };