diff --git a/.vscode/mcp.json b/.vscode/mcp.json new file mode 100644 index 00000000..7ca6ed4b --- /dev/null +++ b/.vscode/mcp.json @@ -0,0 +1,11 @@ +{ + "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 7a045bbd..4730e18c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -49,57 +49,6 @@ 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 70f3f2fd..77d0a252 100644 --- a/doc/architecture/postit.md +++ b/doc/architecture/postit.md @@ -129,51 +129,30 @@ 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 deux -mécanismes distincts : +posé sur `MainWindow.axaml`. La pile est gérée par les +événements du `SessionStatusViewModel` : -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. +| É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. La garde est implémentée -dans `App.PushPageAsync` (et consommée par tous les chemins -de nav utilisateur) : +taper **Retour** N fois pour sortir. Le handler +`OpenSettingsRequested` est gardé pour bloquer ce cas : ```csharp -var stack = window.NavRoot.NavigationStack; -if (stack.Count > 0 && ReferenceEquals(stack[stack.Count - 1], page)) +var settingsPage = provider.GetRequiredService(); +var stack = w.NavRoot.NavigationStack; +if (stack.Count > 0 && ReferenceEquals(stack[stack.Count - 1], settingsPage)) { - return Task.CompletedTask; // déjà au sommet, no-op silencieux + return; // déjà au sommet, no-op silencieux } -return window.NavRoot.PushAsync(page); +_ = w.NavRoot.PushAsync(settingsPage); ``` La comparaison est par référence, pas par type : on ne veut @@ -199,11 +178,9 @@ 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 é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`. + les trois événements qui pilotent la navigation + (`LoginSucceeded`, `LogoutCompleted`, + `OpenSettingsRequested`). - `MainPageViewModel` / `HomePageViewModel` / `SignaturePageViewModel` sont `Transient` — une nouvelle @@ -256,14 +233,10 @@ 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") : 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`. + "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. diff --git a/src/PostIt.Tests/MainPageButtonsTests.cs b/src/PostIt.Tests/MainPageButtonsTests.cs deleted file mode 100644 index 767f9c2e..00000000 --- a/src/PostIt.Tests/MainPageButtonsTests.cs +++ /dev/null @@ -1,239 +0,0 @@ -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 88e06195..62b3a343 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 b34b88d4..b08143b4 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/App.axaml b/src/PostIt/PostIt/App.axaml index 92497b74..b179024a 100644 --- a/src/PostIt/PostIt/App.axaml +++ b/src/PostIt/PostIt/App.axaml @@ -2,8 +2,10 @@ 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 2065bd53..6f93edf9 100644 --- a/src/PostIt/PostIt/App.axaml.cs +++ b/src/PostIt/PostIt/App.axaml.cs @@ -1,5 +1,4 @@ using System; -using System.Linq; using System.Threading.Tasks; using Microsoft.Extensions.DependencyInjection; using Avalonia; @@ -49,11 +48,71 @@ public partial class App : Application // build is ever reconfigured to skip the early check. if (TryHandOffCustomSchemeUrl()) return; - this.ServiceProvider = BuildServices(); - AttachServiceProvider(ServiceProvider); - var settings = ServiceProvider.GetRequiredService(); - var sessionStatus = ServiceProvider.GetRequiredService(); - var api = ServiceProvider.GetRequiredService(); + 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); DataTemplates.Clear(); DataTemplates.Add(new ViewLocator(ServiceProvider)); @@ -87,7 +146,8 @@ public partial class App : Application if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) { - var homeVm = ServiceProvider.GetRequiredService(); + var homePage = ServiceProvider.GetRequiredService(); + homePage.DataContext = ServiceProvider.GetRequiredService(); window = new MainWindow(); window.SessionBanner.DataContext = sessionStatus; @@ -95,8 +155,9 @@ 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; - _ = PushPageAsync(homeVm); + _ = window.NavRoot.PushAsync(homePage); // When the user logs out, route back to HomePage. We // ReplaceAsync the current top so we don't grow the stack @@ -106,6 +167,8 @@ 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(); }; @@ -117,7 +180,35 @@ public partial class App : Application _ = PushMainPageAsync(); }; - window.Opened += async (_, _) => await BootAsync(this.ServiceProvider, api); + // 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); } else if (ApplicationLifetime is ISingleViewApplicationLifetime singleView) { @@ -128,112 +219,6 @@ 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 = @@ -261,18 +246,19 @@ public partial class App : Application } /// - /// Resolve a fresh MainPageViewModel from DI and push its - /// mapped page (via ) on top + /// Resolve a fresh MainPage + VM from DI and push it 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 Task PushMainPageAsync() + public static async Task PushMainPageAsync() { var app = (App)Current; var mainVm = app.ServiceProvider.GetRequiredService(); - return app.PushPageAsync(mainVm); + var mainPage = app.ServiceProvider.GetRequiredService(); + mainPage.DataContext = mainVm; + await app.window.FindControl("NavRoot").PushAsync(mainPage).ConfigureAwait(true); } private bool TryHandOffCustomSchemeUrl() @@ -307,48 +293,4 @@ 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.Android/Services/ContactService.Mobile.cs b/src/PostIt/PostIt/Services/ContactService.Mobile.cs similarity index 94% rename from src/PostIt/PostIt.Android/Services/ContactService.Mobile.cs rename to src/PostIt/PostIt/Services/ContactService.Mobile.cs index c869256d..8dbd134d 100644 --- a/src/PostIt/PostIt.Android/Services/ContactService.Mobile.cs +++ b/src/PostIt/PostIt/Services/ContactService.Mobile.cs @@ -6,10 +6,8 @@ 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.Android.Services; +namespace PostIt.Services; /// /// Mobile implementation backed by MAUI Essentials @@ -51,7 +49,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); @@ -69,7 +67,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/ViewLocator.cs b/src/PostIt/PostIt/ViewLocator.cs index fd92c802..e725d0d9 100644 --- a/src/PostIt/PostIt/ViewLocator.cs +++ b/src/PostIt/PostIt/ViewLocator.cs @@ -21,18 +21,6 @@ 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 { @@ -40,11 +28,8 @@ 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 e8256df6..d1d16306 100644 --- a/src/PostIt/PostIt/ViewModels/MainPageViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/MainPageViewModel.cs @@ -2,14 +2,11 @@ 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; @@ -50,6 +47,9 @@ 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,46 +81,9 @@ 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() { @@ -152,33 +115,21 @@ 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, IServiceProvider? services = null) + public MainPageViewModel(BlogApiClient blogClient, Settings? settings = null) { - SettingsModel = new Settings(); - BlogClient = blogClient ?? throw new ArgumentNullException(nameof(blogClient)); ; - Services = services; + SettingsModel = new Settings(); + BlogClient = blogClient ?? throw new ArgumentNullException(nameof(blogClient));; Init(settings); - } + } partial void OnSearchTextChanged(string value) => ApplyFilter(); @@ -346,30 +297,10 @@ 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 async Task OpenSignatureDev() + internal void OpenSettings() { - 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); + CurrentViewModel = SettingsModel; } private async Task RefreshPostsAsync() @@ -432,23 +363,42 @@ 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 async Task ManageAcl() + public void ManageAcl() { - if (SelectedPost is null) - { - StatusMessage = "Select an existing post before managing ACL."; - return; - } - await ((App)App.Current!).PushPageAsync(GetACLViewModel(SelectedPost)).ConfigureAwait(true); + if (SelectedPost is null) return; + ManageAclRequested?.Invoke(this, SelectedPost); } + /// + /// 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 async Task OpenCircles() - { - var circlesVm = ResolveServices().GetRequiredService(); - await ((App)App.Current!).PushPageAsync(circlesVm).ConfigureAwait(true); - } + public void OpenCircles() => OpenCirclesRequested?.Invoke(this, EventArgs.Empty); } diff --git a/src/PostIt/PostIt/ViewModels/PostAclDialogViewModel.cs b/src/PostIt/PostIt/ViewModels/PostAclDialogViewModel.cs index ae48fd8c..68b96b7c 100644 --- a/src/PostIt/PostIt/ViewModels/PostAclDialogViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/PostAclDialogViewModel.cs @@ -8,7 +8,6 @@ using CommunityToolkit.Mvvm.Input; using Yavsc.Blogspot; using Yavsc.Api.Client; using Yavsc.Api.Client.Dtos; -using Yavsc.Abstract.Identity.Security; namespace PostIt.ViewModels; @@ -41,7 +40,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; } @@ -81,6 +80,9 @@ 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)"; } @@ -106,9 +108,11 @@ public partial class PostAclDialogViewModel : ViewModelBase IsBusy = true; try { - var created = await _aclClient.GrantAsync(new CircleAuthorization + var created = await _aclClient.GrantAsync(new CircleAuthorizationDto { - CircleId = SelectedCircleToAdd.Id + CircleId = SelectedCircleToAdd.Id, + BlogPostId = Post.Id, + Comment = false, }); if (created is not null) { @@ -131,7 +135,7 @@ public partial class PostAclDialogViewModel : ViewModelBase } [RelayCommand] - public async Task RevokeAsync(CircleAuthorization? acl) + public async Task RevokeAsync(CircleAuthorizationDto? 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 a1ad48ce..55f2cab4 100644 --- a/src/PostIt/PostIt/ViewModels/SessionStatusViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/SessionStatusViewModel.cs @@ -2,7 +2,6 @@ using System; using System.Threading.Tasks; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; -using Microsoft.Extensions.DependencyInjection; using PostIt.Services; namespace PostIt.ViewModels; @@ -33,6 +32,15 @@ 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; } @@ -136,10 +144,9 @@ public partial class SessionStatusViewModel : ViewModelBase } [RelayCommand] - internal async Task OpenSettings() + public async System.Threading.Tasks.Task OpenSettingsCommand() { - var app = (App)App.Current!; - await app.PushPageAsync(app.ServiceProvider.GetRequiredService()).ConfigureAwait(true); + OpenSettingsRequested?.Invoke(); + await System.Threading.Tasks.Task.CompletedTask; } - } diff --git a/src/PostIt/PostIt/Views/MainPage.axaml b/src/PostIt/PostIt/Views/MainPage.axaml index e30d1a84..0246857e 100644 --- a/src/PostIt/PostIt/Views/MainPage.axaml +++ b/src/PostIt/PostIt/Views/MainPage.axaml @@ -33,12 +33,8 @@