feat: implement signature drag-and-drop functionality and enhance PDF page overlays

This commit is contained in:
insleker 2025-09-18 12:50:14 +08:00
parent 2043bfc14c
commit 69d5a9a248
10 changed files with 147 additions and 104 deletions

View File

@ -396,75 +396,6 @@
"link": null,
"locked": false
},
{
"id": "P2kfltnFMgp1Hpns5eRsk",
"type": "text",
"x": 109.57327992864577,
"y": 337.2651308292386,
"width": 88.30944720085046,
"height": 24.379859477817877,
"angle": 0,
"strokeColor": "#374151",
"backgroundColor": "#ffffff",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [
"nQmqS53zA9IffPy8AAZwV"
],
"frameId": null,
"index": "a9",
"roundness": null,
"seed": 1154314520,
"version": 112,
"versionNonce": 1095921782,
"isDeleted": false,
"boundElements": [],
"updated": 1756647235527,
"link": null,
"locked": false,
"text": "Page view:",
"fontSize": 18.059155168753982,
"fontFamily": 6,
"textAlign": "left",
"verticalAlign": "top",
"containerId": null,
"originalText": "Page view:",
"autoResize": true,
"lineHeight": 1.35
},
{
"id": "vmM82c6vkYHi9E8_orBEx",
"type": "rectangle",
"x": 233.72997171382946,
"y": 328.23555324486165,
"width": 338.60915941413714,
"height": 36.118310337507964,
"angle": 0,
"strokeColor": "#6b7280",
"backgroundColor": "#ffffff",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [
"nQmqS53zA9IffPy8AAZwV"
],
"frameId": null,
"index": "aA",
"roundness": null,
"seed": 288329240,
"version": 110,
"versionNonce": 128154090,
"isDeleted": false,
"boundElements": [],
"updated": 1756647235527,
"link": null,
"locked": false
},
{
"id": "Q0v5ejctIV2msui0iDFEg",
"type": "rectangle",

View File

@ -12,6 +12,7 @@ Refs:
## Welcome / First screen
Purpose: let the user open a PDF quickly via drag & drop or file picker.
Route: root
Design notes:
@ -29,8 +30,8 @@ Purpose: provide basic configuration before/after opening a PDF.
Route: root --> settings
Design notes:
- Opened via "Configure" button in the top bar.
- Modal with simple sections (e.g., General, Display).
- Opened via "Configure" button in the right of top bar.
- Model with simple sections (e.g., General, Display).
- Primary action to save, secondary to cancel.
Illustration:
@ -61,6 +62,7 @@ Design notes:
- There is an empty card with "new signature" prompt and 2 buttons: "from file" and "draw".
- "from file" opens a file picker to select an image as a signature card.
- "draw" opens a simple drawing interface (draw canvas) to create a signature card.
- There is a button at bottom to export PDF with placed signatures.
- Interaction: drag a signature card from the right drawer onto the currently visible page to place it.
Signature controls (after placing on page):

View File

@ -247,7 +247,7 @@ void main() {
expect(container.read(pdfViewModelProvider).currentPage, 2);
});
testWidgets('PDF View: scroll thumbnails to reveal and select last page', (
testWidgets('PDF View: scroll thumb to reveal and select last page', (
tester,
) async {
final pdfBytes =
@ -308,6 +308,4 @@ void main() {
await tester.pumpAndSettle();
expect(container.read(pdfViewModelProvider).currentPage, 3);
});
//TODO: Scroll Thumbs
}

View File

@ -123,7 +123,7 @@ class _PdfMockContinuousListState extends ConsumerState<PdfMockContinuousList> {
return Container(
color:
candidateData.isNotEmpty
? Colors.blue.withOpacity(0.3)
? Colors.blue.withValues(alpha: 0.3)
: Colors.grey.shade200,
child: Center(
child: Builder(

View File

@ -5,6 +5,8 @@ import 'package:pdf_signature/data/repositories/document_repository.dart';
import '../../../../domain/models/model.dart';
import 'signature_overlay.dart';
import '../../signature/widgets/signature_drag_data.dart';
import '../../signature/view_model/dragging_signature_view_model.dart';
/// Builds all overlays for a given page: placed signatures and the active one.
class PdfPageOverlays extends ConsumerWidget {
@ -37,6 +39,61 @@ class PdfPageOverlays extends ConsumerWidget {
final activeRect = pdfViewModel.activeRect;
final widgets = <Widget>[];
// Base DragTarget filling the whole page to accept drops from signature cards.
widgets.add(
// Use a Positioned.fill inside a LayoutBuilder to compute normalized coordinates.
Positioned.fill(
child: LayoutBuilder(
builder: (context, constraints) {
final isDragging = ref.watch(isDraggingSignatureViewModelProvider);
// Only activate DragTarget hit tests while dragging to preserve wheel scrolling.
final target = DragTarget<SignatureDragData>(
onAcceptWithDetails: (details) {
final box = context.findRenderObject() as RenderBox?;
if (box == null) return;
final local = box.globalToLocal(details.offset);
final w = constraints.maxWidth;
final h = constraints.maxHeight;
if (w <= 0 || h <= 0) return;
final nx = (local.dx / w).clamp(0.0, 1.0);
final ny = (local.dy / h).clamp(0.0, 1.0);
// Default size of the placed signature in normalized units
const defW = 0.2;
const defH = 0.1;
final left = (nx - defW / 2).clamp(0.0, 1.0 - defW);
final top = (ny - defH / 2).clamp(0.0, 1.0 - defH);
final rect = Rect.fromLTWH(left, top, defW, defH);
final d = details.data;
ref
.read(pdfViewModelProvider.notifier)
.addPlacement(
page: pageNumber,
rect: rect,
asset: d.card?.asset,
rotationDeg: d.card?.rotationDeg ?? 0.0,
graphicAdjust: d.card?.graphicAdjust,
);
},
builder: (context, candidateData, rejectedData) {
// Visual hint when hovering a draggable over the page.
return DecoratedBox(
decoration: BoxDecoration(
color:
candidateData.isNotEmpty
? Colors.blue.withValues(alpha: 0.12)
: Colors.transparent,
),
child: const SizedBox.expand(),
);
},
);
return IgnorePointer(ignoring: !isDragging, child: target);
},
),
),
);
for (int i = 0; i < placed.length; i++) {
// Stored as UI-space rects (SignatureCardStateNotifier.pageSize).
final p = placed[i];
@ -47,6 +104,7 @@ class PdfPageOverlays extends ConsumerWidget {
rect: uiRect,
placement: p,
placedIndex: i,
pageNumber: pageNumber,
),
);
}

View File

@ -117,15 +117,6 @@ class _PdfViewerWidgetState extends ConsumerState<PdfViewerWidget> {
},
viewerOverlayBuilder: (context, size, handle) {
return [
PdfPageOverlays(
pageSize: widget.pageSize,
pageNumber: pdfViewModel.currentPage,
onDragSignature: widget.onDragSignature,
onResizeSignature: widget.onResizeSignature,
onConfirmSignature: widget.onConfirmSignature,
onClearActiveOverlay: widget.onClearActiveOverlay,
onSelectPlaced: widget.onSelectPlaced,
),
// Vertical scroll thumb on the right
PdfViewerScrollThumb(
controller: widget.controller,
@ -166,6 +157,20 @@ class _PdfViewerWidgetState extends ConsumerState<PdfViewerWidget> {
),
];
},
// Per-page overlays to enable page-specific drag targets and placed signatures
pageOverlaysBuilder: (context, pageRect, page) {
return [
PdfPageOverlays(
pageSize: Size(pageRect.width, pageRect.height),
pageNumber: page.pageNumber,
onDragSignature: widget.onDragSignature,
onResizeSignature: widget.onResizeSignature,
onConfirmSignature: widget.onConfirmSignature,
onClearActiveOverlay: widget.onClearActiveOverlay,
onSelectPlaced: widget.onSelectPlaced,
),
];
},
),
);
}

View File

@ -1,8 +1,10 @@
import 'package:flutter/material.dart';
import 'package:flutter_box_transform/flutter_box_transform.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../domain/models/model.dart';
import '../../signature/widgets/rotated_signature_image.dart';
import '../../signature/view_model/signature_view_model.dart';
import '../view_model/pdf_view_model.dart';
/// Minimal overlay widget for rendering a placed signature.
class SignatureOverlay extends ConsumerWidget {
@ -12,12 +14,14 @@ class SignatureOverlay extends ConsumerWidget {
required this.rect,
required this.placement,
required this.placedIndex,
required this.pageNumber,
});
final Size pageSize; // not used directly, kept for API symmetry
final Rect rect; // normalized 0..1 values (left, top, width, height)
final SignaturePlacement placement;
final int placedIndex;
final int pageNumber;
@override
Widget build(BuildContext context, WidgetRef ref) {
@ -26,30 +30,61 @@ class SignatureOverlay extends ConsumerWidget {
.getProcessedBytes(placement.asset, placement.graphicAdjust);
return LayoutBuilder(
builder: (context, constraints) {
final left = rect.left * constraints.maxWidth;
final top = rect.top * constraints.maxHeight;
final width = rect.width * constraints.maxWidth;
final height = rect.height * constraints.maxHeight;
final pageW = constraints.maxWidth;
final pageH = constraints.maxHeight;
final rectPx = Rect.fromLTWH(
rect.left * pageW,
rect.top * pageH,
rect.width * pageW,
rect.height * pageH,
);
return Stack(
children: [
Positioned(
TransformableBox(
key: Key('placed_signature_$placedIndex'),
left: left,
top: top,
width: width,
height: height,
child: DecoratedBox(
decoration: BoxDecoration(
border: Border.all(color: Colors.red, width: 2),
),
child: FittedBox(
fit: BoxFit.contain,
child: RotatedSignatureImage(
bytes: processedBytes,
rotationDeg: placement.rotationDeg,
rect: rectPx,
flip: Flip.none,
// Keep the box within page bounds
clampingRect: Rect.fromLTWH(0, 0, pageW, pageH),
// Disable flips for signatures to avoid mirrored signatures
allowFlippingWhileResizing: false,
allowContentFlipping: false,
onChanged: (result, details) {
final r = result.rect;
// Persist as normalized rect (0..1)
final newRect = Rect.fromLTWH(
(r.left / pageW).clamp(0.0, 1.0),
(r.top / pageH).clamp(0.0, 1.0),
(r.width / pageW).clamp(0.0, 1.0),
(r.height / pageH).clamp(0.0, 1.0),
);
ref
.read(pdfViewModelProvider.notifier)
.updatePlacementRect(
page: pageNumber,
index: placedIndex,
rect: newRect,
);
},
// Keep default handles; you can customize later if needed
contentBuilder:
(context, boxRect, flip) => DecoratedBox(
decoration: BoxDecoration(
border: Border.all(color: Colors.red, width: 2),
),
child: SizedBox(
width: boxRect.width,
height: boxRect.height,
child: FittedBox(
fit: BoxFit.contain,
child: RotatedSignatureImage(
bytes: processedBytes,
rotationDeg: placement.rotationDeg,
),
),
),
),
),
),
),
],
);

View File

@ -0,0 +1,6 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
/// Global flag indicating whether a signature card is currently being dragged.
final isDraggingSignatureViewModelProvider = StateProvider<bool>(
(ref) => false,
);

View File

@ -5,6 +5,7 @@ import 'signature_drag_data.dart';
import 'rotated_signature_image.dart';
import 'package:pdf_signature/l10n/app_localizations.dart';
import '../view_model/signature_view_model.dart';
import '../view_model/dragging_signature_view_model.dart';
class SignatureCard extends ConsumerWidget {
const SignatureCard({
@ -163,6 +164,12 @@ class SignatureCard extends ConsumerWidget {
graphicAdjust: graphicAdjust,
),
),
onDragStarted: () {
ref.read(isDraggingSignatureViewModelProvider.notifier).state = true;
},
onDragEnd: (_) {
ref.read(isDraggingSignatureViewModelProvider.notifier).state = false;
},
feedback: Opacity(
opacity: 0.9,
child: ConstrainedBox(

View File

@ -58,6 +58,7 @@ dependencies:
logging: ^1.3.0
riverpod_annotation: ^2.6.1
colorfilter_generator: ^0.0.8
flutter_box_transform: ^0.4.7
# ml_linalg: ^13.12.6
dev_dependencies: