From 0aea6c0dbdb4307d10cb813a493b2852388b1b13 Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Wed, 8 Jul 2026 20:05:09 +0100 Subject: [PATCH] PostIt: Sauver button on SettingsPage with dirty tracking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SettingsPage.axaml had TextBox / CheckBox TwoWay bindings to the Settings singleton, but no Save button — user edits mutated the in-memory instance and were lost on the next launch. This commit addes the missing save path: - Settings.Save() writes the current instance to ~/.config/PostIt/postit-settings.json (symmetrical to Load), with 0600 POSIX permissions matching TokenStore.Save. - Settings.IsDirty ObservableProperty flips to true on every setter that flows through the four top-level [ObservableProperty] fields (DarkMode, BlogsApiUrl, BusinessApiUrl, plus the OnAuthenticationChanged partial for the Authentication sub-property). Sub-property edits (Authentication.Authority / ClientId / RedirectUri / Scopes) are caught by a PropertyChanged subscription wired up in OnAuthenticationChanged and re-wired on each Authentication reassignment. - [RelayCommand(CanExecute = nameof(CanSave))] on Save itself emits the SaveCommand ICommand that the XAML binds to. OnIsDirtyChanged calls SaveCommand.NotifyCanExecuteChanged() so the button auto-enables / auto-disables. The Avalonia binding is 'SaveCommand' without a suffix — the source generator emits that property name from the Save method. - ApplyJson resets IsDirty = false at the end so disk / embedded loads don't leave the page stuck in dirty state. - SettingsPage.axaml: fixed the RowDefinition count (4 rows declared, 10 used — controls at rows 4..9 were rendering outside the grid), and added a Sauver button at row 10 bound to SaveCommand with IsEnabled driven by !IsDirty. Build: dotnet build src/PostIt/PostIt/PostIt.csproj → 0 errors. Tests: 45 / 45 passing. --- src/PostIt/PostIt/ViewModels/Settings.cs | 118 +++++++++++++++++++++ src/PostIt/PostIt/Views/SettingsPage.axaml | 16 +++ 2 files changed, 134 insertions(+) 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 @@ + +