feat: partially implement multi-signature feature
This commit is contained in:
parent
a53e881d7b
commit
5b71b294ac
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -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);
|
||||
},
|
||||
|
|
|
@ -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>(
|
||||
|
|
|
@ -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'),
|
||||
|
|
|
@ -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);
|
||||
}
|
|
@ -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]));
|
||||
}
|
|
@ -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);
|
||||
}
|
|
@ -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);
|
||||
}
|
|
@ -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);
|
||||
}
|
|
@ -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);
|
||||
}
|
|
@ -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);
|
||||
}
|
|
@ -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);
|
||||
}
|
|
@ -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));
|
||||
}
|
|
@ -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
|
||||
|
|
@ -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);
|
||||
});
|
||||
}
|
|
@ -1,2 +0,0 @@
|
|||
// Split into multiple *_test.dart files. Intentionally left empty.
|
||||
void main() {}
|
Loading…
Reference in New Issue