diff --git a/src/PostIt/PostIt/ViewModels/Settings.cs b/src/PostIt/PostIt/ViewModels/Settings.cs
index 12ed2e02..1cbf5b08 100644
--- a/src/PostIt/PostIt/ViewModels/Settings.cs
+++ b/src/PostIt/PostIt/ViewModels/Settings.cs
@@ -1,5 +1,6 @@
using System.Runtime.CompilerServices;
using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
using IdentityModel.OidcClient;
using Microsoft.Extensions.DependencyInjection;
using System;
@@ -106,8 +107,57 @@ public partial class Settings : ViewModelBase
[ObservableProperty]
public partial string BusinessApiUrl { get; set; } = "https://business.pschneider.fr/api/v1/";
+ ///
+ /// Catch top-level mutations: the four ObservableProperty
+ /// setters above all funnel through here, and we flip
+ /// in lock-step. Sub-property mutations
+ /// (e.g. Authentication.Authority) are caught by the
+ /// subscription wired up in
+ /// below. disables the flag during bulk
+ /// hydration so the disk load itself does not count as a user
+ /// edit.
+ ///
+ private void MarkDirty() => IsDirty = true;
+
+ partial void OnDarkModeChanged(bool value) => MarkDirty();
+ partial void OnBlogsApiUrlChanged(string value) => MarkDirty();
+ partial void OnBusinessApiUrlChanged(string value) => MarkDirty();
+
+ ///
+ /// Authentication can be reassigned wholesale by
+ /// ; on each reassignment we (re)wire a
+ /// PropertyChanged listener so sub-property edits
+ /// (Authority, ClientId, RedirectUri, Scopes) are picked up
+ /// by the dirty tracker. We don't filter on PropertyName: any
+ /// nested setter is treated as a user edit, which matches the
+ /// user's mental model ("I typed in a field, the page is now
+ /// dirty").
+ ///
+ partial void OnAuthenticationChanged(AuthenticationSettings value)
+ {
+ if (value is not null)
+ {
+ value.PropertyChanged += (_, _) => MarkDirty();
+ }
+ MarkDirty();
+ }
+
public bool Loaded { get; private set; } = false;
+ ///
+ /// True when the in-memory state has drifted from the last
+ /// or snapshot. The
+ /// Settings page binds the Sauver button's IsEnabled to
+ /// this flag, so it only enables when the user has actually
+ /// touched something since the last load / save. Cleared by
+ /// (and by ), set by
+ /// every successful setter on the four top-level mutable
+ /// properties and on the sub-properties of
+ /// .
+ ///
+ [ObservableProperty]
+ public partial bool IsDirty { get; private set; } = false;
+
///
/// Guards every mutation of the observable state. [ObservableProperty]
/// generates setters that call SetProperty(...) which fires
@@ -319,6 +369,16 @@ public partial class Settings : ViewModelBase
this.Authentication.Scopes = settings.Authentication.Scopes;
}
}
+ // A disk load (or an embedded-resource fallback) is the
+ // baseline, not a user edit. Clear the dirty flag last
+ // so the OnAuthenticationChanged / sub-property fan-out
+ // triggered by the assignments above doesn't leave it
+ // stuck at true.
+ IsDirty = false;
+ // Re-notify the command in case the button was bound
+ // before Load finished and the CanExecute cache is
+ // stale.
+ SaveCommand.NotifyCanExecuteChanged();
}
catch (Exception ex)
{
@@ -326,6 +386,64 @@ public partial class Settings : ViewModelBase
}
}
+ ///
+ /// Persist the current in-memory state to
+ /// ~/.config/PostIt/postit-settings.json (Linux) /
+ /// equivalent %APPDATA%\PostIt\postit-settings.json
+ /// (Windows). Symmetrical to : same path,
+ /// same directory creation, same 0600 file mode (POSIX)
+ /// as TokenStore.Save. Clears
+ /// on success.
+ ///
+ /// Synchronous on purpose: matches 's
+ /// contract (the file is a few KiB at most, and the Avalonia
+ /// UI thread cannot await here without risking the same
+ /// deadlock 's docstring describes).
+ ///
+ ///
+ [RelayCommand(CanExecute = nameof(CanSave))]
+ public void Save()
+ {
+ var configDir = Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
+ "PostIt");
+ Directory.CreateDirectory(configDir);
+ var configPath = Path.Combine(configDir, SettingsFileName);
+
+ lock (_mutationGate)
+ {
+ try
+ {
+ var json = JsonSerializer.Serialize(this, new JsonSerializerOptions
+ {
+ WriteIndented = true,
+ });
+ File.WriteAllText(configPath, json);
+ if (OperatingSystem.IsLinux() || OperatingSystem.IsMacOS())
+ File.SetUnixFileMode(configPath,
+ UnixFileMode.UserRead | UnixFileMode.UserWrite);
+ IsDirty = false;
+ Console.WriteLine($"💾 Settings saved to {configPath}");
+ }
+ catch (Exception ex)
+ {
+ Console.Error.WriteLine($"🩎 Error saving settings to {configPath}: {ex.Message}");
+ throw;
+ }
+ }
+ }
+
+ private bool CanSave() => IsDirty;
+
+ ///
+ /// Re-notify the SaveCommand (generated by
+ /// [RelayCommand] on ) so XAML
+ /// re-evaluates CanExecute when the dirty flag flips
+ /// outside the scope of a direct save (e.g. on
+ /// / ).
+ ///
+ partial void OnIsDirtyChanged(bool value) => SaveCommand.NotifyCanExecuteChanged();
+
public override bool CanNavigateNext { get => false; protected set => throw new System.NotImplementedException(); }
public override bool CanNavigatePrevious { get => true; protected set => throw new System.NotImplementedException(); }
}
diff --git a/src/PostIt/PostIt/Views/SettingsPage.axaml b/src/PostIt/PostIt/Views/SettingsPage.axaml
index 2557317e..aeba19f0 100644
--- a/src/PostIt/PostIt/Views/SettingsPage.axaml
+++ b/src/PostIt/PostIt/Views/SettingsPage.axaml
@@ -13,6 +13,13 @@
+
+
+
+
+
+
+
@@ -34,5 +41,14 @@
+
+
+