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
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}" }
|
||||
};
|
||||
}
|
||||
|
|
|
|||
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 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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
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