feat: add draw signature feature with widget test
This commit is contained in:
parent
5b0b9d2a02
commit
9a31903d0d
|
@ -121,3 +121,4 @@ app.*.symbols
|
|||
docs/.*
|
||||
.vscode/tasks.json
|
||||
.vscode/launch.json
|
||||
devtools_options.yaml
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
```
|
||||
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -115,7 +115,7 @@ class SignatureState {
|
|||
bgRemoval: false,
|
||||
contrast: 1.0,
|
||||
brightness: 0.0,
|
||||
strokes: const [],
|
||||
strokes: [],
|
||||
imageBytes: null,
|
||||
);
|
||||
SignatureState copyWith({
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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', (
|
||||
|
|
Loading…
Reference in New Issue