feat(postit): signature capture page (dev entry, file persistence)
Builds onb0495514(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 afterb0495514landed. It is folded into this commit rather than amendingb0495514to keep the existing history readable; the change is mechanical and tested by the new SignaturePageViewModelTests.
This commit is contained in:
parent
b049551448
commit
b939c403f6
9 changed files with 581 additions and 0 deletions
163
src/PostIt.Tests/SignaturePageViewModelTests.cs
Normal file
163
src/PostIt.Tests/SignaturePageViewModelTests.cs
Normal file
|
|
@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tests for <see cref="SignaturePageViewModel"/>: the contract
|
||||||
|
/// between the page's view model and the <see cref="SignaturePadControl"/>.
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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<ArgumentOutOfRangeException>(
|
||||||
|
() => new SignaturePageViewModel(0, 100));
|
||||||
|
Assert.Throws<ArgumentOutOfRangeException>(
|
||||||
|
() => new SignaturePageViewModel(100, 0));
|
||||||
|
Assert.Throws<ArgumentOutOfRangeException>(
|
||||||
|
() => 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<ArgumentNullException>(() => 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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -63,6 +63,7 @@ public partial class App : Application
|
||||||
services.AddTransient<LoginPage>();
|
services.AddTransient<LoginPage>();
|
||||||
services.AddTransient<SettingsPage>();
|
services.AddTransient<SettingsPage>();
|
||||||
services.AddTransient<HomePage>();
|
services.AddTransient<HomePage>();
|
||||||
|
services.AddTransient<SignaturePage>();
|
||||||
|
|
||||||
// ViewModels
|
// ViewModels
|
||||||
services.AddSingleton(settings);
|
services.AddSingleton(settings);
|
||||||
|
|
@ -72,6 +73,7 @@ public partial class App : Application
|
||||||
services.AddTransient<SettingsPageViewModel>();
|
services.AddTransient<SettingsPageViewModel>();
|
||||||
services.AddTransient<LoginPageViewModel>();
|
services.AddTransient<LoginPageViewModel>();
|
||||||
services.AddTransient<HomePageViewModel>();
|
services.AddTransient<HomePageViewModel>();
|
||||||
|
services.AddTransient<SignaturePageViewModel>();
|
||||||
|
|
||||||
// Persistent session banner: one instance for the lifetime of
|
// Persistent session banner: one instance for the lifetime of
|
||||||
// the app so the same VM survives page navigation.
|
// the app so the same VM survives page navigation.
|
||||||
|
|
|
||||||
|
|
@ -78,4 +78,26 @@ public sealed class SignaturePadData
|
||||||
return n;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ public class ViewLocator : IDataTemplate
|
||||||
SettingsPageViewModel => _services.GetRequiredService<SettingsPage>(),
|
SettingsPageViewModel => _services.GetRequiredService<SettingsPage>(),
|
||||||
LoginPageViewModel => _services.GetRequiredService<LoginPage>(),
|
LoginPageViewModel => _services.GetRequiredService<LoginPage>(),
|
||||||
HomePageViewModel => _services.GetRequiredService<HomePage>(),
|
HomePageViewModel => _services.GetRequiredService<HomePage>(),
|
||||||
|
SignaturePageViewModel => _services.GetRequiredService<SignaturePage>(),
|
||||||
_ => new TextBlock { Text = $"No view for {data.GetType().Name}" }
|
_ => new TextBlock { Text = $"No view for {data.GetType().Name}" }
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
185
src/PostIt/PostIt/ViewModels/SignaturePageViewModel.cs
Normal file
185
src/PostIt/PostIt/ViewModels/SignaturePageViewModel.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -35,6 +35,17 @@
|
||||||
<Button Command="{Binding New}" Content="New post" />
|
<Button Command="{Binding New}" Content="New post" />
|
||||||
<Button Command="{Binding Save}" Content="Save" />
|
<Button Command="{Binding Save}" Content="Save" />
|
||||||
<Button Command="{Binding Delete}" Content="Delete" />
|
<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>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
using Avalonia;
|
using Avalonia;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Interactivity;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using PostIt.ViewModels;
|
||||||
|
|
||||||
namespace PostIt.Views;
|
namespace PostIt.Views;
|
||||||
|
|
||||||
|
|
@ -10,4 +13,30 @@ public partial class MainPage : ContentPage
|
||||||
InitializeComponent();
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
77
src/PostIt/PostIt/Views/SignaturePage.axaml
Normal file
77
src/PostIt/PostIt/Views/SignaturePage.axaml
Normal 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>
|
||||||
91
src/PostIt/PostIt/Views/SignaturePage.axaml.cs
Normal file
91
src/PostIt/PostIt/Views/SignaturePage.axaml.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue