diff --git a/src/PostIt.Tests/SessionStatusBannerTests.cs b/src/PostIt.Tests/SessionStatusBannerTests.cs
new file mode 100644
index 00000000..d35529db
--- /dev/null
+++ b/src/PostIt.Tests/SessionStatusBannerTests.cs
@@ -0,0 +1,125 @@
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Headless.XUnit;
+using Avalonia.Media;
+using Avalonia.Styling;
+using Avalonia.VisualTree;
+using PostIt.ViewModels;
+using PostIt.Views;
+
+namespace PostIt.Tests;
+
+///
+/// UI tests for . Mounted inside
+/// a real via the headless Avalonia
+/// platform declared in TestApp.cs .
+///
+/// The pattern is the one that UnitTest1.MainPage_Should_Load
+/// established: a test attribute [AvaloniaFact] (from
+/// Avalonia.Headless.XUnit ) instead of plain [Fact] ,
+/// new MainWindow() , window.Show() . The AvaloniaFact
+/// attribute schedules the test body inside a dispatcher, which
+/// is the precondition for the headless Window's
+/// PlatformManager.CreateWindow() to find a registered
+/// service. A plain [Fact] test that calls
+/// new Window().Show() throws because the harness has not
+/// been initialised for that thread.
+///
+/// The session banner's DataContext is not wired in
+/// these tests: App.OnFrameworkInitializationCompleted is
+/// not called in a unit test, so we set the DataContext on the
+/// banner directly. The production code path is exercised
+/// end-to-end by the manual launch, not here.
+///
+public class SessionStatusBannerTests
+{
+ [AvaloniaFact]
+ public void Banner_renders_three_buttons_in_the_visual_tree()
+ {
+ var window = new MainWindow();
+ window.SessionBanner.DataContext = new SessionStatusViewModel();
+ window.Show();
+
+ var buttons = window.SessionBanner.GetVisualDescendants()
+ .OfType()
+ .ToList();
+
+ // Three buttons, named by their content text: Se
+ // déconnecter, Se connecter, Paramètres. If any one is
+ // missing, the user has no way to trigger the
+ // corresponding navigation event.
+ Assert.Equal(3, buttons.Count);
+ Assert.Contains(buttons, b => b.Content as string == "Se déconnecter");
+ Assert.Contains(buttons, b => b.Content as string == "Se connecter");
+ Assert.Contains(buttons, b => b.Content as string == "Paramètres");
+ }
+
+ [AvaloniaFact]
+ public void Banner_login_button_is_visible_when_logged_out()
+ {
+ var window = new MainWindow();
+ var vm = new SessionStatusViewModel();
+ Assert.True(vm.IsLoggedOut); // VM default
+ window.SessionBanner.DataContext = vm;
+ window.Show();
+
+ var login = window.SessionBanner.GetVisualDescendants()
+ .OfType()
+ .Single(b => b.Content as string == "Se connecter");
+
+ // The XAML binds IsVisible to IsLoggedOut. After Show,
+ // the binding has been evaluated.
+ Assert.True(login.IsVisible);
+ }
+
+ [AvaloniaFact]
+ public void Banner_logout_button_is_hidden_when_logged_out()
+ {
+ var window = new MainWindow();
+ var vm = new SessionStatusViewModel();
+ Assert.False(vm.IsLoggedIn); // VM default
+ window.SessionBanner.DataContext = vm;
+ window.Show();
+
+ var logout = window.SessionBanner.GetVisualDescendants()
+ .OfType()
+ .Single(b => b.Content as string == "Se déconnecter");
+
+ Assert.False(logout.IsVisible);
+ }
+
+ [AvaloniaFact]
+ public void Banner_settings_button_is_visible_regardless_of_session()
+ {
+ var window = new MainWindow();
+ window.SessionBanner.DataContext = new SessionStatusViewModel();
+ window.Show();
+
+ var settings = window.SessionBanner.GetVisualDescendants()
+ .OfType()
+ .Single(b => b.Content as string == "Paramètres");
+
+ // Paramètres is the only button with no IsVisible
+ // binding — always shown. The user's only path to the
+ // settings page goes through this button.
+ Assert.True(settings.IsVisible);
+ }
+
+ [AvaloniaFact]
+ public void Banner_session_label_reflects_DataContext()
+ {
+ var window = new MainWindow();
+ window.SessionBanner.DataContext = new SessionStatusViewModel();
+ window.Show();
+
+ var label = window.SessionBanner.GetVisualDescendants()
+ .OfType()
+ .First(t => t.Text == "Déconnecté" || t.Text == "Connecté");
+
+ // Default SessionLabel is "Déconnecté" until Refresh()
+ // is called with a valid session. This pins the default
+ // so a future refactor that breaks the initial value
+ // (e.g. by removing the field initialiser) is caught.
+ Assert.Equal("Déconnecté", label.Text);
+ }
+}
diff --git a/src/PostIt/Directory.Packages.props b/src/PostIt/Directory.Packages.props
index 12ed35df..900f1428 100644
--- a/src/PostIt/Directory.Packages.props
+++ b/src/PostIt/Directory.Packages.props
@@ -12,6 +12,7 @@
+
diff --git a/src/PostIt/PostIt.Desktop/PostIt.Desktop.csproj b/src/PostIt/PostIt.Desktop/PostIt.Desktop.csproj
index 10d48512..c543c550 100644
--- a/src/PostIt/PostIt.Desktop/PostIt.Desktop.csproj
+++ b/src/PostIt/PostIt.Desktop/PostIt.Desktop.csproj
@@ -19,6 +19,7 @@
None
All
+
diff --git a/src/PostIt/PostIt/App.axaml.cs b/src/PostIt/PostIt/App.axaml.cs
index a73096d2..ca7e051d 100644
--- a/src/PostIt/PostIt/App.axaml.cs
+++ b/src/PostIt/PostIt/App.axaml.cs
@@ -5,6 +5,7 @@ using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml;
+using Avalonia.Styling;
using PostIt.Services;
using PostIt.ViewModels;
using PostIt.Views;
@@ -112,8 +113,24 @@ public partial class App : Application
// 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.).
- provider.GetRequiredService().DataContext =
- provider.GetRequiredService();
+ provider.GetRequiredService().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)
{
@@ -190,6 +207,12 @@ public partial class App : Application
}
}
+ private static void ApplyDarkMode(Settings settings)
+ {
+ Application.Current!.RequestedThemeVariant =
+ settings.DarkMode ? ThemeVariant.Dark : ThemeVariant.Light;
+ }
+
///
/// Run once after the main window is shown: try to refresh the
/// cached OIDC tokens silently; on success, push MainPage on top
diff --git a/src/PostIt/PostIt/ViewModels/MainPageViewModel.cs b/src/PostIt/PostIt/ViewModels/MainPageViewModel.cs
index 74996bd9..a7c70ab3 100644
--- a/src/PostIt/PostIt/ViewModels/MainPageViewModel.cs
+++ b/src/PostIt/PostIt/ViewModels/MainPageViewModel.cs
@@ -2,7 +2,6 @@ using System;
using System.Collections.ObjectModel;
using System.Linq;
using System.Threading.Tasks;
-using Avalonia.Styling;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using PostIt.Models;
@@ -38,9 +37,6 @@ public partial class MainPageViewModel : ViewModelBase
[ObservableProperty]
public partial bool IsBusy { get; set; }
- [ObservableProperty]
- ThemeVariant themeVariant = ThemeVariant.Default;
-
[ObservableProperty]
public partial Settings Settings { get; private set; }
diff --git a/src/PostIt/PostIt/Views/SessionStatusBanner.axaml b/src/PostIt/PostIt/Views/SessionStatusBanner.axaml
index 9c6a3f93..0088bd07 100644
--- a/src/PostIt/PostIt/Views/SessionStatusBanner.axaml
+++ b/src/PostIt/PostIt/Views/SessionStatusBanner.axaml
@@ -4,7 +4,6 @@
x:Class="PostIt.Views.SessionStatusBanner"
x:DataType="vm:SessionStatusViewModel">