PostIt: homogenize VM-first navigation flows

This commit is contained in:
Paul Schneider 2026-08-19 17:06:46 +01:00
commit 0065de7000
Signed by: notazof
GPG key ID: 1DD5D838E5343B06
4 changed files with 77 additions and 87 deletions

View file

@ -87,8 +87,7 @@ public partial class App : Application
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
var homePage = ServiceProvider.GetRequiredService<HomePage>();
homePage.DataContext = ServiceProvider.GetRequiredService<HomePageViewModel>();
var homeVm = ServiceProvider.GetRequiredService<HomePageViewModel>();
window = new MainWindow();
window.SessionBanner.DataContext = sessionStatus;
@ -96,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
@ -108,8 +106,6 @@ public partial class App : Application
{
var w = (MainWindow)((IClassicDesktopStyleApplicationLifetime)ApplicationLifetime!).MainWindow!;
var nav = w.NavRoot;
var hp = ServiceProvider.GetRequiredService<HomePage>();
hp.DataContext = ServiceProvider.GetRequiredService<HomePageViewModel>();
_ = nav.PopToRootAsync();
};
@ -121,34 +117,6 @@ 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<SettingsPage>().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<SettingsPage>();
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(this.ServiceProvider, api);
}
else if (ApplicationLifetime is ISingleViewApplicationLifetime singleView)
@ -247,6 +215,17 @@ public partial class App : Application
Settings.BindToServiceProvider(sp);
}
/// <summary>
/// Test-only hook: bind a concrete <see cref="MainWindow"/> so
/// command-driven navigation paths (<see cref="PushPage"/>) can
/// push onto a real <see cref="NavigationPage"/> in headless
/// fixtures that do not run the full desktop lifetime bootstrap.
/// </summary>
internal void AttachMainWindow(MainWindow mainWindow)
{
window = mainWindow ?? throw new ArgumentNullException(nameof(mainWindow));
}
private static void ApplyDarkMode(Settings settings)
{
Application.Current!.RequestedThemeVariant =
@ -274,19 +253,18 @@ public partial class App : Application
}
/// <summary>
/// Resolve a fresh <c>MainPage</c> + VM from DI and push it on top
/// Resolve a fresh <c>MainPageViewModel</c> from DI and push its
/// mapped page (via <see cref="ViewLocator"/>) on top
/// of the current navigation stack. Used both by <see cref="BootAsync"/>
/// (silent refresh at boot) and by <c>SessionStatusViewModel.LoginSucceeded</c>
/// (interactive login from the banner). Pulled out as a helper so
/// the two callers can't drift apart.
/// </summary>
public static async Task PushMainPageAsync()
public static Task PushMainPageAsync()
{
var app = (App)Current;
var mainVm = app.ServiceProvider.GetRequiredService<MainPageViewModel>();
var mainPage = app.ServiceProvider.GetRequiredService<MainPage>();
mainPage.DataContext = mainVm;
await app.window.FindControl<NavigationPage>("NavRoot").PushAsync(mainPage).ConfigureAwait(true);
return app.PushPageAsync(mainVm);
}
private bool TryHandOffCustomSchemeUrl()
@ -322,6 +300,11 @@ public partial class App : Application
}
internal void PushPage(ViewModelBase vm)
{
_ = PushPageAsync(vm);
}
internal Task PushPageAsync(ViewModelBase vm)
{
if (window is null)
{
@ -353,9 +336,9 @@ public partial class App : Application
var stack = window.NavRoot.NavigationStack;
if (stack.Count > 0 && ReferenceEquals(stack[stack.Count - 1], page))
{
return;
return Task.CompletedTask;
}
_ = window.NavRoot.PushAsync(page);
return window.NavRoot.PushAsync(page);
}
}

View file

@ -109,18 +109,18 @@ public partial class MainPageViewModel : ViewModelBase
private SignaturePageViewModel ResolveSignatureModel()
{
var sp = Services ?? (Application.Current as App)?.ServiceProvider;
if (sp is null)
{
throw new InvalidOperationException(
"Cannot resolve SignaturePageViewModel: no IServiceProvider " +
"was injected and App.ServiceProvider is null. This is a " +
"test-time wiring bug — the test must construct an " +
"IServiceProvider that registers SignaturePageViewModel.");
}
var sp = ResolveServices();
return sp.GetRequiredService<SignaturePageViewModel>();
}
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()
{
@ -351,7 +351,7 @@ public partial class MainPageViewModel : ViewModelBase
/// 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 nav-via-CurrentViewModel pattern as
/// Aligned on the same VM-first navigation pattern as
/// <see cref="OpenSettings"/>: the VM resolves the target VM
/// through <see cref="Services"/>, the <c>ViewLocator</c> picks
/// the matching <c>Control</c> at bind time. No
@ -359,14 +359,17 @@ public partial class MainPageViewModel : ViewModelBase
/// access from the view layer.
/// </summary>
[RelayCommand]
internal void OpenSignatureDev()
internal async Task OpenSignatureDev()
{
((App)App.Current).PushPage(SignatureModel);
await ((App)App.Current!).PushPageAsync(SignatureModel).ConfigureAwait(true);
}
private ViewModelBase? GetACLViewModel(BlogPostDto selectedPost)
private ViewModelBase GetACLViewModel(BlogPostDto selectedPost)
{
throw new NotImplementedException();
var sp = ResolveServices();
var aclClient = sp.GetRequiredService<BlogAclApiClient>();
var circleClient = sp.GetRequiredService<CircleApiClient>();
return new PostAclDialogViewModel(selectedPost, aclClient, circleClient);
}
private async Task RefreshPostsAsync()
@ -432,19 +435,16 @@ public partial class MainPageViewModel : ViewModelBase
[RelayCommand(CanExecute = nameof(CanManageAcl))]
public void ManageAcl()
public async Task ManageAcl()
{
if (SelectedPost is null) return;
((App)App.Current).PushPage(GetACLViewModel(SelectedPost));
await ((App)App.Current!).PushPageAsync(GetACLViewModel(SelectedPost)).ConfigureAwait(true);
}
/// <summary>
/// Raised when the user asks to open the circles page (full
/// CRUD on their own circles). Same routing as
/// <see cref="ManageAclRequested"/>.
/// </summary>
public event EventHandler? OpenCirclesRequested;
[RelayCommand]
public void OpenCircles() => OpenCirclesRequested?.Invoke(this, EventArgs.Empty);
public async Task OpenCircles()
{
var circlesVm = ResolveServices().GetRequiredService<CirclesPageViewModel>();
await ((App)App.Current!).PushPageAsync(circlesVm).ConfigureAwait(true);
}
}

View file

@ -33,15 +33,6 @@ public partial class SessionStatusViewModel : ViewModelBase
/// <c>HomePage</c> so the user lands on the blog editor.</summary>
public event System.Action? LoginSucceeded;
/// <summary>Raised when the user clicks the "Paramètres" button on
/// the session banner. <c>App.axaml.cs</c> listens and pushes
/// <c>SettingsPage</c> (resolved from DI, bound to the canonical
/// <c>Settings</c> singleton) on top of the current navigation
/// stack. Same event pattern as <see cref="LogoutCompleted"/> and
/// <see cref="LoginSucceeded"/> so the VM stays decoupled from
/// <c>NavigationPage</c> / window lifetime.</summary>
public event System.Action? OpenSettingsRequested;
[ObservableProperty]
public partial bool IsLoggedIn { get; private set; }
@ -145,10 +136,10 @@ public partial class SessionStatusViewModel : ViewModelBase
}
[RelayCommand]
internal void OpenSettings()
internal async Task OpenSettings()
{
var app = (App)App.Current;
app.PushPage(app.ServiceProvider.GetRequiredService<Settings>());
var app = (App)App.Current!;
await app.PushPageAsync(app.ServiceProvider.GetRequiredService<Settings>()).ConfigureAwait(true);
}
}