PostIt: Sauver button on SettingsPage with dirty tracking
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.
This commit is contained in:
parent
e9df13a477
commit
0aea6c0dbd
2 changed files with 134 additions and 0 deletions
|
|
@ -1,5 +1,6 @@
|
||||||
using System.Runtime.CompilerServices;
|
using System.Runtime.CompilerServices;
|
||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using CommunityToolkit.Mvvm.Input;
|
||||||
using IdentityModel.OidcClient;
|
using IdentityModel.OidcClient;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using System;
|
using System;
|
||||||
|
|
@ -106,8 +107,57 @@ public partial class Settings : ViewModelBase
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
public partial string BusinessApiUrl { get; set; } = "https://business.pschneider.fr/api/v1/";
|
public partial string BusinessApiUrl { get; set; } = "https://business.pschneider.fr/api/v1/";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Catch top-level mutations: the four ObservableProperty
|
||||||
|
/// setters above all funnel through here, and we flip
|
||||||
|
/// <see cref="IsDirty"/> in lock-step. Sub-property mutations
|
||||||
|
/// (e.g. <c>Authentication.Authority</c>) are caught by the
|
||||||
|
/// subscription wired up in <see cref="OnAuthenticationChanged"/>
|
||||||
|
/// below. <see cref="ApplyJson"/> disables the flag during bulk
|
||||||
|
/// hydration so the disk load itself does not count as a user
|
||||||
|
/// edit.
|
||||||
|
/// </summary>
|
||||||
|
private void MarkDirty() => IsDirty = true;
|
||||||
|
|
||||||
|
partial void OnDarkModeChanged(bool value) => MarkDirty();
|
||||||
|
partial void OnBlogsApiUrlChanged(string value) => MarkDirty();
|
||||||
|
partial void OnBusinessApiUrlChanged(string value) => MarkDirty();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Authentication can be reassigned wholesale by
|
||||||
|
/// <see cref="ApplyJson"/>; on each reassignment we (re)wire a
|
||||||
|
/// <c>PropertyChanged</c> 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").
|
||||||
|
/// </summary>
|
||||||
|
partial void OnAuthenticationChanged(AuthenticationSettings value)
|
||||||
|
{
|
||||||
|
if (value is not null)
|
||||||
|
{
|
||||||
|
value.PropertyChanged += (_, _) => MarkDirty();
|
||||||
|
}
|
||||||
|
MarkDirty();
|
||||||
|
}
|
||||||
|
|
||||||
public bool Loaded { get; private set; } = false;
|
public bool Loaded { get; private set; } = false;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// True when the in-memory state has drifted from the last
|
||||||
|
/// <see cref="Load"/> or <see cref="Save"/> snapshot. The
|
||||||
|
/// Settings page binds the Sauver button's <c>IsEnabled</c> to
|
||||||
|
/// this flag, so it only enables when the user has actually
|
||||||
|
/// touched something since the last load / save. Cleared by
|
||||||
|
/// <see cref="Load"/> (and by <see cref="ApplyJson"/>), set by
|
||||||
|
/// every successful setter on the four top-level mutable
|
||||||
|
/// properties and on the sub-properties of
|
||||||
|
/// <see cref="Authentication"/>.
|
||||||
|
/// </summary>
|
||||||
|
[ObservableProperty]
|
||||||
|
public partial bool IsDirty { get; private set; } = false;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Guards every mutation of the observable state. <c>[ObservableProperty]</c>
|
/// Guards every mutation of the observable state. <c>[ObservableProperty]</c>
|
||||||
/// generates setters that call <c>SetProperty(...)</c> which fires
|
/// generates setters that call <c>SetProperty(...)</c> which fires
|
||||||
|
|
@ -319,6 +369,16 @@ public partial class Settings : ViewModelBase
|
||||||
this.Authentication.Scopes = settings.Authentication.Scopes;
|
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)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
|
@ -326,6 +386,64 @@ public partial class Settings : ViewModelBase
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Persist the current in-memory state to
|
||||||
|
/// <c>~/.config/PostIt/postit-settings.json</c> (Linux) /
|
||||||
|
/// equivalent <c>%APPDATA%\PostIt\postit-settings.json</c>
|
||||||
|
/// (Windows). Symmetrical to <see cref="Load"/>: same path,
|
||||||
|
/// same directory creation, same <c>0600</c> file mode (POSIX)
|
||||||
|
/// as <c>TokenStore.Save</c>. Clears <see cref="IsDirty"/>
|
||||||
|
/// on success.
|
||||||
|
///
|
||||||
|
/// <para>Synchronous on purpose: matches <see cref="Load"/>'s
|
||||||
|
/// contract (the file is a few KiB at most, and the Avalonia
|
||||||
|
/// UI thread cannot await here without risking the same
|
||||||
|
/// deadlock <see cref="Load"/>'s docstring describes).
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
[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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Re-notify the <c>SaveCommand</c> (generated by
|
||||||
|
/// <c>[RelayCommand]</c> on <see cref="Save"/>) so XAML
|
||||||
|
/// re-evaluates <c>CanExecute</c> when the dirty flag flips
|
||||||
|
/// outside the scope of a direct save (e.g. on <see cref="Load"/>
|
||||||
|
/// / <see cref="ApplyJson"/>).
|
||||||
|
/// </summary>
|
||||||
|
partial void OnIsDirtyChanged(bool value) => SaveCommand.NotifyCanExecuteChanged();
|
||||||
|
|
||||||
public override bool CanNavigateNext { get => false; protected set => throw new System.NotImplementedException(); }
|
public override bool CanNavigateNext { get => false; protected set => throw new System.NotImplementedException(); }
|
||||||
public override bool CanNavigatePrevious { get => true; protected set => throw new System.NotImplementedException(); }
|
public override bool CanNavigatePrevious { get => true; protected set => throw new System.NotImplementedException(); }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,13 @@
|
||||||
<RowDefinition Height="Auto"/>
|
<RowDefinition Height="Auto"/>
|
||||||
<RowDefinition Height="Auto"/>
|
<RowDefinition Height="Auto"/>
|
||||||
<RowDefinition Height="Auto"/>
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
</Grid.RowDefinitions>
|
</Grid.RowDefinitions>
|
||||||
|
|
||||||
<TextBlock Grid.Row="0" Text="Authority"/>
|
<TextBlock Grid.Row="0" Text="Authority"/>
|
||||||
|
|
@ -34,5 +41,14 @@
|
||||||
<TextBlock Grid.Row="8" Text="Dark mode"/>
|
<TextBlock Grid.Row="8" Text="Dark mode"/>
|
||||||
<CheckBox Grid.Row="9" x:Name="DarkModeCheckBox" IsChecked="{Binding DarkMode, Mode=TwoWay}"/>
|
<CheckBox Grid.Row="9" x:Name="DarkModeCheckBox" IsChecked="{Binding DarkMode, Mode=TwoWay}"/>
|
||||||
|
|
||||||
|
<!-- Sauver: bound to the SaveCommand on the Settings VM, with
|
||||||
|
IsEnabled driven by the inverse of IsDirty so the button
|
||||||
|
auto-disables when there's nothing to persist. -->
|
||||||
|
<Button Grid.Row="10" Content="Sauver"
|
||||||
|
HorizontalAlignment="Right"
|
||||||
|
Margin="0,12,0,0"
|
||||||
|
Command="{Binding SaveCommand}"
|
||||||
|
IsEnabled="{Binding !IsDirty}"/>
|
||||||
|
|
||||||
</Grid>
|
</Grid>
|
||||||
</ContentPage>
|
</ContentPage>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue