refacto static extension for Service provider
This commit is contained in:
parent
d0186d41b0
commit
e007c7d6eb
13 changed files with 173 additions and 158 deletions
2
.vscode/launch.json
vendored
2
.vscode/launch.json
vendored
|
|
@ -48,7 +48,7 @@
|
||||||
"name": "Test PostIt.Android launch (Xamarin.UITest)",
|
"name": "Test PostIt.Android launch (Xamarin.UITest)",
|
||||||
"type": "coreclr",
|
"type": "coreclr",
|
||||||
"request": "launch",
|
"request": "launch",
|
||||||
"program": "${workspaceFolder}/src/PostIt/PostIt.Tests/bin/Debug/net11.0/PostIt.Tests.dll",
|
"program": "${workspaceFolder}/src/PostIt/PostIt.Tests/bin/Debug/net10.0/PostIt.Tests.dll",
|
||||||
"args": [
|
"args": [
|
||||||
],
|
],
|
||||||
"cwd": "${workspaceFolder}/src/PostIt/PostIt.Tests",
|
"cwd": "${workspaceFolder}/src/PostIt/PostIt.Tests",
|
||||||
|
|
|
||||||
14
Makefile
14
Makefile
|
|
@ -161,9 +161,9 @@ ADB_SERIAL ?= emulator-5554
|
||||||
ANDROID_HOME ?= /opt/android-sdk
|
ANDROID_HOME ?= /opt/android-sdk
|
||||||
POSTIT_RID ?= android-x64
|
POSTIT_RID ?= android-x64
|
||||||
EMU_HEADLESS ?= 0
|
EMU_HEADLESS ?= 0
|
||||||
LOGCAT_LINES ?= 200
|
LOGCAT_LINES ?= 600
|
||||||
LOGCAT_FOLLOW ?= 0
|
LOGCAT_FOLLOW ?= 0
|
||||||
LOGCAT_BOOT_WAIT ?= 30
|
LOGCAT_BOOT_WAIT ?= 20
|
||||||
|
|
||||||
ANDROID_PACKAGE_NAME = fr.pschneider.PostIt
|
ANDROID_PACKAGE_NAME = fr.pschneider.PostIt
|
||||||
POSTIT_ANDROID_CSPROJ := src/PostIt/PostIt.Android/PostIt.Android.csproj
|
POSTIT_ANDROID_CSPROJ := src/PostIt/PostIt.Android/PostIt.Android.csproj
|
||||||
|
|
@ -249,9 +249,9 @@ qemu-logcat:
|
||||||
fi; \
|
fi; \
|
||||||
echo " Following PID $$PID (LOGCAT_FOLLOW=$(LOGCAT_FOLLOW), LOGCAT_LINES=$(LOGCAT_LINES))"; \
|
echo " Following PID $$PID (LOGCAT_FOLLOW=$(LOGCAT_FOLLOW), LOGCAT_LINES=$(LOGCAT_LINES))"; \
|
||||||
if [ "$(LOGCAT_FOLLOW)" = "1" ]; then \
|
if [ "$(LOGCAT_FOLLOW)" = "1" ]; then \
|
||||||
adb -s $(ADB_SERIAL) logcat -v time --pid=$$PID $(ANDROID_PACKAGE_NAME):F; \
|
adb -s $(ADB_SERIAL) logcat -v time --pid=$$PID $(ANDROID_PACKAGE_NAME); \
|
||||||
else \
|
else \
|
||||||
adb -s $(ADB_SERIAL) logcat -d -v time -t $(LOGCAT_LINES) --pid=$$PID $(ANDROID_PACKAGE_NAME):F; \
|
adb -s $(ADB_SERIAL) logcat -d -v time -t $(LOGCAT_LINES) --pid=$$PID $(ANDROID_PACKAGE_NAME); \
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Clear logcat, launch PostIt.Android, then dump everything that was
|
# Clear logcat, launch PostIt.Android, then dump everything that was
|
||||||
|
|
@ -270,14 +270,16 @@ qemu-logcat-boot:
|
||||||
-n $(ANDROID_PACKAGE_NAME)/PostIt.Android.PostItMainActivity
|
-n $(ANDROID_PACKAGE_NAME)/PostIt.Android.PostItMainActivity
|
||||||
@echo " Waiting $(LOGCAT_BOOT_WAIT)s for the app to start rendering..."
|
@echo " Waiting $(LOGCAT_BOOT_WAIT)s for the app to start rendering..."
|
||||||
@sleep $(LOGCAT_BOOT_WAIT)
|
@sleep $(LOGCAT_BOOT_WAIT)
|
||||||
|
|
||||||
@echo " Dumping logcat (PostIt PID + system buffer):"
|
@echo " Dumping logcat (PostIt PID + system buffer):"
|
||||||
@PID=$$(adb -s $(ADB_SERIAL) shell pidof $(ANDROID_PACKAGE_NAME) 2>/dev/null | tr -d '\r\n'); \
|
@PID=$$(adb -s $(ADB_SERIAL) shell pidof $(ANDROID_PACKAGE_NAME) 2>/dev/null | tr -d '\r\n'); \
|
||||||
if [ -n "$$PID" ]; then \
|
if [ -n "$$PID" ]; then \
|
||||||
echo " (PID $$PID at dump time)"; \
|
echo " ✅ (PID $$PID at dump time)"; \
|
||||||
adb -s $(ADB_SERIAL) logcat -d -v time --pid=$$PID; \
|
adb -s $(ADB_SERIAL) logcat -d -v time --pid=$$PID; \
|
||||||
else \
|
else \
|
||||||
echo " (PostIt process not running at dump time — dumping last $(LOGCAT_LINES) lines unfiltered)"; \
|
echo " 👿 (PostIt process not running at dump time — dumping last $(LOGCAT_LINES) lines unfiltered)"; \
|
||||||
adb -s $(ADB_SERIAL) logcat -d -v time -t $(LOGCAT_LINES); \
|
adb -s $(ADB_SERIAL) logcat -d -v time -t $(LOGCAT_LINES); \
|
||||||
|
exit 1; \
|
||||||
fi
|
fi
|
||||||
|
|
||||||
qemu: qemu-run qemu-wait-boot qemu-install
|
qemu: qemu-run qemu-wait-boot qemu-install
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,8 @@
|
||||||
<ApplicationDisplayVersion>1.0</ApplicationDisplayVersion>
|
<ApplicationDisplayVersion>1.0</ApplicationDisplayVersion>
|
||||||
<AndroidPackageFormat>apk</AndroidPackageFormat>
|
<AndroidPackageFormat>apk</AndroidPackageFormat>
|
||||||
<AndroidEnableProfiledAot>false</AndroidEnableProfiledAot>
|
<AndroidEnableProfiledAot>false</AndroidEnableProfiledAot>
|
||||||
|
<AndroidLinkMode>SdkOnly</AndroidLinkMode>
|
||||||
|
<TrimMode>partial</TrimMode>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
using Avalonia;
|
using Avalonia;
|
||||||
using Avalonia.Headless.XUnit;
|
using Avalonia.Headless.XUnit;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using PostIt.Helpers;
|
||||||
using PostIt.Services;
|
using PostIt.Services;
|
||||||
using PostIt.ViewModels;
|
using PostIt.ViewModels;
|
||||||
using PostIt.Views;
|
using PostIt.Views;
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ using System.Text.Json;
|
||||||
using Avalonia;
|
using Avalonia;
|
||||||
using Avalonia.Headless.XUnit;
|
using Avalonia.Headless.XUnit;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using PostIt.Helpers;
|
||||||
using PostIt.Services;
|
using PostIt.Services;
|
||||||
using PostIt.ViewModels;
|
using PostIt.ViewModels;
|
||||||
using PostIt.Views;
|
using PostIt.Views;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net11.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<IsPackable>false</IsPackable>
|
<IsPackable>false</IsPackable>
|
||||||
|
|
@ -16,7 +16,9 @@
|
||||||
<PackageReference Include="Xamarin.UITest" />
|
<PackageReference Include="Xamarin.UITest" />
|
||||||
<PackageReference Include="xunit.v3" />
|
<PackageReference Include="xunit.v3" />
|
||||||
<PackageReference Include="xunit.runner.visualstudio" />
|
<PackageReference Include="xunit.runner.visualstudio" />
|
||||||
|
|
||||||
<PackageReference Include="coverlet.collector" />
|
<PackageReference Include="coverlet.collector" />
|
||||||
|
|
||||||
<PackageReference Include="Avalonia.Headless" />
|
<PackageReference Include="Avalonia.Headless" />
|
||||||
<PackageReference Include="Avalonia.Headless.XUnit" />
|
<PackageReference Include="Avalonia.Headless.XUnit" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
@ -27,7 +29,5 @@
|
||||||
<Using Include="Xunit" />
|
<Using Include="Xunit" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Avalonia.Headless.XUnit" />
|
|
||||||
<PackageReference Include="Xamarin.UITest" />
|
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ using Microsoft.Extensions.DependencyInjection;
|
||||||
using PostIt.Services;
|
using PostIt.Services;
|
||||||
using PostIt.ViewModels;
|
using PostIt.ViewModels;
|
||||||
using PostIt.Views;
|
using PostIt.Views;
|
||||||
using Yavsc.Api.Client;
|
using PostIt.Helpers;
|
||||||
|
|
||||||
namespace PostIt;
|
namespace PostIt;
|
||||||
|
|
||||||
|
|
@ -28,7 +28,7 @@ public partial class App : Application
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public IServiceProvider? ServiceProvider { get; private set; }
|
public IServiceProvider? ServiceProvider { get; private set; }
|
||||||
|
|
||||||
MainWindow window;
|
public MainWindow? Window { get; private set; }
|
||||||
|
|
||||||
public override void Initialize()
|
public override void Initialize()
|
||||||
{
|
{
|
||||||
|
|
@ -42,119 +42,58 @@ public partial class App : Application
|
||||||
{
|
{
|
||||||
if (TryHandOffCustomSchemeUrl()) return;
|
if (TryHandOffCustomSchemeUrl()) return;
|
||||||
|
|
||||||
this.ServiceProvider = BuildServices(new ServiceCollection());
|
this.ServiceProvider = new ServiceCollection().BuildServices();
|
||||||
var settings = ServiceProvider.GetRequiredService<Settings>();
|
var settings = ServiceProvider.GetRequiredService<Settings>();
|
||||||
|
|
||||||
DataTemplates.Clear();
|
|
||||||
DataTemplates.Add(new ViewLocator(ServiceProvider));
|
|
||||||
|
|
||||||
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||||
{
|
{
|
||||||
desktop.MainWindow = CreateMainWindow();
|
desktop.MainWindow = CreateMainWindow();
|
||||||
|
ApplyDarkMode(settings);
|
||||||
}
|
}
|
||||||
else if (ApplicationLifetime is IActivityApplicationLifetime singleViewFactoryApplicationLifetime)
|
else if (ApplicationLifetime is IActivityApplicationLifetime singleViewFactoryApplicationLifetime)
|
||||||
{
|
{
|
||||||
singleViewFactoryApplicationLifetime.MainViewFactory =
|
singleViewFactoryApplicationLifetime.MainViewFactory =
|
||||||
() => CreateMainWindow();
|
() =>
|
||||||
|
{
|
||||||
|
Window = CreateMainWindow();
|
||||||
|
ApplyDarkMode(settings);
|
||||||
|
return Window;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
else if (ApplicationLifetime is ISingleViewApplicationLifetime singleViewPlatform)
|
else if (ApplicationLifetime is ISingleViewApplicationLifetime singleViewPlatform)
|
||||||
{
|
{
|
||||||
singleViewPlatform.MainView = CreateMainWindow();
|
singleViewPlatform.MainView = CreateMainWindow();
|
||||||
|
ApplyDarkMode(settings);
|
||||||
}
|
}
|
||||||
ApplyDarkMode(settings);
|
|
||||||
base.OnFrameworkInitializationCompleted();
|
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()
|
private MainWindow CreateMainWindow()
|
||||||
{
|
{
|
||||||
window = new MainWindow();
|
Window = new MainWindow();
|
||||||
var api = ServiceProvider!.GetRequiredService<YavscApiClient>();
|
var api = ServiceProvider!.GetRequiredService<YavscApiClient>();
|
||||||
window.Opened += async (_, _) => await BootAsync(this.ServiceProvider!, api);
|
Window.Opened += async (_, _) => await BootAsync(this.ServiceProvider!, api);
|
||||||
var sessionStatus = ServiceProvider!.GetRequiredService<SessionStatusViewModel>();
|
var sessionStatus = ServiceProvider!.GetRequiredService<SessionStatusViewModel>();
|
||||||
sessionStatus.LogoutCompleted += () =>
|
sessionStatus.LogoutCompleted += () =>
|
||||||
{
|
{
|
||||||
window.NavRoot.PopToRootAsync();
|
Window.NavRoot.PopToRootAsync();
|
||||||
};
|
};
|
||||||
|
|
||||||
sessionStatus.LoginSucceeded += () =>
|
sessionStatus.LoginSucceeded += () =>
|
||||||
{
|
{
|
||||||
PushMainPageAsync();
|
PushMainPageAsync().Wait();
|
||||||
};
|
};
|
||||||
|
|
||||||
var homeVm = ServiceProvider!.GetRequiredService<HomePageViewModel>();
|
var homeVm = ServiceProvider!.GetRequiredService<HomePageViewModel>();
|
||||||
|
|
||||||
this.PushPageAsync(homeVm).Wait();
|
this.PushPageAsync(homeVm).Wait();
|
||||||
window.SessionBanner.DataContext = sessionStatus;
|
Window.SessionBanner.DataContext = sessionStatus;
|
||||||
return window;
|
return Window;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Test-only hook: bind a concrete <see cref="MainWindow"/> so
|
/// Test-only hook: bind a concrete <see cref="MainWindow"/> so
|
||||||
/// command-driven navigation paths (<see cref="PushPage"/>) can
|
/// command-driven navigation paths (<see cref="PushPage"/>) can
|
||||||
/// push onto a real <see cref="NavigationPage"/> in headless
|
/// push onto a real <see cref="NavigationPage"/> in headless
|
||||||
|
|
@ -162,7 +101,7 @@ public partial class App : Application
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal void AttachMainWindow(MainWindow mainWindow)
|
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)
|
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
|
/// (interactive login from the banner). Pulled out as a helper so
|
||||||
/// the two callers can't drift apart.
|
/// the two callers can't drift apart.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static Task PushMainPageAsync()
|
public static async Task PushMainPageAsync()
|
||||||
{
|
{
|
||||||
var app = (App)Current!;
|
var app = (App)Current!;
|
||||||
var mainVm = app.ServiceProvider!.GetRequiredService<MainViewModel>();
|
var mainVm = app.ServiceProvider!.GetRequiredService<MainViewModel>();
|
||||||
return app.PushPageAsync(mainVm);
|
await app.PushPageAsync(mainVm);
|
||||||
}
|
}
|
||||||
|
|
||||||
private bool TryHandOffCustomSchemeUrl()
|
private bool TryHandOffCustomSchemeUrl()
|
||||||
|
|
@ -238,53 +177,8 @@ public partial class App : Application
|
||||||
return true;
|
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()
|
internal async Task GoBackAsync()
|
||||||
{
|
{
|
||||||
await window.NavRoot.PopAsync();
|
await Window!.NavRoot.PopAsync();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
77
src/PostIt/PostIt/Helpers/ServiceCollectionHelpers.cs
Normal file
77
src/PostIt/PostIt/Helpers/ServiceCollectionHelpers.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
50
src/PostIt/PostIt/Helpers/ViewModelBaseHelpers.cs
Normal file
50
src/PostIt/PostIt/Helpers/ViewModelBaseHelpers.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,7 @@ using Avalonia;
|
||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using CommunityToolkit.Mvvm.Input;
|
using CommunityToolkit.Mvvm.Input;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using PostIt.Helpers;
|
||||||
using PostIt.Services;
|
using PostIt.Services;
|
||||||
using Yavsc.Api.Client;
|
using Yavsc.Api.Client;
|
||||||
using Yavsc.Api.Client.Dtos;
|
using Yavsc.Api.Client.Dtos;
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ using CommunityToolkit.Mvvm.Input;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Yavsc.Blogspot;
|
using Yavsc.Blogspot;
|
||||||
using Yavsc.Api.Client;
|
using Yavsc.Api.Client;
|
||||||
|
using PostIt.Helpers;
|
||||||
|
|
||||||
namespace PostIt.ViewModels;
|
namespace PostIt.ViewModels;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ using System.Threading.Tasks;
|
||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using CommunityToolkit.Mvvm.Input;
|
using CommunityToolkit.Mvvm.Input;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using PostIt.Helpers;
|
||||||
using PostIt.Services;
|
using PostIt.Services;
|
||||||
|
|
||||||
namespace PostIt.ViewModels;
|
namespace PostIt.ViewModels;
|
||||||
|
|
@ -139,7 +140,7 @@ public partial class SessionStatusViewModel : ViewModelBase
|
||||||
internal async Task OpenSettings()
|
internal async Task OpenSettings()
|
||||||
{
|
{
|
||||||
var app = (App)App.Current!;
|
var app = (App)App.Current!;
|
||||||
await app.PushPageAsync(app.ServiceProvider.GetRequiredService<Settings>()).ConfigureAwait(true);
|
await app.PushPageAsync(app.ServiceProvider!.GetRequiredService<Settings>()).ConfigureAwait(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -47,22 +47,7 @@ public partial class Settings : ViewModelBase
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static Settings? s_current;
|
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]
|
[ObservableProperty]
|
||||||
public partial AuthenticationSettings Authentication { get; set; } = new();
|
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
|
/// setters above all funnel through here, and we flip
|
||||||
/// <see cref="IsDirty"/> in lock-step. Sub-property mutations
|
/// <see cref="IsDirty"/> in lock-step. Sub-property mutations
|
||||||
/// (e.g. <c>Authentication.Authority</c>) are caught by the
|
/// (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
|
/// below. <see cref="ApplyJson"/> disables the flag during bulk
|
||||||
/// hydration so the disk load itself does not count as a user
|
/// hydration so the disk load itself does not count as a user
|
||||||
/// edit.
|
/// edit.
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue