diff --git a/src/PostIt.Tests/MainPageButtonsTests.cs b/src/PostIt.Tests/MainPageButtonsTests.cs index 767f9c2e..26636635 100644 --- a/src/PostIt.Tests/MainPageButtonsTests.cs +++ b/src/PostIt.Tests/MainPageButtonsTests.cs @@ -5,7 +5,6 @@ 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; @@ -79,24 +78,7 @@ public class MainPageButtonsTests { 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()); + var vm = new MainPageViewModel(blog); if (selectedPost is not null) vm.SelectedPost = selectedPost; return vm; } @@ -116,13 +98,6 @@ public class MainPageButtonsTests { 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); @@ -145,11 +120,8 @@ public class MainPageButtonsTests 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(); - } + button.Focus(); + window.KeyPressQwerty(PhysicalKey.Enter, RawInputModifiers.None); return stackBefore; } @@ -211,20 +183,16 @@ public class MainPageButtonsTests [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. + // Arrange: the "[DEV] Signature" button uses XAML's + // Click="OpenSignatureDev" attribute, so we don't bind + // a Command here — we drive the click directly. The + // handler resolves App.ServiceProvider, which is null + // in a unit test, and early-returns; that is the + // failure mode the test pins. 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); 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 ef35c875..5c9c7567 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,11 @@ 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 serviceProvider = BuildServices(); + AttachServiceProvider(serviceProvider); + var settings = serviceProvider.GetRequiredService(); + var sessionStatus = serviceProvider.GetRequiredService(); + var api = serviceProvider.GetRequiredService(); DataTemplates.Clear(); DataTemplates.Add(new ViewLocator(ServiceProvider)); @@ -87,7 +86,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 +95,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 +107,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 +120,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) { @@ -215,17 +246,6 @@ public partial class App : Application 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 = @@ -253,18 +273,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() @@ -299,46 +320,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 ."); - } - - if (view is not Page page) - { - throw new InvalidOperationException( - $"Template for {vm.GetType().Name} returned {view.GetType().Name}, expected a Page."); - } - - 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/ViewModels/MainPageViewModel.cs b/src/PostIt/PostIt/ViewModels/MainPageViewModel.cs index e3a541ea..a5169ae2 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,6 +115,7 @@ 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 @@ -171,11 +135,10 @@ public partial class MainPageViewModel : ViewModelBase /// . 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; Init(settings); } @@ -346,30 +309,15 @@ 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); + CurrentViewModel = SettingsModel; } - private ViewModelBase GetACLViewModel(BlogPostDto selectedPost) + private ViewModelBase? GetACLViewModel(BlogPostDto selectedPost) { - var sp = ResolveServices(); - var aclClient = sp.GetRequiredService(); - var circleClient = sp.GetRequiredService(); - return new PostAclDialogViewModel(selectedPost, aclClient, circleClient); + throw new NotImplementedException(); } private async Task RefreshPostsAsync() @@ -435,16 +383,19 @@ public partial class MainPageViewModel : ViewModelBase [RelayCommand(CanExecute = nameof(CanManageAcl))] - public async Task ManageAcl() + public void ManageAcl() { if (SelectedPost is null) return; - await ((App)App.Current!).PushPageAsync(GetACLViewModel(SelectedPost)).ConfigureAwait(true); + CurrentViewModel = GetACLViewModel(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/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..86bcf7dd 100644 --- a/src/PostIt/PostIt/Views/MainPage.axaml +++ b/src/PostIt/PostIt/Views/MainPage.axaml @@ -59,8 +59,8 @@ MainPage.axaml.cs once the SignalR handler lands. -->