feat: add draw signature feature with widget test
This commit is contained in:
parent
5b0b9d2a02
commit
9a31903d0d
|
@ -121,3 +121,4 @@ app.*.symbols
|
||||||
docs/.*
|
docs/.*
|
||||||
.vscode/tasks.json
|
.vscode/tasks.json
|
||||||
.vscode/launch.json
|
.vscode/launch.json
|
||||||
|
devtools_options.yaml
|
||||||
|
|
|
@ -10,8 +10,11 @@ checkout [`docs/FRs.md`](docs/FRs.md)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
flutter pub get
|
flutter pub get
|
||||||
|
|
||||||
|
# run the app
|
||||||
flutter run
|
flutter run
|
||||||
|
|
||||||
|
# run unit tests and widget tests
|
||||||
flutter test
|
flutter test
|
||||||
|
|
||||||
flutter build
|
flutter build
|
||||||
|
|
|
@ -115,5 +115,13 @@ Feature: save signed PDF
|
||||||
Given a PDF is open with no signatures placed
|
Given a PDF is open with no signatures placed
|
||||||
When the user attempts to save
|
When the user attempts to save
|
||||||
Then the user is notified there is nothing to save
|
Then the user is notified there is nothing to save
|
||||||
|
|
||||||
|
Scenario: Loading sign when exporting/saving files
|
||||||
|
Given a signature is placed with a position and size relative to the page
|
||||||
|
When the user starts exporting the document
|
||||||
|
And the export process is not yet finished
|
||||||
|
Then the user is notified that the export is still in progress
|
||||||
|
And the user cannot edit the document
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
@ -6,6 +6,8 @@ import 'package:pdfrx/pdfrx.dart';
|
||||||
import 'package:path_provider/path_provider.dart' as pp;
|
import 'package:path_provider/path_provider.dart' as pp;
|
||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
import '../share/export_service.dart';
|
import '../share/export_service.dart';
|
||||||
|
import 'package:hand_signature/signature.dart' as hand;
|
||||||
|
import 'package:meta/meta.dart';
|
||||||
|
|
||||||
part 'viewer_state.dart';
|
part 'viewer_state.dart';
|
||||||
part 'viewer_widgets.dart';
|
part 'viewer_widgets.dart';
|
||||||
|
@ -14,6 +16,8 @@ part 'viewer_widgets.dart';
|
||||||
final useMockViewerProvider = Provider<bool>((_) => false);
|
final useMockViewerProvider = Provider<bool>((_) => false);
|
||||||
// Export service injection for testability
|
// Export service injection for testability
|
||||||
final exportServiceProvider = Provider<ExportService>((_) => ExportService());
|
final exportServiceProvider = Provider<ExportService>((_) => ExportService());
|
||||||
|
// Export DPI setting (points per inch mapping), default 144 DPI
|
||||||
|
final exportDpiProvider = StateProvider<double>((_) => 144.0);
|
||||||
// Controls whether signature overlay is visible (used to hide on non-stamped pages during export)
|
// Controls whether signature overlay is visible (used to hide on non-stamped pages during export)
|
||||||
final signatureVisibilityProvider = StateProvider<bool>((_) => true);
|
final signatureVisibilityProvider = StateProvider<bool>((_) => true);
|
||||||
// Save path picker (injected for tests)
|
// Save path picker (injected for tests)
|
||||||
|
@ -55,6 +59,12 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
|
||||||
static const Size _pageSize = SignatureController.pageSize;
|
static const Size _pageSize = SignatureController.pageSize;
|
||||||
final GlobalKey _captureKey = GlobalKey();
|
final GlobalKey _captureKey = GlobalKey();
|
||||||
|
|
||||||
|
// Exposed for tests to trigger the invalid-file SnackBar without UI.
|
||||||
|
@visibleForTesting
|
||||||
|
void debugShowInvalidSignatureSnackBar() {
|
||||||
|
ref.read(signatureProvider.notifier).setInvalidSelected(context);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _pickPdf() async {
|
Future<void> _pickPdf() async {
|
||||||
final typeGroup = const fs.XTypeGroup(label: 'PDF', extensions: ['pdf']);
|
final typeGroup = const fs.XTypeGroup(label: 'PDF', extensions: ['pdf']);
|
||||||
final file = await fs.openFile(acceptedTypeGroups: [typeGroup]);
|
final file = await fs.openFile(acceptedTypeGroups: [typeGroup]);
|
||||||
|
@ -86,9 +96,7 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
|
||||||
sig.setImageBytes(bytes);
|
sig.setImageBytes(bytes);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _loadInvalidSignature() {
|
// removed invalid loader; not part of normal app
|
||||||
ref.read(signatureProvider.notifier).setInvalidSelected(context);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _onDragSignature(Offset delta) {
|
void _onDragSignature(Offset delta) {
|
||||||
ref.read(signatureProvider.notifier).drag(delta);
|
ref.read(signatureProvider.notifier).drag(delta);
|
||||||
|
@ -101,15 +109,15 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
|
||||||
Future<void> _openDrawCanvas() async {
|
Future<void> _openDrawCanvas() async {
|
||||||
final pdf = ref.read(pdfProvider);
|
final pdf = ref.read(pdfProvider);
|
||||||
if (!pdf.markedForSigning) return;
|
if (!pdf.markedForSigning) return;
|
||||||
final current = ref.read(signatureProvider).strokes;
|
final result = await showModalBottomSheet<Uint8List>(
|
||||||
final result = await showModalBottomSheet<List<List<Offset>>>(
|
|
||||||
context: context,
|
context: context,
|
||||||
isScrollControlled: true,
|
isScrollControlled: true,
|
||||||
builder: (_) => DrawCanvas(strokes: current),
|
enableDrag: false,
|
||||||
|
builder: (_) => const DrawCanvas(),
|
||||||
);
|
);
|
||||||
if (result != null) {
|
if (result != null && result.isNotEmpty) {
|
||||||
ref.read(signatureProvider.notifier).setStrokes(result);
|
// Use the drawn image as signature content
|
||||||
ref.read(signatureProvider.notifier).ensureRectForStrokes();
|
ref.read(signatureProvider.notifier).setImageBytes(result);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -129,6 +137,7 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
|
||||||
if (path == null || path.trim().isEmpty) return;
|
if (path == null || path.trim().isEmpty) return;
|
||||||
final fullPath = _ensurePdfExtension(path.trim());
|
final fullPath = _ensurePdfExtension(path.trim());
|
||||||
final exporter = ref.read(exportServiceProvider);
|
final exporter = ref.read(exportServiceProvider);
|
||||||
|
final targetDpi = ref.read(exportDpiProvider);
|
||||||
// Multi-page export: iterate pages by navigating the viewer
|
// Multi-page export: iterate pages by navigating the viewer
|
||||||
final controller = ref.read(pdfProvider.notifier);
|
final controller = ref.read(pdfProvider.notifier);
|
||||||
final current = pdf.currentPage;
|
final current = pdf.currentPage;
|
||||||
|
@ -137,6 +146,7 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
|
||||||
boundaryKey: _captureKey,
|
boundaryKey: _captureKey,
|
||||||
outputPath: fullPath,
|
outputPath: fullPath,
|
||||||
pageCount: pdf.pageCount,
|
pageCount: pdf.pageCount,
|
||||||
|
targetDpi: targetDpi,
|
||||||
onGotoPage: (p) async {
|
onGotoPage: (p) async {
|
||||||
controller.jumpTo(p);
|
controller.jumpTo(p);
|
||||||
// Show overlay only on the signed page (if any)
|
// Show overlay only on the signed page (if any)
|
||||||
|
@ -197,6 +207,7 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildToolbar(PdfState pdf) {
|
Widget _buildToolbar(PdfState pdf) {
|
||||||
|
final dpi = ref.watch(exportDpiProvider);
|
||||||
final pageInfo = 'Page ${pdf.currentPage}/${pdf.pageCount}';
|
final pageInfo = 'Page ${pdf.currentPage}/${pdf.pageCount}';
|
||||||
return Wrap(
|
return Wrap(
|
||||||
spacing: 8,
|
spacing: 8,
|
||||||
|
@ -244,6 +255,31 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Text('DPI:'),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
DropdownButton<double>(
|
||||||
|
key: const Key('ddl_export_dpi'),
|
||||||
|
value: dpi,
|
||||||
|
items:
|
||||||
|
const [96.0, 144.0, 200.0, 300.0]
|
||||||
|
.map(
|
||||||
|
(v) => DropdownMenuItem(
|
||||||
|
value: v,
|
||||||
|
child: Text(v.toStringAsFixed(0)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
|
onChanged: (v) {
|
||||||
|
if (v != null) {
|
||||||
|
ref.read(exportDpiProvider.notifier).state = v;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
key: const Key('btn_mark_signing'),
|
key: const Key('btn_mark_signing'),
|
||||||
onPressed: _toggleMarkForSigning,
|
onPressed: _toggleMarkForSigning,
|
||||||
|
@ -407,8 +443,6 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
|
||||||
children: [
|
children: [
|
||||||
if (sig.imageBytes != null)
|
if (sig.imageBytes != null)
|
||||||
Image.memory(sig.imageBytes!, fit: BoxFit.contain)
|
Image.memory(sig.imageBytes!, fit: BoxFit.contain)
|
||||||
else if (sig.strokes.isNotEmpty)
|
|
||||||
CustomPaint(painter: StrokesPainter(sig.strokes))
|
|
||||||
else
|
else
|
||||||
const Center(child: Text('Signature')),
|
const Center(child: Text('Signature')),
|
||||||
Positioned(
|
Positioned(
|
||||||
|
|
|
@ -115,7 +115,7 @@ class SignatureState {
|
||||||
bgRemoval: false,
|
bgRemoval: false,
|
||||||
contrast: 1.0,
|
contrast: 1.0,
|
||||||
brightness: 0.0,
|
brightness: 0.0,
|
||||||
strokes: const [],
|
strokes: [],
|
||||||
imageBytes: null,
|
imageBytes: null,
|
||||||
);
|
);
|
||||||
SignatureState copyWith({
|
SignatureState copyWith({
|
||||||
|
|
|
@ -1,40 +1,34 @@
|
||||||
part of 'viewer.dart';
|
part of 'viewer.dart';
|
||||||
|
|
||||||
class DrawCanvas extends StatefulWidget {
|
class DrawCanvas extends StatefulWidget {
|
||||||
const DrawCanvas({super.key, required this.strokes});
|
const DrawCanvas({
|
||||||
final List<List<Offset>> strokes;
|
super.key,
|
||||||
|
this.control,
|
||||||
|
this.onConfirm,
|
||||||
|
this.debugBytesSink,
|
||||||
|
});
|
||||||
|
|
||||||
|
final hand.HandSignatureControl? control;
|
||||||
|
final ValueChanged<Uint8List?>? onConfirm;
|
||||||
|
// For tests: allows observing exported bytes without relying on Navigator
|
||||||
|
@visibleForTesting
|
||||||
|
final ValueNotifier<Uint8List?>? debugBytesSink;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<DrawCanvas> createState() => _DrawCanvasState();
|
State<DrawCanvas> createState() => _DrawCanvasState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _DrawCanvasState extends State<DrawCanvas> {
|
class _DrawCanvasState extends State<DrawCanvas> {
|
||||||
late List<List<Offset>> _strokes;
|
late final hand.HandSignatureControl _control =
|
||||||
final GlobalKey _canvasKey = GlobalKey();
|
widget.control ??
|
||||||
|
hand.HandSignatureControl(
|
||||||
@override
|
initialSetup: const hand.SignaturePathSetup(
|
||||||
void initState() {
|
threshold: 3.0,
|
||||||
super.initState();
|
smoothRatio: 0.7,
|
||||||
_strokes = widget.strokes.map((s) => List.of(s)).toList();
|
velocityRange: 2.0,
|
||||||
}
|
pressureRatio: 0.0,
|
||||||
|
),
|
||||||
void _startStroke(Offset localPosition) {
|
);
|
||||||
setState(() => _strokes.add([localPosition]));
|
|
||||||
}
|
|
||||||
|
|
||||||
void _extendStroke(Offset localPosition) {
|
|
||||||
if (_strokes.isEmpty) return;
|
|
||||||
setState(() => _strokes.last.add(localPosition));
|
|
||||||
}
|
|
||||||
|
|
||||||
void _undo() {
|
|
||||||
if (_strokes.isEmpty) return;
|
|
||||||
setState(() => _strokes.removeLast());
|
|
||||||
}
|
|
||||||
|
|
||||||
void _clear() {
|
|
||||||
setState(() => _strokes.clear());
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
@ -48,19 +42,40 @@ class _DrawCanvasState extends State<DrawCanvas> {
|
||||||
children: [
|
children: [
|
||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
key: const Key('btn_canvas_confirm'),
|
key: const Key('btn_canvas_confirm'),
|
||||||
onPressed: () => Navigator.of(context).pop(_strokes),
|
onPressed: () async {
|
||||||
|
// Export signature to PNG bytes
|
||||||
|
final data = await _control.toImage(
|
||||||
|
color: Colors.black,
|
||||||
|
background: Colors.transparent,
|
||||||
|
fit: true,
|
||||||
|
width: 1024,
|
||||||
|
height: 512,
|
||||||
|
);
|
||||||
|
final bytes = data?.buffer.asUint8List();
|
||||||
|
// print("onPressed, Exported signature bytes: ${bytes?.length}");
|
||||||
|
// Notify tests if provided
|
||||||
|
widget.debugBytesSink?.value = bytes;
|
||||||
|
if (widget.onConfirm != null) {
|
||||||
|
// print("onConfirm callback called");
|
||||||
|
widget.onConfirm!(bytes);
|
||||||
|
} else {
|
||||||
|
if (context.mounted) {
|
||||||
|
Navigator.of(context).pop(bytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
child: const Text('Confirm'),
|
child: const Text('Confirm'),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
OutlinedButton(
|
OutlinedButton(
|
||||||
key: const Key('btn_canvas_undo'),
|
key: const Key('btn_canvas_undo'),
|
||||||
onPressed: _undo,
|
onPressed: () => _control.stepBack(),
|
||||||
child: const Text('Undo'),
|
child: const Text('Undo'),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
OutlinedButton(
|
OutlinedButton(
|
||||||
key: const Key('btn_canvas_clear'),
|
key: const Key('btn_canvas_clear'),
|
||||||
onPressed: _clear,
|
onPressed: () => _control.clear(),
|
||||||
child: const Text('Clear'),
|
child: const Text('Clear'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
@ -68,30 +83,24 @@ class _DrawCanvasState extends State<DrawCanvas> {
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
key: const Key('draw_canvas'),
|
key: const Key('draw_canvas'),
|
||||||
height: 240,
|
height: math.max(MediaQuery.of(context).size.height * 0.6, 350),
|
||||||
child: Focus(
|
child: AspectRatio(
|
||||||
// prevent text selection focus stealing on desktop
|
aspectRatio: 10 / 3,
|
||||||
canRequestFocus: false,
|
child: Container(
|
||||||
|
constraints: const BoxConstraints.expand(),
|
||||||
|
color: Colors.white,
|
||||||
child: Listener(
|
child: Listener(
|
||||||
key: _canvasKey,
|
|
||||||
behavior: HitTestBehavior.opaque,
|
behavior: HitTestBehavior.opaque,
|
||||||
onPointerDown: (e) {
|
onPointerDown: (_) {},
|
||||||
final box =
|
child: hand.HandSignature(
|
||||||
_canvasKey.currentContext!.findRenderObject()
|
key: const Key('hand_signature_pad'),
|
||||||
as RenderBox;
|
control: _control,
|
||||||
_startStroke(box.globalToLocal(e.position));
|
drawer: const hand.ShapeSignatureDrawer(
|
||||||
},
|
color: Colors.black,
|
||||||
onPointerMove: (e) {
|
width: 1.5,
|
||||||
final box =
|
maxWidth: 6.0,
|
||||||
_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)),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
@ -102,26 +111,3 @@ class _DrawCanvasState extends State<DrawCanvas> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class StrokesPainter extends CustomPainter {
|
|
||||||
final List<List<Offset>> strokes;
|
|
||||||
StrokesPainter(this.strokes);
|
|
||||||
|
|
||||||
@override
|
|
||||||
void paint(Canvas canvas, Size size) {
|
|
||||||
final p =
|
|
||||||
Paint()
|
|
||||||
..color = Colors.black
|
|
||||||
..strokeWidth = 2
|
|
||||||
..style = PaintingStyle.stroke;
|
|
||||||
for (final s in strokes) {
|
|
||||||
for (int i = 1; i < s.length; i++) {
|
|
||||||
canvas.drawLine(s[i - 1], s[i], p);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
bool shouldRepaint(covariant StrokesPainter oldDelegate) =>
|
|
||||||
oldDelegate.strokes != strokes;
|
|
||||||
}
|
|
||||||
|
|
|
@ -3,6 +3,7 @@ import 'dart:io';
|
||||||
import 'package:flutter/rendering.dart';
|
import 'package:flutter/rendering.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
import 'package:pdf/widgets.dart' as pw;
|
import 'package:pdf/widgets.dart' as pw;
|
||||||
|
import 'package:pdf/pdf.dart' as pdf;
|
||||||
|
|
||||||
// NOTE:
|
// NOTE:
|
||||||
// - This exporter uses a raster snapshot of the UI (RepaintBoundary) and embeds it into a new PDF.
|
// - This exporter uses a raster snapshot of the UI (RepaintBoundary) and embeds it into a new PDF.
|
||||||
|
@ -14,6 +15,8 @@ class ExportService {
|
||||||
Future<bool> exportSignedPdfFromBoundary({
|
Future<bool> exportSignedPdfFromBoundary({
|
||||||
required GlobalKey boundaryKey,
|
required GlobalKey boundaryKey,
|
||||||
required String outputPath,
|
required String outputPath,
|
||||||
|
double pixelRatio = 4.0,
|
||||||
|
double targetDpi = 144.0,
|
||||||
}) async {
|
}) async {
|
||||||
try {
|
try {
|
||||||
final boundary =
|
final boundary =
|
||||||
|
@ -21,19 +24,32 @@ class ExportService {
|
||||||
as RenderRepaintBoundary?;
|
as RenderRepaintBoundary?;
|
||||||
if (boundary == null) return false;
|
if (boundary == null) return false;
|
||||||
// Render current view to image
|
// Render current view to image
|
||||||
final ui.Image image = await boundary.toImage(pixelRatio: 2.0);
|
// Higher pixelRatio improves exported quality
|
||||||
|
final ui.Image image = await boundary.toImage(pixelRatio: pixelRatio);
|
||||||
final byteData = await image.toByteData(format: ui.ImageByteFormat.png);
|
final byteData = await image.toByteData(format: ui.ImageByteFormat.png);
|
||||||
if (byteData == null) return false;
|
if (byteData == null) return false;
|
||||||
final pngBytes = byteData.buffer.asUint8List();
|
final pngBytes = byteData.buffer.asUint8List();
|
||||||
|
|
||||||
// Compose single-page PDF with the image
|
// Compose single-page PDF with the image, using page size that matches the image
|
||||||
final doc = pw.Document();
|
final doc = pw.Document();
|
||||||
final img = pw.MemoryImage(pngBytes);
|
final img = pw.MemoryImage(pngBytes);
|
||||||
|
final pageFormat = pdf.PdfPageFormat(
|
||||||
|
image.width.toDouble() * 72.0 / targetDpi,
|
||||||
|
image.height.toDouble() * 72.0 / targetDpi,
|
||||||
|
);
|
||||||
|
// Zero margins and cover the entire page area to avoid letterboxing/cropping
|
||||||
doc.addPage(
|
doc.addPage(
|
||||||
pw.Page(
|
pw.Page(
|
||||||
|
pageTheme: pw.PageTheme(
|
||||||
|
margin: pw.EdgeInsets.zero,
|
||||||
|
pageFormat: pageFormat,
|
||||||
|
),
|
||||||
build:
|
build:
|
||||||
(context) =>
|
(context) => pw.Container(
|
||||||
pw.Center(child: pw.Image(img, fit: pw.BoxFit.contain)),
|
width: double.infinity,
|
||||||
|
height: double.infinity,
|
||||||
|
child: pw.Image(img, fit: pw.BoxFit.fill),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
final bytes = await doc.save();
|
final bytes = await doc.save();
|
||||||
|
@ -53,7 +69,8 @@ class ExportService {
|
||||||
required String outputPath,
|
required String outputPath,
|
||||||
required int pageCount,
|
required int pageCount,
|
||||||
required Future<void> Function(int page) onGotoPage,
|
required Future<void> Function(int page) onGotoPage,
|
||||||
double pixelRatio = 3.0,
|
double pixelRatio = 4.0,
|
||||||
|
double targetDpi = 144.0,
|
||||||
}) async {
|
}) async {
|
||||||
try {
|
try {
|
||||||
final doc = pw.Document();
|
final doc = pw.Document();
|
||||||
|
@ -79,11 +96,23 @@ class ExportService {
|
||||||
if (byteData == null) return false;
|
if (byteData == null) return false;
|
||||||
final pngBytes = byteData.buffer.asUint8List();
|
final pngBytes = byteData.buffer.asUint8List();
|
||||||
final img = pw.MemoryImage(pngBytes);
|
final img = pw.MemoryImage(pngBytes);
|
||||||
|
final pageFormat = pdf.PdfPageFormat(
|
||||||
|
image.width.toDouble() * 72.0 / targetDpi,
|
||||||
|
image.height.toDouble() * 72.0 / targetDpi,
|
||||||
|
);
|
||||||
|
// Zero margins and size page to the image dimensions to avoid borders
|
||||||
doc.addPage(
|
doc.addPage(
|
||||||
pw.Page(
|
pw.Page(
|
||||||
|
pageTheme: pw.PageTheme(
|
||||||
|
margin: pw.EdgeInsets.zero,
|
||||||
|
pageFormat: pageFormat,
|
||||||
|
),
|
||||||
build:
|
build:
|
||||||
(context) =>
|
(context) => pw.Container(
|
||||||
pw.Center(child: pw.Image(img, fit: pw.BoxFit.contain)),
|
width: double.infinity,
|
||||||
|
height: double.infinity,
|
||||||
|
child: pw.Image(img, fit: pw.BoxFit.fill),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:pdf_signature/features/pdf/viewer.dart';
|
import 'package:pdf_signature/features/pdf/viewer.dart';
|
||||||
|
|
|
@ -8,9 +8,13 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
import 'dart:ui' show PointerDeviceKind;
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:pdf_signature/features/pdf/viewer.dart';
|
import 'package:pdf_signature/features/pdf/viewer.dart';
|
||||||
import 'package:pdf_signature/features/share/export_service.dart';
|
import 'package:pdf_signature/features/share/export_service.dart';
|
||||||
|
import 'package:hand_signature/signature.dart' as hand;
|
||||||
|
|
||||||
// Fakes for export service (top-level; Dart does not allow local class declarations)
|
// Fakes for export service (top-level; Dart does not allow local class declarations)
|
||||||
class RecordingExporter extends ExportService {
|
class RecordingExporter extends ExportService {
|
||||||
|
@ -22,6 +26,7 @@ class RecordingExporter extends ExportService {
|
||||||
required int pageCount,
|
required int pageCount,
|
||||||
required Future<void> Function(int page) onGotoPage,
|
required Future<void> Function(int page) onGotoPage,
|
||||||
double pixelRatio = 2.0,
|
double pixelRatio = 2.0,
|
||||||
|
double targetDpi = 144.0,
|
||||||
}) async {
|
}) async {
|
||||||
called = true;
|
called = true;
|
||||||
// Ensure extension
|
// Ensure extension
|
||||||
|
@ -41,6 +46,7 @@ class BasicExporter extends ExportService {
|
||||||
required int pageCount,
|
required int pageCount,
|
||||||
required Future<void> Function(int page) onGotoPage,
|
required Future<void> Function(int page) onGotoPage,
|
||||||
double pixelRatio = 2.0,
|
double pixelRatio = 2.0,
|
||||||
|
double targetDpi = 144.0,
|
||||||
}) async {
|
}) async {
|
||||||
for (var i = 1; i <= pageCount; i++) {
|
for (var i = 1; i <= pageCount; i++) {
|
||||||
await onGotoPage(i);
|
await onGotoPage(i);
|
||||||
|
@ -50,7 +56,7 @@ class BasicExporter extends ExportService {
|
||||||
}
|
}
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
Future<void> _pumpWithOpenPdf(WidgetTester tester) async {
|
Future<void> pumpWithOpenPdf(WidgetTester tester) async {
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
ProviderScope(
|
ProviderScope(
|
||||||
overrides: [
|
overrides: [
|
||||||
|
@ -65,7 +71,7 @@ void main() {
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _pumpWithOpenPdfAndSig(WidgetTester tester) async {
|
Future<void> pumpWithOpenPdfAndSig(WidgetTester tester) async {
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
ProviderScope(
|
ProviderScope(
|
||||||
overrides: [
|
overrides: [
|
||||||
|
@ -84,7 +90,7 @@ void main() {
|
||||||
}
|
}
|
||||||
|
|
||||||
testWidgets('Open a PDF and navigate pages', (tester) async {
|
testWidgets('Open a PDF and navigate pages', (tester) async {
|
||||||
await _pumpWithOpenPdf(tester);
|
await pumpWithOpenPdf(tester);
|
||||||
final pageInfo = find.byKey(const Key('lbl_page_info'));
|
final pageInfo = find.byKey(const Key('lbl_page_info'));
|
||||||
expect(pageInfo, findsOneWidget);
|
expect(pageInfo, findsOneWidget);
|
||||||
expect((tester.widget<Text>(pageInfo)).data, 'Page 1/5');
|
expect((tester.widget<Text>(pageInfo)).data, 'Page 1/5');
|
||||||
|
@ -99,7 +105,7 @@ void main() {
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('Jump to a specific page', (tester) async {
|
testWidgets('Jump to a specific page', (tester) async {
|
||||||
await _pumpWithOpenPdf(tester);
|
await pumpWithOpenPdf(tester);
|
||||||
|
|
||||||
final goto = find.byKey(const Key('txt_goto'));
|
final goto = find.byKey(const Key('txt_goto'));
|
||||||
await tester.enterText(goto, '4');
|
await tester.enterText(goto, '4');
|
||||||
|
@ -110,7 +116,7 @@ void main() {
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('Select a page for signing', (tester) async {
|
testWidgets('Select a page for signing', (tester) async {
|
||||||
await _pumpWithOpenPdf(tester);
|
await pumpWithOpenPdf(tester);
|
||||||
|
|
||||||
await tester.tap(find.byKey(const Key('btn_mark_signing')));
|
await tester.tap(find.byKey(const Key('btn_mark_signing')));
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
@ -118,26 +124,29 @@ void main() {
|
||||||
expect(find.byKey(const Key('btn_load_signature_picker')), findsOneWidget);
|
expect(find.byKey(const Key('btn_load_signature_picker')), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('Show invalid/unsupported file SnackBar via test hook', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await pumpWithOpenPdf(tester);
|
||||||
|
final dynamic state =
|
||||||
|
tester.state(find.byType(PdfSignatureHomePage)) as dynamic;
|
||||||
|
state.debugShowInvalidSignatureSnackBar();
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.text('Invalid or unsupported file'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
testWidgets('Import a signature image', (tester) async {
|
testWidgets('Import a signature image', (tester) async {
|
||||||
await _pumpWithOpenPdfAndSig(tester);
|
await pumpWithOpenPdfAndSig(tester);
|
||||||
await tester.tap(find.byKey(const Key('btn_mark_signing')));
|
await tester.tap(find.byKey(const Key('btn_mark_signing')));
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
// overlay present from provider override
|
// overlay present from provider override
|
||||||
expect(find.byKey(const Key('signature_overlay')), findsOneWidget);
|
expect(find.byKey(const Key('signature_overlay')), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('Handle invalid or unsupported files', (tester) async {
|
// Removed: Load Invalid button is not part of normal app UI.
|
||||||
await _pumpWithOpenPdf(tester);
|
|
||||||
await tester.tap(find.byKey(const Key('btn_mark_signing')));
|
|
||||||
await tester.pump();
|
|
||||||
|
|
||||||
await tester.tap(find.byKey(const Key('btn_load_invalid_signature')));
|
|
||||||
await tester.pump();
|
|
||||||
expect(find.text('Invalid or unsupported file'), findsOneWidget);
|
|
||||||
});
|
|
||||||
|
|
||||||
testWidgets('Resize and move signature within page bounds', (tester) async {
|
testWidgets('Resize and move signature within page bounds', (tester) async {
|
||||||
await _pumpWithOpenPdfAndSig(tester);
|
await pumpWithOpenPdfAndSig(tester);
|
||||||
await tester.tap(find.byKey(const Key('btn_mark_signing')));
|
await tester.tap(find.byKey(const Key('btn_mark_signing')));
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
|
||||||
|
@ -163,7 +172,7 @@ void main() {
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('Lock aspect ratio while resizing', (tester) async {
|
testWidgets('Lock aspect ratio while resizing', (tester) async {
|
||||||
await _pumpWithOpenPdfAndSig(tester);
|
await pumpWithOpenPdfAndSig(tester);
|
||||||
await tester.tap(find.byKey(const Key('btn_mark_signing')));
|
await tester.tap(find.byKey(const Key('btn_mark_signing')));
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
|
||||||
|
@ -188,7 +197,7 @@ void main() {
|
||||||
testWidgets('Background removal and adjustments controls change state', (
|
testWidgets('Background removal and adjustments controls change state', (
|
||||||
tester,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
await _pumpWithOpenPdfAndSig(tester);
|
await pumpWithOpenPdfAndSig(tester);
|
||||||
await tester.tap(find.byKey(const Key('btn_mark_signing')));
|
await tester.tap(find.byKey(const Key('btn_mark_signing')));
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
|
||||||
|
@ -210,32 +219,57 @@ void main() {
|
||||||
expect(find.byKey(const Key('signature_overlay')), findsOneWidget);
|
expect(find.byKey(const Key('signature_overlay')), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('Draw signature: draw, undo, clear, confirm places on page', (
|
testWidgets('DrawCanvas exports non-empty bytes on confirm', (tester) async {
|
||||||
tester,
|
Uint8List? exported;
|
||||||
) async {
|
final sink = ValueNotifier<Uint8List?>(null);
|
||||||
await _pumpWithOpenPdfAndSig(tester);
|
final control = hand.HandSignatureControl();
|
||||||
await tester.tap(find.byKey(const Key('btn_mark_signing')));
|
await tester.pumpWidget(
|
||||||
await tester.pump();
|
MaterialApp(
|
||||||
|
home: Scaffold(
|
||||||
// Open draw canvas
|
body: DrawCanvas(
|
||||||
await tester.tap(find.byKey(const Key('btn_draw_signature')));
|
control: control,
|
||||||
|
debugBytesSink: sink,
|
||||||
|
onConfirm: (bytes) {
|
||||||
|
exported = bytes;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
final canvas = find.byKey(const Key('draw_canvas'));
|
|
||||||
await tester.drag(canvas, const Offset(80, 0));
|
// Draw a simple stroke inside the pad
|
||||||
await tester.pump();
|
final pad = find.byKey(const Key('hand_signature_pad'));
|
||||||
await tester.tap(find.byKey(const Key('btn_canvas_undo')));
|
expect(pad, findsOneWidget);
|
||||||
await tester.pump();
|
final rect = tester.getRect(pad);
|
||||||
await tester.drag(canvas, const Offset(50, 0));
|
final g = await tester.startGesture(
|
||||||
await tester.pump();
|
Offset(rect.left + 20, rect.center.dy),
|
||||||
await tester.tap(find.byKey(const Key('btn_canvas_clear')));
|
kind: PointerDeviceKind.touch,
|
||||||
await tester.pump();
|
);
|
||||||
await tester.drag(canvas, const Offset(40, 0));
|
for (int i = 0; i < 10; i++) {
|
||||||
await tester.pump();
|
await g.moveBy(
|
||||||
|
const Offset(12, 0),
|
||||||
|
timeStamp: Duration(milliseconds: 16 * (i + 1)),
|
||||||
|
);
|
||||||
|
await tester.pump(const Duration(milliseconds: 16));
|
||||||
|
}
|
||||||
|
await g.up();
|
||||||
|
await tester.pump(const Duration(milliseconds: 50));
|
||||||
|
|
||||||
|
// Confirm export
|
||||||
await tester.tap(find.byKey(const Key('btn_canvas_confirm')));
|
await tester.tap(find.byKey(const Key('btn_canvas_confirm')));
|
||||||
await tester.pumpAndSettle();
|
// Wait until notifier receives bytes
|
||||||
|
await tester.pumpAndSettle(const Duration(milliseconds: 50));
|
||||||
|
await tester.runAsync(() async {
|
||||||
|
final end = DateTime.now().add(const Duration(seconds: 2));
|
||||||
|
while (sink.value == null && DateTime.now().isBefore(end)) {
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 20));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
exported ??= sink.value;
|
||||||
|
|
||||||
// Overlay present with drawn strokes painter
|
expect(exported, isNotNull);
|
||||||
expect(find.byKey(const Key('signature_overlay')), findsOneWidget);
|
expect(exported!.isNotEmpty, isTrue);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('Save uses file selector (via provider) and injected exporter', (
|
testWidgets('Save uses file selector (via provider) and injected exporter', (
|
||||||
|
|
Loading…
Reference in New Issue