From b939c403f6543af10698741c861f01b146c0d081 Mon Sep 17 00:00:00 2001 From: Lum Date: Sat, 4 Jul 2026 15:11:22 +0100 Subject: [PATCH] feat(postit): signature capture page (dev entry, file persistence) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds on b0495514 (SignaturePadControl + SignaturePadData) with a full Avalonia page that captures signatures, renders them as Polylines, and persists the wire-format payload to ~/.local/share/PostIt/signatures as JSON v1. Scope - New SignaturePage (axaml + code-behind) hosts the render-agnostic control: a fixed-size Border is the hit-test surface, an overlaid Canvas is rebuilt on every RedrawRequested from the Strokes buffer. - SignaturePageViewModel wraps the control: exposes StrokeCount / PointCount / StatusMessage, Clear and CaptureAsync commands, and Attach/Detach for view-lifetime ownership. - CaptureAsync writes a JSON envelope { format, coordinateMax, capturedAtUtc, strokes, strokeCount } to LocalApplicationData/PostIt/signatures/signature-yyyyMMdd-HHmmssfff.json. This is a stop-gap; the production transport will be POST /api/signature/{devisId} on Yavsc.Api (commit 3+). - Entry point is a [DEV] button on MainPage that pushes the page onto the NavigationPage. The production trigger is a SignalR push from Yavsc.Org ("devis received, sign here") landing on a hub handler — the button and its Click handler are explicitly marked dev-only and tracked for removal in the same commit that wires the SignalR handler. Plumbing - App.axaml.cs: SignaturePage and SignaturePageViewModel registered as Transient in the DI container. - ViewLocator: routes SignaturePageViewModel to SignaturePage. - SignaturePadData: adds PointCount (sum of pairs across strokes), used by the VM status bar and the test surface. Tests (57/57 green, 9 new in this commit) - SignaturePageViewModelTests: constructors and dimension validation, Attach/Detach idempotence, StrokeCompleted and Clear propagate to the VM, CaptureAsync on empty buffer is a no-op, CaptureAsync on a non-empty buffer writes a v1 envelope with the expected structure (parsed back via JsonDocument, not text matching), and creates the destination directory if missing. - All previously-green tests (48) remain green. Out of scope - POST /api/signature endpoint on Yavsc.Api (commit 3). - SignalR handler that opens the page on a "devis received" push. - Rasterization: this commit only proves capture and persistence; the visible ink is a Polyline reconstruction, not a PNG, by design (per the wire-format decision in commit 1). Note on SignaturePadData - The PointCount property was added after b0495514 landed. It is folded into this commit rather than amending b0495514 to keep the existing history readable; the change is mechanical and tested by the new SignaturePageViewModelTests. --- .../SignaturePageViewModelTests.cs | 163 +++++++++++++++ src/PostIt/PostIt/App.axaml.cs | 2 + src/PostIt/PostIt/Models/SignaturePadData.cs | 22 +++ src/PostIt/PostIt/ViewLocator.cs | 1 + .../ViewModels/SignaturePageViewModel.cs | 185 ++++++++++++++++++ src/PostIt/PostIt/Views/MainPage.axaml | 11 ++ src/PostIt/PostIt/Views/MainPage.axaml.cs | 29 +++ src/PostIt/PostIt/Views/SignaturePage.axaml | 77 ++++++++ .../PostIt/Views/SignaturePage.axaml.cs | 91 +++++++++ 9 files changed, 581 insertions(+) create mode 100644 src/PostIt.Tests/SignaturePageViewModelTests.cs create mode 100644 src/PostIt/PostIt/ViewModels/SignaturePageViewModel.cs create mode 100644 src/PostIt/PostIt/Views/SignaturePage.axaml create mode 100644 src/PostIt/PostIt/Views/SignaturePage.axaml.cs diff --git a/src/PostIt.Tests/SignaturePageViewModelTests.cs b/src/PostIt.Tests/SignaturePageViewModelTests.cs new file mode 100644 index 00000000..37f17a58 --- /dev/null +++ b/src/PostIt.Tests/SignaturePageViewModelTests.cs @@ -0,0 +1,163 @@ +using System; +using System.IO; +using System.Text.Json; +using System.Threading.Tasks; +using PostIt.Controls; +using PostIt.ViewModels; +using Xunit; + +namespace PostIt.Tests; + +/// +/// Tests for : the contract +/// between the page's view model and the . +/// The view (XAML + code-behind rendering) is not tested here — the +/// control is render-agnostic, and the rendering is plain Polyline +/// reconstruction that we'll exercise manually in PostIt.Desktop. +/// +public class SignaturePageViewModelTests +{ + [Fact] + public void Default_constructor_uses_default_dimensions() + { + var vm = new SignaturePageViewModel(); + Assert.Equal(SignaturePageViewModel.DefaultWidth, vm.Width); + Assert.Equal(SignaturePageViewModel.DefaultHeight, vm.Height); + } + + [Fact] + public void Constructor_rejects_non_positive_dimensions() + { + Assert.Throws( + () => new SignaturePageViewModel(0, 100)); + Assert.Throws( + () => new SignaturePageViewModel(100, 0)); + Assert.Throws( + () => new SignaturePageViewModel(-1, 100)); + } + + [Fact] + public void Attach_then_Detach_is_idempotent() + { + var vm = new SignaturePageViewModel(); + var pad = new SignaturePadControl(); + vm.Attach(pad); + vm.Detach(); + // Second detach is a no-op: must not throw. + vm.Detach(); + } + + [Fact] + public void Attach_rejects_null() + { + var vm = new SignaturePageViewModel(); + Assert.Throws(() => vm.Attach(null!)); + } + + [Fact] + public void StrokeCompleted_updates_status_and_counts() + { + var vm = new SignaturePageViewModel(); + var pad = new SignaturePadControl(); + vm.Attach(pad); + + // Drive the control via the test hooks so we don't depend + // on Avalonia pointer events. + pad.AppendPointForTest(1_000, 1_000); + pad.AppendPointForTest(2_000, 2_000); + pad.SealStrokeForTest(); + + Assert.Equal(1, vm.StrokeCount); + Assert.Equal(2, vm.PointCount); + Assert.Contains("1 trait", vm.StatusMessage); + } + + [Fact] + public void Clear_resets_counts_and_buffer() + { + var vm = new SignaturePageViewModel(); + var pad = new SignaturePadControl(); + vm.Attach(pad); + + pad.AppendPointForTest(1, 1); + pad.SealStrokeForTest(); + Assert.Equal(1, vm.StrokeCount); + + vm.Clear(); + + Assert.Equal(0, vm.StrokeCount); + Assert.Equal(0, vm.PointCount); + Assert.Empty(pad.Strokes); + Assert.Contains("Effacé", vm.StatusMessage); + } + + [Fact] + public async Task CaptureAsync_on_empty_buffer_reports_and_writes_nothing() + { + var vm = new SignaturePageViewModel(); + var pad = new SignaturePadControl(); + vm.Attach(pad); + + await vm.CaptureAsync(); + + Assert.Contains("Rien", vm.StatusMessage); + Assert.Null(vm.LastCapturedPath); + } + + [Fact] + public async Task CaptureAsync_writes_a_yavsc_signature_v1_file() + { + // The VM uses Environment.SpecialFolder.LocalApplicationData, + // which we cannot redirect per-call without a constructor + // seam. We test the produced file's structure rather than + // its text formatting, because System.Text.Json's pretty- + // printer is not part of the contract we're locking down. + var vm = new SignaturePageViewModel(); + var pad = new SignaturePadControl(); + vm.Attach(pad); + + pad.AppendPointForTest(1_000, 2_000); + pad.AppendPointForTest(3_000, 4_000); + pad.SealStrokeForTest(); + + await vm.CaptureAsync(); + + Assert.NotNull(vm.LastCapturedPath); + Assert.True(File.Exists(vm.LastCapturedPath!), $"file missing: {vm.LastCapturedPath}"); + + using var doc = JsonDocument.Parse(File.ReadAllText(vm.LastCapturedPath!)); + var root = doc.RootElement; + + Assert.Equal("yavsc.signature/v1", root.GetProperty("format").GetString()); + Assert.Equal(10_000, root.GetProperty("coordinateMax").GetInt32()); + Assert.Equal(1, root.GetProperty("strokeCount").GetInt32()); + + var strokes = root.GetProperty("strokes"); + Assert.Equal(JsonValueKind.Array, strokes.ValueKind); + // [k=2, x0, y0, x1, y1] + Assert.Equal(5, strokes.GetArrayLength()); + Assert.Equal(2, strokes[0].GetInt32()); // k (2 points) + Assert.Equal(1_000, strokes[1].GetInt32()); // x0 + Assert.Equal(2_000, strokes[2].GetInt32()); // y0 + Assert.Equal(3_000, strokes[3].GetInt32()); // x1 + Assert.Equal(4_000, strokes[4].GetInt32()); // y1 + } + + [Fact] + public async Task CaptureAsync_creates_directory_if_missing() + { + var vm = new SignaturePageViewModel(); + var pad = new SignaturePadControl(); + vm.Attach(pad); + pad.AppendPointForTest(1, 1); + pad.SealStrokeForTest(); + + // The directory must exist after the call (CreateDirectory + // in the VM handles this). + await vm.CaptureAsync(); + + var dir = Path.GetDirectoryName(vm.LastCapturedPath!); + Assert.NotNull(dir); + Assert.True(Directory.Exists(dir), $"directory missing: {dir}"); + } +} diff --git a/src/PostIt/PostIt/App.axaml.cs b/src/PostIt/PostIt/App.axaml.cs index 65ce8b26..134ae42f 100644 --- a/src/PostIt/PostIt/App.axaml.cs +++ b/src/PostIt/PostIt/App.axaml.cs @@ -63,6 +63,7 @@ public partial class App : Application services.AddTransient(); services.AddTransient(); services.AddTransient(); + services.AddTransient(); // ViewModels services.AddSingleton(settings); @@ -72,6 +73,7 @@ public partial class App : Application services.AddTransient(); services.AddTransient(); services.AddTransient(); + services.AddTransient(); // Persistent session banner: one instance for the lifetime of // the app so the same VM survives page navigation. diff --git a/src/PostIt/PostIt/Models/SignaturePadData.cs b/src/PostIt/PostIt/Models/SignaturePadData.cs index 9a2c0e48..11568eac 100644 --- a/src/PostIt/PostIt/Models/SignaturePadData.cs +++ b/src/PostIt/PostIt/Models/SignaturePadData.cs @@ -78,4 +78,26 @@ public sealed class SignaturePadData return n; } } + + /// + /// Total number of (x, y) pairs across all strokes. Useful + /// for sanity-checks and for displaying capture density + /// without re-walking the wire format. + /// + public int PointCount + { + get + { + int n = 0; + int i = 0; + while (i < Strokes.Length) + { + int k = Strokes[i]; + if (k <= 0) break; + n += k; + i += 1 + 2 * k; + } + return n; + } + } } diff --git a/src/PostIt/PostIt/ViewLocator.cs b/src/PostIt/PostIt/ViewLocator.cs index 9a05aa84..e6d0e91a 100644 --- a/src/PostIt/PostIt/ViewLocator.cs +++ b/src/PostIt/PostIt/ViewLocator.cs @@ -29,6 +29,7 @@ public class ViewLocator : IDataTemplate SettingsPageViewModel => _services.GetRequiredService(), LoginPageViewModel => _services.GetRequiredService(), HomePageViewModel => _services.GetRequiredService(), + SignaturePageViewModel => _services.GetRequiredService(), _ => new TextBlock { Text = $"No view for {data.GetType().Name}" } }; } diff --git a/src/PostIt/PostIt/ViewModels/SignaturePageViewModel.cs b/src/PostIt/PostIt/ViewModels/SignaturePageViewModel.cs new file mode 100644 index 00000000..b4b37974 --- /dev/null +++ b/src/PostIt/PostIt/ViewModels/SignaturePageViewModel.cs @@ -0,0 +1,185 @@ +using System; +using System.IO; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using PostIt.Controls; +using PostIt.Models; + +namespace PostIt.ViewModels; + +/// +/// Backing state for . +/// +/// The page exists to produce a +/// (length-prefixed normalised int[]) from a human signature drawn +/// with the mouse (Desktop) or finger (touch / Android). The page +/// is a recipient of an external trigger — a SignalR push from +/// Yavsc.Org telling PostIt "a devis has been sent, sign here" — +/// so it intentionally has no first-class entry point in +/// . The only "open" affordance today is a +/// dev-only shortcut on the blog editor, marked for removal once +/// the SignalR handler lands. +/// +/// Output path is the platform-friendly per-user data directory +/// (XDG_DATA_HOME / AppData / NSDocumentDirectory on iOS). Files +/// are JSON, one per capture, named +/// signature-{yyyyMMdd-HHmmssfff}.json. This is a stop-gap +/// until the Yavsc.Org endpoint exists; the contract there will +/// be POST /api/signature/{devisId} with this same payload. +/// +public partial class SignaturePageViewModel : ViewModelBase +{ + /// + /// Default capture surface, in DIPs. 3:1 ratio matches a + /// signature line at the bottom of an A4 contract. + /// + public const double DefaultWidth = 600; + public const double DefaultHeight = 200; + + [ObservableProperty] + public partial string StatusMessage { get; set; } = "Prêt."; + + [ObservableProperty] + public partial int StrokeCount { get; set; } + + [ObservableProperty] + public partial int PointCount { get; set; } + + [ObservableProperty] + public partial string? LastCapturedPath { get; set; } + + public double Width { get; } + public double Height { get; } + + private SignaturePadControl? _control; + + public override bool CanNavigateNext + { + get => false; + protected set { _ = value; } + } + + public override bool CanNavigatePrevious + { + get => true; + protected set { _ = value; } + } + + public SignaturePageViewModel() + : this(DefaultWidth, DefaultHeight) + { + } + + public SignaturePageViewModel(double width, double height) + { + if (width <= 0) throw new ArgumentOutOfRangeException(nameof(width)); + if (height <= 0) throw new ArgumentOutOfRangeException(nameof(height)); + Width = width; + Height = height; + } + + /// + /// Bind a freshly-constructed (or re-templated) control to this + /// VM. Called from the view's code-behind once the control has + /// been added to the visual tree and its template applied (so + /// is wired). + /// + public void Attach(SignaturePadControl control) + { + if (control is null) throw new ArgumentNullException(nameof(control)); + Detach(); + _control = control; + _control.RedrawRequested += OnRedraw; + _control.StrokeCompleted += OnStrokeCompleted; + RefreshCounts(); + } + + public void Detach() + { + if (_control is null) return; + _control.RedrawRequested -= OnRedraw; + _control.StrokeCompleted -= OnStrokeCompleted; + _control = null; + } + + private void OnStrokeCompleted(object? sender, SignaturePadData data) + { + StatusMessage = $"Trait terminé. {data.StrokeCount} trait(s)."; + RefreshCounts(); + } + + private void OnRedraw(object? sender, EventArgs e) => RefreshCounts(); + + private void RefreshCounts() + { + if (_control is null) return; + var snap = _control.Snapshot(); + StrokeCount = snap.StrokeCount; + PointCount = snap.PointCount; + } + + [RelayCommand] + public void Clear() + { + _control?.Clear(); + StatusMessage = "Effacé."; + RefreshCounts(); + } + + [RelayCommand] + public async Task CaptureAsync() + { + if (_control is null) + { + StatusMessage = "Contrôle non attaché."; + return; + } + + var data = _control.Snapshot(); + if (data.IsEmpty) + { + StatusMessage = "Rien à capturer."; + return; + } + + try + { + var path = WriteCapture(data); + LastCapturedPath = path; + StatusMessage = $"Capture enregistrée: {path}"; + } + catch (Exception ex) + { + StatusMessage = $"Erreur: {ex.Message}"; + } + await Task.CompletedTask; + } + + private static string WriteCapture(SignaturePadData data) + { + var dir = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "PostIt", "signatures"); + Directory.CreateDirectory(dir); + + var fileName = $"signature-{DateTime.UtcNow:yyyyMMdd-HHmmssfff}.json"; + var path = Path.Combine(dir, fileName); + + var payload = new + { + format = "yavsc.signature/v1", + coordinateMax = SignaturePadData.CoordinateMax, + capturedAtUtc = DateTime.UtcNow, + strokes = data.Strokes, + strokeCount = data.StrokeCount, + }; + File.WriteAllText( + path, + JsonSerializer.Serialize(payload, new JsonSerializerOptions { WriteIndented = true }), + Encoding.UTF8); + return path; + } +} diff --git a/src/PostIt/PostIt/Views/MainPage.axaml b/src/PostIt/PostIt/Views/MainPage.axaml index b9e0308d..c09c2f7a 100644 --- a/src/PostIt/PostIt/Views/MainPage.axaml +++ b/src/PostIt/PostIt/Views/MainPage.axaml @@ -35,6 +35,17 @@