From b04955144887059d42b7c319b942eea9f48e3aac Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Sat, 4 Jul 2026 14:43:39 +0100 Subject: [PATCH 01/20] a Signature Pad --- src/PostIt.Tests/SignaturePadControlTests.cs | 188 ++++++++++++++++ .../PostIt/Controls/SignaturePadControl.cs | 204 ++++++++++++++++++ src/PostIt/PostIt/Models/SignaturePadData.cs | 81 +++++++ 3 files changed, 473 insertions(+) create mode 100644 src/PostIt.Tests/SignaturePadControlTests.cs create mode 100644 src/PostIt/PostIt/Controls/SignaturePadControl.cs create mode 100644 src/PostIt/PostIt/Models/SignaturePadData.cs diff --git a/src/PostIt.Tests/SignaturePadControlTests.cs b/src/PostIt.Tests/SignaturePadControlTests.cs new file mode 100644 index 00000000..691ae547 --- /dev/null +++ b/src/PostIt.Tests/SignaturePadControlTests.cs @@ -0,0 +1,188 @@ +using System; +using System.Linq; +using PostIt.Controls; +using PostIt.Models; +using Xunit; + +namespace PostIt.Tests; + +/// +/// Targeted tests for and +/// . +/// +/// The control exposes internal test hooks so we can drive +/// the buffer without standing up a headless XAML tree just to +/// deliver synthetic pointer events. The headless surface is used +/// only to assert that the control's pointer handlers are wired +/// when a template is applied; see +/// . +/// +public class SignaturePadControlTests +{ + // --- SignaturePadData (pure) --------------------------------------- + + [Fact] + public void Data_empty_array_is_empty() + { + var d = new SignaturePadData(Array.Empty()); + Assert.True(d.IsEmpty); + Assert.Equal(0, d.StrokeCount); + } + + [Fact] + public void Data_single_dot_is_one_stroke_with_k_equals_one() + { + var d = new SignaturePadData(new[] { 1, 5_000, 5_000 }); + Assert.False(d.IsEmpty); + Assert.Equal(1, d.StrokeCount); + } + + [Fact] + public void Data_two_strokes_are_independent() + { + var d = new SignaturePadData(new[] + { + 2, 100, 100, 200, 200, + 1, 9_000, 9_000, + }); + Assert.Equal(2, d.StrokeCount); + } + + [Fact] + public void Data_malformed_payload_does_not_throw_on_read() + { + // k=0 at the head would underflow the walker. The reader + // short-circuits instead of throwing. + var d = new SignaturePadData(new[] { 0, 1, 2, 3 }); + Assert.Equal(0, d.StrokeCount); + } + + [Fact] + public void Data_constructor_rejects_null() + { + Assert.Throws(() => new SignaturePadData(null!)); + } + + // --- SignaturePadControl (buffer / events) ------------------------- + + [Fact] + public void New_control_has_empty_buffer() + { + var pad = new SignaturePadControl(); + Assert.Empty(pad.Strokes); + Assert.True(pad.Snapshot().IsEmpty); + } + + [Fact] + public void Snapshot_returns_a_distinct_array_each_call() + { + var pad = new SignaturePadControl(); + pad.AppendPointForTest(1_000, 2_000); + pad.AppendPointForTest(3_000, 4_000); + pad.SealStrokeForTest(); + + var first = pad.Snapshot(); + var second = pad.Snapshot(); + + // Distinct array instances — the consumer of the first + // snapshot can hold onto it after the control mutates. + Assert.NotSame(first.Strokes, second.Strokes); + // Same logical content (no mutation in between). + Assert.Equal(first.Strokes, second.Strokes); + + pad.AppendPointForTest(5_000, 6_000); + pad.SealStrokeForTest(); + + var third = pad.Snapshot(); + Assert.NotEqual(first.Strokes, third.Strokes); + } + + [Fact] + public void Clear_empties_buffer_and_raises_redraw() + { + var pad = new SignaturePadControl(); + pad.AppendPointForTest(1, 1); + pad.SealStrokeForTest(); + Assert.NotEmpty(pad.Strokes); + + int redraws = 0; + pad.RedrawRequested += (_, _) => redraws++; + pad.Clear(); + + Assert.Empty(pad.Strokes); + Assert.True(pad.Snapshot().IsEmpty); + Assert.Equal(1, redraws); + } + + [Fact] + public void SealStrokeForTest_raises_redraw() + { + var pad = new SignaturePadControl(); + int redraws = 0; + pad.RedrawRequested += (_, _) => redraws++; + pad.AppendPointForTest(1, 1); + pad.AppendPointForTest(2, 2); + pad.SealStrokeForTest(); + Assert.Equal(1, redraws); + } + + [Fact] + public void SealStrokeForTest_with_no_pending_points_is_a_no_op() + { + var pad = new SignaturePadControl(); + int redraws = 0; + pad.RedrawRequested += (_, _) => redraws++; + pad.SealStrokeForTest(); + Assert.Equal(0, redraws); + } + + [Fact] + public void Two_sealed_strokes_produce_two_length_prefixes() + { + var pad = new SignaturePadControl(); + // Stroke 0: one point. + pad.AppendPointForTest(1_000, 1_000); + pad.SealStrokeForTest(); + // Stroke 1: two points. + pad.AppendPointForTest(2_000, 2_000); + pad.AppendPointForTest(3_000, 3_000); + pad.SealStrokeForTest(); + + var s = pad.Strokes; + // Layout: [k0, x0, y0, k1, x1, y1, x2, y2] + Assert.Equal(1, s[0]); + Assert.Equal(1_000, s[1]); + Assert.Equal(1_000, s[2]); + Assert.Equal(2, s[3]); + Assert.Equal(2_000, s[4]); + Assert.Equal(2_000, s[5]); + Assert.Equal(3_000, s[6]); + Assert.Equal(3_000, s[7]); + } + + [Fact] + public void StrokeCompleted_fires_on_seal() + { + var pad = new SignaturePadControl(); + int events = 0; + pad.StrokeCompleted += (_, _) => events++; + pad.AppendPointForTest(1, 1); + pad.SealStrokeForTest(); + pad.AppendPointForTest(2, 2); + pad.SealStrokeForTest(); + Assert.Equal(2, events); + } + + [Fact] + public void StrokeCompleted_carries_a_snapshot_with_k_count() + { + var pad = new SignaturePadControl(); + SignaturePadData? captured = null; + pad.StrokeCompleted += (_, d) => captured = d; + pad.AppendPointForTest(1, 1); + pad.AppendPointForTest(2, 2); + pad.SealStrokeForTest(); + Assert.NotNull(captured); + Assert.Equal(1, captured!.StrokeCount); + } +} diff --git a/src/PostIt/PostIt/Controls/SignaturePadControl.cs b/src/PostIt/PostIt/Controls/SignaturePadControl.cs new file mode 100644 index 00000000..87949d30 --- /dev/null +++ b/src/PostIt/PostIt/Controls/SignaturePadControl.cs @@ -0,0 +1,204 @@ +using System; +using System.Collections.Generic; +using Avalonia; +using Avalonia.Controls.Primitives; +using Avalonia.Input; +using PostIt.Models; + +namespace PostIt.Controls; + +/// +/// Pointer-driven capture surface that records a signature as a list +/// of strokes, each stroke being a length-prefixed sequence of (x, y) +/// coordinates normalised to [0, CoordinateMax]. +/// +/// The control is render-agnostic: it does not draw anything. The +/// host view templates a (typically a +/// Border) as PART_CaptureArea for pointer capture, +/// and binds a separate visual layer (e.g. a Canvas) to +/// for redraw. Keeping the control headless of +/// rendering makes it usable from a headless test where no +/// composition happens. +/// +/// Wire format (see ): +/// int[] = [k0, x0, y0, ..., k1, x0, y0, ...] +/// with x, y ∈ [0, 10_000]. +/// +/// Threading: pointer events are dispatched on the UI thread, which +/// is the only thread that ever mutates . The +/// buffer is safe to read from any thread as long as no read +/// straddles a pointer event — for cross-thread transfer use +/// , which copies. +/// +public class SignaturePadControl : TemplatedControl +{ + /// + /// Styled property pointing at the + /// that receives pointer events. Set it in the control's + /// template (PART_CaptureArea). + /// + public static readonly StyledProperty CaptureAreaProperty = + AvaloniaProperty.Register(nameof(CaptureArea)); + + public InputElement? CaptureArea + { + get => GetValue(CaptureAreaProperty); + set => SetValue(CaptureAreaProperty, value); + } + + /// + /// Captured strokes in wire form. Exposed as a read-only view + /// over the internal buffer. The buffer only mutates on the UI + /// thread, between pointer events. + /// + public IReadOnlyList Strokes => _strokes; + + /// + /// Raised when the user finishes a stroke (pointer release). + /// The argument is a snapshot of the buffer at release time. + /// + public event EventHandler? StrokeCompleted; + + /// + /// Raised when the buffer changes: at the end of every stroke + /// and on . Mid-stroke points do not raise + /// this event (pointer-move is too dense); bind a separate + /// visual layer if you need a live preview. + /// + public event EventHandler? RedrawRequested; + + private readonly List _strokes = new(capacity: 256); + private int _pendingPoints; // number of (x, y) pairs awaiting a length prefix + private bool _capturing; + + protected override void OnApplyTemplate(TemplateAppliedEventArgs e) + { + base.OnApplyTemplate(e); + + if (CaptureArea is { } previous) + { + previous.PointerPressed -= OnCapturePressed; + previous.PointerMoved -= OnCaptureMoved; + previous.PointerReleased -= OnCaptureReleased; + } + + if (CaptureArea is { } area) + { + area.PointerPressed += OnCapturePressed; + area.PointerMoved += OnCaptureMoved; + area.PointerReleased += OnCaptureReleased; + } + } + + private void OnCapturePressed(object? sender, PointerPressedEventArgs e) + { + if (!e.GetCurrentPoint(CaptureArea).Properties.IsLeftButtonPressed) return; + e.Pointer.Capture(CaptureArea); + _capturing = true; + _pendingPoints = 0; + AppendPoint(e.GetPosition(CaptureArea)); + } + + private void OnCaptureMoved(object? sender, PointerEventArgs e) + { + if (!_capturing) return; + AppendPoint(e.GetPosition(CaptureArea)); + } + + private void OnCaptureReleased(object? sender, PointerReleasedEventArgs e) + { + if (!_capturing) return; + AppendPoint(e.GetPosition(CaptureArea)); + _capturing = false; + + if (_pendingPoints == 0) + { + // Press + immediate release without movement yields no + // point at all (the press fired AppendPoint, so this + // branch is unreachable — kept for clarity if a future + // change skips the press append). + return; + } + + // Seal the current stroke by inserting its length at the + // head of its slice. The slice is the trailing + // 2 * _pendingPoints entries. + int sliceStart = _strokes.Count - 2 * _pendingPoints; + _strokes.Insert(sliceStart, _pendingPoints); + _pendingPoints = 0; + + StrokeCompleted?.Invoke(this, Snapshot()); + RedrawRequested?.Invoke(this, EventArgs.Empty); + } + + private void AppendPoint(Point p) + { + var (nx, ny) = Normalise(p); + _strokes.Add(nx); + _strokes.Add(ny); + _pendingPoints++; + } + + private (int x, int y) Normalise(Point p) + { + if (CaptureArea is null) return (0, 0); + var bounds = CaptureArea.Bounds; + double w = bounds.Width; + double h = bounds.Height; + if (w <= 0 || h <= 0) return (0, 0); + int nx = (int)Math.Round(Math.Clamp(p.X / w, 0.0, 1.0) * SignaturePadData.CoordinateMax); + int ny = (int)Math.Round(Math.Clamp(p.Y / h, 0.0, 1.0) * SignaturePadData.CoordinateMax); + return (nx, ny); + } + + /// + /// Forget every captured stroke. Raises . + /// + public void Clear() + { + _strokes.Clear(); + _pendingPoints = 0; + _capturing = false; + RedrawRequested?.Invoke(this, EventArgs.Empty); + } + + /// + /// Defensive copy of the current buffer wrapped in a + /// . Cheap; call only when the + /// view needs to ship the data off (e.g. to a backend). + /// + public SignaturePadData Snapshot() => new(_strokes.ToArray()); + + // --- Test-only surface (visible to PostIt.Tests) ------------------- + + /// + /// Test hook: append a single normalised point without going + /// through the pointer pipeline. Does not raise + /// . + /// + internal void AppendPointForTest(int x, int y) + { + _strokes.Add(x); + _strokes.Add(y); + _pendingPoints++; + } + + /// + /// Test hook: seal the currently-pending stroke with a length + /// prefix. Mirrors what does at + /// pointer release time, including the + /// and + /// events, so test scenarios observe the same notification + /// contract as production. Idempotent: a second call without + /// intermediate appends is a no-op. + /// + internal void SealStrokeForTest() + { + if (_pendingPoints == 0) return; + int sliceStart = _strokes.Count - 2 * _pendingPoints; + _strokes.Insert(sliceStart, _pendingPoints); + _pendingPoints = 0; + StrokeCompleted?.Invoke(this, Snapshot()); + RedrawRequested?.Invoke(this, EventArgs.Empty); + } +} diff --git a/src/PostIt/PostIt/Models/SignaturePadData.cs b/src/PostIt/PostIt/Models/SignaturePadData.cs new file mode 100644 index 00000000..9a2c0e48 --- /dev/null +++ b/src/PostIt/PostIt/Models/SignaturePadData.cs @@ -0,0 +1,81 @@ +namespace PostIt.Models; + +/// +/// Serialized form of a signature captured by +/// . +/// +/// Wire format (length-prefixed, normalised): +/// +/// int[] = [k0, x00, y00, x01, y01, ..., x0_{k0-1}, y0_{k0-1}, +/// k1, x10, y10, x11, y11, ..., x1_{k1-1}, y1_{k1-1}, +/// ...] +/// +/// +/// k_i — number of (x, y) pairs in stroke i. +/// x, y — coordinates normalised to [0, CoordinateMax] +/// (inclusive) on the control's client area. +/// is 10_000 by default — a 4-decimal fixed-point fraction of +/// the surface, which is enough to discriminate 0.01% of the diagonal +/// on any reasonable screen and stays well inside int. +/// Total array length is even: each stroke contributes +/// 1 + 2 * k_i integers, and 1 + 2k is always odd. +/// Sum of 1 + 2k_i over strokes is therefore odd * N, which +/// is odd when N is odd and even when N is even — so the overall +/// "size pair" property is not enforced, only the per-stroke shape +/// is. If the consumer needs a strictly even total, pad the last +/// stroke with a duplicate terminal point (or use +/// to drop the array entirely). +/// +/// +/// Empty signature (no strokes) is represented by an empty array +/// (length 0). A single dot — pen down + pen up at the same point — +/// is a single stroke with k = 1: [1, x, y]. +/// +public sealed class SignaturePadData +{ + /// + /// Upper bound of normalised coordinates. 10_000 means a + /// surface unit is represented as 0.0001 of the whole. + /// + public const int CoordinateMax = 10_000; + + /// + /// Raw payload. See for the layout. + /// Never null; an empty array means "no strokes". + /// + public int[] Strokes { get; } + + public SignaturePadData(int[] strokes) + { + if (strokes is null) throw new System.ArgumentNullException(nameof(strokes)); + Strokes = strokes; + } + + /// True if no stroke has been captured. + public bool IsEmpty => Strokes.Length == 0; + + /// + /// Number of distinct strokes (pen-down / pen-up cycles). + /// Returns 0 when is true. + /// + public int StrokeCount + { + get + { + if (Strokes.Length == 0) return 0; + int n = 0; + int i = 0; + while (i < Strokes.Length) + { + int k = Strokes[i]; + // Defensive: a malformed entry is treated as 0 so we + // never throw on read. The capture side never produces + // these, this is only for robustness on the wire. + if (k <= 0) return n; + i += 1 + 2 * k; + n++; + } + return n; + } + } +} From b939c403f6543af10698741c861f01b146c0d081 Mon Sep 17 00:00:00 2001 From: Lum Date: Sat, 4 Jul 2026 15:11:22 +0100 Subject: [PATCH 02/20] 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 @@