From 5b71b294ace2f7dcefcdb0ca79678f18a79183ec Mon Sep 17 00:00:00 2001 From: insleker Date: Fri, 29 Aug 2025 22:57:04 +0800 Subject: [PATCH] feat: partially implement multi-signature feature --- docs/FRs.md | 4 + lib/data/model/model.dart | 6 + lib/data/services/export_service.dart | 113 +++++++++--- .../features/pdf/view_model/view_model.dart | 33 ++++ lib/ui/features/pdf/widgets/pdf_screen.dart | 168 ++++++++++++------ ...ltiple_placed_signatures_across_pages.dart | 43 +++++ .../a_signature_image_is_loaded_or_drawn.dart | 14 ++ .../step/a_signature_is_placed_on_page_2.dart | 40 +++++ ...s_are_shown_on_their_respective_pages.dart | 15 ++ ...o_page_3_and_places_another_signature.dart | 20 +++ ...the_user_places_a_signature_on_page_1.dart | 19 ++ ...in_multiple_locations_in_the_document.dart | 47 +++++ ...laces_two_signatures_on_the_same_page.dart | 57 ++++++ ...atures_are_placed_on_the_current_page.dart | 36 ++++ .../support_multiple_signatures.feature | 38 ++++ test/widget/pdf_navigation_widget_test.dart | 86 +++++++++ test/widget/widget_test.dart | 2 - 17 files changed, 653 insertions(+), 88 deletions(-) create mode 100644 test/features/step/a_pdf_is_open_and_contains_multiple_placed_signatures_across_pages.dart create mode 100644 test/features/step/a_signature_image_is_loaded_or_drawn.dart create mode 100644 test/features/step/a_signature_is_placed_on_page_2.dart create mode 100644 test/features/step/both_signatures_are_shown_on_their_respective_pages.dart create mode 100644 test/features/step/the_user_navigates_to_page_3_and_places_another_signature.dart create mode 100644 test/features/step/the_user_places_a_signature_on_page_1.dart create mode 100644 test/features/step/the_user_places_it_in_multiple_locations_in_the_document.dart create mode 100644 test/features/step/the_user_places_two_signatures_on_the_same_page.dart create mode 100644 test/features/step/three_signatures_are_placed_on_the_current_page.dart create mode 100644 test/features/support_multiple_signatures.feature create mode 100644 test/widget/pdf_navigation_widget_test.dart delete mode 100644 test/widget/widget_test.dart diff --git a/docs/FRs.md b/docs/FRs.md index 31ac17d..343bae1 100644 --- a/docs/FRs.md +++ b/docs/FRs.md @@ -38,3 +38,7 @@ * role: user * functionality: app provide localization support * benefit: improve accessibility and usability for non-English speakers +* name: [support multiple signatures](../test/features/support_multiple_signatures.feature) + * role: user + * functionality: the ability to sign multiple locations within a PDF document + * benefit: documents requiring multiple signatures can be signed simultaneously diff --git a/lib/data/model/model.dart b/lib/data/model/model.dart index a502803..9e9b511 100644 --- a/lib/data/model/model.dart +++ b/lib/data/model/model.dart @@ -8,6 +8,8 @@ class PdfState { final String? pickedPdfPath; final Uint8List? pickedPdfBytes; final int? signedPage; + // Multiple signature placements per page, stored as UI-space rects (e.g., 400x560) + final Map> placementsByPage; const PdfState({ required this.loaded, required this.pageCount, @@ -15,6 +17,7 @@ class PdfState { this.pickedPdfPath, this.pickedPdfBytes, this.signedPage, + this.placementsByPage = const {}, }); factory PdfState.initial() => const PdfState( loaded: false, @@ -22,6 +25,7 @@ class PdfState { currentPage: 1, pickedPdfBytes: null, signedPage: null, + placementsByPage: {}, ); PdfState copyWith({ bool? loaded, @@ -30,6 +34,7 @@ class PdfState { String? pickedPdfPath, Uint8List? pickedPdfBytes, int? signedPage, + Map>? placementsByPage, }) => PdfState( loaded: loaded ?? this.loaded, pageCount: pageCount ?? this.pageCount, @@ -37,6 +42,7 @@ class PdfState { pickedPdfPath: pickedPdfPath ?? this.pickedPdfPath, pickedPdfBytes: pickedPdfBytes ?? this.pickedPdfBytes, signedPage: signedPage ?? this.signedPage, + placementsByPage: placementsByPage ?? this.placementsByPage, ); } diff --git a/lib/data/services/export_service.dart b/lib/data/services/export_service.dart index 105502d..3a16cab 100644 --- a/lib/data/services/export_service.dart +++ b/lib/data/services/export_service.dart @@ -32,6 +32,7 @@ class ExportService { required Rect? signatureRectUi, required Size uiPageSize, required Uint8List? signatureImageBytes, + Map>? placementsByPage, double targetDpi = 144.0, }) async { // print( @@ -51,6 +52,7 @@ class ExportService { signatureRectUi: signatureRectUi, uiPageSize: uiPageSize, signatureImageBytes: signatureImageBytes, + placementsByPage: placementsByPage, targetDpi: targetDpi, ); if (bytes == null) return false; @@ -70,6 +72,7 @@ class ExportService { required Rect? signatureRectUi, required Size uiPageSize, required Uint8List? signatureImageBytes, + Map>? placementsByPage, double targetDpi = 144.0, }) async { final out = pw.Document(version: pdf.PdfVersion.pdf_1_4, compress: false); @@ -91,13 +94,25 @@ class ExportService { final bgImg = pw.MemoryImage(bgPng); pw.MemoryImage? sigImgObj; - final shouldStamp = + final hasMulti = + (placementsByPage != null && placementsByPage.isNotEmpty); + final pagePlacements = + hasMulti + ? (placementsByPage[pageIndex] ?? const []) + : const []; + final shouldStampSingle = + !hasMulti && signedPage != null && pageIndex == signedPage && signatureRectUi != null && signatureImageBytes != null && signatureImageBytes.isNotEmpty; - if (shouldStamp) { + final shouldStampMulti = + hasMulti && + pagePlacements.isNotEmpty && + signatureImageBytes != null && + signatureImageBytes.isNotEmpty; + if (shouldStampSingle || shouldStampMulti) { try { sigImgObj = pw.MemoryImage(signatureImageBytes); } catch (_) { @@ -125,18 +140,34 @@ class ExportService { ), ]; if (sigImgObj != null) { - final r = signatureRectUi!; - final left = r.left / uiPageSize.width * widthPts; - final top = r.top / uiPageSize.height * heightPts; - final w = r.width / uiPageSize.width * widthPts; - final h = r.height / uiPageSize.height * heightPts; - children.add( - pw.Positioned( - left: left, - top: top, - child: pw.Image(sigImgObj, width: w, height: h), - ), - ); + if (hasMulti && pagePlacements.isNotEmpty) { + for (final r in pagePlacements) { + final left = r.left / uiPageSize.width * widthPts; + final top = r.top / uiPageSize.height * heightPts; + final w = r.width / uiPageSize.width * widthPts; + final h = r.height / uiPageSize.height * heightPts; + children.add( + pw.Positioned( + left: left, + top: top, + child: pw.Image(sigImgObj, width: w, height: h), + ), + ); + } + } else if (shouldStampSingle) { + final r = signatureRectUi; + final left = r.left / uiPageSize.width * widthPts; + final top = r.top / uiPageSize.height * heightPts; + final w = r.width / uiPageSize.width * widthPts; + final h = r.height / uiPageSize.height * heightPts; + children.add( + pw.Positioned( + left: left, + top: top, + child: pw.Image(sigImgObj, width: w, height: h), + ), + ); + } } return pw.Stack(children: children); }, @@ -152,13 +183,23 @@ class ExportService { final widthPts = pdf.PdfPageFormat.a4.width; final heightPts = pdf.PdfPageFormat.a4.height; pw.MemoryImage? sigImgObj; - final shouldStamp = + final hasMulti = + (placementsByPage != null && placementsByPage.isNotEmpty); + final pagePlacements = + hasMulti ? (placementsByPage[1] ?? const []) : const []; + final shouldStampSingle = + !hasMulti && signedPage != null && signedPage == 1 && signatureRectUi != null && signatureImageBytes != null && signatureImageBytes.isNotEmpty; - if (shouldStamp) { + final shouldStampMulti = + hasMulti && + pagePlacements.isNotEmpty && + signatureImageBytes != null && + signatureImageBytes.isNotEmpty; + if (shouldStampSingle || shouldStampMulti) { try { // If it's already PNG, keep as-is to preserve alpha; otherwise decode/encode PNG final asStr = String.fromCharCodes(signatureImageBytes.take(8)); @@ -192,18 +233,34 @@ class ExportService { ), ]; if (sigImgObj != null) { - final r = signatureRectUi!; - final left = r.left / uiPageSize.width * widthPts; - final top = r.top / uiPageSize.height * heightPts; - final w = r.width / uiPageSize.width * widthPts; - final h = r.height / uiPageSize.height * heightPts; - children.add( - pw.Positioned( - left: left, - top: top, - child: pw.Image(sigImgObj, width: w, height: h), - ), - ); + if (hasMulti && pagePlacements.isNotEmpty) { + for (final r in pagePlacements) { + final left = r.left / uiPageSize.width * widthPts; + final top = r.top / uiPageSize.height * heightPts; + final w = r.width / uiPageSize.width * widthPts; + final h = r.height / uiPageSize.height * heightPts; + children.add( + pw.Positioned( + left: left, + top: top, + child: pw.Image(sigImgObj, width: w, height: h), + ), + ); + } + } else if (shouldStampSingle) { + final r = signatureRectUi; + final left = r.left / uiPageSize.width * widthPts; + final top = r.top / uiPageSize.height * heightPts; + final w = r.width / uiPageSize.width * widthPts; + final h = r.height / uiPageSize.height * heightPts; + children.add( + pw.Positioned( + left: left, + top: top, + child: pw.Image(sigImgObj, width: w, height: h), + ), + ); + } } return pw.Stack(children: children); }, diff --git a/lib/ui/features/pdf/view_model/view_model.dart b/lib/ui/features/pdf/view_model/view_model.dart index 01b32e6..fcaa356 100644 --- a/lib/ui/features/pdf/view_model/view_model.dart +++ b/lib/ui/features/pdf/view_model/view_model.dart @@ -18,6 +18,7 @@ class PdfController extends StateNotifier { currentPage: 1, pickedPdfPath: null, signedPage: null, + placementsByPage: {}, ); } @@ -33,6 +34,7 @@ class PdfController extends StateNotifier { pickedPdfPath: path, pickedPdfBytes: bytes, signedPage: null, + placementsByPage: {}, ); } @@ -57,6 +59,37 @@ class PdfController extends StateNotifier { if (!state.loaded) return; state = state.copyWith(pageCount: count.clamp(1, 9999)); } + + // Multiple-signature helpers + void addPlacement({required int page, required Rect rect}) { + if (!state.loaded) return; + final p = page.clamp(1, state.pageCount); + final map = Map>.from(state.placementsByPage); + final list = List.from(map[p] ?? const []); + list.add(rect); + map[p] = list; + state = state.copyWith(placementsByPage: map); + } + + void removePlacement({required int page, required int index}) { + if (!state.loaded) return; + final p = page.clamp(1, state.pageCount); + final map = Map>.from(state.placementsByPage); + final list = List.from(map[p] ?? const []); + if (index >= 0 && index < list.length) { + list.removeAt(index); + if (list.isEmpty) { + map.remove(p); + } else { + map[p] = list; + } + state = state.copyWith(placementsByPage: map); + } + } + + List placementsOn(int page) { + return List.from(state.placementsByPage[page] ?? const []); + } } final pdfProvider = StateNotifierProvider( diff --git a/lib/ui/features/pdf/widgets/pdf_screen.dart b/lib/ui/features/pdf/widgets/pdf_screen.dart index bb07a77..4b91568 100644 --- a/lib/ui/features/pdf/widgets/pdf_screen.dart +++ b/lib/ui/features/pdf/widgets/pdf_screen.dart @@ -70,6 +70,16 @@ class _PdfSignatureHomePageState extends ConsumerState { } } + void _placeCurrentSignatureOnPage() { + final pdf = ref.read(pdfProvider); + final sig = ref.read(signatureProvider); + if (!pdf.loaded || sig.rect == null) return; + ref + .read(pdfProvider.notifier) + .addPlacement(page: pdf.currentPage, rect: sig.rect!); + // Keep the active rect so the user can place multiple times if desired. + } + void _onDragSignature(Offset delta) { ref.read(signatureProvider.notifier).drag(delta); } @@ -130,6 +140,7 @@ class _PdfSignatureHomePageState extends ConsumerState { signatureRectUi: sig.rect, uiPageSize: SignatureController.pageSize, signatureImageBytes: processed ?? sig.imageBytes, + placementsByPage: pdf.placementsByPage, targetDpi: targetDpi, ); if (bytes != null) { @@ -161,6 +172,7 @@ class _PdfSignatureHomePageState extends ConsumerState { signatureRectUi: sig.rect, uiPageSize: SignatureController.pageSize, signatureImageBytes: processed ?? sig.imageBytes, + placementsByPage: pdf.placementsByPage, targetDpi: targetDpi, ); if (useMock) { @@ -187,6 +199,7 @@ class _PdfSignatureHomePageState extends ConsumerState { signatureRectUi: sig.rect, uiPageSize: SignatureController.pageSize, signatureImageBytes: processed ?? sig.imageBytes, + placementsByPage: pdf.placementsByPage, targetDpi: targetDpi, ); } @@ -408,6 +421,16 @@ class _PdfSignatureHomePageState extends ConsumerState { onPressed: disabled || !pdf.loaded ? null : _openDrawCanvas, child: Text(l.drawSignature), ), + OutlinedButton( + key: const Key('btn_place_signature'), + onPressed: + disabled || + !pdf.loaded || + ref.read(signatureProvider).rect == null + ? null + : _placeCurrentSignatureOnPage, + child: const Text('Place on page'), + ), ], ], ); @@ -428,7 +451,7 @@ class _PdfSignatureHomePageState extends ConsumerState { key: const Key('page_stack'), children: [ Container( - key: const Key('pdf_page'), + key: ValueKey('pdf_page_view_${pdf.currentPage}'), color: Colors.grey.shade200, child: Center( child: Text( @@ -446,8 +469,8 @@ class _PdfSignatureHomePageState extends ConsumerState { builder: (context, ref, _) { final sig = ref.watch(signatureProvider); final visible = ref.watch(signatureVisibilityProvider); - return sig.rect != null && visible - ? _buildSignatureOverlay(sig) + return visible + ? _buildPageOverlays(sig) : const SizedBox.shrink(); }, ), @@ -486,6 +509,7 @@ class _PdfSignatureHomePageState extends ConsumerState { key: const Key('page_stack'), children: [ PdfPageView( + key: ValueKey('pdf_page_view_$pageNum'), document: document, pageNumber: pageNum, alignment: Alignment.center, @@ -494,8 +518,8 @@ class _PdfSignatureHomePageState extends ConsumerState { builder: (context, ref, _) { final sig = ref.watch(signatureProvider); final visible = ref.watch(signatureVisibilityProvider); - return sig.rect != null && visible - ? _buildSignatureOverlay(sig) + return visible + ? _buildPageOverlays(sig) : const SizedBox.shrink(); }, ), @@ -511,8 +535,11 @@ class _PdfSignatureHomePageState extends ConsumerState { return const SizedBox.shrink(); } - Widget _buildSignatureOverlay(SignatureState sig) { - final r = sig.rect!; + Widget _buildSignatureOverlay( + SignatureState sig, + Rect r, { + bool interactive = true, + }) { return LayoutBuilder( builder: (context, constraints) { final scaleX = constraints.maxWidth / _pageSize.width; @@ -529,61 +556,70 @@ class _PdfSignatureHomePageState extends ConsumerState { top: top, width: width, height: height, - child: GestureDetector( - key: const Key('signature_overlay'), - behavior: HitTestBehavior.opaque, - onPanStart: (_) {}, - onPanUpdate: - (d) => _onDragSignature( - Offset(d.delta.dx / scaleX, d.delta.dy / scaleY), + child: Builder( + builder: (context) { + Widget content = DecoratedBox( + decoration: BoxDecoration( + color: Color.fromRGBO( + 0, + 0, + 0, + 0.05 + math.min(0.25, (sig.contrast - 1.0).abs()), + ), + border: Border.all(color: Colors.indigo, width: 2), ), - child: DecoratedBox( - decoration: BoxDecoration( - color: Color.fromRGBO( - 0, - 0, - 0, - 0.05 + math.min(0.25, (sig.contrast - 1.0).abs()), - ), - border: Border.all(color: Colors.indigo, width: 2), - ), - child: Stack( - children: [ - Consumer( - builder: (context, ref, _) { - final processed = ref.watch( - processedSignatureImageProvider, - ); - final bytes = processed ?? sig.imageBytes; - if (bytes == null) { - return Center( - child: Text( - AppLocalizations.of(context).signature, - ), + child: Stack( + children: [ + Consumer( + builder: (context, ref, _) { + final processed = ref.watch( + processedSignatureImageProvider, ); - } - return Image.memory(bytes, fit: BoxFit.contain); - }, - ), - Positioned( - right: 0, - bottom: 0, - child: GestureDetector( - key: const Key('signature_handle'), - behavior: HitTestBehavior.opaque, - onPanUpdate: - (d) => _onResizeSignature( - Offset( - d.delta.dx / scaleX, - d.delta.dy / scaleY, + final bytes = processed ?? sig.imageBytes; + if (bytes == null) { + return Center( + child: Text( + AppLocalizations.of(context).signature, ), - ), - child: const Icon(Icons.open_in_full, size: 20), + ); + } + return Image.memory(bytes, fit: BoxFit.contain); + }, ), - ), - ], - ), - ), + if (interactive) + Positioned( + right: 0, + bottom: 0, + child: GestureDetector( + key: const Key('signature_handle'), + behavior: HitTestBehavior.opaque, + onPanUpdate: + (d) => _onResizeSignature( + Offset( + d.delta.dx / scaleX, + d.delta.dy / scaleY, + ), + ), + child: const Icon(Icons.open_in_full, size: 20), + ), + ), + ], + ), + ); + if (interactive) { + content = GestureDetector( + key: const Key('signature_overlay'), + behavior: HitTestBehavior.opaque, + onPanStart: (_) {}, + onPanUpdate: + (d) => _onDragSignature( + Offset(d.delta.dx / scaleX, d.delta.dy / scaleY), + ), + child: content, + ); + } + return content; + }, ), ), ], @@ -592,6 +628,22 @@ class _PdfSignatureHomePageState extends ConsumerState { ); } + Widget _buildPageOverlays(SignatureState sig) { + final pdf = ref.watch(pdfProvider); + final current = pdf.currentPage; + final placed = pdf.placementsByPage[current] ?? const []; + final widgets = []; + for (final r in placed) { + widgets.add(_buildSignatureOverlay(sig, r, interactive: false)); + } + // Show the active editing rect only on the selected (signed) page + if (sig.rect != null && + (pdf.signedPage == null || pdf.signedPage == current)) { + widgets.add(_buildSignatureOverlay(sig, sig.rect!, interactive: true)); + } + return Stack(children: widgets); + } + Widget _buildAdjustmentsPanel(SignatureState sig) { return Column( key: const Key('adjustments_panel'), diff --git a/test/features/step/a_pdf_is_open_and_contains_multiple_placed_signatures_across_pages.dart b/test/features/step/a_pdf_is_open_and_contains_multiple_placed_signatures_across_pages.dart new file mode 100644 index 0000000..0f70a2f --- /dev/null +++ b/test/features/step/a_pdf_is_open_and_contains_multiple_placed_signatures_across_pages.dart @@ -0,0 +1,43 @@ +import 'dart:typed_data'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter/material.dart'; +import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart'; +import '_world.dart'; + +/// Usage: a PDF is open and contains multiple placed signatures across pages +Future aPdfIsOpenAndContainsMultiplePlacedSignaturesAcrossPages( + WidgetTester tester, +) async { + final container = TestWorld.container ?? ProviderContainer(); + TestWorld.container = container; + container + .read(pdfProvider.notifier) + .openPicked(path: 'mock.pdf', pageCount: 6); + // Ensure signature image exists + container + .read(signatureProvider.notifier) + .setImageBytes(Uint8List.fromList([1, 2, 3])); + // Place on two pages + container + .read(pdfProvider.notifier) + .addPlacement(page: 1, rect: const Rect.fromLTWH(10, 10, 80, 40)); + container + .read(pdfProvider.notifier) + .addPlacement(page: 4, rect: const Rect.fromLTWH(120, 200, 100, 50)); + // Keep backward compatibility with existing export step expectations + container.read(pdfProvider.notifier).setSignedPage(1); + container.read(signatureProvider.notifier).placeDefaultRect(); +} + +/// Usage: all placed signatures appear on their corresponding pages in the output +Future allPlacedSignaturesAppearOnTheirCorrespondingPagesInTheOutput( + WidgetTester tester, +) async { + // In this logic-level test suite, we simply assert that placements exist + // on multiple pages and that a simulated export has bytes. + final container = TestWorld.container ?? ProviderContainer(); + expect(container.read(pdfProvider.notifier).placementsOn(1), isNotEmpty); + expect(container.read(pdfProvider.notifier).placementsOn(4), isNotEmpty); + expect(TestWorld.lastExportBytes, isNotNull); +} diff --git a/test/features/step/a_signature_image_is_loaded_or_drawn.dart b/test/features/step/a_signature_image_is_loaded_or_drawn.dart new file mode 100644 index 0000000..e9ba667 --- /dev/null +++ b/test/features/step/a_signature_image_is_loaded_or_drawn.dart @@ -0,0 +1,14 @@ +import 'dart:typed_data'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart'; +import '_world.dart'; + +/// Usage: a signature image is loaded or drawn +Future aSignatureImageIsLoadedOrDrawn(WidgetTester tester) async { + final container = TestWorld.container ?? ProviderContainer(); + TestWorld.container = container; + container + .read(signatureProvider.notifier) + .setImageBytes(Uint8List.fromList([1, 2, 3])); +} diff --git a/test/features/step/a_signature_is_placed_on_page_2.dart b/test/features/step/a_signature_is_placed_on_page_2.dart new file mode 100644 index 0000000..acaff2d --- /dev/null +++ b/test/features/step/a_signature_is_placed_on_page_2.dart @@ -0,0 +1,40 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter/material.dart'; +import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart'; +import '_world.dart'; + +/// Usage: a signature is placed on page 2 +Future aSignatureIsPlacedOnPage2(WidgetTester tester) async { + final container = TestWorld.container ?? ProviderContainer(); + TestWorld.container = container; + container + .read(pdfProvider.notifier) + .openPicked(path: 'mock.pdf', pageCount: 8); + container + .read(pdfProvider.notifier) + .addPlacement(page: 2, rect: const Rect.fromLTWH(50, 100, 80, 40)); +} + +/// Usage: the user navigates to page 5 and places another signature +Future theUserNavigatesToPage5AndPlacesAnotherSignature( + WidgetTester tester, +) async { + final container = TestWorld.container ?? ProviderContainer(); + container.read(pdfProvider.notifier).jumpTo(5); + container + .read(pdfProvider.notifier) + .addPlacement(page: 5, rect: const Rect.fromLTWH(60, 120, 80, 40)); +} + +/// Usage: the signature on page 2 remains +Future theSignatureOnPage2Remains(WidgetTester tester) async { + final container = TestWorld.container ?? ProviderContainer(); + expect(container.read(pdfProvider.notifier).placementsOn(2), isNotEmpty); +} + +/// Usage: the signature on page 5 is shown on page 5 +Future theSignatureOnPage5IsShownOnPage5(WidgetTester tester) async { + final container = TestWorld.container ?? ProviderContainer(); + expect(container.read(pdfProvider.notifier).placementsOn(5), isNotEmpty); +} diff --git a/test/features/step/both_signatures_are_shown_on_their_respective_pages.dart b/test/features/step/both_signatures_are_shown_on_their_respective_pages.dart new file mode 100644 index 0000000..4106f3d --- /dev/null +++ b/test/features/step/both_signatures_are_shown_on_their_respective_pages.dart @@ -0,0 +1,15 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart'; +import '_world.dart'; + +/// Usage: both signatures are shown on their respective pages +Future bothSignaturesAreShownOnTheirRespectivePages( + WidgetTester tester, +) async { + final container = TestWorld.container ?? ProviderContainer(); + final p1 = container.read(pdfProvider.notifier).placementsOn(1); + final p3 = container.read(pdfProvider.notifier).placementsOn(3); + expect(p1, isNotEmpty); + expect(p3, isNotEmpty); +} diff --git a/test/features/step/the_user_navigates_to_page_3_and_places_another_signature.dart b/test/features/step/the_user_navigates_to_page_3_and_places_another_signature.dart new file mode 100644 index 0000000..237e6fa --- /dev/null +++ b/test/features/step/the_user_navigates_to_page_3_and_places_another_signature.dart @@ -0,0 +1,20 @@ +import 'dart:typed_data'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart'; +import '_world.dart'; + +/// Usage: the user navigates to page 3 and places another signature +Future theUserNavigatesToPage3AndPlacesAnotherSignature( + WidgetTester tester, +) async { + final container = TestWorld.container ?? ProviderContainer(); + TestWorld.container = container; + container.read(pdfProvider.notifier).jumpTo(3); + container + .read(signatureProvider.notifier) + .setImageBytes(Uint8List.fromList([1, 2, 3])); + container.read(signatureProvider.notifier).placeDefaultRect(); + final rect = container.read(signatureProvider).rect!; + container.read(pdfProvider.notifier).addPlacement(page: 3, rect: rect); +} diff --git a/test/features/step/the_user_places_a_signature_on_page_1.dart b/test/features/step/the_user_places_a_signature_on_page_1.dart new file mode 100644 index 0000000..64e5ca4 --- /dev/null +++ b/test/features/step/the_user_places_a_signature_on_page_1.dart @@ -0,0 +1,19 @@ +import 'dart:typed_data'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart'; +import '_world.dart'; + +/// Usage: the user places a signature on page 1 +Future theUserPlacesASignatureOnPage1(WidgetTester tester) async { + final container = TestWorld.container ?? ProviderContainer(); + TestWorld.container = container; + // Ensure image exists so placement is meaningful + container + .read(signatureProvider.notifier) + .setImageBytes(Uint8List.fromList([1, 2, 3])); + // Place a default rect on page 1 + container.read(signatureProvider.notifier).placeDefaultRect(); + final rect = container.read(signatureProvider).rect!; + container.read(pdfProvider.notifier).addPlacement(page: 1, rect: rect); +} diff --git a/test/features/step/the_user_places_it_in_multiple_locations_in_the_document.dart b/test/features/step/the_user_places_it_in_multiple_locations_in_the_document.dart new file mode 100644 index 0000000..0c4faa9 --- /dev/null +++ b/test/features/step/the_user_places_it_in_multiple_locations_in_the_document.dart @@ -0,0 +1,47 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter/material.dart'; +import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart'; +import '_world.dart'; + +/// Usage: the user places it in multiple locations in the document +Future theUserPlacesItInMultipleLocationsInTheDocument( + WidgetTester tester, +) async { + final container = TestWorld.container ?? ProviderContainer(); + TestWorld.container = container; + final notifier = container.read(pdfProvider.notifier); + // Always open a fresh doc to avoid state bleed between scenarios + notifier.openPicked(path: 'mock.pdf', pageCount: 6); + // Place two on page 2 and one on page 4 + notifier.addPlacement(page: 2, rect: const Rect.fromLTWH(10, 10, 80, 40)); + notifier.addPlacement(page: 2, rect: const Rect.fromLTWH(120, 50, 80, 40)); + notifier.addPlacement(page: 4, rect: const Rect.fromLTWH(20, 200, 100, 50)); +} + +/// Usage: identical signature instances appear in each location +Future identicalSignatureInstancesAppearInEachLocation( + WidgetTester tester, +) async { + final container = TestWorld.container ?? ProviderContainer(); + TestWorld.container = container; + final state = container.read(pdfProvider); + final p2 = state.placementsByPage[2] ?? const []; + final p4 = state.placementsByPage[4] ?? const []; + expect(p2.length, greaterThanOrEqualTo(2)); + expect(p4.length, greaterThanOrEqualTo(1)); +} + +/// Usage: adjusting one instance does not affect the others +Future adjustingOneInstanceDoesNotAffectTheOthers( + WidgetTester tester, +) async { + final container = TestWorld.container ?? ProviderContainer(); + final before = container.read(pdfProvider.notifier).placementsOn(2); + expect(before.length, greaterThanOrEqualTo(2)); + final modified = before[0].inflate(5); + container.read(pdfProvider.notifier).removePlacement(page: 2, index: 0); + container.read(pdfProvider.notifier).addPlacement(page: 2, rect: modified); + final after = container.read(pdfProvider.notifier).placementsOn(2); + expect(after.any((r) => r == before[1]), isTrue); +} diff --git a/test/features/step/the_user_places_two_signatures_on_the_same_page.dart b/test/features/step/the_user_places_two_signatures_on_the_same_page.dart new file mode 100644 index 0000000..2d57bb9 --- /dev/null +++ b/test/features/step/the_user_places_two_signatures_on_the_same_page.dart @@ -0,0 +1,57 @@ +import 'dart:typed_data'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter/material.dart'; +import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart'; +import '_world.dart'; + +/// Usage: the user places two signatures on the same page +Future theUserPlacesTwoSignaturesOnTheSamePage( + WidgetTester tester, +) async { + final container = TestWorld.container ?? ProviderContainer(); + TestWorld.container = container; + container + .read(signatureProvider.notifier) + .setImageBytes(Uint8List.fromList([1, 2, 3])); + // First + container.read(signatureProvider.notifier).placeDefaultRect(); + final r1 = container.read(signatureProvider).rect!; + container.read(pdfProvider.notifier).addPlacement(page: 1, rect: r1); + // Second (offset a bit) + final r2 = r1.shift(const Offset(30, 30)); + container.read(pdfProvider.notifier).addPlacement(page: 1, rect: r2); +} + +/// Usage: each signature can be dragged and resized independently +Future eachSignatureCanBeDraggedAndResizedIndependently( + WidgetTester tester, +) async { + final container = TestWorld.container ?? ProviderContainer(); + final list = container.read(pdfProvider.notifier).placementsOn(1); + expect(list.length, greaterThanOrEqualTo(2)); + // Independence is modeled by distinct rects; ensure not equal and both within page + expect(list[0], isNot(equals(list[1]))); + for (final r in list.take(2)) { + expect(r.left, greaterThanOrEqualTo(0)); + expect(r.top, greaterThanOrEqualTo(0)); + } +} + +/// Usage: dragging or resizing one does not change the other +Future draggingOrResizingOneDoesNotChangeTheOther( + WidgetTester tester, +) async { + final container = TestWorld.container ?? ProviderContainer(); + final list = container.read(pdfProvider.notifier).placementsOn(1); + expect(list.length, greaterThanOrEqualTo(2)); + final before = List.from(list.take(2)); + // Simulate changing the first only + final changed = before[0].shift(const Offset(5, 5)); + container.read(pdfProvider.notifier).removePlacement(page: 1, index: 0); + container.read(pdfProvider.notifier).addPlacement(page: 1, rect: changed); + final after = container.read(pdfProvider.notifier).placementsOn(1); + expect(after[0], isNot(equals(before[0]))); + // The other remains the same (order may differ after remove/add, check set containment) + expect(after.any((r) => r == before[1]), isTrue); +} diff --git a/test/features/step/three_signatures_are_placed_on_the_current_page.dart b/test/features/step/three_signatures_are_placed_on_the_current_page.dart new file mode 100644 index 0000000..217245d --- /dev/null +++ b/test/features/step/three_signatures_are_placed_on_the_current_page.dart @@ -0,0 +1,36 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter/material.dart'; +import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart'; +import '_world.dart'; + +/// Usage: three signatures are placed on the current page +Future threeSignaturesArePlacedOnTheCurrentPage( + WidgetTester tester, +) async { + final container = TestWorld.container ?? ProviderContainer(); + TestWorld.container = container; + container + .read(pdfProvider.notifier) + .openPicked(path: 'mock.pdf', pageCount: 5); + final n = container.read(pdfProvider.notifier); + n.addPlacement(page: 1, rect: const Rect.fromLTWH(10, 10, 80, 40)); + n.addPlacement(page: 1, rect: const Rect.fromLTWH(100, 50, 80, 40)); + n.addPlacement(page: 1, rect: const Rect.fromLTWH(200, 90, 80, 40)); +} + +/// Usage: the user deletes one selected signature +Future theUserDeletesOneSelectedSignature(WidgetTester tester) async { + final container = TestWorld.container ?? ProviderContainer(); + // Remove the middle one (index 1) + container.read(pdfProvider.notifier).removePlacement(page: 1, index: 1); +} + +/// Usage: only the selected signature is removed +Future onlyTheSelectedSignatureIsRemoved(WidgetTester tester) async { + final container = TestWorld.container ?? ProviderContainer(); + final list = container.read(pdfProvider.notifier).placementsOn(1); + expect(list.length, 2); + expect(list[0].left, equals(10)); + expect(list[1].left, equals(200)); +} diff --git a/test/features/support_multiple_signatures.feature b/test/features/support_multiple_signatures.feature new file mode 100644 index 0000000..7198fa4 --- /dev/null +++ b/test/features/support_multiple_signatures.feature @@ -0,0 +1,38 @@ +Feature: support multiple signatures + + Scenario: Place signatures on different pages + Given a multi-page PDF is open + When the user places a signature on page 1 + And the user navigates to page 3 and places another signature + Then both signatures are shown on their respective pages + + Scenario: Place multiple signatures on the same page independently + Given a PDF page is selected for signing + When the user places two signatures on the same page + Then each signature can be dragged and resized independently + And dragging or resizing one does not change the other + + Scenario: Reuse the same signature asset in multiple locations + Given a signature image is loaded or drawn + When the user places it in multiple locations in the document + Then identical signature instances appear in each location + And adjusting one instance does not affect the others + + Scenario: Remove one of many signatures + Given three signatures are placed on the current page + When the user deletes one selected signature + Then only the selected signature is removed + And the other signatures remain unchanged + + Scenario: Keep earlier signatures while navigating between pages + Given a signature is placed on page 2 + When the user navigates to page 5 and places another signature + Then the signature on page 2 remains + And the signature on page 5 is shown on page 5 + + Scenario: Save a document with multiple signatures across pages + Given a PDF is open and contains multiple placed signatures across pages + When the user saves/exports the document + Then all placed signatures appear on their corresponding pages in the output + And other page content remains unaltered + diff --git a/test/widget/pdf_navigation_widget_test.dart b/test/widget/pdf_navigation_widget_test.dart new file mode 100644 index 0000000..cead7bf --- /dev/null +++ b/test/widget/pdf_navigation_widget_test.dart @@ -0,0 +1,86 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'package:pdf_signature/ui/features/pdf/widgets/pdf_screen.dart'; +import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart'; +import 'package:pdf_signature/data/model/model.dart'; +import 'package:pdf_signature/data/services/providers.dart'; +import 'package:pdf_signature/l10n/app_localizations.dart'; + +class _TestPdfController extends PdfController { + _TestPdfController() : super() { + // Start with a loaded multi-page doc, page 1 of 5 + state = PdfState.initial().copyWith( + loaded: true, + pageCount: 5, + currentPage: 1, + ); + } +} + +void main() { + testWidgets('PDF navigation: prev/next and goto update page label', ( + tester, + ) async { + await tester.pumpWidget( + ProviderScope( + overrides: [ + useMockViewerProvider.overrideWithValue(true), + pdfProvider.overrideWith((ref) => _TestPdfController()), + ], + child: MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + locale: const Locale('en'), + home: const PdfSignatureHomePage(), + ), + ), + ); + + // Initial label and page view key + expect(find.byKey(const Key('lbl_page_info')), findsOneWidget); + Text label() => tester.widget(find.byKey(const Key('lbl_page_info'))); + expect(label().data, equals('Page 1/5')); + expect(find.byKey(const ValueKey('pdf_page_view_1')), findsOneWidget); + + // Next + await tester.tap(find.byKey(const Key('btn_next'))); + await tester.pumpAndSettle(); + expect(label().data, equals('Page 2/5')); + expect(find.byKey(const ValueKey('pdf_page_view_2')), findsOneWidget); + + // Prev + await tester.tap(find.byKey(const Key('btn_prev'))); + await tester.pumpAndSettle(); + expect(label().data, equals('Page 1/5')); + expect(find.byKey(const ValueKey('pdf_page_view_1')), findsOneWidget); + + // Goto specific page + await tester.tap(find.byKey(const Key('txt_goto'))); + await tester.pumpAndSettle(); + await tester.enterText(find.byKey(const Key('txt_goto')), '4'); + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pumpAndSettle(); + expect(label().data, equals('Page 4/5')); + expect(find.byKey(const ValueKey('pdf_page_view_4')), findsOneWidget); + + // Goto beyond upper bound -> clamp to 5 + await tester.tap(find.byKey(const Key('txt_goto'))); + await tester.pumpAndSettle(); + await tester.enterText(find.byKey(const Key('txt_goto')), '999'); + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pumpAndSettle(); + expect(label().data, equals('Page 5/5')); + expect(find.byKey(const ValueKey('pdf_page_view_5')), findsOneWidget); + + // Goto below 1 -> clamp to 1 + await tester.tap(find.byKey(const Key('txt_goto'))); + await tester.pumpAndSettle(); + await tester.enterText(find.byKey(const Key('txt_goto')), '0'); + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pumpAndSettle(); + expect(label().data, equals('Page 1/5')); + expect(find.byKey(const ValueKey('pdf_page_view_1')), findsOneWidget); + }); +} diff --git a/test/widget/widget_test.dart b/test/widget/widget_test.dart deleted file mode 100644 index 1d9bbc2..0000000 --- a/test/widget/widget_test.dart +++ /dev/null @@ -1,2 +0,0 @@ -// Split into multiple *_test.dart files. Intentionally left empty. -void main() {}