refacto static extension for Service provider

This commit is contained in:
Paul Schneider 2026-08-23 23:07:40 +01:00
commit e007c7d6eb
Signed by: notazof
GPG key ID: 1DD5D838E5343B06
13 changed files with 173 additions and 158 deletions

View file

@ -8,7 +8,9 @@
<ApplicationVersion>1</ApplicationVersion>
<ApplicationDisplayVersion>1.0</ApplicationDisplayVersion>
<AndroidPackageFormat>apk</AndroidPackageFormat>
<AndroidEnableProfiledAot>false</AndroidEnableProfiledAot>
<AndroidEnableProfiledAot>false</AndroidEnableProfiledAot>
<AndroidLinkMode>SdkOnly</AndroidLinkMode>
<TrimMode>partial</TrimMode>
</PropertyGroup>
<ItemGroup>

View file

@ -2,6 +2,7 @@
using Avalonia;
using Avalonia.Headless.XUnit;
using Microsoft.Extensions.DependencyInjection;
using PostIt.Helpers;
using PostIt.Services;
using PostIt.ViewModels;
using PostIt.Views;

View file

@ -4,6 +4,7 @@ using System.Text.Json;
using Avalonia;
using Avalonia.Headless.XUnit;
using Microsoft.Extensions.DependencyInjection;
using PostIt.Helpers;
using PostIt.Services;
using PostIt.ViewModels;
using PostIt.Views;

View file

@ -1,6 +1,6 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net11.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
@ -16,7 +16,9 @@
<PackageReference Include="Xamarin.UITest" />
<PackageReference Include="xunit.v3" />
<PackageReference Include="xunit.runner.visualstudio" />
<PackageReference Include="coverlet.collector" />
<PackageReference Include="Avalonia.Headless" />
<PackageReference Include="Avalonia.Headless.XUnit" />
</ItemGroup>
@ -27,7 +29,5 @@
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Avalonia.Headless.XUnit" />
<PackageReference Include="Xamarin.UITest" />
</ItemGroup>
</Project>

View file

@ -10,7 +10,7 @@ using Microsoft.Extensions.DependencyInjection;
using PostIt.Services;
using PostIt.ViewModels;
using PostIt.Views;
using Yavsc.Api.Client;
using PostIt.Helpers;
namespace PostIt;
@ -28,7 +28,7 @@ public partial class App : Application
/// </summary>
public IServiceProvider? ServiceProvider { get; private set; }
MainWindow window;
public MainWindow? Window { get; private set; }
public override void Initialize()
{
@ -42,119 +42,58 @@ public partial class App : Application
{
if (TryHandOffCustomSchemeUrl()) return;
this.ServiceProvider = BuildServices(new ServiceCollection());
this.ServiceProvider = new ServiceCollection().BuildServices();
var settings = ServiceProvider.GetRequiredService<Settings>();
DataTemplates.Clear();
DataTemplates.Add(new ViewLocator(ServiceProvider));
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
desktop.MainWindow = CreateMainWindow();
ApplyDarkMode(settings);
}
else if (ApplicationLifetime is IActivityApplicationLifetime singleViewFactoryApplicationLifetime)
{
singleViewFactoryApplicationLifetime.MainViewFactory =
() => CreateMainWindow();
() =>
{
Window = CreateMainWindow();
ApplyDarkMode(settings);
return Window;
};
}
else if (ApplicationLifetime is ISingleViewApplicationLifetime singleViewPlatform)
{
singleViewPlatform.MainView = CreateMainWindow();
ApplyDarkMode(settings);
}
ApplyDarkMode(settings);
base.OnFrameworkInitializationCompleted();
}
internal static IServiceProvider BuildServices(ServiceCollection services)
{
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);
// Vues
services.AddTransient<MainPage>();
// 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<SettingsPage>();
services.AddTransient<HomePage>();
services.AddTransient<SignaturePage>();
services.AddTransient<CirclesPage>();
// 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<PostAclDialog>();
services.AddTransient<AddCircleMemberDialog>();
// ViewModels
services.AddSingleton(settings);
services.AddSingleton<YavscApiClient>(api);
services.AddSingleton(client);
services.AddSingleton(circleClient);
services.AddSingleton(blogAclClient);
services.AddSingleton(userSearchClient);
services.AddSingleton<IContactService>(contactService);
services.AddSingleton<IUserDirectory>(userDirectory);
services.AddTransient<MainViewModel>();
services.AddTransient<HomePageViewModel>();
services.AddTransient<SignaturePageViewModel>();
services.AddTransient<CirclesPageViewModel>();
// 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<SessionStatusBanner>();
return services.BuildServiceProvider();
}
private MainWindow CreateMainWindow()
{
window = new MainWindow();
Window = new MainWindow();
var api = ServiceProvider!.GetRequiredService<YavscApiClient>();
window.Opened += async (_, _) => await BootAsync(this.ServiceProvider!, api);
var sessionStatus = ServiceProvider!.GetRequiredService<SessionStatusViewModel>();
Window.Opened += async (_, _) => await BootAsync(this.ServiceProvider!, api);
var sessionStatus = ServiceProvider!.GetRequiredService<SessionStatusViewModel>();
sessionStatus.LogoutCompleted += () =>
{
window.NavRoot.PopToRootAsync();
Window.NavRoot.PopToRootAsync();
};
sessionStatus.LoginSucceeded += () =>
{
PushMainPageAsync();
PushMainPageAsync().Wait();
};
var homeVm = ServiceProvider!.GetRequiredService<HomePageViewModel>();
this.PushPageAsync(homeVm).Wait();
window.SessionBanner.DataContext = sessionStatus;
return window;
Window.SessionBanner.DataContext = sessionStatus;
return Window;
}
/// <summary>
/// <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
@ -162,7 +101,7 @@ public partial class App : Application
/// </summary>
internal void AttachMainWindow(MainWindow mainWindow)
{
window = mainWindow ?? throw new ArgumentNullException(nameof(mainWindow));
Window = mainWindow ?? throw new ArgumentNullException(nameof(mainWindow));
}
private static void ApplyDarkMode(Settings settings)
@ -199,11 +138,11 @@ public partial class App : Application
/// (interactive login from the banner). Pulled out as a helper so
/// the two callers can't drift apart.
/// </summary>
public static Task PushMainPageAsync()
public static async Task PushMainPageAsync()
{
var app = (App)Current!;
var mainVm = app.ServiceProvider!.GetRequiredService<MainViewModel>();
return app.PushPageAsync(mainVm);
await app.PushPageAsync(mainVm);
}
private bool TryHandOffCustomSchemeUrl()
@ -238,53 +177,8 @@ public partial class App : Application
return true;
}
internal void PushPage(ViewModelBase vm)
{
_ = PushPageAsync(vm);
}
internal async 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 <null>.");
}
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;
}
await window.NavRoot.PushAsync(page);
}
internal async Task GoBackAsync()
{
await window.NavRoot.PopAsync();
await Window!.NavRoot.PopAsync();
}
}

View file

@ -0,0 +1,77 @@
using System;
using Microsoft.Extensions.DependencyInjection;
using PostIt.Services;
using PostIt.ViewModels;
using PostIt.Views;
using Yavsc.Api.Client;
namespace PostIt.Helpers;
public static class ServiceCollectionHelpers
{
public static IServiceProvider BuildServices(this ServiceCollection services)
{
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);
// Vues
services.AddTransient<MainPage>();
// 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<SettingsPage>();
services.AddTransient<HomePage>();
services.AddTransient<SignaturePage>();
services.AddTransient<CirclesPage>();
// 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<PostAclDialog>();
services.AddTransient<AddCircleMemberDialog>();
// ViewModels
services.AddSingleton(settings);
services.AddSingleton<YavscApiClient>(api);
services.AddSingleton(client);
services.AddSingleton(circleClient);
services.AddSingleton(blogAclClient);
services.AddSingleton(userSearchClient);
services.AddSingleton<IContactService>(contactService);
services.AddSingleton<IUserDirectory>(userDirectory);
services.AddTransient<MainViewModel>();
services.AddTransient<HomePageViewModel>();
services.AddTransient<SignaturePageViewModel>();
services.AddTransient<CirclesPageViewModel>();
// 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<SessionStatusBanner>();
return services.BuildServiceProvider();
}
}

View file

@ -0,0 +1,50 @@
using System;
using System.Linq;
using System.Threading.Tasks;
using Avalonia.Controls;
using PostIt.ViewModels;
namespace PostIt.Helpers;
public static class ViewModelBaseHelpers
{
public static async Task PushPageAsync(this App app, ViewModelBase vm)
{
if (app.Window is null)
{
throw new InvalidOperationException("MainWindow is not initialized yet.");
}
var template = app.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 <null>.");
}
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 = app.Window.NavRoot.NavigationStack;
if (stack.Count > 0 && ReferenceEquals(stack[stack.Count - 1], page))
{
return;
}
await app.Window.NavRoot.PushAsync(page);
}
}

View file

@ -5,6 +5,7 @@ using Avalonia;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Microsoft.Extensions.DependencyInjection;
using PostIt.Helpers;
using PostIt.Services;
using Yavsc.Api.Client;
using Yavsc.Api.Client.Dtos;

View file

@ -8,6 +8,7 @@ using CommunityToolkit.Mvvm.Input;
using Microsoft.Extensions.DependencyInjection;
using Yavsc.Blogspot;
using Yavsc.Api.Client;
using PostIt.Helpers;
namespace PostIt.ViewModels;

View file

@ -3,6 +3,7 @@ using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Microsoft.Extensions.DependencyInjection;
using PostIt.Helpers;
using PostIt.Services;
namespace PostIt.ViewModels;
@ -139,7 +140,7 @@ public partial class SessionStatusViewModel : ViewModelBase
internal async Task OpenSettings()
{
var app = (App)App.Current!;
await app.PushPageAsync(app.ServiceProvider.GetRequiredService<Settings>()).ConfigureAwait(true);
await app.PushPageAsync(app.ServiceProvider!.GetRequiredService<Settings>()).ConfigureAwait(true);
}
}

View file

@ -47,22 +47,7 @@ public partial class Settings : ViewModelBase
/// </summary>
private static Settings? s_current;
/// <summary>
/// Wire the canonical Settings instance to a DI container. Called
/// exactly once from <c>App.axaml.cs</c> after the singleton has
/// been registered. Subsequent calls are no-ops: the DI container
/// owns the instance lifetime and we don't want a stray
/// <c>BindToServiceProvider</c> in a test fixture to silently
/// rebind the production instance.
/// </summary>
public static void BindToServiceProvider(IServiceProvider services)
{
if (services is null) throw new ArgumentNullException(nameof(services));
Interlocked.CompareExchange(ref s_current,
services.GetService<Settings>() ?? throw new InvalidOperationException(
"Settings is not registered in the DI container."),
null);
}
[ObservableProperty]
public partial AuthenticationSettings Authentication { get; set; } = new();
@ -81,7 +66,7 @@ public partial class Settings : ViewModelBase
/// setters above all funnel through here, and we flip
/// <see cref="IsDirty"/> in lock-step. Sub-property mutations
/// (e.g. <c>Authentication.Authority</c>) are caught by the
/// subscription wired up in <see cref="OnAuthenticationChanged"/>
/// subscription wired up in
/// below. <see cref="ApplyJson"/> disables the flag during bulk
/// hydration so the disk load itself does not count as a user
/// edit.