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

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.
This commit is contained in:
Lum 2026-07-04 15:11:22 +01:00
commit b939c403f6
9 changed files with 581 additions and 0 deletions

View file

@ -63,6 +63,7 @@ public partial class App : Application
services.AddTransient<LoginPage>();
services.AddTransient<SettingsPage>();
services.AddTransient<HomePage>();
services.AddTransient<SignaturePage>();
// ViewModels
services.AddSingleton(settings);
@ -72,6 +73,7 @@ public partial class App : Application
services.AddTransient<SettingsPageViewModel>();
services.AddTransient<LoginPageViewModel>();
services.AddTransient<HomePageViewModel>();
services.AddTransient<SignaturePageViewModel>();
// Persistent session banner: one instance for the lifetime of
// the app so the same VM survives page navigation.

View file

@ -78,4 +78,26 @@ public sealed class SignaturePadData
return n;
}
}
/// <summary>
/// Total number of (x, y) pairs across all strokes. Useful
/// for sanity-checks and for displaying capture density
/// without re-walking the wire format.
/// </summary>
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;
}
}
}

View file

@ -29,6 +29,7 @@ public class ViewLocator : IDataTemplate
SettingsPageViewModel => _services.GetRequiredService<SettingsPage>(),
LoginPageViewModel => _services.GetRequiredService<LoginPage>(),
HomePageViewModel => _services.GetRequiredService<HomePage>(),
SignaturePageViewModel => _services.GetRequiredService<SignaturePage>(),
_ => new TextBlock { Text = $"No view for {data.GetType().Name}" }
};
}

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;
}
}

View file

@ -35,6 +35,17 @@
<Button Command="{Binding New}" Content="New post" />
<Button Command="{Binding Save}" Content="Save" />
<Button Command="{Binding Delete}" Content="Delete" />
<!--
DEV ONLY: temporary shortcut to open the signature
capture page. Production entry point is a SignalR
push from Yavsc.Org ("devis received, sign here").
Remove this button and its Click handler in
MainPage.axaml.cs once the SignalR handler lands.
-->
<Button x:Name="OpenSignatureDevButton"
Content="[DEV] Signature"
Click="OpenSignatureDev"
ToolTip.Tip="DEV ONLY — to remove when SignalR handler lands" />
</StackPanel>
</StackPanel>

View file

@ -1,5 +1,8 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Interactivity;
using Microsoft.Extensions.DependencyInjection;
using PostIt.ViewModels;
namespace PostIt.Views;
@ -10,4 +13,30 @@ public partial class MainPage : ContentPage
InitializeComponent();
}
/// <summary>
/// DEV ONLY: temporary shortcut to open the signature capture
/// page from the blog editor. The production entry point is a
/// SignalR push from Yavsc.Org ("devis received, sign here"),
/// which is the only path that carries the devis identifier
/// needed to bind the capture to a specific contract.
///
/// Remove this method and the corresponding button in
/// MainPage.axaml.cs once the SignalR handler lands.
/// </summary>
private void OpenSignatureDev(object? sender, RoutedEventArgs e)
{
// Resolve via the App's DI container so the page gets
// the canonical services (Api client, settings, ...).
var app = Application.Current as App;
var services = app?.Services;
if (services is null) return;
var page = services.GetRequiredService<SignaturePage>();
page.DataContext = services.GetRequiredService<SignaturePageViewModel>();
if (this.VisualRoot is MainWindow window)
{
_ = window.NavRoot.PushAsync(page);
}
}
}

View file

@ -0,0 +1,77 @@
<ContentPage xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://github.com/avaloniaui/avalonia"
xmlns:vm="using:PostIt.ViewModels"
xmlns:controls="using:PostIt.Controls"
x:Class="PostIt.Views.SignaturePage"
x:DataType="vm:SignaturePageViewModel"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch">
<Design.DataContext>
<vm:SignaturePageViewModel />
</Design.DataContext>
<Grid Margin="12" RowSpacing="12"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<TextBlock Grid.Row="0"
Text="Capture de signature"
FontSize="20"
FontWeight="Bold" />
<!--
Capture surface. The SignaturePadControl is render-
agnostic, so we host it inside a fixed-size Border
that is the PART_CaptureArea, and overlay a Canvas
for the visual feedback. The view's code-behind
repaints the canvas on every RedrawRequested.
-->
<Border Grid.Row="1"
x:Name="PadFrame"
Width="{Binding Width}"
Height="{Binding Height}"
BorderBrush="Black"
BorderThickness="1"
Background="White"
HorizontalAlignment="Left"
VerticalAlignment="Top"
ClipToBounds="True">
<Grid>
<controls:SignaturePadControl x:Name="Pad" />
<Canvas x:Name="InkLayer"
Background="Transparent"
IsHitTestVisible="False" />
</Grid>
</Border>
<StackPanel Grid.Row="2"
Orientation="Horizontal"
Spacing="8">
<Button Content="Effacer" Command="{Binding Clear}" />
<Button Content="Capturer" Command="{Binding CaptureAsync}" />
</StackPanel>
<TextBlock Grid.Row="3"
Text="{Binding StatusMessage}"
Foreground="Gray"
TextWrapping="Wrap" />
<TextBlock Grid.Row="4"
FontSize="11"
Foreground="DarkSlateGray"
TextWrapping="Wrap">
<Run Text="Strokes: " />
<Run Text="{Binding StrokeCount, Mode=OneWay}" />
<Run Text=" · Points: " />
<Run Text="{Binding PointCount, Mode=OneWay}" />
</TextBlock>
</Grid>
</ContentPage>

View file

@ -0,0 +1,91 @@
using System;
using System.Collections.Generic;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Shapes;
using Avalonia.Media;
using PostIt.Controls;
using PostIt.ViewModels;
namespace PostIt.Views;
public partial class SignaturePage : ContentPage
{
private static readonly IBrush StrokeBrush = new SolidColorBrush(Color.FromRgb(0x10, 0x10, 0x10));
private const double StrokeThickness = 2.0;
private const double CoordinateMax = 10_000.0;
private SignaturePageViewModel? _vm;
private SignaturePadControl? _control;
public SignaturePage()
{
InitializeComponent();
// Wire the capture area: the Pad itself is the control, the
// surrounding Border (PadFrame) is the hit-test region. We
// set CaptureArea once the control's template has been
// applied — for an inline control with no template, that
// happens on first measure, which is guaranteed before
// the user can interact, so attaching here is safe.
_control = Pad;
_control.CaptureArea = PadFrame;
DataContextChanged += (_, _) => RebindViewModel(DataContext as SignaturePageViewModel);
}
private void RebindViewModel(SignaturePageViewModel? vm)
{
if (_vm is not null)
{
_vm.Detach();
_control!.RedrawRequested -= OnRedrawRequested;
}
_vm = vm;
if (_vm is null || _control is null) return;
_vm.Attach(_control);
_control.RedrawRequested += OnRedrawRequested;
Repaint();
}
private void OnRedrawRequested(object? sender, EventArgs e) => Repaint();
private void Repaint()
{
if (_control is null || InkLayer is null) return;
InkLayer.Children.Clear();
var w = PadFrame.Bounds.Width;
var h = PadFrame.Bounds.Height;
if (w <= 0 || h <= 0) return;
var strokes = _control.Strokes;
int i = 0;
while (i < strokes.Count)
{
int k = strokes[i];
if (k <= 0) break;
i++; // skip the length prefix
var poly = new Polyline
{
Stroke = StrokeBrush,
StrokeThickness = StrokeThickness,
StrokeLineCap = PenLineCap.Round,
StrokeJoin = PenLineJoin.Round,
};
var pts = new List<Point>(k);
for (int p = 0; p < k; p++)
{
int nx = strokes[i++];
int ny = strokes[i++];
pts.Add(new Point(nx / CoordinateMax * w, ny / CoordinateMax * h));
}
poly.Points = pts;
InkLayer.Children.Add(poly);
}
}
}