From 9a31903d0d5897cc6b5ff7e6857a7c332b246611 Mon Sep 17 00:00:00 2001 From: insleker Date: Wed, 27 Aug 2025 20:55:04 +0800 Subject: [PATCH] feat: add draw signature feature with widget test --- .gitignore | 1 + README.md | 3 + docs/use_cases.md | 8 ++ lib/features/pdf/viewer.dart | 56 ++++++++-- lib/features/pdf/viewer_state.dart | 2 +- lib/features/pdf/viewer_widgets.dart | 140 +++++++++++-------------- lib/features/share/export_service.dart | 43 ++++++-- test/signature_state_test.dart | 1 - test/widget/widget_test.dart | 116 ++++++++++++-------- 9 files changed, 232 insertions(+), 138 deletions(-) diff --git a/.gitignore b/.gitignore index 57ccf8f..8ecdb7f 100644 --- a/.gitignore +++ b/.gitignore @@ -121,3 +121,4 @@ app.*.symbols docs/.* .vscode/tasks.json .vscode/launch.json +devtools_options.yaml diff --git a/README.md b/README.md index 51c7d30..dd72714 100644 --- a/README.md +++ b/README.md @@ -10,8 +10,11 @@ checkout [`docs/FRs.md`](docs/FRs.md) ```bash flutter pub get + +# run the app flutter run +# run unit tests and widget tests flutter test flutter build diff --git a/docs/use_cases.md b/docs/use_cases.md index 4565b39..3da7684 100644 --- a/docs/use_cases.md +++ b/docs/use_cases.md @@ -115,5 +115,13 @@ Feature: save signed PDF Given a PDF is open with no signatures placed When the user attempts to save Then the user is notified there is nothing to save + + Scenario: Loading sign when exporting/saving files + Given a signature is placed with a position and size relative to the page + When the user starts exporting the document + And the export process is not yet finished + Then the user is notified that the export is still in progress + And the user cannot edit the document + ``` diff --git a/lib/features/pdf/viewer.dart b/lib/features/pdf/viewer.dart index 1b0a015..6304d01 100644 --- a/lib/features/pdf/viewer.dart +++ b/lib/features/pdf/viewer.dart @@ -6,6 +6,8 @@ import 'package:pdfrx/pdfrx.dart'; import 'package:path_provider/path_provider.dart' as pp; import 'dart:typed_data'; import '../share/export_service.dart'; +import 'package:hand_signature/signature.dart' as hand; +import 'package:meta/meta.dart'; part 'viewer_state.dart'; part 'viewer_widgets.dart'; @@ -14,6 +16,8 @@ part 'viewer_widgets.dart'; final useMockViewerProvider = Provider((_) => false); // Export service injection for testability final exportServiceProvider = Provider((_) => ExportService()); +// Export DPI setting (points per inch mapping), default 144 DPI +final exportDpiProvider = StateProvider((_) => 144.0); // Controls whether signature overlay is visible (used to hide on non-stamped pages during export) final signatureVisibilityProvider = StateProvider((_) => true); // Save path picker (injected for tests) @@ -55,6 +59,12 @@ class _PdfSignatureHomePageState extends ConsumerState { static const Size _pageSize = SignatureController.pageSize; final GlobalKey _captureKey = GlobalKey(); + // Exposed for tests to trigger the invalid-file SnackBar without UI. + @visibleForTesting + void debugShowInvalidSignatureSnackBar() { + ref.read(signatureProvider.notifier).setInvalidSelected(context); + } + Future _pickPdf() async { final typeGroup = const fs.XTypeGroup(label: 'PDF', extensions: ['pdf']); final file = await fs.openFile(acceptedTypeGroups: [typeGroup]); @@ -86,9 +96,7 @@ class _PdfSignatureHomePageState extends ConsumerState { sig.setImageBytes(bytes); } - void _loadInvalidSignature() { - ref.read(signatureProvider.notifier).setInvalidSelected(context); - } + // removed invalid loader; not part of normal app void _onDragSignature(Offset delta) { ref.read(signatureProvider.notifier).drag(delta); @@ -101,15 +109,15 @@ class _PdfSignatureHomePageState extends ConsumerState { Future _openDrawCanvas() async { final pdf = ref.read(pdfProvider); if (!pdf.markedForSigning) return; - final current = ref.read(signatureProvider).strokes; - final result = await showModalBottomSheet>>( + final result = await showModalBottomSheet( context: context, isScrollControlled: true, - builder: (_) => DrawCanvas(strokes: current), + enableDrag: false, + builder: (_) => const DrawCanvas(), ); - if (result != null) { - ref.read(signatureProvider.notifier).setStrokes(result); - ref.read(signatureProvider.notifier).ensureRectForStrokes(); + if (result != null && result.isNotEmpty) { + // Use the drawn image as signature content + ref.read(signatureProvider.notifier).setImageBytes(result); } } @@ -129,6 +137,7 @@ class _PdfSignatureHomePageState extends ConsumerState { if (path == null || path.trim().isEmpty) return; final fullPath = _ensurePdfExtension(path.trim()); final exporter = ref.read(exportServiceProvider); + final targetDpi = ref.read(exportDpiProvider); // Multi-page export: iterate pages by navigating the viewer final controller = ref.read(pdfProvider.notifier); final current = pdf.currentPage; @@ -137,6 +146,7 @@ class _PdfSignatureHomePageState extends ConsumerState { boundaryKey: _captureKey, outputPath: fullPath, pageCount: pdf.pageCount, + targetDpi: targetDpi, onGotoPage: (p) async { controller.jumpTo(p); // Show overlay only on the signed page (if any) @@ -197,6 +207,7 @@ class _PdfSignatureHomePageState extends ConsumerState { } Widget _buildToolbar(PdfState pdf) { + final dpi = ref.watch(exportDpiProvider); final pageInfo = 'Page ${pdf.currentPage}/${pdf.pageCount}'; return Wrap( spacing: 8, @@ -244,6 +255,31 @@ class _PdfSignatureHomePageState extends ConsumerState { ), ], ), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Text('DPI:'), + const SizedBox(width: 8), + DropdownButton( + key: const Key('ddl_export_dpi'), + value: dpi, + items: + const [96.0, 144.0, 200.0, 300.0] + .map( + (v) => DropdownMenuItem( + value: v, + child: Text(v.toStringAsFixed(0)), + ), + ) + .toList(), + onChanged: (v) { + if (v != null) { + ref.read(exportDpiProvider.notifier).state = v; + } + }, + ), + ], + ), ElevatedButton( key: const Key('btn_mark_signing'), onPressed: _toggleMarkForSigning, @@ -407,8 +443,6 @@ class _PdfSignatureHomePageState extends ConsumerState { children: [ if (sig.imageBytes != null) Image.memory(sig.imageBytes!, fit: BoxFit.contain) - else if (sig.strokes.isNotEmpty) - CustomPaint(painter: StrokesPainter(sig.strokes)) else const Center(child: Text('Signature')), Positioned( diff --git a/lib/features/pdf/viewer_state.dart b/lib/features/pdf/viewer_state.dart index 4190bf2..c84dd1b 100644 --- a/lib/features/pdf/viewer_state.dart +++ b/lib/features/pdf/viewer_state.dart @@ -115,7 +115,7 @@ class SignatureState { bgRemoval: false, contrast: 1.0, brightness: 0.0, - strokes: const [], + strokes: [], imageBytes: null, ); SignatureState copyWith({ diff --git a/lib/features/pdf/viewer_widgets.dart b/lib/features/pdf/viewer_widgets.dart index 0f7061b..5b52487 100644 --- a/lib/features/pdf/viewer_widgets.dart +++ b/lib/features/pdf/viewer_widgets.dart @@ -1,40 +1,34 @@ part of 'viewer.dart'; class DrawCanvas extends StatefulWidget { - const DrawCanvas({super.key, required this.strokes}); - final List> strokes; + const DrawCanvas({ + super.key, + this.control, + this.onConfirm, + this.debugBytesSink, + }); + + final hand.HandSignatureControl? control; + final ValueChanged? onConfirm; + // For tests: allows observing exported bytes without relying on Navigator + @visibleForTesting + final ValueNotifier? debugBytesSink; @override State createState() => _DrawCanvasState(); } class _DrawCanvasState extends State { - late List> _strokes; - final GlobalKey _canvasKey = GlobalKey(); - - @override - void initState() { - super.initState(); - _strokes = widget.strokes.map((s) => List.of(s)).toList(); - } - - void _startStroke(Offset localPosition) { - setState(() => _strokes.add([localPosition])); - } - - void _extendStroke(Offset localPosition) { - if (_strokes.isEmpty) return; - setState(() => _strokes.last.add(localPosition)); - } - - void _undo() { - if (_strokes.isEmpty) return; - setState(() => _strokes.removeLast()); - } - - void _clear() { - setState(() => _strokes.clear()); - } + late final hand.HandSignatureControl _control = + widget.control ?? + hand.HandSignatureControl( + initialSetup: const hand.SignaturePathSetup( + threshold: 3.0, + smoothRatio: 0.7, + velocityRange: 2.0, + pressureRatio: 0.0, + ), + ); @override Widget build(BuildContext context) { @@ -48,19 +42,40 @@ class _DrawCanvasState extends State { children: [ ElevatedButton( key: const Key('btn_canvas_confirm'), - onPressed: () => Navigator.of(context).pop(_strokes), + onPressed: () async { + // Export signature to PNG bytes + final data = await _control.toImage( + color: Colors.black, + background: Colors.transparent, + fit: true, + width: 1024, + height: 512, + ); + final bytes = data?.buffer.asUint8List(); + // print("onPressed, Exported signature bytes: ${bytes?.length}"); + // Notify tests if provided + widget.debugBytesSink?.value = bytes; + if (widget.onConfirm != null) { + // print("onConfirm callback called"); + widget.onConfirm!(bytes); + } else { + if (context.mounted) { + Navigator.of(context).pop(bytes); + } + } + }, child: const Text('Confirm'), ), const SizedBox(width: 8), OutlinedButton( key: const Key('btn_canvas_undo'), - onPressed: _undo, + onPressed: () => _control.stepBack(), child: const Text('Undo'), ), const SizedBox(width: 8), OutlinedButton( key: const Key('btn_canvas_clear'), - onPressed: _clear, + onPressed: () => _control.clear(), child: const Text('Clear'), ), ], @@ -68,30 +83,24 @@ class _DrawCanvasState extends State { const SizedBox(height: 8), SizedBox( key: const Key('draw_canvas'), - height: 240, - child: Focus( - // prevent text selection focus stealing on desktop - canRequestFocus: false, - child: Listener( - key: _canvasKey, - behavior: HitTestBehavior.opaque, - onPointerDown: (e) { - final box = - _canvasKey.currentContext!.findRenderObject() - as RenderBox; - _startStroke(box.globalToLocal(e.position)); - }, - onPointerMove: (e) { - final box = - _canvasKey.currentContext!.findRenderObject() - as RenderBox; - _extendStroke(box.globalToLocal(e.position)); - }, - child: DecoratedBox( - decoration: BoxDecoration( - border: Border.all(color: Colors.black26), + height: math.max(MediaQuery.of(context).size.height * 0.6, 350), + child: AspectRatio( + aspectRatio: 10 / 3, + child: Container( + constraints: const BoxConstraints.expand(), + color: Colors.white, + child: Listener( + behavior: HitTestBehavior.opaque, + onPointerDown: (_) {}, + child: hand.HandSignature( + key: const Key('hand_signature_pad'), + control: _control, + drawer: const hand.ShapeSignatureDrawer( + color: Colors.black, + width: 1.5, + maxWidth: 6.0, + ), ), - child: CustomPaint(painter: StrokesPainter(_strokes)), ), ), ), @@ -102,26 +111,3 @@ class _DrawCanvasState extends State { ); } } - -class StrokesPainter extends CustomPainter { - final List> strokes; - StrokesPainter(this.strokes); - - @override - void paint(Canvas canvas, Size size) { - final p = - Paint() - ..color = Colors.black - ..strokeWidth = 2 - ..style = PaintingStyle.stroke; - for (final s in strokes) { - for (int i = 1; i < s.length; i++) { - canvas.drawLine(s[i - 1], s[i], p); - } - } - } - - @override - bool shouldRepaint(covariant StrokesPainter oldDelegate) => - oldDelegate.strokes != strokes; -} diff --git a/lib/features/share/export_service.dart b/lib/features/share/export_service.dart index c599d17..b0b3e3b 100644 --- a/lib/features/share/export_service.dart +++ b/lib/features/share/export_service.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'package:flutter/rendering.dart'; import 'package:flutter/widgets.dart'; import 'package:pdf/widgets.dart' as pw; +import 'package:pdf/pdf.dart' as pdf; // NOTE: // - This exporter uses a raster snapshot of the UI (RepaintBoundary) and embeds it into a new PDF. @@ -14,6 +15,8 @@ class ExportService { Future exportSignedPdfFromBoundary({ required GlobalKey boundaryKey, required String outputPath, + double pixelRatio = 4.0, + double targetDpi = 144.0, }) async { try { final boundary = @@ -21,19 +24,32 @@ class ExportService { as RenderRepaintBoundary?; if (boundary == null) return false; // Render current view to image - final ui.Image image = await boundary.toImage(pixelRatio: 2.0); + // Higher pixelRatio improves exported quality + final ui.Image image = await boundary.toImage(pixelRatio: pixelRatio); final byteData = await image.toByteData(format: ui.ImageByteFormat.png); if (byteData == null) return false; final pngBytes = byteData.buffer.asUint8List(); - // Compose single-page PDF with the image + // Compose single-page PDF with the image, using page size that matches the image final doc = pw.Document(); final img = pw.MemoryImage(pngBytes); + final pageFormat = pdf.PdfPageFormat( + image.width.toDouble() * 72.0 / targetDpi, + image.height.toDouble() * 72.0 / targetDpi, + ); + // Zero margins and cover the entire page area to avoid letterboxing/cropping doc.addPage( pw.Page( + pageTheme: pw.PageTheme( + margin: pw.EdgeInsets.zero, + pageFormat: pageFormat, + ), build: - (context) => - pw.Center(child: pw.Image(img, fit: pw.BoxFit.contain)), + (context) => pw.Container( + width: double.infinity, + height: double.infinity, + child: pw.Image(img, fit: pw.BoxFit.fill), + ), ), ); final bytes = await doc.save(); @@ -53,7 +69,8 @@ class ExportService { required String outputPath, required int pageCount, required Future Function(int page) onGotoPage, - double pixelRatio = 3.0, + double pixelRatio = 4.0, + double targetDpi = 144.0, }) async { try { final doc = pw.Document(); @@ -79,11 +96,23 @@ class ExportService { if (byteData == null) return false; final pngBytes = byteData.buffer.asUint8List(); final img = pw.MemoryImage(pngBytes); + final pageFormat = pdf.PdfPageFormat( + image.width.toDouble() * 72.0 / targetDpi, + image.height.toDouble() * 72.0 / targetDpi, + ); + // Zero margins and size page to the image dimensions to avoid borders doc.addPage( pw.Page( + pageTheme: pw.PageTheme( + margin: pw.EdgeInsets.zero, + pageFormat: pageFormat, + ), build: - (context) => - pw.Center(child: pw.Image(img, fit: pw.BoxFit.contain)), + (context) => pw.Container( + width: double.infinity, + height: double.infinity, + child: pw.Image(img, fit: pw.BoxFit.fill), + ), ), ); } diff --git a/test/signature_state_test.dart b/test/signature_state_test.dart index 4504103..84392ba 100644 --- a/test/signature_state_test.dart +++ b/test/signature_state_test.dart @@ -1,6 +1,5 @@ import 'dart:typed_data'; -import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:pdf_signature/features/pdf/viewer.dart'; diff --git a/test/widget/widget_test.dart b/test/widget/widget_test.dart index f8f3381..6778b95 100644 --- a/test/widget/widget_test.dart +++ b/test/widget/widget_test.dart @@ -8,9 +8,13 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'dart:typed_data'; +import 'dart:ui' show PointerDeviceKind; +import 'dart:async'; import 'package:pdf_signature/features/pdf/viewer.dart'; import 'package:pdf_signature/features/share/export_service.dart'; +import 'package:hand_signature/signature.dart' as hand; // Fakes for export service (top-level; Dart does not allow local class declarations) class RecordingExporter extends ExportService { @@ -22,6 +26,7 @@ class RecordingExporter extends ExportService { required int pageCount, required Future Function(int page) onGotoPage, double pixelRatio = 2.0, + double targetDpi = 144.0, }) async { called = true; // Ensure extension @@ -41,6 +46,7 @@ class BasicExporter extends ExportService { required int pageCount, required Future Function(int page) onGotoPage, double pixelRatio = 2.0, + double targetDpi = 144.0, }) async { for (var i = 1; i <= pageCount; i++) { await onGotoPage(i); @@ -50,7 +56,7 @@ class BasicExporter extends ExportService { } void main() { - Future _pumpWithOpenPdf(WidgetTester tester) async { + Future pumpWithOpenPdf(WidgetTester tester) async { await tester.pumpWidget( ProviderScope( overrides: [ @@ -65,7 +71,7 @@ void main() { await tester.pump(); } - Future _pumpWithOpenPdfAndSig(WidgetTester tester) async { + Future pumpWithOpenPdfAndSig(WidgetTester tester) async { await tester.pumpWidget( ProviderScope( overrides: [ @@ -84,7 +90,7 @@ void main() { } testWidgets('Open a PDF and navigate pages', (tester) async { - await _pumpWithOpenPdf(tester); + await pumpWithOpenPdf(tester); final pageInfo = find.byKey(const Key('lbl_page_info')); expect(pageInfo, findsOneWidget); expect((tester.widget(pageInfo)).data, 'Page 1/5'); @@ -99,7 +105,7 @@ void main() { }); testWidgets('Jump to a specific page', (tester) async { - await _pumpWithOpenPdf(tester); + await pumpWithOpenPdf(tester); final goto = find.byKey(const Key('txt_goto')); await tester.enterText(goto, '4'); @@ -110,7 +116,7 @@ void main() { }); testWidgets('Select a page for signing', (tester) async { - await _pumpWithOpenPdf(tester); + await pumpWithOpenPdf(tester); await tester.tap(find.byKey(const Key('btn_mark_signing'))); await tester.pump(); @@ -118,26 +124,29 @@ void main() { expect(find.byKey(const Key('btn_load_signature_picker')), findsOneWidget); }); + testWidgets('Show invalid/unsupported file SnackBar via test hook', ( + tester, + ) async { + await pumpWithOpenPdf(tester); + final dynamic state = + tester.state(find.byType(PdfSignatureHomePage)) as dynamic; + state.debugShowInvalidSignatureSnackBar(); + await tester.pump(); + expect(find.text('Invalid or unsupported file'), findsOneWidget); + }); + testWidgets('Import a signature image', (tester) async { - await _pumpWithOpenPdfAndSig(tester); + await pumpWithOpenPdfAndSig(tester); await tester.tap(find.byKey(const Key('btn_mark_signing'))); await tester.pump(); // overlay present from provider override expect(find.byKey(const Key('signature_overlay')), findsOneWidget); }); - testWidgets('Handle invalid or unsupported files', (tester) async { - await _pumpWithOpenPdf(tester); - await tester.tap(find.byKey(const Key('btn_mark_signing'))); - await tester.pump(); - - await tester.tap(find.byKey(const Key('btn_load_invalid_signature'))); - await tester.pump(); - expect(find.text('Invalid or unsupported file'), findsOneWidget); - }); + // Removed: Load Invalid button is not part of normal app UI. testWidgets('Resize and move signature within page bounds', (tester) async { - await _pumpWithOpenPdfAndSig(tester); + await pumpWithOpenPdfAndSig(tester); await tester.tap(find.byKey(const Key('btn_mark_signing'))); await tester.pump(); @@ -163,7 +172,7 @@ void main() { }); testWidgets('Lock aspect ratio while resizing', (tester) async { - await _pumpWithOpenPdfAndSig(tester); + await pumpWithOpenPdfAndSig(tester); await tester.tap(find.byKey(const Key('btn_mark_signing'))); await tester.pump(); @@ -188,7 +197,7 @@ void main() { testWidgets('Background removal and adjustments controls change state', ( tester, ) async { - await _pumpWithOpenPdfAndSig(tester); + await pumpWithOpenPdfAndSig(tester); await tester.tap(find.byKey(const Key('btn_mark_signing'))); await tester.pump(); @@ -210,32 +219,57 @@ void main() { expect(find.byKey(const Key('signature_overlay')), findsOneWidget); }); - testWidgets('Draw signature: draw, undo, clear, confirm places on page', ( - tester, - ) async { - await _pumpWithOpenPdfAndSig(tester); - await tester.tap(find.byKey(const Key('btn_mark_signing'))); - await tester.pump(); - - // Open draw canvas - await tester.tap(find.byKey(const Key('btn_draw_signature'))); + testWidgets('DrawCanvas exports non-empty bytes on confirm', (tester) async { + Uint8List? exported; + final sink = ValueNotifier(null); + final control = hand.HandSignatureControl(); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DrawCanvas( + control: control, + debugBytesSink: sink, + onConfirm: (bytes) { + exported = bytes; + }, + ), + ), + ), + ); await tester.pumpAndSettle(); - final canvas = find.byKey(const Key('draw_canvas')); - await tester.drag(canvas, const Offset(80, 0)); - await tester.pump(); - await tester.tap(find.byKey(const Key('btn_canvas_undo'))); - await tester.pump(); - await tester.drag(canvas, const Offset(50, 0)); - await tester.pump(); - await tester.tap(find.byKey(const Key('btn_canvas_clear'))); - await tester.pump(); - await tester.drag(canvas, const Offset(40, 0)); - await tester.pump(); + + // Draw a simple stroke inside the pad + final pad = find.byKey(const Key('hand_signature_pad')); + expect(pad, findsOneWidget); + final rect = tester.getRect(pad); + final g = await tester.startGesture( + Offset(rect.left + 20, rect.center.dy), + kind: PointerDeviceKind.touch, + ); + for (int i = 0; i < 10; i++) { + await g.moveBy( + const Offset(12, 0), + timeStamp: Duration(milliseconds: 16 * (i + 1)), + ); + await tester.pump(const Duration(milliseconds: 16)); + } + await g.up(); + await tester.pump(const Duration(milliseconds: 50)); + + // Confirm export await tester.tap(find.byKey(const Key('btn_canvas_confirm'))); - await tester.pumpAndSettle(); + // Wait until notifier receives bytes + await tester.pumpAndSettle(const Duration(milliseconds: 50)); + await tester.runAsync(() async { + final end = DateTime.now().add(const Duration(seconds: 2)); + while (sink.value == null && DateTime.now().isBefore(end)) { + await Future.delayed(const Duration(milliseconds: 20)); + } + }); + exported ??= sink.value; - // Overlay present with drawn strokes painter - expect(find.byKey(const Key('signature_overlay')), findsOneWidget); + expect(exported, isNotNull); + expect(exported!.isNotEmpty, isTrue); }); testWidgets('Save uses file selector (via provider) and injected exporter', (