test are green !?!

This commit is contained in:
Paul Schneider 2026-08-23 21:28:26 +01:00
commit d0186d41b0
Signed by: notazof
GPG key ID: 1DD5D838E5343B06
25 changed files with 145 additions and 230 deletions

View file

@ -165,9 +165,10 @@ LOGCAT_LINES ?= 200
LOGCAT_FOLLOW ?= 0 LOGCAT_FOLLOW ?= 0
LOGCAT_BOOT_WAIT ?= 30 LOGCAT_BOOT_WAIT ?= 30
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
POSTIT_APK_DIR := src/PostIt/PostIt.Android/bin/$(CONFIG)/net10.0-android/$(POSTIT_RID) POSTIT_APK_DIR := src/PostIt/PostIt.Android/bin/$(CONFIG)/net10.0-android/$(POSTIT_RID)
POSTIT_APK := $(POSTIT_APK_DIR)/fr.pschneider.PostIt-Signed.apk POSTIT_APK := $(POSTIT_APK_DIR)/$(ANDROID_PACKAGE_NAME)-Signed.apk
qemu-run: qemu-run:
@echo " Starting AVD $(AVD_NAME) on $(ADB_SERIAL)..." @echo " Starting AVD $(AVD_NAME) on $(ADB_SERIAL)..."
@ -227,12 +228,12 @@ qemu-install: qemu-build
adb -s $(ADB_SERIAL) install -r "$(POSTIT_APK)" -r adb -s $(ADB_SERIAL) install -r "$(POSTIT_APK)" -r
qemu-uninstall: qemu-uninstall:
adb -s $(ADB_SERIAL) uninstall fr.pschneider.PostIt adb -s $(ADB_SERIAL) uninstall $(ANDROID_PACKAGE_NAME)
# Dump recent logcat output for the running PostIt.Android process. # Dump recent logcat output for the running PostIt.Android process.
# By default, prints the last $(LOGCAT_LINES) lines (one-shot, with # By default, prints the last $(LOGCAT_LINES) lines (one-shot, with
# `-d`). Set LOGCAT_FOLLOW=1 to follow the stream live instead. # `-d`). Set LOGCAT_FOLLOW=1 to follow the stream live instead.
# Filtering is by PID (pidof fr.pschneider.PostIt), not by tag, # Filtering is by PID (pidof $(ANDROID_PACKAGE_NAME)), not by tag,
# because Mono/Xamarin can emit logs under several tags # because Mono/Xamarin can emit logs under several tags
# (mono, PostIt.Android, Avalonia.Android) and tag-based filtering # (mono, PostIt.Android, Avalonia.Android) and tag-based filtering
# would miss the ones not matching. PID-based filtering is exact. # would miss the ones not matching. PID-based filtering is exact.
@ -240,17 +241,17 @@ qemu-uninstall:
# silently with no output; that is the expected behaviour for # silently with no output; that is the expected behaviour for
# "no logs yet". # "no logs yet".
qemu-logcat: qemu-logcat:
@PID=$$(adb -s $(ADB_SERIAL) shell pidof fr.pschneider.PostIt 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 [ -z "$$PID" ]; then \ if [ -z "$$PID" ]; then \
echo " fr.pschneider.PostIt is not running on $(ADB_SERIAL)."; \ echo " $(ANDROID_PACKAGE_NAME) is not running on $(ADB_SERIAL)."; \
echo " Start the app first (am start -n fr.pschneider.PostIt/PostIt.Android.PostItMainActivity)"; \ echo " Start the app first (am start -n $(ANDROID_PACKAGE_NAME)/PostIt.Android.PostItMainActivity)"; \
exit 1; \ exit 1; \
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 fr.pschneider.PostIt:F; \ adb -s $(ADB_SERIAL) logcat -v time --pid=$$PID $(ANDROID_PACKAGE_NAME):F; \
else \ else \
adb -s $(ADB_SERIAL) logcat -d -v time -t $(LOGCAT_LINES) --pid=$$PID fr.pschneider.PostIt:F; \ adb -s $(ADB_SERIAL) logcat -d -v time -t $(LOGCAT_LINES) --pid=$$PID $(ANDROID_PACKAGE_NAME):F; \
fi fi
# Clear logcat, launch PostIt.Android, then dump everything that was # Clear logcat, launch PostIt.Android, then dump everything that was
@ -264,13 +265,13 @@ LOGCAT_BOOT_WAIT ?= 15
qemu-logcat-boot: qemu-logcat-boot:
@echo " Clearing logcat buffer..." @echo " Clearing logcat buffer..."
adb -s $(ADB_SERIAL) logcat -c adb -s $(ADB_SERIAL) logcat -c
@echo " Launching fr.pschneider.PostIt..." @echo " Launching $(ANDROID_PACKAGE_NAME)..."
adb -s $(ADB_SERIAL) shell am start \ adb -s $(ADB_SERIAL) shell am start \
-n fr.pschneider.PostIt/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 fr.pschneider.PostIt 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; \

View file

@ -1,9 +1,13 @@
<Project> <Project>
<!-- Pull in shared package versions from the repository root. -->
<Import <Import
Project="$([MSBuild]::GetPathOfFileAbove('Directory.Packages.props', '$(MSBuildThisFileDirectory)../'))" /> Project="$([MSBuild]::GetPathOfFileAbove('Directory.Packages.props', '$(MSBuildThisFileDirectory)../'))" />
<!-- PostIt-product-specific versions --> <!-- PostIt-product-specific versions -->
<ItemGroup> <!-- Avalonia packages --> <!-- https://learn.microsoft.com/en-us/nuget/consume-packages/central-package-management -->
<PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>
<ItemGroup>
<!-- Avalonia packages -->
<!-- Important: keep version in sync! --> <!-- Important: keep version in sync! -->
<PackageVersion Include="Avalonia" Version="12.1.1" /> <PackageVersion Include="Avalonia" Version="12.1.1" />
<PackageVersion Include="Avalonia.Themes.Fluent" Version="12.1.1" /> <PackageVersion Include="Avalonia.Themes.Fluent" Version="12.1.1" />
@ -11,28 +15,23 @@
<PackageVersion Include="Avalonia.Desktop" Version="12.1.1" /> <PackageVersion Include="Avalonia.Desktop" Version="12.1.1" />
<PackageVersion Include="Avalonia.Browser" Version="12.1.1" /> <PackageVersion Include="Avalonia.Browser" Version="12.1.1" />
<PackageVersion Include="Avalonia.Android" Version="12.1.1" /> <PackageVersion Include="Avalonia.Android" Version="12.1.1" />
<PackageVersion Include="Avalonia.Headless" Version="12.1.1" />
<PackageVersion Include="Avalonia.Headless.Xunit" Version="12.1.1" />
<PackageVersion Include="Avalonia.AvaloniaEdit" Version="12.0.0" />
<PackageVersion Include="Material.Avalonia" Version="3.19.0" />
<PackageVersion Include="AvaloniaUI.DiagnosticsSupport" Version="2.2.3" /> <PackageVersion Include="AvaloniaUI.DiagnosticsSupport" Version="2.2.3" />
<PackageVersion Include="CommunityToolkit.Mvvm" Version="8.4.2" /> <PackageVersion Include="CommunityToolkit.Mvvm" Version="8.4.2" />
<PackageVersion Include="Xamarin.AndroidX.Browser" Version="1.10.0.1" />
<PackageVersion Include="Xamarin.AndroidX.Core.SplashScreen" Version="1.0.1.15" /> <PackageVersion Include="Xamarin.AndroidX.Core.SplashScreen" Version="1.0.1.15" />
<PackageVersion Include="Xamarin.AndroidX.Lifecycle.Runtime" Version="2.11.0.1" />
<PackageVersion Include="Xamarin.AndroidX.Lifecycle.Common" Version="2.11.0.1" />
<PackageVersion Include="Xamarin.UITest" Version="4.4.2" />
<PackageVersion Include="Xamarin.AndroidX.Core.Core.KtX" Version="1.19.0.1" /> <PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.11" />
<PackageVersion Include="Xamarin.AndroidX.Compose.Runtime.Annotation.Jvm" Version="1.11.4" /> <PackageVersion Include="Microsoft.Maui.Essentials" Version="10.0.100" />
<PackageVersion Include="Xamarin.AndroidX.Core" Version="1.19.0.1" />
<PackageVersion Include="Xamarin.AndroidX.AppCompat" Version="1.7.1.4" />
<PackageVersion Include="Xamarin.AndroidX.Browser" Version="1.10.0.1" />
<PackageVersion Include="Xamarin.UITest.Core" Version="4.4.2" />
<PackageVersion Include="Xamarin.UITest.AndroidX" Version="4.4.2" />
<PackageVersion Include="Xamarin.UITest.Queries" Version="4.4.2" />
<PackageVersion Include="Xamarin.UITest" Version="4.4.2" />
<PackageVersion Include="Avalonia.Headless" Version="12.1.1" /> </ItemGroup>
<PackageVersion Include="Avalonia.Headless.Xunit" Version="12.1.1" />
<PackageVersion Include="Avalonia.AvaloniaEdit" Version="12.0.0" />
<PackageVersion Include="Material.Avalonia" Version="3.19.0" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.11" />
<PackageVersion Include="Microsoft.Maui.Essentials" Version="10.0.100" />
</ItemGroup>
</Project> </Project>

View file

@ -2,6 +2,12 @@
using Android.Runtime; using Android.Runtime;
using Avalonia; using Avalonia;
using Avalonia.Android; using Avalonia.Android;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Avalonia.Controls;
using Avalonia.Styling;
using Yavsc.Api.Client;
namespace PostIt.Android namespace PostIt.Android
{ {

View file

@ -1,11 +1,8 @@
using Android.App; using Android.App;
using Android.Content.PM;
using Android.Content; using Android.Content;
using Android.Content.PM;
using Avalonia;
using Avalonia.Android; using Avalonia.Android;
using AndroidX.Emoji2.Text;
using AndroidX.Core.Provider;
using Android;
using Android.Graphics;
namespace PostIt.Android; namespace PostIt.Android;
@ -15,11 +12,10 @@ namespace PostIt.Android;
Theme = "@style/MyTheme.NoActionBar", Theme = "@style/MyTheme.NoActionBar",
Icon = "@drawable/icon", Icon = "@drawable/icon",
MainLauncher = true, MainLauncher = true,
LaunchMode = LaunchMode.SingleTask,
ConfigurationChanges = ConfigChanges.Orientation | ConfigChanges.ScreenSize | ConfigChanges.UiMode)] ConfigurationChanges = ConfigChanges.Orientation | ConfigChanges.ScreenSize | ConfigChanges.UiMode)]
public class MainActivity : AvaloniaMainActivity public class MainActivity : AvaloniaMainActivity
{ {
/// <summary> /// <summary>
/// Strongly-typed handle to the current MainActivity instance, set in /// Strongly-typed handle to the current MainActivity instance, set in
/// <see cref="OnCreate"/> and consumed by platform services such as /// <see cref="OnCreate"/> and consumed by platform services such as
/// <see cref="Services.AndroidSystemBrowser"/> which need to launch /// <see cref="Services.AndroidSystemBrowser"/> which need to launch
@ -33,8 +29,7 @@ public class MainActivity : AvaloniaMainActivity
PlatformBootstrap.EnsureInitialized(); PlatformBootstrap.EnsureInitialized();
Current = this; Current = this;
} }
/// <summary>
/// <summary>
/// Receives the deep-link Intent fired by the system browser after the /// Receives the deep-link Intent fired by the system browser after the
/// user completes the OIDC login on https://yavsc.pschneider.fr. The /// user completes the OIDC login on https://yavsc.pschneider.fr. The
/// Intent URI has the shape <c>android://postit-signin?code=...&amp;state=...</c>. /// Intent URI has the shape <c>android://postit-signin?code=...&amp;state=...</c>.

View file

@ -10,19 +10,22 @@
<AndroidPackageFormat>apk</AndroidPackageFormat> <AndroidPackageFormat>apk</AndroidPackageFormat>
<AndroidEnableProfiledAot>false</AndroidEnableProfiledAot> <AndroidEnableProfiledAot>false</AndroidEnableProfiledAot>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<AndroidResource Include="Icon.png"> <AndroidResource Include="Icon.png">
<Link>Resources\drawable\Icon.png</Link> <Link>Resources\drawable\Icon.png</Link>
</AndroidResource> </AndroidResource>
</ItemGroup> </ItemGroup>
<ItemGroup>
<ProjectReference Include="..\PostIt\PostIt.csproj" />
</ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Avalonia.Android" /> <PackageReference Include="Avalonia.Android" />
<PackageReference Include="Xamarin.AndroidX.Core.SplashScreen" /> <PackageReference Include="Xamarin.AndroidX.Core.SplashScreen" />
<PackageReference Include="Avalonia.Browser" />
<PackageReference Include="Xamarin.AndroidX.Browser" /> <PackageReference Include="Xamarin.AndroidX.Browser" />
<PackageReference Include="IdentityModel.OidcClient" /> <PackageReference Include="Xamarin.AndroidX.Lifecycle.Runtime" />
<PackageReference Include="Xamarin.AndroidX.Lifecycle.Common" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\PostIt\PostIt.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View file

@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" android:installLocation="auto"> <manifest xmlns:android="http://schemas.android.com/apk/res/android" android:installLocation="auto">
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<application android:label="PostIt" android:icon="@drawable/Icon"> <application android:label="PostIt" android:icon="@drawable/Icon">
<!-- <!--
Deep-link receiver for the OIDC Authorization Code + PKCE flow. Deep-link receiver for the OIDC Authorization Code + PKCE flow.

View file

@ -8,5 +8,14 @@
<item name="android:windowActionBar">false</item> <item name="android:windowActionBar">false</item>
<item name="android:windowBackground">@null</item> <item name="android:windowBackground">@null</item>
<item name="android:windowNoTitle">true</item> <item name="android:windowNoTitle">true</item>
<item name="android:windowSplashScreenBackground">@color/splash_background</item>
<item name="android:windowSplashScreenAnimatedIcon">@drawable/avalonia_anim</item>
<item name="android:windowSplashScreenAnimationDuration">1000</item>
<item name="postSplashScreenTheme">@style/MyTheme.Main</item>
</style>
<style name="MyTheme.Main"
parent ="MyTheme.NoActionBar">
<item name="android:windowIsTranslucent">true</item>
</style> </style>
</resources> </resources>

View file

@ -5,8 +5,8 @@
</style> </style>
<style name="MyTheme.NoActionBar" parent="@style/Theme.AppCompat.DayNight.NoActionBar"> <style name="MyTheme.NoActionBar" parent="@style/Theme.AppCompat.DayNight.NoActionBar">
<item name="android:windowBackground">@drawable/splash_screen</item>
<item name="android:windowActionBar">false</item> <item name="android:windowActionBar">false</item>
<item name="android:windowBackground">@drawable/splash_screen</item>
<item name="android:windowNoTitle">true</item> <item name="android:windowNoTitle">true</item>
</style> </style>
</resources> </resources>

View file

@ -40,9 +40,9 @@ public sealed class AndroidSystemBrowser : IBrowser
var callbackTask = MainActivity.AndroidOidcCallbackSink.AwaitNextCallbackAsync(); var callbackTask = MainActivity.AndroidOidcCallbackSink.AwaitNextCallbackAsync();
var tabsIntent = new CustomTabsIntent.Builder() var tabsIntent = new CustomTabsIntent.Builder()
.SetShowTitle(true) .SetShowTitle(true)!
.Build(); .Build();
tabsIntent.LaunchUrl(_activity, uri); tabsIntent!.LaunchUrl(_activity, uri);
string responseUri; string responseUri;
try try

View file

@ -15,7 +15,4 @@
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\PostIt\PostIt.csproj" /> <ProjectReference Include="..\PostIt\PostIt.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<PackageReference Include="GitVersion.MsBuild" />
</ItemGroup>
</Project> </Project>

View file

@ -24,7 +24,4 @@
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\PostIt\PostIt.csproj" /> <ProjectReference Include="..\PostIt\PostIt.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<PackageReference Include="GitVersion.MsBuild" />
</ItemGroup>
</Project> </Project>

View file

@ -68,9 +68,6 @@ sealed class Program
public static AppBuilder BuildAvaloniaApp() public static AppBuilder BuildAvaloniaApp()
=> AppBuilder.Configure<App>() => AppBuilder.Configure<App>()
.UsePlatformDetect() .UsePlatformDetect()
#if DEBUG
.WithDeveloperTools()
#endif
.WithInterFont() .WithInterFont()
.LogToTrace(); .LogToTrace();
} }

View file

@ -72,7 +72,7 @@ public class MainPageButtonsTests
{ } { }
} }
private static MainPageViewModel MakeViewModel(BlogPostDto? selectedPost = null) private static MainViewModel MakeViewModel(BlogPostDto? selectedPost = null)
{ {
var api = new ThrowingApi(); var api = new ThrowingApi();
var blog = new BlogApiClient(api, "http://localhost/"); var blog = new BlogApiClient(api, "http://localhost/");
@ -93,7 +93,7 @@ public class MainPageButtonsTests
services.AddTransient<SignaturePage>(); services.AddTransient<SignaturePage>();
services.AddTransient<CirclesPage>(); services.AddTransient<CirclesPage>();
services.AddTransient<PostAclDialog>(); services.AddTransient<PostAclDialog>();
var vm = new MainPageViewModel(blog, services: services.BuildServiceProvider()); var vm = new MainViewModel(blog, services: services.BuildServiceProvider());
if (selectedPost is not null) vm.SelectedPost = selectedPost; if (selectedPost is not null) vm.SelectedPost = selectedPost;
return vm; return vm;
} }
@ -109,7 +109,7 @@ public class MainPageButtonsTests
/// realised and <c>KeyPressQwerty</c> has a real /// realised and <c>KeyPressQwerty</c> has a real
/// <see cref="TopLevel"/> to dispatch against. /// <see cref="TopLevel"/> to dispatch against.
/// </summary> /// </summary>
private static (MainWindow window, MainPage page) MountMainPage(MainPageViewModel vm) private static (MainWindow window, MainPage page) MountMainPage(MainViewModel vm)
{ {
var window = new MainWindow(); var window = new MainWindow();
var page = new MainPage { DataContext = vm }; var page = new MainPage { DataContext = vm };

View file

@ -40,7 +40,7 @@ public class MainPageSaveTests
var recorder = new CallRecorder(); var recorder = new CallRecorder();
var api = new RecordingYavscApiClient(recorder); var api = new RecordingYavscApiClient(recorder);
var blog = new BlogApiClient(api, "http://localhost/"); var blog = new BlogApiClient(api, "http://localhost/");
var viewModel = new MainPageViewModel(blog); var viewModel = new MainViewModel(blog);
var page = new MainPage { DataContext = viewModel }; var page = new MainPage { DataContext = viewModel };
// MainPage is a ContentPage (a Page, not a Control), so it // MainPage is a ContentPage (a Page, not a Control), so it

View file

@ -16,7 +16,7 @@ public class PostItViewModelTests
// throws on any call (we never call the API in this test). // throws on any call (we never call the API in this test).
var fakeApi = new ThrowingYavscApiClient(); var fakeApi = new ThrowingYavscApiClient();
var blog = new BlogApiClient(fakeApi, "http://localhost/"); var blog = new BlogApiClient(fakeApi, "http://localhost/");
var viewModel = new MainPageViewModel(blog); var viewModel = new MainViewModel(blog);
viewModel.Posts.Add(new BlogPostDto { Id = 1, Title = "First post", Article = "Hello world", AuthorId = "alice" }); viewModel.Posts.Add(new BlogPostDto { Id = 1, Title = "First post", Article = "Hello world", AuthorId = "alice" });
viewModel.Posts.Add(new BlogPostDto { Id = 2, Title = "Second post", Article = "Nothing here", AuthorId = "bob" }); viewModel.Posts.Add(new BlogPostDto { Id = 2, Title = "Second post", Article = "Nothing here", AuthorId = "bob" });

View file

@ -1,13 +1,16 @@
<Application xmlns="https://github.com/avaloniaui" <Application xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="using:PostIt" xmlns:local="using:PostIt"
x:Class="PostIt.App"> x:Class="PostIt.App"
RequestedThemeVariant="Default">
<!-- "Default" ThemeVariant follows system theme variant. "Dark" or "Light" are other available options. -->
<!-- ViewLocator is registered in App.axaml.cs with the real DI container. --> <Application.DataTemplates>
<local:ViewLocator />
</Application.DataTemplates>
<Application.Styles> <Application.Styles>
<FluentTheme /> <FluentTheme />
<StyleInclude Source="avares://AvaloniaEdit/Themes/Fluent/AvaloniaEdit.xaml" /> <StyleInclude Source="avares://AvaloniaEdit/Themes/Fluent/AvaloniaEdit.xaml" />
</Application.Styles> </Application.Styles>
</Application> </Application>

View file

@ -1,16 +1,16 @@
using System; using System;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Avalonia; using Avalonia;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml; using Avalonia.Markup.Xaml;
using Avalonia.Styling; using Avalonia.Styling;
using Microsoft.Extensions.DependencyInjection;
using PostIt.Services; using PostIt.Services;
using Yavsc.Api.Client;
using PostIt.ViewModels; using PostIt.ViewModels;
using PostIt.Views; using PostIt.Views;
using Yavsc.Api.Client;
namespace PostIt; namespace PostIt;
@ -28,9 +28,7 @@ public partial class App : Application
/// </summary> /// </summary>
public IServiceProvider? ServiceProvider { get; private set; } public IServiceProvider? ServiceProvider { get; private set; }
public App() MainWindow window;
{
}
public override void Initialize() public override void Initialize()
{ {
@ -42,101 +40,31 @@ public partial class App : Application
public override void OnFrameworkInitializationCompleted() public override void OnFrameworkInitializationCompleted()
{ {
// Belt-and-braces 2nd-instance guard. The primary check now
// lives in PostIt.Desktop.Program.Main and exits before
// Avalonia boots — preventing a flash of the MainWindow on
// every postit://callback launch. This block is kept for any
// entry point that bypasses Program.Main (PostIt.Browser,
// PostIt.Android's process lifecycle, ad-hoc tests that build
// App directly) and as defence-in-depth in case the Desktop
// build is ever reconfigured to skip the early check.
if (TryHandOffCustomSchemeUrl()) return; if (TryHandOffCustomSchemeUrl()) return;
this.ServiceProvider = BuildServices(new ServiceCollection()); this.ServiceProvider = BuildServices(new ServiceCollection());
AttachServiceProvider(ServiceProvider);
var settings = ServiceProvider.GetRequiredService<Settings>(); var settings = ServiceProvider.GetRequiredService<Settings>();
DataTemplates.Clear(); DataTemplates.Clear();
DataTemplates.Add(new ViewLocator(ServiceProvider)); DataTemplates.Add(new ViewLocator(ServiceProvider));
// Wire the Settings singleton onto the SettingsPage singleton
// once, at composition time. The page is registered as a
// singleton (see above) precisely so this binding is stable
// for the lifetime of the app: every push to / pop from the
// navigation stack finds the same ContentPage with the same
// DataContext, and the TwoWay bindings inside the page keep
// mutating the same in-memory Settings instance that the rest
// of the app reads (OidcClientOptions construction, etc.).
ServiceProvider.GetRequiredService<SettingsPage>().DataContext = settings;
// Settings.DarkMode was previously a dead field: it round-
// tripped through the settings file and the SettingsPage
// CheckBox, but no consumer ever read it. Wire it here to
// Application.RequestedThemeVariant so the toggle takes
// effect immediately, and seed the initial theme from the
// value Load() just populated (so a dark-mode user lands on
// a dark window on first launch, not on a default-light
// window that flips after the user touches the toggle).
ApplyDarkMode(settings);
settings.PropertyChanged += (_, e) =>
{
if (e.PropertyName == nameof(Settings.DarkMode))
{
ApplyDarkMode(settings);
}
};
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{ {
desktop.MainWindow = CreateMainWindow(); desktop.MainWindow = CreateMainWindow();
} }
else if (ApplicationLifetime is IActivityApplicationLifetime singleViewFactoryApplicationLifetime) else if (ApplicationLifetime is IActivityApplicationLifetime singleViewFactoryApplicationLifetime)
{ {
singleViewFactoryApplicationLifetime.MainViewFactory = () => CreateMainWindow(); singleViewFactoryApplicationLifetime.MainViewFactory =
() => CreateMainWindow();
} }
else if (ApplicationLifetime is ISingleViewApplicationLifetime singleViewPlatform) else if (ApplicationLifetime is ISingleViewApplicationLifetime singleViewPlatform)
{ {
singleViewPlatform.MainView = CreateMainWindow(); singleViewPlatform.MainView = CreateMainWindow();
} }
ApplyDarkMode(settings);
base.OnFrameworkInitializationCompleted(); base.OnFrameworkInitializationCompleted();
} }
MainWindow window;
private MainWindow CreateMainWindow()
{
window = new MainWindow();
var api = ServiceProvider!.GetRequiredService<YavscApiClient>();
window.Opened += async (_, _) => await BootAsync(this.ServiceProvider!, api);
var sessionStatus = ServiceProvider!.GetRequiredService<SessionStatusViewModel>();
sessionStatus.LogoutCompleted += () =>
{
window.NavRoot.PopToRootAsync();
};
sessionStatus.LoginSucceeded += () =>
{
PushMainPageAsync();
};
var homeVm = ServiceProvider!.GetRequiredService<HomePageViewModel>();
this.PushPageAsync(homeVm).Wait();
window.SessionBanner.DataContext = sessionStatus;
return window;
}
/// <summary>
/// Build the DI container the app uses. Pulled out of
/// <see cref="OnFrameworkInitializationCompleted"/> so headless
/// tests can construct the same container at <c>TestApp</c> 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.
/// </summary>
internal static IServiceProvider BuildServices(ServiceCollection services) internal static IServiceProvider BuildServices(ServiceCollection services)
{ {
var settings = new Settings(); var settings = new Settings();
@ -182,14 +110,13 @@ public partial class App : Application
// ViewModels // ViewModels
services.AddSingleton(settings); services.AddSingleton(settings);
services.AddSingleton<YavscApiClient>(api); services.AddSingleton<YavscApiClient>(api);
services.AddSingleton<IYavscApiClient>(api);
services.AddSingleton(client); services.AddSingleton(client);
services.AddSingleton(circleClient); services.AddSingleton(circleClient);
services.AddSingleton(blogAclClient); services.AddSingleton(blogAclClient);
services.AddSingleton(userSearchClient); services.AddSingleton(userSearchClient);
services.AddSingleton<IContactService>(contactService); services.AddSingleton<IContactService>(contactService);
services.AddSingleton<IUserDirectory>(userDirectory); services.AddSingleton<IUserDirectory>(userDirectory);
services.AddTransient<MainPageViewModel>(); services.AddTransient<MainViewModel>();
services.AddTransient<HomePageViewModel>(); services.AddTransient<HomePageViewModel>();
services.AddTransient<SignaturePageViewModel>(); services.AddTransient<SignaturePageViewModel>();
services.AddTransient<CirclesPageViewModel>(); services.AddTransient<CirclesPageViewModel>();
@ -204,21 +131,30 @@ public partial class App : Application
return services.BuildServiceProvider(); return services.BuildServiceProvider();
} }
/// <summary> private MainWindow CreateMainWindow()
/// Attach a pre-built DI container to this <see cref="App"/>
/// instance. Used by headless tests after
/// <see cref="BuildServices"/>; in production this happens
/// implicitly via <see cref="OnFrameworkInitializationCompleted"/>.
/// Idempotent w.r.t. <see cref="Settings.BindToServiceProvider"/>:
/// re-binding from a second App boot is a no-op.
/// </summary>
internal void AttachServiceProvider(IServiceProvider sp)
{ {
ServiceProvider = sp; window = new MainWindow();
Settings.BindToServiceProvider(sp); var api = ServiceProvider!.GetRequiredService<YavscApiClient>();
window.Opened += async (_, _) => await BootAsync(this.ServiceProvider!, api);
var sessionStatus = ServiceProvider!.GetRequiredService<SessionStatusViewModel>();
sessionStatus.LogoutCompleted += () =>
{
window.NavRoot.PopToRootAsync();
};
sessionStatus.LoginSucceeded += () =>
{
PushMainPageAsync();
};
var homeVm = ServiceProvider!.GetRequiredService<HomePageViewModel>();
this.PushPageAsync(homeVm).Wait();
window.SessionBanner.DataContext = sessionStatus;
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
@ -266,7 +202,7 @@ public partial class App : Application
public static Task PushMainPageAsync() public static Task PushMainPageAsync()
{ {
var app = (App)Current!; var app = (App)Current!;
var mainVm = app.ServiceProvider!.GetRequiredService<MainPageViewModel>(); var mainVm = app.ServiceProvider!.GetRequiredService<MainViewModel>();
return app.PushPageAsync(mainVm); return app.PushPageAsync(mainVm);
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

View file

@ -1,32 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net10.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<LangVersion>latest</LangVersion> <LangVersion>latest</LangVersion>
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
<AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-beta.1+1.Branch.release-1.0.8-rc1.Sha.1167169aa89e1bf25290e9a152d27b357a500ab3</InformationalVersion>
<Version>1.1.0-beta.1</Version>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<AvaloniaResource Include="Assets\**" /> <AvaloniaResource Include="Assets\**" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<PackageReference Include="Avalonia" />
<PackageReference Include="Avalonia.Themes.Fluent" />
<PackageReference Include="Avalonia.Fonts.Inter" />
<PackageReference Include="Avalonia.AvaloniaEdit" />
<PackageReference Include="AvaloniaUI.DiagnosticsSupport">
<IncludeAssets Condition="'$(Configuration)' != 'Debug'">None</IncludeAssets>
<PrivateAssets Condition="'$(Configuration)' != 'Debug'">All</PrivateAssets>
</PackageReference>
<PackageReference Include="CommunityToolkit.Mvvm" />
<PackageReference Include="IdentityModel.OidcClient" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
<ProjectReference Include="../../Yavsc.Abstract/Yavsc.Abstract.csproj" />
<ProjectReference Include="../../Yavsc.Api.Client/Yavsc.Api.Client.csproj" />
</ItemGroup>
<ItemGroup> <ItemGroup>
<Content Include="postit-settings.json"> <Content Include="postit-settings.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
@ -38,6 +20,21 @@
</EmbeddedResource> </EmbeddedResource>
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="GitVersion.MsBuild" /> <PackageReference Include="Avalonia" />
<PackageReference Include="Avalonia.Themes.Fluent" />
<PackageReference Include="Avalonia.Fonts.Inter" />
<PackageReference Include="AvaloniaUI.DiagnosticsSupport">
<IncludeAssets Condition="'$(Configuration)' != 'Debug'">None</IncludeAssets>
<PrivateAssets Condition="'$(Configuration)' != 'Debug'">All</PrivateAssets>
</PackageReference>
<PackageReference Include="CommunityToolkit.Mvvm" />
<PackageReference Include="Avalonia.AvaloniaEdit" />
<PackageReference Include="IdentityModel.OidcClient" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../Yavsc.Abstract/Yavsc.Abstract.csproj" />
<ProjectReference Include="../../Yavsc.Api.Client/Yavsc.Api.Client.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View file

@ -1,4 +1,5 @@
using System; using System;
using System.Diagnostics.CodeAnalysis;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Controls.Templates; using Avalonia.Controls.Templates;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
@ -10,17 +11,19 @@ namespace PostIt;
/// <summary> /// <summary>
/// Given a view model, returns the corresponding view if possible. /// Given a view model, returns the corresponding view if possible.
/// </summary> /// </summary>
[RequiresUnreferencedCode(
"Default implementation of ViewLocator involves reflection which may be trimmed away.",
Url = "https://docs.avaloniaui.net/docs/concepts/view-locator")]
public class ViewLocator : IDataTemplate public class ViewLocator : IDataTemplate
{ {
private readonly IServiceProvider _services; private readonly IServiceProvider _services;
public ViewLocator(IServiceProvider services) public ViewLocator(IServiceProvider services)
{ {
_services = services; _services = services;
} }
public Control Build(object? data) public Control Build(object? data)
{ {
try try
{ {
@ -32,11 +35,12 @@ public class ViewLocator : IDataTemplate
} }
} }
private Control BuildCore(object? data) private Control BuildCore(object? data)
{ {
return data switch return data switch
{ {
MainPageViewModel => _services.GetRequiredService<MainPage>(), MainViewModel => _services.GetRequiredService<MainPage>(),
Settings => _services.GetRequiredService<SettingsPage>(), Settings => _services.GetRequiredService<SettingsPage>(),
HomePageViewModel => _services.GetRequiredService<HomePage>(), HomePageViewModel => _services.GetRequiredService<HomePage>(),
SignaturePageViewModel => _services.GetRequiredService<SignaturePage>(), SignaturePageViewModel => _services.GetRequiredService<SignaturePage>(),
@ -48,5 +52,5 @@ public class ViewLocator : IDataTemplate
}; };
} }
public bool Match(object? data) => data is ViewModelBase; public bool Match(object? data) => data is ViewModelBase;
} }

View file

@ -11,7 +11,7 @@ using Yavsc.Api.Client;
namespace PostIt.ViewModels; namespace PostIt.ViewModels;
public partial class MainPageViewModel : ViewModelBase public partial class MainViewModel : ViewModelBase
{ {
/// <summary>Window/tab title. Cosmetic — bound by /// <summary>Window/tab title. Cosmetic — bound by
/// <c>MainPage.axaml</c> if at all. Not the post title.</summary> /// <c>MainPage.axaml</c> if at all. Not the post title.</summary>
@ -120,7 +120,7 @@ public partial class MainPageViewModel : ViewModelBase
} }
public MainPageViewModel() public MainViewModel()
{ {
SettingsModel = new Settings(); SettingsModel = new Settings();
Init(SettingsModel); Init(SettingsModel);
@ -170,7 +170,7 @@ public partial class MainPageViewModel : ViewModelBase
/// <see cref="BlogApiClient"/>. Production code uses the /// <see cref="BlogApiClient"/>. Production code uses the
/// (Settings, BlogApiClient) overload below. /// (Settings, BlogApiClient) overload below.
/// </summary> /// </summary>
public MainPageViewModel(BlogApiClient blogClient, Settings? settings = null, IServiceProvider? services = null) public MainViewModel(BlogApiClient blogClient, Settings? settings = null, IServiceProvider? services = null)
{ {
SettingsModel = new Settings(); SettingsModel = new Settings();
BlogClient = blogClient ?? throw new ArgumentNullException(nameof(blogClient)); ; BlogClient = blogClient ?? throw new ArgumentNullException(nameof(blogClient)); ;

View file

@ -64,24 +64,6 @@ public partial class Settings : ViewModelBase
null); null);
} }
/// <summary>
/// Returns the canonical Settings instance previously bound through
/// <see cref="BindToServiceProvider"/>, or <c>null</c> when called
/// outside a running Avalonia application (tests, CLI tools).
/// </summary>
public static Settings? GetCurrent() => Volatile.Read(ref s_current);
/// <summary>
/// Resolve the canonical Settings instance or throw. Use this in
/// production code paths that must not silently fall back to a
/// freshly-constructed <see cref="Settings"/> (which used to be
/// the root cause of the postit://callback crash: two Settings
/// instances racing on PropertyChanged from different threads).
/// </summary>
public static Settings RequireCurrent() =>
GetCurrent() ?? throw new InvalidOperationException(
"Settings.Current is not bound. Call App.OnFrameworkInitializationCompleted first.");
[ObservableProperty] [ObservableProperty]
public partial AuthenticationSettings Authentication { get; set; } = new(); public partial AuthenticationSettings Authentication { get; set; } = new();

View file

@ -4,8 +4,7 @@ namespace PostIt.ViewModels;
public abstract partial class ViewModelBase : ObservableObject public abstract partial class ViewModelBase : ObservableObject
{ {
/// <summary>
/// <summary>
/// Gets if the user can navigate to the next page /// Gets if the user can navigate to the next page
/// </summary> /// </summary>
public abstract bool CanNavigateNext { get; protected set; } public abstract bool CanNavigateNext { get; protected set; }

View file

@ -8,11 +8,11 @@
xmlns:AvaloniaEdit="clr-namespace:AvaloniaEdit;assembly=AvaloniaEdit" xmlns:AvaloniaEdit="clr-namespace:AvaloniaEdit;assembly=AvaloniaEdit"
mc:Ignorable="d" mc:Ignorable="d"
x:Class="PostIt.Views.MainPage" x:Class="PostIt.Views.MainPage"
x:DataType="vm:MainPageViewModel" x:DataType="vm:MainViewModel"
HorizontalAlignment="Stretch" HorizontalAlignment="Stretch"
VerticalAlignment="Stretch"> VerticalAlignment="Stretch">
<Design.DataContext> <Design.DataContext>
<vm:MainPageViewModel /> <vm:MainViewModel />
</Design.DataContext> </Design.DataContext>
<Grid Margin="12" RowSpacing="12" <Grid Margin="12" RowSpacing="12"

View file

@ -1,27 +1,17 @@
<Window xmlns="https://github.com/avaloniaui" <Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:PostIt.ViewModels" xmlns:vm="using:PostIt.ViewModels"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:views="using:PostIt.Views" xmlns:views="using:PostIt.Views"
x:DataType="vm:MainPageViewModel" mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="PostIt.Views.MainWindow" x:Class="PostIt.Views.MainWindow"
> Icon="/Assets/avalonia-logo.ico"
Title="PostIt" >
<!-- <DockPanel LastChildFill="True">
Root layout: persistent session banner on top, navigation
surface below. The banner is the single source of truth for
"Connecté / Déconnecté" and the logout button — visible on
every page (HomePage, MainPage) so the user never has to dig
through a menu to find their session state.
The navigation stack is built programmatically in
App.OnFrameworkInitializationCompleted: HomePage is the
root, MainPage is pushed on top when the silent refresh
succeeds at boot or after a fresh login from HomePage.
-->
<DockPanel LastChildFill="True">
<views:SessionStatusBanner x:Name="SessionBanner" <views:SessionStatusBanner x:Name="SessionBanner"
DockPanel.Dock="Bottom"/> DockPanel.Dock="Bottom"/>
<NavigationPage x:Name="NavRoot"/> <NavigationPage x:Name="NavRoot"/>
</DockPanel> </DockPanel>
</Window> </Window>