feat(postit): signature capture page (dev entry, file persistence)

Builds on f9cfd560 (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 f9cfd560 landed. It is
  folded into this commit rather than amending f9cfd560 to keep
  the existing history readable; the change is mechanical and
  tested by the new SignaturePageViewModelTests.
This commit is contained in:
Lum 2026-07-04 15:11:22 +01:00
commit 7397f54ea2
9 changed files with 581 additions and 0 deletions

View file

@ -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;
/// <summary>
/// Backing state for <see cref="PostIt.Views.SignaturePage"/>.
///
/// The page exists to produce a <see cref="SignaturePadData"/>
/// (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
/// <see cref="MainPage"/>. 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
/// <c>signature-{yyyyMMdd-HHmmssfff}.json</c>. This is a stop-gap
/// until the Yavsc.Org endpoint exists; the contract there will
/// be <c>POST /api/signature/{devisId}</c> with this same payload.
/// </summary>
public partial class SignaturePageViewModel : ViewModelBase
{
/// <summary>
/// Default capture surface, in DIPs. 3:1 ratio matches a
/// signature line at the bottom of an A4 contract.
/// </summary>
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;
}
/// <summary>
/// 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
/// <see cref="SignaturePadControl.CaptureArea"/> is wired).
/// </summary>
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;
}
}