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 @@
+
+
diff --git a/src/PostIt/PostIt/Views/MainPage.axaml.cs b/src/PostIt/PostIt/Views/MainPage.axaml.cs
index 769234e3..1535d2f4 100644
--- a/src/PostIt/PostIt/Views/MainPage.axaml.cs
+++ b/src/PostIt/PostIt/Views/MainPage.axaml.cs
@@ -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();
}
+ ///
+ /// 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.
+ ///
+ 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();
+ page.DataContext = services.GetRequiredService();
+
+ if (this.VisualRoot is MainWindow window)
+ {
+ _ = window.NavRoot.PushAsync(page);
+ }
+ }
}
diff --git a/src/PostIt/PostIt/Views/SignaturePage.axaml b/src/PostIt/PostIt/Views/SignaturePage.axaml
new file mode 100644
index 00000000..d868f2a4
--- /dev/null
+++ b/src/PostIt/PostIt/Views/SignaturePage.axaml
@@ -0,0 +1,77 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/PostIt/PostIt/Views/SignaturePage.axaml.cs b/src/PostIt/PostIt/Views/SignaturePage.axaml.cs
new file mode 100644
index 00000000..da6b8b7b
--- /dev/null
+++ b/src/PostIt/PostIt/Views/SignaturePage.axaml.cs
@@ -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(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);
+ }
+ }
+}