feat: implement signature drag-and-drop functionality and enhance PDF page overlays
This commit is contained in:
parent
2043bfc14c
commit
69d5a9a248
|
|
@ -396,75 +396,6 @@
|
||||||
"link": null,
|
"link": null,
|
||||||
"locked": false
|
"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",
|
"id": "Q0v5ejctIV2msui0iDFEg",
|
||||||
"type": "rectangle",
|
"type": "rectangle",
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ Refs:
|
||||||
## Welcome / First screen
|
## Welcome / First screen
|
||||||
|
|
||||||
Purpose: let the user open a PDF quickly via drag & drop or file picker.
|
Purpose: let the user open a PDF quickly via drag & drop or file picker.
|
||||||
|
|
||||||
Route: root
|
Route: root
|
||||||
|
|
||||||
Design notes:
|
Design notes:
|
||||||
|
|
@ -29,8 +30,8 @@ Purpose: provide basic configuration before/after opening a PDF.
|
||||||
Route: root --> settings
|
Route: root --> settings
|
||||||
|
|
||||||
Design notes:
|
Design notes:
|
||||||
- Opened via "Configure" button in the top bar.
|
- Opened via "Configure" button in the right of top bar.
|
||||||
- Modal with simple sections (e.g., General, Display).
|
- Model with simple sections (e.g., General, Display).
|
||||||
- Primary action to save, secondary to cancel.
|
- Primary action to save, secondary to cancel.
|
||||||
|
|
||||||
Illustration:
|
Illustration:
|
||||||
|
|
@ -61,6 +62,7 @@ Design notes:
|
||||||
- There is an empty card with "new signature" prompt and 2 buttons: "from file" and "draw".
|
- 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.
|
- "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.
|
- "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.
|
- Interaction: drag a signature card from the right drawer onto the currently visible page to place it.
|
||||||
|
|
||||||
Signature controls (after placing on page):
|
Signature controls (after placing on page):
|
||||||
|
|
|
||||||
|
|
@ -247,7 +247,7 @@ void main() {
|
||||||
expect(container.read(pdfViewModelProvider).currentPage, 2);
|
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,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
final pdfBytes =
|
final pdfBytes =
|
||||||
|
|
@ -308,6 +308,4 @@ void main() {
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(container.read(pdfViewModelProvider).currentPage, 3);
|
expect(container.read(pdfViewModelProvider).currentPage, 3);
|
||||||
});
|
});
|
||||||
|
|
||||||
//TODO: Scroll Thumbs
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -123,7 +123,7 @@ class _PdfMockContinuousListState extends ConsumerState<PdfMockContinuousList> {
|
||||||
return Container(
|
return Container(
|
||||||
color:
|
color:
|
||||||
candidateData.isNotEmpty
|
candidateData.isNotEmpty
|
||||||
? Colors.blue.withOpacity(0.3)
|
? Colors.blue.withValues(alpha: 0.3)
|
||||||
: Colors.grey.shade200,
|
: Colors.grey.shade200,
|
||||||
child: Center(
|
child: Center(
|
||||||
child: Builder(
|
child: Builder(
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@ import 'package:pdf_signature/data/repositories/document_repository.dart';
|
||||||
|
|
||||||
import '../../../../domain/models/model.dart';
|
import '../../../../domain/models/model.dart';
|
||||||
import 'signature_overlay.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.
|
/// Builds all overlays for a given page: placed signatures and the active one.
|
||||||
class PdfPageOverlays extends ConsumerWidget {
|
class PdfPageOverlays extends ConsumerWidget {
|
||||||
|
|
@ -37,6 +39,61 @@ class PdfPageOverlays extends ConsumerWidget {
|
||||||
final activeRect = pdfViewModel.activeRect;
|
final activeRect = pdfViewModel.activeRect;
|
||||||
final widgets = <Widget>[];
|
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++) {
|
for (int i = 0; i < placed.length; i++) {
|
||||||
// Stored as UI-space rects (SignatureCardStateNotifier.pageSize).
|
// Stored as UI-space rects (SignatureCardStateNotifier.pageSize).
|
||||||
final p = placed[i];
|
final p = placed[i];
|
||||||
|
|
@ -47,6 +104,7 @@ class PdfPageOverlays extends ConsumerWidget {
|
||||||
rect: uiRect,
|
rect: uiRect,
|
||||||
placement: p,
|
placement: p,
|
||||||
placedIndex: i,
|
placedIndex: i,
|
||||||
|
pageNumber: pageNumber,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -117,15 +117,6 @@ class _PdfViewerWidgetState extends ConsumerState<PdfViewerWidget> {
|
||||||
},
|
},
|
||||||
viewerOverlayBuilder: (context, size, handle) {
|
viewerOverlayBuilder: (context, size, handle) {
|
||||||
return [
|
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
|
// Vertical scroll thumb on the right
|
||||||
PdfViewerScrollThumb(
|
PdfViewerScrollThumb(
|
||||||
controller: widget.controller,
|
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,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_box_transform/flutter_box_transform.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import '../../../../domain/models/model.dart';
|
import '../../../../domain/models/model.dart';
|
||||||
import '../../signature/widgets/rotated_signature_image.dart';
|
import '../../signature/widgets/rotated_signature_image.dart';
|
||||||
import '../../signature/view_model/signature_view_model.dart';
|
import '../../signature/view_model/signature_view_model.dart';
|
||||||
|
import '../view_model/pdf_view_model.dart';
|
||||||
|
|
||||||
/// Minimal overlay widget for rendering a placed signature.
|
/// Minimal overlay widget for rendering a placed signature.
|
||||||
class SignatureOverlay extends ConsumerWidget {
|
class SignatureOverlay extends ConsumerWidget {
|
||||||
|
|
@ -12,12 +14,14 @@ class SignatureOverlay extends ConsumerWidget {
|
||||||
required this.rect,
|
required this.rect,
|
||||||
required this.placement,
|
required this.placement,
|
||||||
required this.placedIndex,
|
required this.placedIndex,
|
||||||
|
required this.pageNumber,
|
||||||
});
|
});
|
||||||
|
|
||||||
final Size pageSize; // not used directly, kept for API symmetry
|
final Size pageSize; // not used directly, kept for API symmetry
|
||||||
final Rect rect; // normalized 0..1 values (left, top, width, height)
|
final Rect rect; // normalized 0..1 values (left, top, width, height)
|
||||||
final SignaturePlacement placement;
|
final SignaturePlacement placement;
|
||||||
final int placedIndex;
|
final int placedIndex;
|
||||||
|
final int pageNumber;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
|
@ -26,22 +30,52 @@ class SignatureOverlay extends ConsumerWidget {
|
||||||
.getProcessedBytes(placement.asset, placement.graphicAdjust);
|
.getProcessedBytes(placement.asset, placement.graphicAdjust);
|
||||||
return LayoutBuilder(
|
return LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
final left = rect.left * constraints.maxWidth;
|
final pageW = constraints.maxWidth;
|
||||||
final top = rect.top * constraints.maxHeight;
|
final pageH = constraints.maxHeight;
|
||||||
final width = rect.width * constraints.maxWidth;
|
final rectPx = Rect.fromLTWH(
|
||||||
final height = rect.height * constraints.maxHeight;
|
rect.left * pageW,
|
||||||
|
rect.top * pageH,
|
||||||
|
rect.width * pageW,
|
||||||
|
rect.height * pageH,
|
||||||
|
);
|
||||||
|
|
||||||
return Stack(
|
return Stack(
|
||||||
children: [
|
children: [
|
||||||
Positioned(
|
TransformableBox(
|
||||||
key: Key('placed_signature_$placedIndex'),
|
key: Key('placed_signature_$placedIndex'),
|
||||||
left: left,
|
rect: rectPx,
|
||||||
top: top,
|
flip: Flip.none,
|
||||||
width: width,
|
// Keep the box within page bounds
|
||||||
height: height,
|
clampingRect: Rect.fromLTWH(0, 0, pageW, pageH),
|
||||||
child: DecoratedBox(
|
// 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(
|
decoration: BoxDecoration(
|
||||||
border: Border.all(color: Colors.red, width: 2),
|
border: Border.all(color: Colors.red, width: 2),
|
||||||
),
|
),
|
||||||
|
child: SizedBox(
|
||||||
|
width: boxRect.width,
|
||||||
|
height: boxRect.height,
|
||||||
child: FittedBox(
|
child: FittedBox(
|
||||||
fit: BoxFit.contain,
|
fit: BoxFit.contain,
|
||||||
child: RotatedSignatureImage(
|
child: RotatedSignatureImage(
|
||||||
|
|
@ -51,6 +85,7 @@ class SignatureOverlay extends ConsumerWidget {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
);
|
||||||
|
|
@ -5,6 +5,7 @@ import 'signature_drag_data.dart';
|
||||||
import 'rotated_signature_image.dart';
|
import 'rotated_signature_image.dart';
|
||||||
import 'package:pdf_signature/l10n/app_localizations.dart';
|
import 'package:pdf_signature/l10n/app_localizations.dart';
|
||||||
import '../view_model/signature_view_model.dart';
|
import '../view_model/signature_view_model.dart';
|
||||||
|
import '../view_model/dragging_signature_view_model.dart';
|
||||||
|
|
||||||
class SignatureCard extends ConsumerWidget {
|
class SignatureCard extends ConsumerWidget {
|
||||||
const SignatureCard({
|
const SignatureCard({
|
||||||
|
|
@ -163,6 +164,12 @@ class SignatureCard extends ConsumerWidget {
|
||||||
graphicAdjust: graphicAdjust,
|
graphicAdjust: graphicAdjust,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
onDragStarted: () {
|
||||||
|
ref.read(isDraggingSignatureViewModelProvider.notifier).state = true;
|
||||||
|
},
|
||||||
|
onDragEnd: (_) {
|
||||||
|
ref.read(isDraggingSignatureViewModelProvider.notifier).state = false;
|
||||||
|
},
|
||||||
feedback: Opacity(
|
feedback: Opacity(
|
||||||
opacity: 0.9,
|
opacity: 0.9,
|
||||||
child: ConstrainedBox(
|
child: ConstrainedBox(
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,7 @@ dependencies:
|
||||||
logging: ^1.3.0
|
logging: ^1.3.0
|
||||||
riverpod_annotation: ^2.6.1
|
riverpod_annotation: ^2.6.1
|
||||||
colorfilter_generator: ^0.0.8
|
colorfilter_generator: ^0.0.8
|
||||||
|
flutter_box_transform: ^0.4.7
|
||||||
# ml_linalg: ^13.12.6
|
# ml_linalg: ^13.12.6
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue