From 5b0b9d2a024d889a2bedb04594b5c58cc56a6837 Mon Sep 17 00:00:00 2001 From: insleker Date: Wed, 27 Aug 2025 16:27:58 +0800 Subject: [PATCH] feat: output other pages which are not signed --- README.md | 15 ++++ docs/use_cases.md | 12 ++- lib/features/pdf/viewer.dart | 86 ++++++++++++++++---- lib/features/pdf/viewer_state.dart | 57 +++++++++++-- lib/features/pdf/viewer_widgets.dart | 40 +++++++--- lib/features/share/export_service.dart | 57 +++++++++++++ pubspec.yaml | 1 + test/pdf_state_test.dart | 44 ++++++++++ test/signature_state_test.dart | 77 ++++++++++++++++++ test/widget/widget_test.dart | 106 +++++++++++++++++++++++++ 10 files changed, 459 insertions(+), 36 deletions(-) create mode 100644 test/pdf_state_test.dart create mode 100644 test/signature_state_test.dart diff --git a/README.md b/README.md index 35eeb98..51c7d30 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,18 @@ # pdf_signature A GUI app to create a signature on PDF page interactively. + +## Features + +checkout [`docs/FRs.md`](docs/FRs.md) + +## Build + +```bash +flutter pub get +flutter run + +flutter test + +flutter build +``` diff --git a/docs/use_cases.md b/docs/use_cases.md index 330f96d..4565b39 100644 --- a/docs/use_cases.md +++ b/docs/use_cases.md @@ -100,8 +100,16 @@ Feature: save signed PDF Scenario: Export the signed document to a new file Given a PDF is open and contains at least one placed signature When the user saves/exports the document - Then a new PDF file is saved at the chosen location with specified file name - And the signatures appear on the corresponding pages in the output + Then a new PDF file is saved at specified full path, location and file name + And the signatures appear on the corresponding page in the output + And keep other unchanged content(pages) intact in the output + + Scenario: Vector-accurate stamping into PDF page coordinates + Given a signature is placed with a position and size relative to the page + When the user saves/exports the document + Then the signature is stamped at the exact PDF page coordinates and size + And the stamp remains crisp at any zoom level (not rasterized by the screen) + And other page content remains vector and unaltered Scenario: Prevent saving when nothing is placed Given a PDF is open with no signatures placed diff --git a/lib/features/pdf/viewer.dart b/lib/features/pdf/viewer.dart index 2608a28..1b0a015 100644 --- a/lib/features/pdf/viewer.dart +++ b/lib/features/pdf/viewer.dart @@ -3,7 +3,7 @@ import 'package:file_selector/file_selector.dart' as fs; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:pdfrx/pdfrx.dart'; -import 'dart:io' show Platform; +import 'package:path_provider/path_provider.dart' as pp; import 'dart:typed_data'; import '../share/export_service.dart'; @@ -12,6 +12,36 @@ part 'viewer_widgets.dart'; // Testing hook: allow using a mock viewer instead of pdfrx to avoid async I/O in widget tests final useMockViewerProvider = Provider((_) => false); +// Export service injection for testability +final exportServiceProvider = Provider((_) => ExportService()); +// 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) +final savePathPickerProvider = Provider Function()>((ref) { + return () async { + String? initialDir; + try { + final d = await pp.getDownloadsDirectory(); + initialDir = d?.path; + } catch (_) {} + if (initialDir == null) { + try { + final d = await pp.getApplicationDocumentsDirectory(); + initialDir = d.path; + } catch (_) {} + } + final location = await fs.getSaveLocation( + suggestedName: 'signed.pdf', + acceptedTypeGroups: [ + const fs.XTypeGroup(label: 'PDF', extensions: ['pdf']), + ], + initialDirectory: initialDir, + ); + if (location == null) return null; + final path = location.path; + return path.toLowerCase().endsWith('.pdf') ? path : '$path.pdf'; + }; +}); class PdfSignatureHomePage extends ConsumerStatefulWidget { const PdfSignatureHomePage({super.key}); @@ -94,20 +124,38 @@ class _PdfSignatureHomePageState extends ConsumerState { ); return; } - // Pick a directory to save (fallback when save-as dialog API isn't available) - final dir = await fs.getDirectoryPath(); - if (dir == null) return; - final sep = Platform.pathSeparator; - final path = '$dir${sep}signed.pdf'; - final exporter = ExportService(); - final ok = await exporter.exportSignedPdfFromBoundary( + final pick = ref.read(savePathPickerProvider); + final path = await pick(); + if (path == null || path.trim().isEmpty) return; + final fullPath = _ensurePdfExtension(path.trim()); + final exporter = ref.read(exportServiceProvider); + // Multi-page export: iterate pages by navigating the viewer + final controller = ref.read(pdfProvider.notifier); + final current = pdf.currentPage; + final targetPage = pdf.signedPage; // may be null if not marked + final ok = await exporter.exportMultiPageFromBoundary( boundaryKey: _captureKey, - outputPath: path, + outputPath: fullPath, + pageCount: pdf.pageCount, + onGotoPage: (p) async { + controller.jumpTo(p); + // Show overlay only on the signed page (if any) + // If a target page is specified, show overlay only on that page. + // If not specified, keep overlay visible (backwards compatible single-page case). + final show = targetPage == null ? true : (targetPage == p); + ref.read(signatureVisibilityProvider.notifier).state = show; + // Allow build to occur + await Future.delayed(const Duration(milliseconds: 20)); + }, ); + // Restore page + controller.jumpTo(current); + // Restore visibility + ref.read(signatureVisibilityProvider.notifier).state = true; if (ok) { ScaffoldMessenger.of( context, - ).showSnackBar(SnackBar(content: Text('Saved: $path'))); + ).showSnackBar(SnackBar(content: Text('Saved: $fullPath'))); } else { ScaffoldMessenger.of( context, @@ -115,6 +163,13 @@ class _PdfSignatureHomePageState extends ConsumerState { } } + // Removed manual full-path dialog; using file_selector.getSaveLocation via provider + + String _ensurePdfExtension(String name) { + if (!name.toLowerCase().endsWith('.pdf')) return '$name.pdf'; + return name; + } + @override Widget build(BuildContext context) { final pdf = ref.watch(pdfProvider); @@ -208,11 +263,6 @@ class _PdfSignatureHomePageState extends ConsumerState { onPressed: _loadSignatureFromFile, child: const Text('Load Signature from file'), ), - OutlinedButton( - key: const Key('btn_load_invalid_signature'), - onPressed: _loadInvalidSignature, - child: const Text('Load Invalid'), - ), ElevatedButton( key: const Key('btn_draw_signature'), onPressed: _openDrawCanvas, @@ -254,7 +304,8 @@ class _PdfSignatureHomePageState extends ConsumerState { Consumer( builder: (context, ref, _) { final sig = ref.watch(signatureProvider); - return sig.rect != null + final visible = ref.watch(signatureVisibilityProvider); + return sig.rect != null && visible ? _buildSignatureOverlay(sig) : const SizedBox.shrink(); }, @@ -301,7 +352,8 @@ class _PdfSignatureHomePageState extends ConsumerState { Consumer( builder: (context, ref, _) { final sig = ref.watch(signatureProvider); - return sig.rect != null + final visible = ref.watch(signatureVisibilityProvider); + return sig.rect != null && visible ? _buildSignatureOverlay(sig) : const SizedBox.shrink(); }, diff --git a/lib/features/pdf/viewer_state.dart b/lib/features/pdf/viewer_state.dart index a24eca8..4190bf2 100644 --- a/lib/features/pdf/viewer_state.dart +++ b/lib/features/pdf/viewer_state.dart @@ -6,18 +6,21 @@ class PdfState { final int currentPage; final bool markedForSigning; final String? pickedPdfPath; + final int? signedPage; const PdfState({ required this.loaded, required this.pageCount, required this.currentPage, required this.markedForSigning, this.pickedPdfPath, + this.signedPage, }); factory PdfState.initial() => const PdfState( loaded: false, pageCount: 0, currentPage: 1, markedForSigning: false, + signedPage: null, ); PdfState copyWith({ bool? loaded, @@ -25,12 +28,14 @@ class PdfState { int? currentPage, bool? markedForSigning, String? pickedPdfPath, + int? signedPage, }) => PdfState( loaded: loaded ?? this.loaded, pageCount: pageCount ?? this.pageCount, currentPage: currentPage ?? this.currentPage, markedForSigning: markedForSigning ?? this.markedForSigning, pickedPdfPath: pickedPdfPath ?? this.pickedPdfPath, + signedPage: signedPage ?? this.signedPage, ); } @@ -44,6 +49,7 @@ class PdfController extends StateNotifier { currentPage: 1, markedForSigning: false, pickedPdfPath: null, + signedPage: null, ); } @@ -54,6 +60,7 @@ class PdfController extends StateNotifier { currentPage: 1, markedForSigning: false, pickedPdfPath: path, + signedPage: null, ); } @@ -65,7 +72,14 @@ class PdfController extends StateNotifier { void toggleMark() { if (!state.loaded) return; - state = state.copyWith(markedForSigning: !state.markedForSigning); + if (state.signedPage != null) { + state = state.copyWith(markedForSigning: false, signedPage: null); + } else { + state = state.copyWith( + markedForSigning: true, + signedPage: state.currentPage, + ); + } } void setPageCount(int count) { @@ -168,24 +182,55 @@ class SignatureController extends StateNotifier { void resize(Offset delta) { if (state.rect == null) return; final r = state.rect!; - double newW = (r.width + delta.dx).clamp(20, pageSize.width); - double newH = (r.height + delta.dy).clamp(20, pageSize.height); + double newW = r.width + delta.dx; + double newH = r.height + delta.dy; if (state.aspectLocked) { final aspect = r.width / r.height; - if ((delta.dx / r.width).abs() >= (delta.dy / r.height).abs()) { + // Keep ratio based on the dominant proportional delta + final dxRel = (delta.dx / r.width).abs(); + final dyRel = (delta.dy / r.height).abs(); + if (dxRel >= dyRel) { + newW = newW.clamp(20.0, double.infinity); newH = newW / aspect; } else { + newH = newH.clamp(20.0, double.infinity); newW = newH * aspect; } + // Scale down to fit within page bounds while preserving ratio + final scaleW = pageSize.width / newW; + final scaleH = pageSize.height / newH; + final scale = math.min(1.0, math.min(scaleW, scaleH)); + newW *= scale; + newH *= scale; + // Ensure minimum size of 20x20, scaling up proportionally if needed + final minScale = math.max(1.0, math.max(20.0 / newW, 20.0 / newH)); + newW *= minScale; + newH *= minScale; + Rect resized = Rect.fromLTWH(r.left, r.top, newW, newH); + resized = _clampRectPositionToPage(resized); + state = state.copyWith(rect: resized); + return; } + // Unlocked aspect: clamp each dimension independently + newW = newW.clamp(20.0, pageSize.width); + newH = newH.clamp(20.0, pageSize.height); Rect resized = Rect.fromLTWH(r.left, r.top, newW, newH); resized = _clampRectToPage(resized); state = state.copyWith(rect: resized); } Rect _clampRectToPage(Rect r) { - double left = r.left.clamp(0.0, pageSize.width - r.width); - double top = r.top.clamp(0.0, pageSize.height - r.height); + // Ensure size never exceeds page bounds first, to avoid invalid clamp ranges + final double w = r.width.clamp(20.0, pageSize.width); + final double h = r.height.clamp(20.0, pageSize.height); + final double left = r.left.clamp(0.0, pageSize.width - w); + final double top = r.top.clamp(0.0, pageSize.height - h); + return Rect.fromLTWH(left, top, w, h); + } + + Rect _clampRectPositionToPage(Rect r) { + final double left = r.left.clamp(0.0, pageSize.width - r.width); + final double top = r.top.clamp(0.0, pageSize.height - r.height); return Rect.fromLTWH(left, top, r.width, r.height); } diff --git a/lib/features/pdf/viewer_widgets.dart b/lib/features/pdf/viewer_widgets.dart index d74649d..0f7061b 100644 --- a/lib/features/pdf/viewer_widgets.dart +++ b/lib/features/pdf/viewer_widgets.dart @@ -10,6 +10,7 @@ class DrawCanvas extends StatefulWidget { class _DrawCanvasState extends State { late List> _strokes; + final GlobalKey _canvasKey = GlobalKey(); @override void initState() { @@ -17,12 +18,13 @@ class _DrawCanvasState extends State { _strokes = widget.strokes.map((s) => List.of(s)).toList(); } - void _onPanStart(DragStartDetails d) { - setState(() => _strokes.add([d.localPosition])); + void _startStroke(Offset localPosition) { + setState(() => _strokes.add([localPosition])); } - void _onPanUpdate(DragUpdateDetails d) { - setState(() => _strokes.last.add(d.localPosition)); + void _extendStroke(Offset localPosition) { + if (_strokes.isEmpty) return; + setState(() => _strokes.last.add(localPosition)); } void _undo() { @@ -67,14 +69,30 @@ class _DrawCanvasState extends State { SizedBox( key: const Key('draw_canvas'), height: 240, - child: GestureDetector( - onPanStart: _onPanStart, - onPanUpdate: _onPanUpdate, - child: DecoratedBox( - decoration: BoxDecoration( - border: Border.all(color: Colors.black26), + 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), + ), + child: CustomPaint(painter: StrokesPainter(_strokes)), ), - child: CustomPaint(painter: StrokesPainter(_strokes)), ), ), ), diff --git a/lib/features/share/export_service.dart b/lib/features/share/export_service.dart index 4647ae3..c599d17 100644 --- a/lib/features/share/export_service.dart +++ b/lib/features/share/export_service.dart @@ -4,6 +4,12 @@ import 'package:flutter/rendering.dart'; import 'package:flutter/widgets.dart'; import 'package:pdf/widgets.dart' as pw; +// NOTE: +// - This exporter uses a raster snapshot of the UI (RepaintBoundary) and embeds it into a new PDF. +// - It does NOT perform vector-accurate stamping into the source PDF. +// - Vector stamping remains unimplemented with FOSS-only constraints because the `pdf` package +// cannot import/modify existing PDF pages. If/when a suitable FOSS library exists, wire it here. + class ExportService { Future exportSignedPdfFromBoundary({ required GlobalKey boundaryKey, @@ -38,4 +44,55 @@ class ExportService { return false; } } + + /// Multi-page export by navigating the viewer and capturing each page. + /// onGotoPage must navigate the UI to the requested page and return when the + /// page is ready to render. We'll still wait for a frame for safety. + Future exportMultiPageFromBoundary({ + required GlobalKey boundaryKey, + required String outputPath, + required int pageCount, + required Future Function(int page) onGotoPage, + double pixelRatio = 3.0, + }) async { + try { + final doc = pw.Document(); + for (int i = 1; i <= pageCount; i++) { + await onGotoPage(i); + // Give Flutter and the PDF viewer time to render the page + await Future.delayed(const Duration(milliseconds: 120)); + for (int f = 0; f < 2; f++) { + try { + await WidgetsBinding.instance.endOfFrame; + } catch (_) { + // Best-effort if not in a frame-driven context + await Future.delayed(const Duration(milliseconds: 16)); + } + } + + final boundary = + boundaryKey.currentContext?.findRenderObject() + as RenderRepaintBoundary?; + if (boundary == null) return false; + 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(); + final img = pw.MemoryImage(pngBytes); + doc.addPage( + pw.Page( + build: + (context) => + pw.Center(child: pw.Image(img, fit: pw.BoxFit.contain)), + ), + ); + } + final bytes = await doc.save(); + final file = File(outputPath); + await file.writeAsBytes(bytes, flush: true); + return true; + } catch (e) { + return false; + } + } } diff --git a/pubspec.yaml b/pubspec.yaml index 5139310..c0e50f0 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -41,6 +41,7 @@ dependencies: path_provider: ^2.1.5 pdfrx: ^1.3.5 pdf: ^3.10.8 + hand_signature: ^3.1.0+2 dev_dependencies: flutter_test: diff --git a/test/pdf_state_test.dart b/test/pdf_state_test.dart new file mode 100644 index 0000000..f9a8ea2 --- /dev/null +++ b/test/pdf_state_test.dart @@ -0,0 +1,44 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:pdf_signature/features/pdf/viewer.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +void main() { + test('openPicked loads document and initializes state', () { + final container = ProviderContainer(); + addTearDown(container.dispose); + final notifier = container.read(pdfProvider.notifier); + notifier.openPicked(path: 'test.pdf', pageCount: 7); + final state = container.read(pdfProvider); + expect(state.loaded, isTrue); + expect(state.pickedPdfPath, 'test.pdf'); + expect(state.pageCount, 7); + expect(state.currentPage, 1); + expect(state.markedForSigning, isFalse); + }); + + test('jumpTo clamps within page boundaries', () { + final container = ProviderContainer(); + addTearDown(container.dispose); + final notifier = container.read(pdfProvider.notifier); + notifier.openPicked(path: 'test.pdf', pageCount: 5); + notifier.jumpTo(10); + expect(container.read(pdfProvider).currentPage, 5); + notifier.jumpTo(0); + expect(container.read(pdfProvider).currentPage, 1); + notifier.jumpTo(3); + expect(container.read(pdfProvider).currentPage, 3); + }); + + test('setPageCount updates count without toggling other flags', () { + final container = ProviderContainer(); + addTearDown(container.dispose); + final notifier = container.read(pdfProvider.notifier); + notifier.openPicked(path: 'test.pdf', pageCount: 2); + notifier.toggleMark(); + notifier.setPageCount(9); + final s = container.read(pdfProvider); + expect(s.pageCount, 9); + expect(s.loaded, isTrue); + expect(s.markedForSigning, isTrue); + }); +} diff --git a/test/signature_state_test.dart b/test/signature_state_test.dart new file mode 100644 index 0000000..4504103 --- /dev/null +++ b/test/signature_state_test.dart @@ -0,0 +1,77 @@ +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'; + +void main() { + test('placeDefaultRect centers a reasonable default rect', () { + final container = ProviderContainer(); + addTearDown(container.dispose); + final sig = container.read(signatureProvider); + // Should be null initially + expect(sig.rect, isNull); + + // Place using default pageSize (400x560) + container.read(signatureProvider.notifier).placeDefaultRect(); + final placed = container.read(signatureProvider).rect!; + + // Default should be within bounds and not tiny + expect(placed.left, greaterThanOrEqualTo(0)); + expect(placed.top, greaterThanOrEqualTo(0)); + expect(placed.right, lessThanOrEqualTo(400)); + expect(placed.bottom, lessThanOrEqualTo(560)); + expect(placed.width, greaterThan(50)); + expect(placed.height, greaterThan(20)); + }); + + test('drag clamps to canvas bounds', () { + final container = ProviderContainer(); + addTearDown(container.dispose); + container.read(signatureProvider.notifier).placeDefaultRect(); + final before = container.read(signatureProvider).rect!; + // Drag far outside bounds + container + .read(signatureProvider.notifier) + .drag(const Offset(10000, -10000)); + final after = container.read(signatureProvider).rect!; + expect(after.left, greaterThanOrEqualTo(0)); + expect(after.top, greaterThanOrEqualTo(0)); + expect(after.right, lessThanOrEqualTo(400)); + expect(after.bottom, lessThanOrEqualTo(560)); + // Ensure it actually moved + expect(after.center, isNot(equals(before.center))); + }); + + test('resize respects aspect lock and clamps', () { + final container = ProviderContainer(); + addTearDown(container.dispose); + final notifier = container.read(signatureProvider.notifier); + notifier.placeDefaultRect(); + final before = container.read(signatureProvider).rect!; + notifier.toggleAspect(true); + notifier.resize(const Offset(1000, 1000)); + final after = container.read(signatureProvider).rect!; + // With aspect lock the ratio should remain approximately the same + final ratioBefore = before.width / before.height; + final ratioAfter = after.width / after.height; + expect((ratioBefore - ratioAfter).abs(), lessThan(0.05)); + // Still within bounds + expect(after.left, greaterThanOrEqualTo(0)); + expect(after.top, greaterThanOrEqualTo(0)); + expect(after.right, lessThanOrEqualTo(400)); + expect(after.bottom, lessThanOrEqualTo(560)); + }); + + test('setImageBytes ensures a rect exists for display', () { + final container = ProviderContainer(); + addTearDown(container.dispose); + final notifier = container.read(signatureProvider.notifier); + expect(container.read(signatureProvider).rect, isNull); + notifier.setImageBytes(Uint8List.fromList([0, 1, 2])); + expect(container.read(signatureProvider).imageBytes, isNotNull); + // placeDefaultRect is called when bytes are set if rect was null + expect(container.read(signatureProvider).rect, isNotNull); + }); +} diff --git a/test/widget/widget_test.dart b/test/widget/widget_test.dart index bc07192..f8f3381 100644 --- a/test/widget/widget_test.dart +++ b/test/widget/widget_test.dart @@ -10,6 +10,44 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:pdf_signature/features/pdf/viewer.dart'; +import 'package:pdf_signature/features/share/export_service.dart'; + +// Fakes for export service (top-level; Dart does not allow local class declarations) +class RecordingExporter extends ExportService { + bool called = false; + @override + Future exportMultiPageFromBoundary({ + required GlobalKey boundaryKey, + required String outputPath, + required int pageCount, + required Future Function(int page) onGotoPage, + double pixelRatio = 2.0, + }) async { + called = true; + // Ensure extension + expect(outputPath.toLowerCase().endsWith('.pdf'), isTrue); + for (var i = 1; i <= pageCount; i++) { + await onGotoPage(i); + } + return true; + } +} + +class BasicExporter extends ExportService { + @override + Future exportMultiPageFromBoundary({ + required GlobalKey boundaryKey, + required String outputPath, + required int pageCount, + required Future Function(int page) onGotoPage, + double pixelRatio = 2.0, + }) async { + for (var i = 1; i <= pageCount; i++) { + await onGotoPage(i); + } + return true; + } +} void main() { Future _pumpWithOpenPdf(WidgetTester tester) async { @@ -199,4 +237,72 @@ void main() { // Overlay present with drawn strokes painter expect(find.byKey(const Key('signature_overlay')), findsOneWidget); }); + + testWidgets('Save uses file selector (via provider) and injected exporter', ( + tester, + ) async { + final fake = RecordingExporter(); + await tester.pumpWidget( + ProviderScope( + overrides: [ + pdfProvider.overrideWith( + (ref) => PdfController()..openPicked(path: 'test.pdf'), + ), + signatureProvider.overrideWith( + (ref) => SignatureController()..placeDefaultRect(), + ), + useMockViewerProvider.overrideWith((ref) => true), + exportServiceProvider.overrideWith((_) => fake), + savePathPickerProvider.overrideWith( + (_) => () async => 'C:/tmp/output.pdf', + ), + ], + child: const MaterialApp(home: PdfSignatureHomePage()), + ), + ); + await tester.pump(); + + // Mark signing to set signedPage + await tester.tap(find.byKey(const Key('btn_mark_signing'))); + await tester.pump(); + + // Trigger save + await tester.tap(find.byKey(const Key('btn_save_pdf'))); + await tester.pumpAndSettle(); + + expect(fake.called, isTrue); + expect(find.textContaining('Saved:'), findsOneWidget); + }); + + testWidgets('Only signed page shows overlay during export flow', ( + tester, + ) async { + await tester.pumpWidget( + ProviderScope( + overrides: [ + pdfProvider.overrideWith( + (ref) => PdfController()..openPicked(path: 'test.pdf'), + ), + signatureProvider.overrideWith( + (ref) => SignatureController()..placeDefaultRect(), + ), + useMockViewerProvider.overrideWith((ref) => true), + exportServiceProvider.overrideWith((_) => BasicExporter()), + savePathPickerProvider.overrideWith( + (_) => () async => 'C:/tmp/output.pdf', + ), + ], + child: const MaterialApp(home: PdfSignatureHomePage()), + ), + ); + await tester.pump(); + // Mark signing on page 1 + await tester.tap(find.byKey(const Key('btn_mark_signing'))); + await tester.pump(); + // Save -> open dialog -> confirm + await tester.tap(find.byKey(const Key('btn_save_pdf'))); + await tester.pumpAndSettle(); + // After export, overlay visible again + expect(find.byKey(const Key('signature_overlay')), findsOneWidget); + }); }