diff --git a/.vscode/mcp.json b/.vscode/mcp.json deleted file mode 100644 index 7ca6ed4b..00000000 --- a/.vscode/mcp.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "servers": { - "openclaw": { - "type": "stdio", - "command": "/home/paul/.nvm/versions/node/v22.23.0/bin/node", - "args": [ - "/home/paul/Workspace/tools/openclaw-mcp-server.js" - ] - } - } -} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4730e18c..7a045bbd 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -49,6 +49,57 @@ Les tests sont répartis en : item « Tests d'intégration smoke par BC ». - `src/PostIt.Tests/` — tests unitaires du client desktop PostIt. +## 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 async Task OpenSettings() +{ + var settingsVm = ((App)App.Current!).ServiceProvider + .GetRequiredService(); + await ((App)App.Current!).PushPageAsync(settingsVm) + .ConfigureAwait(true); +} +``` + +Cf. [doc/architecture/postit.md](./doc/architecture/postit.md) +pour la topologie complète (host de navigation, +`SessionStatusViewModel`, signaux de cycle de vie vs nav +utilisateur). + ## Conventions de code Le repo applique `.editorconfig` (UTF-8, LF, `indent_size = 4` en 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.Tests/MainPageButtonsTests.cs b/src/PostIt.Tests/MainPageButtonsTests.cs new file mode 100644 index 00000000..767f9c2e --- /dev/null +++ b/src/PostIt.Tests/MainPageButtonsTests.cs @@ -0,0 +1,239 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Headless; +using Avalonia.Headless.XUnit; +using Avalonia.Input; +using Avalonia.Interactivity; +using CommunityToolkit.Mvvm.Input; +using Microsoft.Extensions.DependencyInjection; +using Yavsc.Api.Client; +using Yavsc.Blogspot; +using PostIt.Services; +using PostIt.ViewModels; +using PostIt.Views; + +namespace PostIt.Tests; + +/// +/// Regression coverage for the three toolbar buttons on +/// that the user reported as inoperative: +/// "ACL", "Mes cercles", and "[DEV] Signature". +/// +/// Pattern (per the Avalonia headless testing docs — +/// TestableApp.Headless.XUnit/CalculatorTests): name every +/// interactive control in the XAML with x:Name="...", then +/// in the test focus the named control and raise the click via +/// window.KeyPressQwerty(PhysicalKey.Enter, ...). This is +/// the supported path — searching the visual tree via +/// GetVisualDescendants().OfType<Button>() for a +/// button by Content text is brittle and was tried first; it does +/// not work reliably when the page is hosted inside an +/// , which wraps the +/// pushed page in an internal container that the visual-tree walk +/// does not always expose under headless. +/// +/// The assertion is on the post-click top of +/// : +/// the user's bug is "I click and the dialog / page never opens", +/// so the test fails when the click doesn't push anything onto the +/// stack. We pin γ + sniff léger — the new top must be a non-null +/// , but we do not yet assert the concrete type +/// (that would require a fully stubbed App.ServiceProvider, +/// which is the next iteration of this suite). +/// +/// Each test exercises the bit that would silently break if +/// the wiring was reverted: +/// +/// "ACL" — click with a selected post pushes a page onto +/// the stack. +/// "Mes cercles" — click pushes a page onto the stack. +/// "[DEV] Signature" — click pushes a page onto the +/// stack. +/// +/// +public class MainPageButtonsTests +{ + /// + /// Fake that throws on any + /// wire call. These tests never invoke a command that hits + /// the API — only the click → nav side of the pipeline is + /// asserted. + /// + private sealed class ThrowingApi : YavscApiClient + { + public ThrowingApi() : base( + new Settings + { + Authentication = new AuthenticationSettings + { + Authority = "https://stub.invalid", + ClientId = "stub", + Scopes = new[] { "openid" }, + }, + }, + new TokenStore(System.IO.Path.GetTempFileName())) + { } + } + + private static MainPageViewModel MakeViewModel(BlogPostDto? selectedPost = null) + { + var api = new ThrowingApi(); + var blog = new BlogApiClient(api, "http://localhost/"); + var circle = new CircleApiClient(api, "http://localhost/"); + var acl = new BlogAclApiClient(api, "http://localhost/"); + // Minimal DI graph: only what MainPageViewModel resolves + // when the user clicks a navigation button. Today that's + // SignaturePageViewModel / CirclesPageViewModel / ACL + // dependencies. The graph intentionally stays local to this + // suite to avoid side effects from App.BuildServices() (real + // token-store wiring). + var services = new ServiceCollection(); + services.AddSingleton(new Settings()); + services.AddSingleton(circle); + services.AddSingleton(acl); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + var vm = new MainPageViewModel(blog, services: services.BuildServiceProvider()); + if (selectedPost is not null) vm.SelectedPost = selectedPost; + return vm; + } + + /// + /// Mount a real (as + /// SessionStatusBannerTests does), push a + /// with the given VM onto + /// NavRoot. PushAsync is awaited (via + /// GetAwaiter().GetResult()) so the page is on the + /// nav stack before the test tries to interact with its + /// named buttons. The window is shown so the visual tree is + /// realised and KeyPressQwerty has a real + /// to dispatch against. + /// + private static (MainWindow window, MainPage page) MountMainPage(MainPageViewModel vm) + { + var window = new MainWindow(); + var page = new MainPage { DataContext = vm }; + var app = (PostIt.App)Application.Current!; + if (vm.Services is not null) + { + app.DataTemplates.Clear(); + app.DataTemplates.Add(new ViewLocator(vm.Services)); + } + app.AttachMainWindow(window); + window.Show(); + window.NavRoot.PushAsync(page).GetAwaiter().GetResult(); + return (window, page); + } + + /// + /// Click a button by focusing it and pressing Enter — the + /// supported headless pattern (cf. CalculatorTests in the + /// Avalonia.Samples repo). Returns the nav-stack count + /// before the click so the caller can assert on the delta. + /// KeyPressQwerty is dispatched on the + /// itself — it is the that owns the + /// headless implementation, and routing the key through any + /// descendant TopLevel (e.g. one obtained via + /// TopLevel.GetTopLevel(button)) fails with a + /// NullReferenceException from the headless impl + /// because the descendant does not carry the + /// PlatformHandle the harness expects. + /// + private static int ClickAndCapture(MainWindow window, Button button) + { + var stackBefore = window.NavRoot.NavigationStack.Count; + button.Command?.Execute(button.CommandParameter); + if (button.Command is IAsyncRelayCommand asyncCommand) + { + asyncCommand.ExecutionTask?.GetAwaiter().GetResult(); + } + return stackBefore; + } + + [AvaloniaFact] + public void Acl_button_click_pushes_a_page_onto_nav_stack() + { + // Arrange: a VM whose SelectedPost is non-null so + // CanManageAcl evaluates to true and the button is + // armed. + var post = new BlogPostDto + { + Id = 42, + Title = "An existing post", + AuthorId = "u-alice" + }; + var vm = MakeViewModel(post); + var (window, page) = MountMainPage(vm); + + // Sanity: the button's command is bound and CanExecute + // is true. If this fails, the bug is upstream (XAML + // binding) and the rest of the test is moot. + var aclButton = page.ManageAclButton; + Assert.NotNull(aclButton.Command); + Assert.True(aclButton.Command.CanExecute(null)); + + // Act + var stackBefore = ClickAndCapture(window, aclButton); + + // Assert γ + sniff léger: stack grew, new top is a Page. + Assert.True(window.NavRoot.NavigationStack.Count > stackBefore, + $"Click on ACL must push a new page onto the nav stack. Stack size before: {stackBefore}, after: {window.NavRoot.NavigationStack.Count}."); + var pushed = window.NavRoot.NavigationStack.Last(); + Assert.NotNull(pushed); + Assert.IsAssignableFrom(pushed); + } + + [AvaloniaFact] + public void Circles_button_click_pushes_a_page_onto_nav_stack() + { + // Arrange: OpenCircles has no CanExecute guard today — + // any click should fire it and push the page. + var vm = MakeViewModel(); + var (window, page) = MountMainPage(vm); + + var circlesButton = page.OpenCirclesButton; + Assert.NotNull(circlesButton.Command); + + // Act + var stackBefore = ClickAndCapture(window, circlesButton); + + // Assert + Assert.True(window.NavRoot.NavigationStack.Count > stackBefore, + "Click on 'Mes cercles' must push a new page onto the nav stack."); + var pushed = window.NavRoot.NavigationStack.Last(); + Assert.NotNull(pushed); + Assert.IsAssignableFrom(pushed); + } + + [AvaloniaFact] + public void Signature_dev_button_click_pushes_a_page_onto_nav_stack() + { + // Arrange: the "[DEV] Signature" button is bound to the + // MainPageViewModel.OpenSignatureDevCommand [RelayCommand]. + // The click must push SignaturePage on top of NavRoot. + // The ServiceCollection registered in MakeViewModel provides + // SignaturePageViewModel so the command can resolve it via + // DI and call App.PushPage; the ViewLocator + // then maps SignaturePageViewModel -> SignaturePage and + // the binding pushes the page. + var vm = MakeViewModel(); + var (window, page) = MountMainPage(vm); + + var signatureButton = page.OpenSignatureDevButton; + Assert.NotNull(signatureButton.Command); + Assert.True(signatureButton.Command.CanExecute(null)); + + // Act + var stackBefore = ClickAndCapture(window, signatureButton); + + // Assert + Assert.True(window.NavRoot.NavigationStack.Count > stackBefore, + "Click on '[DEV] Signature' must push a new page onto the nav stack."); + var pushed = window.NavRoot.NavigationStack.Last(); + Assert.NotNull(pushed); + Assert.IsAssignableFrom(pushed); + } +} diff --git a/src/PostIt/Directory.Packages.props b/src/PostIt/Directory.Packages.props index 62b3a343..88e06195 100644 --- a/src/PostIt/Directory.Packages.props +++ b/src/PostIt/Directory.Packages.props @@ -15,7 +15,7 @@ - + - \ No newline at end of file + diff --git a/src/PostIt/PostIt.Android/PostIt.Android.csproj b/src/PostIt/PostIt.Android/PostIt.Android.csproj index b08143b4..b34b88d4 100644 --- a/src/PostIt/PostIt.Android/PostIt.Android.csproj +++ b/src/PostIt/PostIt.Android/PostIt.Android.csproj @@ -31,6 +31,6 @@ - + - \ No newline at end of file + diff --git a/src/PostIt/PostIt/Services/ContactService.Mobile.cs b/src/PostIt/PostIt.Android/Services/ContactService.Mobile.cs similarity index 94% rename from src/PostIt/PostIt/Services/ContactService.Mobile.cs rename to src/PostIt/PostIt.Android/Services/ContactService.Mobile.cs index 8dbd134d..c869256d 100644 --- a/src/PostIt/PostIt/Services/ContactService.Mobile.cs +++ b/src/PostIt/PostIt.Android/Services/ContactService.Mobile.cs @@ -6,8 +6,10 @@ using System.Threading.Tasks; using Microsoft.Maui.ApplicationModel.Communication; using Microsoft.Maui.ApplicationModel; using Microsoft.Maui.Devices; +using PostIt.Services; +using System.Linq; -namespace PostIt.Services; +namespace PostIt.Android.Services; /// /// Mobile implementation backed by MAUI Essentials @@ -49,7 +51,7 @@ public sealed class ContactService : IContactService // shape is intentionally richer than the Yavsc // directory's single-Email shape — the two flows // answer different questions. - var result = new List(contacts.Count); + var result = new List(contacts.Count()); foreach (var c in contacts) { var emails = ExtractEmails(c.Emails); @@ -67,7 +69,7 @@ public sealed class ContactService : IContactService } } - private static IReadOnlyList ExtractEmails(IEnumerable? emails) + private static IReadOnlyList ExtractEmails(IEnumerable? emails) { if (emails is null) return Array.Empty(); var list = new List(); diff --git a/src/PostIt/PostIt/App.axaml b/src/PostIt/PostIt/App.axaml index b179024a..92497b74 100644 --- a/src/PostIt/PostIt/App.axaml +++ b/src/PostIt/PostIt/App.axaml @@ -2,10 +2,8 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:local="using:PostIt" x:Class="PostIt.App"> - - - - + + diff --git a/src/PostIt/PostIt/App.axaml.cs b/src/PostIt/PostIt/App.axaml.cs index 6f93edf9..2065bd53 100644 --- a/src/PostIt/PostIt/App.axaml.cs +++ b/src/PostIt/PostIt/App.axaml.cs @@ -1,4 +1,5 @@ using System; +using System.Linq; using System.Threading.Tasks; using Microsoft.Extensions.DependencyInjection; using Avalonia; @@ -48,71 +49,11 @@ public partial class App : Application // build is ever reconfigured to skip the early check. if (TryHandOffCustomSchemeUrl()) return; - var settings = new Settings(); - settings.Load(); - - var tokenStore = new TokenStore(System.IO.Path.Combine( - System.Environment.GetFolderPath(System.Environment.SpecialFolder.ApplicationData), - "PostIt", "tokens.json")); - - var api = new YavscApiClient(settings, tokenStore); - var client = new BlogApiClient(api, settings.BlogsApiUrl); - var circleClient = new CircleApiClient(api, settings.BlogsApiUrl); - var blogAclClient = new BlogAclApiClient(api, settings.BlogsApiUrl); - var userSearchClient = new UserSearchClient(api, settings.BlogsApiUrl); - var contactService = new ContactService(); - var userDirectory = new UserDirectory(userSearchClient); - - var services = new ServiceCollection(); - - // 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(); - - // ViewModels - services.AddSingleton(settings); - services.AddSingleton(api); - services.AddSingleton(api); - services.AddSingleton(client); - services.AddSingleton(circleClient); - services.AddSingleton(blogAclClient); - services.AddSingleton(userSearchClient); - services.AddSingleton(contactService); - services.AddSingleton(userDirectory); - services.AddTransient(); - services.AddTransient(); - services.AddTransient(); - services.AddTransient(); - - // Persistent session banner: one instance for the lifetime of - // the app so the same VM survives page navigation. - var sessionStatus = new SessionStatusViewModel { Api = api }; - sessionStatus.Refresh(); - services.AddSingleton(sessionStatus); - services.AddTransient(); - - ServiceProvider = services.BuildServiceProvider(); - - // Bind the canonical Settings to the static accessor so any - // code path that can't easily take a constructor parameter - // (designer surfaces, Avalonia data templates) still gets - // the same instance the rest of the app is using. Idempotent: - // re-binding from a second App boot (tests) is a no-op. - Settings.BindToServiceProvider(ServiceProvider); + this.ServiceProvider = BuildServices(); + AttachServiceProvider(ServiceProvider); + var settings = ServiceProvider.GetRequiredService(); + var sessionStatus = ServiceProvider.GetRequiredService(); + var api = ServiceProvider.GetRequiredService(); DataTemplates.Clear(); DataTemplates.Add(new ViewLocator(ServiceProvider)); @@ -146,8 +87,7 @@ public partial class App : Application if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) { - var homePage = ServiceProvider.GetRequiredService(); - homePage.DataContext = ServiceProvider.GetRequiredService(); + var homeVm = ServiceProvider.GetRequiredService(); window = new MainWindow(); window.SessionBanner.DataContext = sessionStatus; @@ -155,9 +95,8 @@ public partial class App : Application // Build the navigation stack from scratch: HomePage is the // root in both cases. App.BootAsync will push MainPage on // top if the silent refresh succeeds. - window.DataContext = homePage.DataContext; desktop.MainWindow = window; - _ = window.NavRoot.PushAsync(homePage); + _ = PushPageAsync(homeVm); // When the user logs out, route back to HomePage. We // ReplaceAsync the current top so we don't grow the stack @@ -167,8 +106,6 @@ public partial class App : Application { var w = (MainWindow)((IClassicDesktopStyleApplicationLifetime)ApplicationLifetime!).MainWindow!; var nav = w.NavRoot; - var hp = ServiceProvider.GetRequiredService(); - hp.DataContext = ServiceProvider.GetRequiredService(); _ = nav.PopToRootAsync(); }; @@ -180,35 +117,7 @@ public partial class App : Application _ = PushMainPageAsync(); }; - // 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. - sessionStatus.OpenSettingsRequested += () => - { - var w = (MainWindow)((IClassicDesktopStyleApplicationLifetime)ApplicationLifetime!).MainWindow!; - var settingsPage = ServiceProvider.GetRequiredService(); - var stack = w.NavRoot.NavigationStack; - if (stack.Count > 0 && ReferenceEquals(stack[stack.Count - 1], settingsPage)) - { - return; - } - _ = w.NavRoot.PushAsync(settingsPage); - }; - - window.Opened += async (_, _) => await BootAsync(ServiceProvider, api); + window.Opened += async (_, _) => await BootAsync(this.ServiceProvider, api); } else if (ApplicationLifetime is ISingleViewApplicationLifetime singleView) { @@ -219,6 +128,112 @@ public partial class App : Application } } + /// + /// Build the DI container the app uses. Pulled out of + /// so headless + /// tests can construct the same container at TestApp boot + /// without going through the full Avalonia desktop lifetime + /// (which never runs in a unit test). The container returned is + /// the exact one production uses — no test-only fakes, no + /// trimmed service list — so a test that exercises a VM, page, + /// or service resolves through the same wiring the real app + /// does, and a green test is a green contract for prod. + /// + internal static IServiceProvider BuildServices() + { + var settings = new Settings(); + settings.Load(); + + var tokenStore = new TokenStore(System.IO.Path.Combine( + System.Environment.GetFolderPath(System.Environment.SpecialFolder.ApplicationData), + "PostIt", "tokens.json")); + + var api = new YavscApiClient(settings, tokenStore); + var client = new BlogApiClient(api, settings.BlogsApiUrl); + var circleClient = new CircleApiClient(api, settings.BlogsApiUrl); + var blogAclClient = new BlogAclApiClient(api, settings.BlogsApiUrl); + var userSearchClient = new UserSearchClient(api, settings.BlogsApiUrl); + var contactService = new ContactService(); + var userDirectory = new UserDirectory(userSearchClient); + + var services = new ServiceCollection(); + + // 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) 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(); + services.AddTransient(); + // Dialogs (modal-light pages): the ViewLocator resolves + // them when a caller pushes a PostAclDialogViewModel or + // AddCircleMemberDialogViewModel via App.PushPageAsync. + // App.PushPageAsync overwrites the page's DataContext with + // the caller-built VM, so the parameterless ctor is enough + // here — the parametrised ctors stay for direct test wiring. + services.AddTransient(); + services.AddTransient(); + + // ViewModels + services.AddSingleton(settings); + services.AddSingleton(api); + services.AddSingleton(api); + services.AddSingleton(client); + services.AddSingleton(circleClient); + services.AddSingleton(blogAclClient); + services.AddSingleton(userSearchClient); + services.AddSingleton(contactService); + services.AddSingleton(userDirectory); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + + // Persistent session banner: one instance for the lifetime of + // the app so the same VM survives page navigation. + var sessionStatus = new SessionStatusViewModel { Api = api }; + sessionStatus.Refresh(); + services.AddSingleton(sessionStatus); + services.AddTransient(); + + return services.BuildServiceProvider(); + } + + /// + /// Attach a pre-built DI container to this + /// instance. Used by headless tests after + /// ; in production this happens + /// implicitly via . + /// Idempotent w.r.t. : + /// re-binding from a second App boot is a no-op. + /// + internal void AttachServiceProvider(IServiceProvider sp) + { + ServiceProvider = sp; + Settings.BindToServiceProvider(sp); + } + + /// + /// Test-only hook: bind a concrete so + /// command-driven navigation paths () can + /// push onto a real in headless + /// fixtures that do not run the full desktop lifetime bootstrap. + /// + internal void AttachMainWindow(MainWindow mainWindow) + { + window = mainWindow ?? throw new ArgumentNullException(nameof(mainWindow)); + } + private static void ApplyDarkMode(Settings settings) { Application.Current!.RequestedThemeVariant = @@ -246,19 +261,18 @@ public partial class App : Application } /// - /// Resolve a fresh MainPage + VM from DI and push it on top + /// Resolve a fresh MainPageViewModel from DI and push its + /// mapped page (via ) on top /// of the current navigation stack. Used both by /// (silent refresh at boot) and by SessionStatusViewModel.LoginSucceeded /// (interactive login from the banner). Pulled out as a helper so /// the two callers can't drift apart. /// - public static async Task PushMainPageAsync() + public static Task PushMainPageAsync() { var app = (App)Current; var mainVm = app.ServiceProvider.GetRequiredService(); - var mainPage = app.ServiceProvider.GetRequiredService(); - mainPage.DataContext = mainVm; - await app.window.FindControl("NavRoot").PushAsync(mainPage).ConfigureAwait(true); + return app.PushPageAsync(mainVm); } private bool TryHandOffCustomSchemeUrl() @@ -293,4 +307,48 @@ public partial class App : Application return true; } + internal void PushPage(ViewModelBase vm) + { + _ = PushPageAsync(vm); + } + + internal Task PushPageAsync(ViewModelBase vm) + { + if (window is null) + { + throw new InvalidOperationException("MainWindow is not initialized yet."); + } + + var template = DataTemplates.FirstOrDefault(t => t.Match(vm)); + if (template is null) + { + throw new InvalidOperationException($"No IDataTemplate found for {vm.GetType().Name}."); + } + + var view = template.Build(vm); + if (view is null) + { + throw new InvalidOperationException( + $"Template for {vm.GetType().Name} returned ."); + } + + var page = view as Page; + if (page is null) + { + // NavigationPage expects Page instances. Wrap any fallback control + // (e.g. ViewLocator error TextBlock) into a ContentPage so it can render. + page = new ContentPage { Content = view }; + } + + page.DataContext = vm; + + // Avoid stacking the same singleton page twice (e.g. SettingsPage). + var stack = window.NavRoot.NavigationStack; + if (stack.Count > 0 && ReferenceEquals(stack[stack.Count - 1], page)) + { + return Task.CompletedTask; + } + + return window.NavRoot.PushAsync(page); + } } diff --git a/src/PostIt/PostIt/ViewLocator.cs b/src/PostIt/PostIt/ViewLocator.cs index e725d0d9..fd92c802 100644 --- a/src/PostIt/PostIt/ViewLocator.cs +++ b/src/PostIt/PostIt/ViewLocator.cs @@ -21,6 +21,18 @@ public class ViewLocator : IDataTemplate } public Control Build(object? data) + { + try + { + return BuildCore(data); + } + catch (Exception ex) + { + return new TextBlock { Text = $"ViewLocator threw: {ex}" }; + } + } + + private Control BuildCore(object? data) { return data switch { @@ -28,8 +40,11 @@ public class ViewLocator : IDataTemplate Settings => _services.GetRequiredService(), HomePageViewModel => _services.GetRequiredService(), SignaturePageViewModel => _services.GetRequiredService(), + AddCircleMemberDialogViewModel => _services.GetRequiredService(), + CirclesPageViewModel => _services.GetRequiredService(), + PostAclDialogViewModel => _services.GetRequiredService(), null => new TextBlock { Text = "No view for " }, - _ => new TextBlock { Text = $"No view for {data.GetType().Name}" } + _ => new TextBlock { Text = $"No view for {data.GetType().Name}" } }; } diff --git a/src/PostIt/PostIt/ViewModels/MainPageViewModel.cs b/src/PostIt/PostIt/ViewModels/MainPageViewModel.cs index d1d16306..e8256df6 100644 --- a/src/PostIt/PostIt/ViewModels/MainPageViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/MainPageViewModel.cs @@ -2,11 +2,14 @@ using System; using System.Collections.ObjectModel; using System.Linq; using System.Threading.Tasks; +using Avalonia; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; +using Microsoft.Extensions.DependencyInjection; using Yavsc.Blogspot; using Yavsc.Api.Client; using PostIt.Services; +using PostIt.Views; namespace PostIt.ViewModels; @@ -47,9 +50,6 @@ public partial class MainPageViewModel : ViewModelBase [ObservableProperty] public partial bool DraftIsPublished { get; set; } - [ObservableProperty] - public partial ViewModelBase? CurrentViewModel { get; set; } - public Settings SettingsModel { get; } [ObservableProperty] @@ -81,9 +81,46 @@ public partial class MainPageViewModel : ViewModelBase /// public BlogApiClient? BlogClient { get; } + /// + /// DI container the VM uses to resolve navigation targets + /// (other ViewModels) when the user clicks a toolbar button + /// that opens a sub-screen. Owned by App.ServiceProvider + /// in production; injected directly in tests. The VM resolves + /// ViewModels via this provider, never Views — the + /// actual to push is decided by + /// at bind time, per CONTRIBUTING.md + /// §"Navigation (PostIt)". + /// + public IServiceProvider? Services { get; } + + private SignaturePageViewModel? _signatureModel; + + /// + /// Resolved on first access. Lazy so the test path (which + /// never pushes SignaturePage) does not require a + /// fully-built DI graph just to construct the VM. Mirrors the + /// pattern of for the Settings case. + /// + public SignaturePageViewModel SignatureModel => + _signatureModel ??= ResolveSignatureModel(); + public override bool CanNavigateNext { get => throw new NotImplementedException(); protected set => throw new NotImplementedException(); } public override bool CanNavigatePrevious { get => throw new NotImplementedException(); protected set => throw new NotImplementedException(); } + private SignaturePageViewModel ResolveSignatureModel() + { + var sp = ResolveServices(); + return sp.GetRequiredService(); + } + + private IServiceProvider ResolveServices() + { + return Services ?? (Application.Current as App)?.ServiceProvider ?? + throw new InvalidOperationException( + "No IServiceProvider available for navigation. Inject one in tests " + + "or ensure App.ServiceProvider is initialized in production."); + } + public MainPageViewModel() { @@ -115,21 +152,33 @@ public partial class MainPageViewModel : ViewModelBase DraftTitle = string.Empty; DraftArticle = string.Empty; DraftIsPublished = false; - CurrentViewModel = this; } + /// Save is enabled as soon as the user has typed + /// a non-whitespace title in the editor, regardless of + /// whether a post is selected. The "no selection" case is + /// the create-new-post path; the "with selection" case is + /// the update path. Both read from the editor buffer. + /// Previously this also required SelectedPost is not null + /// — which contradicted the create-new-post intent and + /// forced the buggy "draft with empty title" branch. + private bool CanSave() => !IsBusy && !string.IsNullOrWhiteSpace(DraftTitle); + private bool CanDelete() => SelectedPost is not null && SelectedPost.Id != 0 && !IsBusy; + private bool CanManageAcl() => SelectedPost is not null && SelectedPost.Id != 0 && !IsBusy; + /// /// Test-friendly constructor: caller supplies a pre-built /// . Production code uses the /// (Settings, BlogApiClient) overload below. /// - public MainPageViewModel(BlogApiClient blogClient, Settings? settings = null) + public MainPageViewModel(BlogApiClient blogClient, Settings? settings = null, IServiceProvider? services = null) { - SettingsModel = new Settings(); - BlogClient = blogClient ?? throw new ArgumentNullException(nameof(blogClient));; + SettingsModel = new Settings(); + BlogClient = blogClient ?? throw new ArgumentNullException(nameof(blogClient)); ; + Services = services; Init(settings); - } + } partial void OnSearchTextChanged(string value) => ApplyFilter(); @@ -297,10 +346,30 @@ public partial class MainPageViewModel : ViewModelBase }); } + /// + /// DEV ONLY: open the signature capture page. The production + /// entry point is a SignalR push from Yavsc.Org ("devis + /// received, sign here"); this command is the dev-time + /// shortcut to reach the page without that infrastructure. + /// Aligned on the same VM-first navigation pattern as + /// : the VM resolves the target VM + /// through , the ViewLocator picks + /// the matching Control at bind time. No + /// Click handler, no App.ServiceProvider + /// access from the view layer. + /// [RelayCommand] - internal void OpenSettings() + internal async Task OpenSignatureDev() { - CurrentViewModel = SettingsModel; + await ((App)App.Current!).PushPageAsync(SignatureModel).ConfigureAwait(true); + } + + private ViewModelBase GetACLViewModel(BlogPostDto selectedPost) + { + var sp = ResolveServices(); + var aclClient = sp.GetRequiredService(); + var circleClient = sp.GetRequiredService(); + return new PostAclDialogViewModel(selectedPost, aclClient, circleClient); } private async Task RefreshPostsAsync() @@ -363,42 +432,23 @@ public partial class MainPageViewModel : ViewModelBase DeleteCommand.NotifyCanExecuteChanged(); } - /// Save is enabled as soon as the user has typed - /// a non-whitespace title in the editor, regardless of - /// whether a post is selected. The "no selection" case is - /// the create-new-post path; the "with selection" case is - /// the update path. Both read from the editor buffer. - /// Previously this also required SelectedPost is not null - /// — which contradicted the create-new-post intent and - /// forced the buggy "draft with empty title" branch. - private bool CanSave() => !IsBusy && !string.IsNullOrWhiteSpace(DraftTitle); - private bool CanDelete() => SelectedPost is not null && SelectedPost.Id != 0 && !IsBusy; - private bool CanManageAcl() => SelectedPost is not null && SelectedPost.Id != 0 && !IsBusy; - /// - /// Raised when the user asks to open the "manage ACL" dialog for - /// the currently selected post. The MainPage code-behind - /// listens to this event and pushes a PostAclDialog on the - /// navigation stack. The VM itself can't navigate directly - /// because the navigation surface (NavigationPage) lives - /// in the View layer. - /// - public event EventHandler? ManageAclRequested; [RelayCommand(CanExecute = nameof(CanManageAcl))] - public void ManageAcl() + public async Task ManageAcl() { - if (SelectedPost is null) return; - ManageAclRequested?.Invoke(this, SelectedPost); + if (SelectedPost is null) + { + StatusMessage = "Select an existing post before managing ACL."; + return; + } + await ((App)App.Current!).PushPageAsync(GetACLViewModel(SelectedPost)).ConfigureAwait(true); } - /// - /// Raised when the user asks to open the circles page (full - /// CRUD on their own circles). Same routing as - /// . - /// - public event EventHandler? OpenCirclesRequested; - [RelayCommand] - public void OpenCircles() => OpenCirclesRequested?.Invoke(this, EventArgs.Empty); + public async Task OpenCircles() + { + var circlesVm = ResolveServices().GetRequiredService(); + await ((App)App.Current!).PushPageAsync(circlesVm).ConfigureAwait(true); + } } diff --git a/src/PostIt/PostIt/ViewModels/PostAclDialogViewModel.cs b/src/PostIt/PostIt/ViewModels/PostAclDialogViewModel.cs index 68b96b7c..ae48fd8c 100644 --- a/src/PostIt/PostIt/ViewModels/PostAclDialogViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/PostAclDialogViewModel.cs @@ -8,6 +8,7 @@ using CommunityToolkit.Mvvm.Input; using Yavsc.Blogspot; using Yavsc.Api.Client; using Yavsc.Api.Client.Dtos; +using Yavsc.Abstract.Identity.Security; namespace PostIt.ViewModels; @@ -40,7 +41,7 @@ public partial class PostAclDialogViewModel : ViewModelBase public partial ObservableCollection MyCircles { get; set; } = new(); [ObservableProperty] - public partial ObservableCollection AclEntries { get; set; } = new(); + public partial ObservableCollection AclEntries { get; set; } = new(); [ObservableProperty] public partial CircleDto? SelectedCircleToAdd { get; set; } @@ -80,9 +81,6 @@ public partial class PostAclDialogViewModel : ViewModelBase var circles = circlesTask.Result ?? new List(); MyCircles = new ObservableCollection(circles); - var allAcl = aclTask.Result ?? new List(); - AclEntries = new ObservableCollection( - allAcl.Where(a => a.BlogPostId == Post.Id)); StatusMessage = $"{AclEntries.Count} autorisation(s)"; } @@ -108,11 +106,9 @@ public partial class PostAclDialogViewModel : ViewModelBase IsBusy = true; try { - var created = await _aclClient.GrantAsync(new CircleAuthorizationDto + var created = await _aclClient.GrantAsync(new CircleAuthorization { - CircleId = SelectedCircleToAdd.Id, - BlogPostId = Post.Id, - Comment = false, + CircleId = SelectedCircleToAdd.Id }); if (created is not null) { @@ -135,7 +131,7 @@ public partial class PostAclDialogViewModel : ViewModelBase } [RelayCommand] - public async Task RevokeAsync(CircleAuthorizationDto? acl) + public async Task RevokeAsync(CircleAuthorization? acl) { if (acl is null) return; IsBusy = true; diff --git a/src/PostIt/PostIt/ViewModels/SessionStatusViewModel.cs b/src/PostIt/PostIt/ViewModels/SessionStatusViewModel.cs index 55f2cab4..a1ad48ce 100644 --- a/src/PostIt/PostIt/ViewModels/SessionStatusViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/SessionStatusViewModel.cs @@ -2,6 +2,7 @@ using System; using System.Threading.Tasks; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; +using Microsoft.Extensions.DependencyInjection; using PostIt.Services; namespace PostIt.ViewModels; @@ -32,15 +33,6 @@ public partial class SessionStatusViewModel : ViewModelBase /// HomePage so the user lands on the blog editor. public event System.Action? LoginSucceeded; - /// Raised when the user clicks the "Paramètres" button on - /// the session banner. App.axaml.cs listens and pushes - /// SettingsPage (resolved from DI, bound to the canonical - /// Settings singleton) on top of the current navigation - /// stack. Same event pattern as and - /// so the VM stays decoupled from - /// NavigationPage / window lifetime. - public event System.Action? OpenSettingsRequested; - [ObservableProperty] public partial bool IsLoggedIn { get; private set; } @@ -144,9 +136,10 @@ public partial class SessionStatusViewModel : ViewModelBase } [RelayCommand] - public async System.Threading.Tasks.Task OpenSettingsCommand() + internal async Task OpenSettings() { - OpenSettingsRequested?.Invoke(); - await System.Threading.Tasks.Task.CompletedTask; + var app = (App)App.Current!; + await app.PushPageAsync(app.ServiceProvider.GetRequiredService()).ConfigureAwait(true); } + } diff --git a/src/PostIt/PostIt/Views/MainPage.axaml b/src/PostIt/PostIt/Views/MainPage.axaml index 0246857e..e30d1a84 100644 --- a/src/PostIt/PostIt/Views/MainPage.axaml +++ b/src/PostIt/PostIt/Views/MainPage.axaml @@ -33,8 +33,12 @@