feat: add draw signature feature with widget test

This commit is contained in:
insleker 2025-08-27 20:55:04 +08:00
parent 5b0b9d2a02
commit 9a31903d0d
9 changed files with 232 additions and 138 deletions

1
.gitignore vendored
View File

@ -121,3 +121,4 @@ app.*.symbols
docs/.*
.vscode/tasks.json
.vscode/launch.json
devtools_options.yaml

View File

@ -10,8 +10,11 @@ checkout [`docs/FRs.md`](docs/FRs.md)
```bash
flutter pub get
# run the app
flutter run
# run unit tests and widget tests
flutter test
flutter build

View File

@ -115,5 +115,13 @@ Feature: save signed PDF
Given a PDF is open with no signatures placed
When the user attempts 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
```

View File

@ -6,6 +6,8 @@ import 'package:pdfrx/pdfrx.dart';
import 'package:path_provider/path_provider.dart' as pp;
import 'dart:typed_data';
import '../share/export_service.dart';
import 'package:hand_signature/signature.dart' as hand;
import 'package:meta/meta.dart';
part 'viewer_state.dart';
part 'viewer_widgets.dart';
@ -14,6 +16,8 @@ part 'viewer_widgets.dart';
final useMockViewerProvider = Provider<bool>((_) => false);
// Export service injection for testability
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)
final signatureVisibilityProvider = StateProvider<bool>((_) => true);
// Save path picker (injected for tests)
@ -55,6 +59,12 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
static const Size _pageSize = SignatureController.pageSize;
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 {
final typeGroup = const fs.XTypeGroup(label: 'PDF', extensions: ['pdf']);
final file = await fs.openFile(acceptedTypeGroups: [typeGroup]);
@ -86,9 +96,7 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
sig.setImageBytes(bytes);
}
void _loadInvalidSignature() {
ref.read(signatureProvider.notifier).setInvalidSelected(context);
}
// removed invalid loader; not part of normal app
void _onDragSignature(Offset delta) {
ref.read(signatureProvider.notifier).drag(delta);
@ -101,15 +109,15 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
Future<void> _openDrawCanvas() async {
final pdf = ref.read(pdfProvider);
if (!pdf.markedForSigning) return;
final current = ref.read(signatureProvider).strokes;
final result = await showModalBottomSheet<List<List<Offset>>>(
final result = await showModalBottomSheet<Uint8List>(
context: context,
isScrollControlled: true,
builder: (_) => DrawCanvas(strokes: current),
enableDrag: false,
builder: (_) => const DrawCanvas(),
);
if (result != null) {
ref.read(signatureProvider.notifier).setStrokes(result);
ref.read(signatureProvider.notifier).ensureRectForStrokes();
if (result != null && result.isNotEmpty) {
// Use the drawn image as signature content
ref.read(signatureProvider.notifier).setImageBytes(result);
}
}
@ -129,6 +137,7 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
if (path == null || path.trim().isEmpty) return;
final fullPath = _ensurePdfExtension(path.trim());
final exporter = ref.read(exportServiceProvider);
final targetDpi = ref.read(exportDpiProvider);
// Multi-page export: iterate pages by navigating the viewer
final controller = ref.read(pdfProvider.notifier);
final current = pdf.currentPage;
@ -137,6 +146,7 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
boundaryKey: _captureKey,
outputPath: fullPath,
pageCount: pdf.pageCount,
targetDpi: targetDpi,
onGotoPage: (p) async {
controller.jumpTo(p);
// Show overlay only on the signed page (if any)
@ -197,6 +207,7 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
}
Widget _buildToolbar(PdfState pdf) {
final dpi = ref.watch(exportDpiProvider);
final pageInfo = 'Page ${pdf.currentPage}/${pdf.pageCount}';
return Wrap(
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(
key: const Key('btn_mark_signing'),
onPressed: _toggleMarkForSigning,
@ -407,8 +443,6 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
children: [
if (sig.imageBytes != null)
Image.memory(sig.imageBytes!, fit: BoxFit.contain)
else if (sig.strokes.isNotEmpty)
CustomPaint(painter: StrokesPainter(sig.strokes))
else
const Center(child: Text('Signature')),
Positioned(

View File

@ -115,7 +115,7 @@ class SignatureState {
bgRemoval: false,
contrast: 1.0,
brightness: 0.0,
strokes: const [],
strokes: [],
imageBytes: null,
);
SignatureState copyWith({

View File

@ -1,40 +1,34 @@
part of 'viewer.dart';
class DrawCanvas extends StatefulWidget {
const DrawCanvas({super.key, required this.strokes});
final List<List<Offset>> strokes;
const DrawCanvas({
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
State<DrawCanvas> createState() => _DrawCanvasState();
}
class _DrawCanvasState extends State<DrawCanvas> {
late List<List<Offset>> _strokes;
final GlobalKey _canvasKey = GlobalKey();
@override
void initState() {
super.initState();
_strokes = widget.strokes.map((s) => List.of(s)).toList();
}
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());
}
late final hand.HandSignatureControl _control =
widget.control ??
hand.HandSignatureControl(
initialSetup: const hand.SignaturePathSetup(
threshold: 3.0,
smoothRatio: 0.7,
velocityRange: 2.0,
pressureRatio: 0.0,
),
);
@override
Widget build(BuildContext context) {
@ -48,19 +42,40 @@ class _DrawCanvasState extends State<DrawCanvas> {
children: [
ElevatedButton(
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'),
),
const SizedBox(width: 8),
OutlinedButton(
key: const Key('btn_canvas_undo'),
onPressed: _undo,
onPressed: () => _control.stepBack(),
child: const Text('Undo'),
),
const SizedBox(width: 8),
OutlinedButton(
key: const Key('btn_canvas_clear'),
onPressed: _clear,
onPressed: () => _control.clear(),
child: const Text('Clear'),
),
],
@ -68,30 +83,24 @@ class _DrawCanvasState extends State<DrawCanvas> {
const SizedBox(height: 8),
SizedBox(
key: const Key('draw_canvas'),
height: 240,
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),
height: math.max(MediaQuery.of(context).size.height * 0.6, 350),
child: AspectRatio(
aspectRatio: 10 / 3,
child: Container(
constraints: const BoxConstraints.expand(),
color: Colors.white,
child: Listener(
behavior: HitTestBehavior.opaque,
onPointerDown: (_) {},
child: hand.HandSignature(
key: const Key('hand_signature_pad'),
control: _control,
drawer: const hand.ShapeSignatureDrawer(
color: Colors.black,
width: 1.5,
maxWidth: 6.0,
),
),
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;
}

View File

@ -3,6 +3,7 @@ import 'dart:io';
import 'package:flutter/rendering.dart';
import 'package:flutter/widgets.dart';
import 'package:pdf/widgets.dart' as pw;
import 'package:pdf/pdf.dart' as pdf;
// NOTE:
// - 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({
required GlobalKey boundaryKey,
required String outputPath,
double pixelRatio = 4.0,
double targetDpi = 144.0,
}) async {
try {
final boundary =
@ -21,19 +24,32 @@ class ExportService {
as RenderRepaintBoundary?;
if (boundary == null) return false;
// 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);
if (byteData == null) return false;
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 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(
pw.Page(
pageTheme: pw.PageTheme(
margin: pw.EdgeInsets.zero,
pageFormat: pageFormat,
),
build:
(context) =>
pw.Center(child: pw.Image(img, fit: pw.BoxFit.contain)),
(context) => pw.Container(
width: double.infinity,
height: double.infinity,
child: pw.Image(img, fit: pw.BoxFit.fill),
),
),
);
final bytes = await doc.save();
@ -53,7 +69,8 @@ class ExportService {
required String outputPath,
required int pageCount,
required Future<void> Function(int page) onGotoPage,
double pixelRatio = 3.0,
double pixelRatio = 4.0,
double targetDpi = 144.0,
}) async {
try {
final doc = pw.Document();
@ -79,11 +96,23 @@ class ExportService {
if (byteData == null) return false;
final pngBytes = byteData.buffer.asUint8List();
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(
pw.Page(
pageTheme: pw.PageTheme(
margin: pw.EdgeInsets.zero,
pageFormat: pageFormat,
),
build:
(context) =>
pw.Center(child: pw.Image(img, fit: pw.BoxFit.contain)),
(context) => pw.Container(
width: double.infinity,
height: double.infinity,
child: pw.Image(img, fit: pw.BoxFit.fill),
),
),
);
}

View File

@ -1,6 +1,5 @@
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';

View File

@ -8,9 +8,13 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.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/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)
class RecordingExporter extends ExportService {
@ -22,6 +26,7 @@ class RecordingExporter extends ExportService {
required int pageCount,
required Future<void> Function(int page) onGotoPage,
double pixelRatio = 2.0,
double targetDpi = 144.0,
}) async {
called = true;
// Ensure extension
@ -41,6 +46,7 @@ class BasicExporter extends ExportService {
required int pageCount,
required Future<void> Function(int page) onGotoPage,
double pixelRatio = 2.0,
double targetDpi = 144.0,
}) async {
for (var i = 1; i <= pageCount; i++) {
await onGotoPage(i);
@ -50,7 +56,7 @@ class BasicExporter extends ExportService {
}
void main() {
Future<void> _pumpWithOpenPdf(WidgetTester tester) async {
Future<void> pumpWithOpenPdf(WidgetTester tester) async {
await tester.pumpWidget(
ProviderScope(
overrides: [
@ -65,7 +71,7 @@ void main() {
await tester.pump();
}
Future<void> _pumpWithOpenPdfAndSig(WidgetTester tester) async {
Future<void> pumpWithOpenPdfAndSig(WidgetTester tester) async {
await tester.pumpWidget(
ProviderScope(
overrides: [
@ -84,7 +90,7 @@ void main() {
}
testWidgets('Open a PDF and navigate pages', (tester) async {
await _pumpWithOpenPdf(tester);
await pumpWithOpenPdf(tester);
final pageInfo = find.byKey(const Key('lbl_page_info'));
expect(pageInfo, findsOneWidget);
expect((tester.widget<Text>(pageInfo)).data, 'Page 1/5');
@ -99,7 +105,7 @@ void main() {
});
testWidgets('Jump to a specific page', (tester) async {
await _pumpWithOpenPdf(tester);
await pumpWithOpenPdf(tester);
final goto = find.byKey(const Key('txt_goto'));
await tester.enterText(goto, '4');
@ -110,7 +116,7 @@ void main() {
});
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.pump();
@ -118,26 +124,29 @@ void main() {
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 {
await _pumpWithOpenPdfAndSig(tester);
await pumpWithOpenPdfAndSig(tester);
await tester.tap(find.byKey(const Key('btn_mark_signing')));
await tester.pump();
// overlay present from provider override
expect(find.byKey(const Key('signature_overlay')), findsOneWidget);
});
testWidgets('Handle invalid or unsupported files', (tester) async {
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);
});
// Removed: Load Invalid button is not part of normal app UI.
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.pump();
@ -163,7 +172,7 @@ void main() {
});
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.pump();
@ -188,7 +197,7 @@ void main() {
testWidgets('Background removal and adjustments controls change state', (
tester,
) async {
await _pumpWithOpenPdfAndSig(tester);
await pumpWithOpenPdfAndSig(tester);
await tester.tap(find.byKey(const Key('btn_mark_signing')));
await tester.pump();
@ -210,32 +219,57 @@ void main() {
expect(find.byKey(const Key('signature_overlay')), findsOneWidget);
});
testWidgets('Draw signature: draw, undo, clear, confirm places on page', (
tester,
) async {
await _pumpWithOpenPdfAndSig(tester);
await tester.tap(find.byKey(const Key('btn_mark_signing')));
await tester.pump();
// Open draw canvas
await tester.tap(find.byKey(const Key('btn_draw_signature')));
testWidgets('DrawCanvas exports non-empty bytes on confirm', (tester) async {
Uint8List? exported;
final sink = ValueNotifier<Uint8List?>(null);
final control = hand.HandSignatureControl();
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: DrawCanvas(
control: control,
debugBytesSink: sink,
onConfirm: (bytes) {
exported = bytes;
},
),
),
),
);
await tester.pumpAndSettle();
final canvas = find.byKey(const Key('draw_canvas'));
await tester.drag(canvas, const Offset(80, 0));
await tester.pump();
await tester.tap(find.byKey(const Key('btn_canvas_undo')));
await tester.pump();
await tester.drag(canvas, const Offset(50, 0));
await tester.pump();
await tester.tap(find.byKey(const Key('btn_canvas_clear')));
await tester.pump();
await tester.drag(canvas, const Offset(40, 0));
await tester.pump();
// Draw a simple stroke inside the pad
final pad = find.byKey(const Key('hand_signature_pad'));
expect(pad, findsOneWidget);
final rect = tester.getRect(pad);
final g = await tester.startGesture(
Offset(rect.left + 20, rect.center.dy),
kind: PointerDeviceKind.touch,
);
for (int i = 0; i < 10; i++) {
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.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(find.byKey(const Key('signature_overlay')), findsOneWidget);
expect(exported, isNotNull);
expect(exported!.isNotEmpty, isTrue);
});
testWidgets('Save uses file selector (via provider) and injected exporter', (