From 69d5a9a248da7b973f273a49173e0adadb35af0c Mon Sep 17 00:00:00 2001 From: insleker Date: Thu, 18 Sep 2025 12:50:14 +0800 Subject: [PATCH] feat: implement signature drag-and-drop functionality and enhance PDF page overlays --- .../with_configure_screen.excalidraw | 69 ----------------- docs/wireframe.md | 6 +- integration_test/pdf_view_test.dart | 4 +- .../pdf/widgets/pdf_mock_continuous_list.dart | 2 +- .../pdf/widgets/pdf_page_overlays.dart | 58 ++++++++++++++ .../pdf/widgets/pdf_viewer_widget.dart | 23 +++--- .../pdf/widgets/signature_overlay.dart | 75 ++++++++++++++----- .../dragging_signature_view_model.dart | 6 ++ .../signature/widgets/signature_card.dart | 7 ++ pubspec.yaml | 1 + 10 files changed, 147 insertions(+), 104 deletions(-) create mode 100644 lib/ui/features/signature/view_model/dragging_signature_view_model.dart diff --git a/docs/wireframe.assets/with_configure_screen.excalidraw b/docs/wireframe.assets/with_configure_screen.excalidraw index f9c67f0..d8fb0af 100644 --- a/docs/wireframe.assets/with_configure_screen.excalidraw +++ b/docs/wireframe.assets/with_configure_screen.excalidraw @@ -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", diff --git a/docs/wireframe.md b/docs/wireframe.md index 163a99c..846c575 100644 --- a/docs/wireframe.md +++ b/docs/wireframe.md @@ -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): diff --git a/integration_test/pdf_view_test.dart b/integration_test/pdf_view_test.dart index 45adbac..0c32047 100644 --- a/integration_test/pdf_view_test.dart +++ b/integration_test/pdf_view_test.dart @@ -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 } diff --git a/lib/ui/features/pdf/widgets/pdf_mock_continuous_list.dart b/lib/ui/features/pdf/widgets/pdf_mock_continuous_list.dart index 3fa5248..62a5003 100644 --- a/lib/ui/features/pdf/widgets/pdf_mock_continuous_list.dart +++ b/lib/ui/features/pdf/widgets/pdf_mock_continuous_list.dart @@ -123,7 +123,7 @@ class _PdfMockContinuousListState extends ConsumerState { return Container( color: candidateData.isNotEmpty - ? Colors.blue.withOpacity(0.3) + ? Colors.blue.withValues(alpha: 0.3) : Colors.grey.shade200, child: Center( child: Builder( diff --git a/lib/ui/features/pdf/widgets/pdf_page_overlays.dart b/lib/ui/features/pdf/widgets/pdf_page_overlays.dart index 35b5afd..96616fd 100644 --- a/lib/ui/features/pdf/widgets/pdf_page_overlays.dart +++ b/lib/ui/features/pdf/widgets/pdf_page_overlays.dart @@ -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 = []; + // 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( + 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, ), ); } diff --git a/lib/ui/features/pdf/widgets/pdf_viewer_widget.dart b/lib/ui/features/pdf/widgets/pdf_viewer_widget.dart index 7b77b47..6c80319 100644 --- a/lib/ui/features/pdf/widgets/pdf_viewer_widget.dart +++ b/lib/ui/features/pdf/widgets/pdf_viewer_widget.dart @@ -117,15 +117,6 @@ class _PdfViewerWidgetState extends ConsumerState { }, 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 { ), ]; }, + // 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, + ), + ]; + }, ), ); } diff --git a/lib/ui/features/pdf/widgets/signature_overlay.dart b/lib/ui/features/pdf/widgets/signature_overlay.dart index 9235aa7..d32e08f 100644 --- a/lib/ui/features/pdf/widgets/signature_overlay.dart +++ b/lib/ui/features/pdf/widgets/signature_overlay.dart @@ -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, + ), + ), + ), ), - ), - ), ), ], ); diff --git a/lib/ui/features/signature/view_model/dragging_signature_view_model.dart b/lib/ui/features/signature/view_model/dragging_signature_view_model.dart new file mode 100644 index 0000000..dd13ebe --- /dev/null +++ b/lib/ui/features/signature/view_model/dragging_signature_view_model.dart @@ -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( + (ref) => false, +); diff --git a/lib/ui/features/signature/widgets/signature_card.dart b/lib/ui/features/signature/widgets/signature_card.dart index 9dda3f0..2152f4f 100644 --- a/lib/ui/features/signature/widgets/signature_card.dart +++ b/lib/ui/features/signature/widgets/signature_card.dart @@ -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( diff --git a/pubspec.yaml b/pubspec.yaml index 52ea89d..3ccc01c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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: