diff --git a/src/PostIt/PostIt.Tests/EstimateValidationPageViewModelTests.cs b/src/PostIt/PostIt.Tests/EstimateValidationPageViewModelTests.cs index 27e4456f7..9d173af3c 100644 --- a/src/PostIt/PostIt.Tests/EstimateValidationPageViewModelTests.cs +++ b/src/PostIt/PostIt.Tests/EstimateValidationPageViewModelTests.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Net.Http; using PostIt.Controls; using PostIt.ViewModels; @@ -132,6 +133,51 @@ public class EstimateValidationPageViewModelTests Assert.Contains("client", cli.RoleLabel, StringComparison.OrdinalIgnoreCase); } + [Fact] + public void Load_existing_signature_renders_loaded_strokes_in_pad() + { + var strokes = new[] { 1, 5000, 5000 }; + var front = new FrontOfficeApiClient(new RecordingApi(), "https://business.example/api/v1/"); + var estimate = SampleEstimate(); + estimate.Signatures = new List + { + new() { Type = 0, Strokes = strokes, CoordinateMax = 10_000 }, + }; + var vm = new EstimateValidationPageViewModel(estimate, EstimateListPerspective.Provider, front); + + var pad = new SignaturePadControl(); + vm.Attach(pad); + + var snap = pad.Snapshot(); + Assert.False(snap.IsEmpty); + Assert.Equal(strokes, snap.Strokes); + Assert.True(vm.HasSignature); + } + + [Fact] + public void Load_existing_signature_picks_side_by_perspective() + { + var proStrokes = new[] { 1, 1000, 1000 }; + var cliStrokes = new[] { 1, 9000, 9000 }; + var front = new FrontOfficeApiClient(new RecordingApi(), "https://business.example/api/v1/"); + var estimate = SampleEstimate(); + estimate.Signatures = new List + { + new() { Type = 0, Strokes = proStrokes }, + new() { Type = 1, Strokes = cliStrokes }, + }; + + var proPad = new SignaturePadControl(); + var proVm = new EstimateValidationPageViewModel(estimate, EstimateListPerspective.Provider, front); + proVm.Attach(proPad); + Assert.Equal(proStrokes, proPad.Snapshot().Strokes); + + var cliPad = new SignaturePadControl(); + var cliVm = new EstimateValidationPageViewModel(estimate, EstimateListPerspective.Client, front); + cliVm.Attach(cliPad); + Assert.Equal(cliStrokes, cliPad.Snapshot().Strokes); + } + private sealed class RecordingApi : IYavscApiClient { public HttpClient Http { get; } = new(); diff --git a/src/PostIt/PostIt/Controls/SignaturePadControl.cs b/src/PostIt/PostIt/Controls/SignaturePadControl.cs index dee8af36e..44c467a48 100644 --- a/src/PostIt/PostIt/Controls/SignaturePadControl.cs +++ b/src/PostIt/PostIt/Controls/SignaturePadControl.cs @@ -181,6 +181,38 @@ public class SignaturePadControl : TemplatedControl RedrawRequested?.Invoke(this, EventArgs.Empty); } + /// + /// Replace the buffer with the given wire-format strokes — e.g. a + /// signature loaded back from the server — and raise + /// so the host view repaints the ink. + /// Loaded strokes are all sealed (there is no pending stroke). + /// Malformed input (a non-positive length prefix, or a stroke that + /// runs past the end of the array) is truncated at the offending + /// point so the buffer never holds an inconsistent state. + /// + public void Load(int[] strokes) + { + if (strokes is null) throw new ArgumentNullException(nameof(strokes)); + + _strokes.Clear(); + _pendingPoints = 0; + _capturing = false; + + int i = 0; + while (i < strokes.Length) + { + int k = strokes[i]; + if (k <= 0) break; + if (i + 1 + 2 * k > strokes.Length) break; + _strokes.Add(k); + for (int p = 0; p < 2 * k; p++) + _strokes.Add(strokes[i + 1 + p]); + i += 1 + 2 * k; + } + + RedrawRequested?.Invoke(this, EventArgs.Empty); + } + /// /// Defensive copy of the current buffer wrapped in a /// . Cheap; call only when the diff --git a/src/PostIt/PostIt/ViewModels/Activity/EstimateValidationPageViewModel.cs b/src/PostIt/PostIt/ViewModels/Activity/EstimateValidationPageViewModel.cs index 3b80c051a..a0cae5080 100644 --- a/src/PostIt/PostIt/ViewModels/Activity/EstimateValidationPageViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/Activity/EstimateValidationPageViewModel.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Net.Http; using System.Threading.Tasks; using Avalonia; @@ -111,7 +112,10 @@ public partial class EstimateValidationPageViewModel : ViewModelBase, IActionSta /// /// Bind the page's signature pad control (the inner control of the /// page's ). Called from the view's - /// code-behind once the view is in the visual tree. + /// code-behind once the view is in the visual tree. Also calls + /// so a previously drawn + /// signature — carried on the estimate payload — is rendered back + /// into the pad on reopen. /// public void Attach(SignaturePadControl pad) { @@ -120,6 +124,7 @@ public partial class EstimateValidationPageViewModel : ViewModelBase, IActionSta _pad = pad; _pad.RedrawRequested += OnPadRedraw; RefreshFromPad(); + LoadExistingSignature(); } public void Detach() @@ -131,6 +136,28 @@ public partial class EstimateValidationPageViewModel : ViewModelBase, IActionSta private void OnPadRedraw(object? sender, EventArgs e) => RefreshFromPad(); + /// + /// Render the signature already stored for this side of the + /// estimate back into the pad. The strokes are carried on the + /// estimate payload (); the + /// side is chosen by the current — the + /// provider reopens to see their "Pro" signature, the client their + /// "Client" signature. No network round-trip: the payload is used + /// as-is, in the same wire format PostIt captured. + /// + public void LoadExistingSignature() + { + if (_pad is null) return; + // SignatureType.Pro = 0, SignatureType.Client = 1 (server enum). + int wanted = _perspective == EstimateListPerspective.Provider ? 0 : 1; + var sig = _estimate.Signatures?.FirstOrDefault(s => s.Type == wanted); + if (sig?.Strokes is { Length: > 0 } strokes) + { + _pad.Load(strokes); + RefreshFromPad(); + } + } + private void RefreshFromPad() { if (_pad is null) return; diff --git a/src/Yavsc.Api.Client/Dtos/EstimateDtos.cs b/src/Yavsc.Api.Client/Dtos/EstimateDtos.cs index 43c09d94a..4d35cacdd 100644 --- a/src/Yavsc.Api.Client/Dtos/EstimateDtos.cs +++ b/src/Yavsc.Api.Client/Dtos/EstimateDtos.cs @@ -39,6 +39,37 @@ public sealed class EstimateDto public string CommandType { get; set; } = string.Empty; public DateTime ProviderValidationDate { get; set; } public DateTime ClientValidationDate { get; set; } + + /// + /// Signatures already captured against this estimate (Pro and/or + /// Client), as returned with the estimate payload. Each carries the + /// PostIt wire-format + /// so the pad can repaint a previously drawn signature on reopen + /// without a second round-trip. Null/empty when nobody has signed. + /// + public List? Signatures { get; set; } +} + +/// +/// One stored signature on an estimate, mirroring the JSON shape of +/// the server-side Yavsc.Models.Billing.Signature entity. The +/// are the same PostIt wire-format payload that +/// was captured and are loaded back into the pad unchanged. +/// +public sealed class EstimateSignatureDto +{ + public long Id { get; set; } + public long EstimateId { get; set; } + /// + /// Who signed, as the integer value of the server + /// SignatureType enum: 0 = Pro (provider), + /// 1 = Client. Matched by value on the client so PostIt + /// need not reference the server enum. + /// + public int Type { get; set; } + public int CoordinateMax { get; set; } + public DateTime CapturedAtUtc { get; set; } + public int[]? Strokes { get; set; } } /// diff --git a/src/Yavsc.Api.Test/FrontOfficeApiControllerTests.cs b/src/Yavsc.Api.Test/FrontOfficeApiControllerTests.cs index 53babf7e0..e71aa9ce9 100644 --- a/src/Yavsc.Api.Test/FrontOfficeApiControllerTests.cs +++ b/src/Yavsc.Api.Test/FrontOfficeApiControllerTests.cs @@ -1,6 +1,8 @@ +using System.Linq; using System.Net; using System.Net.Http.Headers; using System.Net.Http.Json; +using System.Text.Json; using Microsoft.Extensions.DependencyInjection; using Yavsc.Api.Test.Fixtures; using Yavsc.Helpers; @@ -244,4 +246,36 @@ public sealed class FrontOfficeApiControllerTests : IClassFixture(TestContext.Current.CancellationToken); + var sigs = body.GetProperty("signatures"); + Assert.Equal(JsonValueKind.Array, sigs.ValueKind); + var first = sigs.EnumerateArray().First(); + // SignatureType.Pro = 0. + Assert.Equal(0, first.GetProperty("type").GetInt32()); + Assert.Equal(JsonValueKind.Array, first.GetProperty("strokes").ValueKind); + } } \ No newline at end of file diff --git a/src/Yavsc.Api/Controllers/Business/EstimateApiController.cs b/src/Yavsc.Api/Controllers/Business/EstimateApiController.cs index 1ee88f953..6abaafc2d 100644 --- a/src/Yavsc.Api/Controllers/Business/EstimateApiController.cs +++ b/src/Yavsc.Api/Controllers/Business/EstimateApiController.cs @@ -40,7 +40,7 @@ namespace Yavsc.Controllers else if (!UserIsAdminOrThis(ownerId)) // throw new Exception("Not authorized") ; // or just do nothing return new StatusCodeResult(StatusCodes.Status403Forbidden); - return Ok(_context.Estimates.Include(e => e.Bill).Where(e => e.OwnerId == ownerId)); + return Ok(_context.Estimates.Include(e => e.Bill).Include(e => e.Signatures).Where(e => e.OwnerId == ownerId)); } // GET: api/Estimate/5 [HttpGet("{id}", Name = "GetEstimate")] @@ -51,7 +51,7 @@ namespace Yavsc.Controllers return BadRequest(ModelState); } - Estimate estimate = _context.Estimates.Include(e => e.Bill).Single(m => m.Id == id); + Estimate estimate = _context.Estimates.Include(e => e.Bill).Include(e => e.Signatures).Single(m => m.Id == id); if (estimate == null) { @@ -209,7 +209,7 @@ namespace Yavsc.Controllers public IActionResult GetOngoingEstimatesAsClient() { var uid = User.GetUserId(); - return Ok(_context.Estimates.Include(e => e.Bill) + return Ok(_context.Estimates.Include(e => e.Bill).Include(e => e.Signatures) .Where(e => e.ClientId == uid && e.ProviderValidationDate != default && e.ClientValidationDate == default)); @@ -222,7 +222,7 @@ namespace Yavsc.Controllers public IActionResult GetOngoingEstimatesAsProvider() { var uid = User.GetUserId(); - return Ok(_context.Estimates.Include(e => e.Bill) + return Ok(_context.Estimates.Include(e => e.Bill).Include(e => e.Signatures) .Where(e => e.OwnerId == uid && e.ClientValidationDate == default)); } diff --git a/src/Yavsc.Server/Models/Billing/Signature.cs b/src/Yavsc.Server/Models/Billing/Signature.cs index 1aac0cd0e..7e568dbd1 100644 --- a/src/Yavsc.Server/Models/Billing/Signature.cs +++ b/src/Yavsc.Server/Models/Billing/Signature.cs @@ -31,7 +31,7 @@ public class Signature public long EstimateId { get; set; } - [ForeignKey(nameof(EstimateId)), JsonIgnore] + [ForeignKey(nameof(EstimateId)), JsonIgnore, System.Text.Json.Serialization.JsonIgnore] public virtual Estimate Estimate { get; set; } /// @@ -48,7 +48,7 @@ public class Signature [Required] public string SignerId { get; set; } - [ForeignKey(nameof(SignerId)), JsonIgnore] + [ForeignKey(nameof(SignerId)), JsonIgnore, System.Text.Json.Serialization.JsonIgnore] public virtual ApplicationUser Signer { get; set; } public SignatureType Type { get; set; }