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