feat: partially implement multi-signature feature

This commit is contained in:
insleker 2025-08-29 22:57:04 +08:00
parent a53e881d7b
commit 5b71b294ac
17 changed files with 653 additions and 88 deletions

View File

@ -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

View File

@ -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<int, List<Rect>> 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<int, List<Rect>>? 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,
);
}

View File

@ -32,6 +32,7 @@ class ExportService {
required Rect? signatureRectUi,
required Size uiPageSize,
required Uint8List? signatureImageBytes,
Map<int, List<Rect>>? 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<int, List<Rect>>? 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 <Rect>[])
: const <Rect>[];
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 <Rect>[]) : const <Rect>[];
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);
},

View File

@ -18,6 +18,7 @@ class PdfController extends StateNotifier<PdfState> {
currentPage: 1,
pickedPdfPath: null,
signedPage: null,
placementsByPage: {},
);
}
@ -33,6 +34,7 @@ class PdfController extends StateNotifier<PdfState> {
pickedPdfPath: path,
pickedPdfBytes: bytes,
signedPage: null,
placementsByPage: {},
);
}
@ -57,6 +59,37 @@ class PdfController extends StateNotifier<PdfState> {
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<int, List<Rect>>.from(state.placementsByPage);
final list = List<Rect>.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<int, List<Rect>>.from(state.placementsByPage);
final list = List<Rect>.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<Rect> placementsOn(int page) {
return List<Rect>.from(state.placementsByPage[page] ?? const []);
}
}
final pdfProvider = StateNotifierProvider<PdfController, PdfState>(

View File

@ -70,6 +70,16 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
}
}
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<PdfSignatureHomePage> {
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<PdfSignatureHomePage> {
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<PdfSignatureHomePage> {
signatureRectUi: sig.rect,
uiPageSize: SignatureController.pageSize,
signatureImageBytes: processed ?? sig.imageBytes,
placementsByPage: pdf.placementsByPage,
targetDpi: targetDpi,
);
}
@ -408,6 +421,16 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
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<PdfSignatureHomePage> {
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<PdfSignatureHomePage> {
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<PdfSignatureHomePage> {
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<PdfSignatureHomePage> {
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<PdfSignatureHomePage> {
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<PdfSignatureHomePage> {
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<PdfSignatureHomePage> {
);
}
Widget _buildPageOverlays(SignatureState sig) {
final pdf = ref.watch(pdfProvider);
final current = pdf.currentPage;
final placed = pdf.placementsByPage[current] ?? const <Rect>[];
final widgets = <Widget>[];
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'),

View File

@ -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<void> 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<void> 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);
}

View File

@ -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<void> aSignatureImageIsLoadedOrDrawn(WidgetTester tester) async {
final container = TestWorld.container ?? ProviderContainer();
TestWorld.container = container;
container
.read(signatureProvider.notifier)
.setImageBytes(Uint8List.fromList([1, 2, 3]));
}

View File

@ -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<void> 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<void> 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<void> 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<void> theSignatureOnPage5IsShownOnPage5(WidgetTester tester) async {
final container = TestWorld.container ?? ProviderContainer();
expect(container.read(pdfProvider.notifier).placementsOn(5), isNotEmpty);
}

View File

@ -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<void> 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);
}

View File

@ -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<void> 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);
}

View File

@ -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<void> 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);
}

View File

@ -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<void> 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<void> 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<void> 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);
}

View File

@ -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<void> 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<void> 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<void> 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<Rect>.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);
}

View File

@ -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<void> 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<void> 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<void> 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));
}

View File

@ -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

View File

@ -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<Text>(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);
});
}

View File

@ -1,2 +0,0 @@
// Split into multiple *_test.dart files. Intentionally left empty.
void main() {}