feat: add open pdf file, open signature picture

This commit is contained in:
insleker 2025-08-27 14:42:51 +08:00
parent 6c2b5f5d4e
commit 8ad34023cd
11 changed files with 1057 additions and 132 deletions

2
.gitignore vendored
View File

@ -119,3 +119,5 @@ app.*.symbols
!/dev/ci/**/Gemfile.lock
docs/.*
.vscode/tasks.json
.vscode/launch.json

View File

@ -22,3 +22,7 @@
* role: user
* functionality: draw a signature using mouse or touch input
* benefit: create a custom signature directly on the PDF if no pre-made signature is available.
* name: save signed PDF
* role: user
* functionality: save/export the signed PDF document
* benefit: easily keep a copy of the signed document for records.

View File

@ -94,3 +94,18 @@ Feature: draw signature
Then the last stroke is removed
```
```gherkin
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
Scenario: Prevent saving when nothing is placed
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
```

20
lib/app.dart Normal file
View File

@ -0,0 +1,20 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:pdf_signature/features/pdf/viewer.dart';
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return ProviderScope(
child: MaterialApp(
title: 'PDF Signature',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
),
home: const PdfSignatureHomePage(),
),
);
}
}

View File

@ -0,0 +1,450 @@
import 'dart:math' as math;
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 'dart:typed_data';
import '../share/export_service.dart';
part 'viewer_state.dart';
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);
class PdfSignatureHomePage extends ConsumerStatefulWidget {
const PdfSignatureHomePage({super.key});
@override
ConsumerState<PdfSignatureHomePage> createState() =>
_PdfSignatureHomePageState();
}
class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
static const Size _pageSize = SignatureController.pageSize;
final GlobalKey _captureKey = GlobalKey();
Future<void> _pickPdf() async {
final typeGroup = const fs.XTypeGroup(label: 'PDF', extensions: ['pdf']);
final file = await fs.openFile(acceptedTypeGroups: [typeGroup]);
if (file != null) {
ref.read(pdfProvider.notifier).openPicked(path: file.path);
ref.read(signatureProvider.notifier).resetForNewPage();
}
}
void _jumpToPage(int page) {
ref.read(pdfProvider.notifier).jumpTo(page);
}
void _toggleMarkForSigning() {
ref.read(pdfProvider.notifier).toggleMark();
}
Future<void> _loadSignatureFromFile() async {
final pdf = ref.read(pdfProvider);
if (!pdf.markedForSigning) return;
final typeGroup = const fs.XTypeGroup(
label: 'Image',
extensions: ['png', 'jpg', 'jpeg', 'webp'],
);
final file = await fs.openFile(acceptedTypeGroups: [typeGroup]);
if (file == null) return;
final bytes = await file.readAsBytes();
final sig = ref.read(signatureProvider.notifier);
sig.setImageBytes(bytes);
}
void _loadInvalidSignature() {
ref.read(signatureProvider.notifier).setInvalidSelected(context);
}
void _onDragSignature(Offset delta) {
ref.read(signatureProvider.notifier).drag(delta);
}
void _onResizeSignature(Offset delta) {
ref.read(signatureProvider.notifier).resize(delta);
}
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>>>(
context: context,
isScrollControlled: true,
builder: (_) => DrawCanvas(strokes: current),
);
if (result != null) {
ref.read(signatureProvider.notifier).setStrokes(result);
ref.read(signatureProvider.notifier).ensureRectForStrokes();
}
}
Future<void> _saveSignedPdf() async {
final pdf = ref.read(pdfProvider);
final sig = ref.read(signatureProvider);
if (!pdf.loaded || sig.rect == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Nothing to save yet'),
), // guard per use-case
);
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(
boundaryKey: _captureKey,
outputPath: path,
);
if (ok) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Saved: $path')));
} else {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('Failed to save PDF')));
}
}
@override
Widget build(BuildContext context) {
final pdf = ref.watch(pdfProvider);
return Scaffold(
appBar: AppBar(title: const Text('PDF Signature')),
body: Padding(
padding: const EdgeInsets.all(12),
child: Column(
children: [
_buildToolbar(pdf),
const SizedBox(height: 8),
Expanded(child: _buildPageArea(pdf)),
Consumer(
builder: (context, ref, _) {
final sig = ref.watch(signatureProvider);
return sig.rect != null
? _buildAdjustmentsPanel(sig)
: const SizedBox.shrink();
},
),
],
),
),
);
}
Widget _buildToolbar(PdfState pdf) {
final pageInfo = 'Page ${pdf.currentPage}/${pdf.pageCount}';
return Wrap(
spacing: 8,
runSpacing: 8,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
OutlinedButton(
key: const Key('btn_open_pdf_picker'),
onPressed: _pickPdf,
child: const Text('Open PDF...'),
),
if (pdf.loaded) ...[
Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
key: const Key('btn_prev'),
onPressed: () => _jumpToPage(pdf.currentPage - 1),
icon: const Icon(Icons.chevron_left),
tooltip: 'Prev',
),
Text(pageInfo, key: const Key('lbl_page_info')),
IconButton(
key: const Key('btn_next'),
onPressed: () => _jumpToPage(pdf.currentPage + 1),
icon: const Icon(Icons.chevron_right),
tooltip: 'Next',
),
],
),
Row(
mainAxisSize: MainAxisSize.min,
children: [
const Text('Go to:'),
SizedBox(
width: 60,
child: TextField(
key: const Key('txt_goto'),
keyboardType: TextInputType.number,
onSubmitted: (v) {
final n = int.tryParse(v);
if (n != null) _jumpToPage(n);
},
),
),
],
),
ElevatedButton(
key: const Key('btn_mark_signing'),
onPressed: _toggleMarkForSigning,
child: Text(
pdf.markedForSigning ? 'Unmark Signing' : 'Mark for Signing',
),
),
if (pdf.loaded)
ElevatedButton(
key: const Key('btn_save_pdf'),
onPressed: _saveSignedPdf,
child: const Text('Save Signed PDF'),
),
if (pdf.markedForSigning) ...[
OutlinedButton(
key: const Key('btn_load_signature_picker'),
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,
child: const Text('Draw Signature'),
),
],
],
],
);
}
Widget _buildPageArea(PdfState pdf) {
if (!pdf.loaded) {
return const Center(child: Text('No PDF loaded'));
}
final useMock = ref.watch(useMockViewerProvider);
if (useMock) {
return Center(
child: AspectRatio(
aspectRatio: _pageSize.width / _pageSize.height,
child: RepaintBoundary(
key: _captureKey,
child: Stack(
key: const Key('page_stack'),
children: [
Container(
key: const Key('pdf_page'),
color: Colors.grey.shade200,
child: Center(
child: Text(
'Page ${pdf.currentPage}/${pdf.pageCount}',
style: const TextStyle(
fontSize: 24,
color: Colors.black54,
),
),
),
),
Consumer(
builder: (context, ref, _) {
final sig = ref.watch(signatureProvider);
return sig.rect != null
? _buildSignatureOverlay(sig)
: const SizedBox.shrink();
},
),
],
),
),
),
);
}
// If a real PDF path is selected, show actual viewer. Otherwise, keep mock sample.
if (pdf.pickedPdfPath != null) {
return PdfDocumentViewBuilder.file(
pdf.pickedPdfPath!,
builder: (context, document) {
if (document == null) {
return const Center(child: CircularProgressIndicator());
}
final pages = document.pages;
final pageNum = pdf.currentPage.clamp(1, pages.length);
final page = pages[pageNum - 1];
final aspect = page.width / page.height;
// Update page count in state if needed (post-frame to avoid build loop)
if (pdf.pageCount != pages.length) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
ref.read(pdfProvider.notifier).setPageCount(pages.length);
}
});
}
return Center(
child: AspectRatio(
aspectRatio: aspect,
child: RepaintBoundary(
key: _captureKey,
child: Stack(
key: const Key('page_stack'),
children: [
PdfPageView(
document: document,
pageNumber: pageNum,
alignment: Alignment.center,
),
Consumer(
builder: (context, ref, _) {
final sig = ref.watch(signatureProvider);
return sig.rect != null
? _buildSignatureOverlay(sig)
: const SizedBox.shrink();
},
),
],
),
),
),
);
},
);
}
// Fallback should not occur when not using mock; still return empty view
return const SizedBox.shrink();
}
Widget _buildSignatureOverlay(SignatureState sig) {
final r = sig.rect!;
return LayoutBuilder(
builder: (context, constraints) {
final scaleX = constraints.maxWidth / _pageSize.width;
final scaleY = constraints.maxHeight / _pageSize.height;
final left = r.left * scaleX;
final top = r.top * scaleY;
final width = r.width * scaleX;
final height = r.height * scaleY;
return Stack(
children: [
Positioned(
left: left,
top: top,
width: width,
height: height,
child: GestureDetector(
key: const Key('signature_overlay'),
behavior: HitTestBehavior.opaque,
onPanStart: (_) {},
onPanUpdate:
(d) => _onDragSignature(
Offset(d.delta.dx / scaleX, d.delta.dy / scaleY),
),
child: DecoratedBox(
decoration: BoxDecoration(
color: Colors.black.withOpacity(
0.05 + math.min(0.25, (sig.contrast - 1.0).abs()),
),
border: Border.all(color: Colors.indigo, width: 2),
),
child: Stack(
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(
right: 0,
bottom: 0,
child: GestureDetector(
key: const Key('signature_handle'),
behavior: HitTestBehavior.opaque,
onPanUpdate:
(d) => _onResizeSignature(
Offset(
d.delta.dx / scaleX,
d.delta.dy / scaleY,
),
),
child: const Icon(Icons.open_in_full, size: 20),
),
),
],
),
),
),
),
],
);
},
);
}
Widget _buildAdjustmentsPanel(SignatureState sig) {
return Column(
key: const Key('adjustments_panel'),
children: [
Row(
children: [
Checkbox(
key: const Key('chk_aspect_lock'),
value: sig.aspectLocked,
onChanged:
(v) => ref
.read(signatureProvider.notifier)
.toggleAspect(v ?? false),
),
const Text('Lock aspect ratio'),
const SizedBox(width: 16),
Switch(
key: const Key('swt_bg_removal'),
value: sig.bgRemoval,
onChanged:
(v) => ref.read(signatureProvider.notifier).setBgRemoval(v),
),
const Text('Background removal'),
],
),
Row(
children: [
const Text('Contrast'),
Expanded(
child: Slider(
key: const Key('sld_contrast'),
min: 0.0,
max: 2.0,
value: sig.contrast,
onChanged:
(v) => ref.read(signatureProvider.notifier).setContrast(v),
),
),
Text(sig.contrast.toStringAsFixed(2)),
],
),
Row(
children: [
const Text('Brightness'),
Expanded(
child: Slider(
key: const Key('sld_brightness'),
min: -1.0,
max: 1.0,
value: sig.brightness,
onChanged:
(v) =>
ref.read(signatureProvider.notifier).setBrightness(v),
),
),
Text(sig.brightness.toStringAsFixed(2)),
],
),
],
);
}
}

View File

@ -0,0 +1,222 @@
part of 'viewer.dart';
class PdfState {
final bool loaded;
final int pageCount;
final int currentPage;
final bool markedForSigning;
final String? pickedPdfPath;
const PdfState({
required this.loaded,
required this.pageCount,
required this.currentPage,
required this.markedForSigning,
this.pickedPdfPath,
});
factory PdfState.initial() => const PdfState(
loaded: false,
pageCount: 0,
currentPage: 1,
markedForSigning: false,
);
PdfState copyWith({
bool? loaded,
int? pageCount,
int? currentPage,
bool? markedForSigning,
String? pickedPdfPath,
}) => PdfState(
loaded: loaded ?? this.loaded,
pageCount: pageCount ?? this.pageCount,
currentPage: currentPage ?? this.currentPage,
markedForSigning: markedForSigning ?? this.markedForSigning,
pickedPdfPath: pickedPdfPath ?? this.pickedPdfPath,
);
}
class PdfController extends StateNotifier<PdfState> {
PdfController() : super(PdfState.initial());
static const int samplePageCount = 5;
void openSample() {
state = state.copyWith(
loaded: true,
pageCount: samplePageCount,
currentPage: 1,
markedForSigning: false,
pickedPdfPath: null,
);
}
void openPicked({required String path, int pageCount = samplePageCount}) {
state = state.copyWith(
loaded: true,
pageCount: pageCount,
currentPage: 1,
markedForSigning: false,
pickedPdfPath: path,
);
}
void jumpTo(int page) {
if (!state.loaded) return;
final clamped = page.clamp(1, state.pageCount);
state = state.copyWith(currentPage: clamped);
}
void toggleMark() {
if (!state.loaded) return;
state = state.copyWith(markedForSigning: !state.markedForSigning);
}
void setPageCount(int count) {
if (!state.loaded) return;
state = state.copyWith(pageCount: count.clamp(1, 9999));
}
}
final pdfProvider = StateNotifierProvider<PdfController, PdfState>(
(ref) => PdfController(),
);
class SignatureState {
final Rect? rect;
final bool aspectLocked;
final bool bgRemoval;
final double contrast;
final double brightness;
final List<List<Offset>> strokes;
final Uint8List? imageBytes;
const SignatureState({
required this.rect,
required this.aspectLocked,
required this.bgRemoval,
required this.contrast,
required this.brightness,
required this.strokes,
this.imageBytes,
});
factory SignatureState.initial() => const SignatureState(
rect: null,
aspectLocked: false,
bgRemoval: false,
contrast: 1.0,
brightness: 0.0,
strokes: const [],
imageBytes: null,
);
SignatureState copyWith({
Rect? rect,
bool? aspectLocked,
bool? bgRemoval,
double? contrast,
double? brightness,
List<List<Offset>>? strokes,
Uint8List? imageBytes,
}) => SignatureState(
rect: rect ?? this.rect,
aspectLocked: aspectLocked ?? this.aspectLocked,
bgRemoval: bgRemoval ?? this.bgRemoval,
contrast: contrast ?? this.contrast,
brightness: brightness ?? this.brightness,
strokes: strokes ?? this.strokes,
imageBytes: imageBytes ?? this.imageBytes,
);
}
class SignatureController extends StateNotifier<SignatureState> {
SignatureController() : super(SignatureState.initial());
static const Size pageSize = Size(400, 560);
void resetForNewPage() {
state = SignatureState.initial();
}
void placeDefaultRect() {
final w = 120.0, h = 60.0;
state = state.copyWith(
rect: Rect.fromCenter(
center: Offset(pageSize.width / 2, pageSize.height * 0.75),
width: w,
height: h,
),
);
}
void loadSample() {
final w = 120.0, h = 60.0;
state = state.copyWith(
rect: Rect.fromCenter(
center: Offset(pageSize.width / 2, pageSize.height * 0.75),
width: w,
height: h,
),
);
}
void setInvalidSelected(BuildContext context) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Invalid or unsupported file')),
);
}
void drag(Offset delta) {
if (state.rect == null) return;
final moved = state.rect!.shift(delta);
state = state.copyWith(rect: _clampRectToPage(moved));
}
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);
if (state.aspectLocked) {
final aspect = r.width / r.height;
if ((delta.dx / r.width).abs() >= (delta.dy / r.height).abs()) {
newH = newW / aspect;
} else {
newW = newH * aspect;
}
}
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);
return Rect.fromLTWH(left, top, r.width, r.height);
}
void toggleAspect(bool v) => state = state.copyWith(aspectLocked: v);
void setBgRemoval(bool v) => state = state.copyWith(bgRemoval: v);
void setContrast(double v) => state = state.copyWith(contrast: v);
void setBrightness(double v) => state = state.copyWith(brightness: v);
void setStrokes(List<List<Offset>> strokes) =>
state = state.copyWith(strokes: strokes);
void ensureRectForStrokes() {
state = state.copyWith(
rect:
state.rect ??
Rect.fromCenter(
center: Offset(pageSize.width / 2, pageSize.height * 0.75),
width: 140,
height: 70,
),
);
}
void setImageBytes(Uint8List bytes) {
state = state.copyWith(imageBytes: bytes);
if (state.rect == null) {
placeDefaultRect();
}
}
}
final signatureProvider =
StateNotifierProvider<SignatureController, SignatureState>(
(ref) => SignatureController(),
);

View File

@ -0,0 +1,109 @@
part of 'viewer.dart';
class DrawCanvas extends StatefulWidget {
const DrawCanvas({super.key, required this.strokes});
final List<List<Offset>> strokes;
@override
State<DrawCanvas> createState() => _DrawCanvasState();
}
class _DrawCanvasState extends State<DrawCanvas> {
late List<List<Offset>> _strokes;
@override
void initState() {
super.initState();
_strokes = widget.strokes.map((s) => List.of(s)).toList();
}
void _onPanStart(DragStartDetails d) {
setState(() => _strokes.add([d.localPosition]));
}
void _onPanUpdate(DragUpdateDetails d) {
setState(() => _strokes.last.add(d.localPosition));
}
void _undo() {
if (_strokes.isEmpty) return;
setState(() => _strokes.removeLast());
}
void _clear() {
setState(() => _strokes.clear());
}
@override
Widget build(BuildContext context) {
return SafeArea(
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
ElevatedButton(
key: const Key('btn_canvas_confirm'),
onPressed: () => Navigator.of(context).pop(_strokes),
child: const Text('Confirm'),
),
const SizedBox(width: 8),
OutlinedButton(
key: const Key('btn_canvas_undo'),
onPressed: _undo,
child: const Text('Undo'),
),
const SizedBox(width: 8),
OutlinedButton(
key: const Key('btn_canvas_clear'),
onPressed: _clear,
child: const Text('Clear'),
),
],
),
const SizedBox(height: 8),
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: CustomPaint(painter: StrokesPainter(_strokes)),
),
),
),
],
),
),
);
}
}
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

@ -0,0 +1,41 @@
import 'dart:ui' as ui;
import 'dart:io';
import 'package:flutter/rendering.dart';
import 'package:flutter/widgets.dart';
import 'package:pdf/widgets.dart' as pw;
class ExportService {
Future<bool> exportSignedPdfFromBoundary({
required GlobalKey boundaryKey,
required String outputPath,
}) async {
try {
final boundary =
boundaryKey.currentContext?.findRenderObject()
as RenderRepaintBoundary?;
if (boundary == null) return false;
// Render current view to image
final ui.Image image = await boundary.toImage(pixelRatio: 2.0);
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
final doc = pw.Document();
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;
}
}
}

View File

@ -1,122 +1,5 @@
import 'package:flutter/material.dart';
import 'package:pdf_signature/app.dart';
export 'package:pdf_signature/app.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
// This widget is the root of your application.
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
// This is the theme of your application.
//
// TRY THIS: Try running your application with "flutter run". You'll see
// the application has a purple toolbar. Then, without quitting the app,
// try changing the seedColor in the colorScheme below to Colors.green
// and then invoke "hot reload" (save your changes or press the "hot
// reload" button in a Flutter-supported IDE, or press "r" if you used
// the command line to start the app).
//
// Notice that the counter didn't reset back to zero; the application
// state is not lost during the reload. To reset the state, use hot
// restart instead.
//
// This works for code too, not just values: Most code changes can be
// tested with just a hot reload.
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
),
home: const MyHomePage(title: 'Flutter Demo Home Page'),
);
}
}
class MyHomePage extends StatefulWidget {
const MyHomePage({super.key, required this.title});
// This widget is the home page of your application. It is stateful, meaning
// that it has a State object (defined below) that contains fields that affect
// how it looks.
// This class is the configuration for the state. It holds the values (in this
// case the title) provided by the parent (in this case the App widget) and
// used by the build method of the State. Fields in a Widget subclass are
// always marked "final".
final String title;
@override
State<MyHomePage> createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
int _counter = 0;
void _incrementCounter() {
setState(() {
// This call to setState tells the Flutter framework that something has
// changed in this State, which causes it to rerun the build method below
// so that the display can reflect the updated values. If we changed
// _counter without calling setState(), then the build method would not be
// called again, and so nothing would appear to happen.
_counter++;
});
}
@override
Widget build(BuildContext context) {
// This method is rerun every time setState is called, for instance as done
// by the _incrementCounter method above.
//
// The Flutter framework has been optimized to make rerunning build methods
// fast, so that you can just rebuild anything that needs updating rather
// than having to individually change instances of widgets.
return Scaffold(
appBar: AppBar(
// TRY THIS: Try changing the color here to a specific color (to
// Colors.amber, perhaps?) and trigger a hot reload to see the AppBar
// change color while the other colors stay the same.
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
// Here we take the value from the MyHomePage object that was created by
// the App.build method, and use it to set our appbar title.
title: Text(widget.title),
),
body: Center(
// Center is a layout widget. It takes a single child and positions it
// in the middle of the parent.
child: Column(
// Column is also a layout widget. It takes a list of children and
// arranges them vertically. By default, it sizes itself to fit its
// children horizontally, and tries to be as tall as its parent.
//
// Column has various properties to control how it sizes itself and
// how it positions its children. Here we use mainAxisAlignment to
// center the children vertically; the main axis here is the vertical
// axis because Columns are vertical (the cross axis would be
// horizontal).
//
// TRY THIS: Invoke "debug painting" (choose the "Toggle Debug Paint"
// action in the IDE, or press "p" in the console), to see the
// wireframe for each widget.
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
const Text('You have pushed the button this many times:'),
Text(
'$_counter',
style: Theme.of(context).textTheme.headlineMedium,
),
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: _incrementCounter,
tooltip: 'Increment',
child: const Icon(Icons.add),
), // This trailing comma makes auto-formatting nicer for build methods.
);
}
}
void main() => runApp(const MyApp());

View File

@ -34,6 +34,13 @@ dependencies:
# The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons.
cupertino_icons: ^1.0.8
flutter_riverpod: ^2.6.1
shared_preferences: ^2.5.3
flutter_dotenv: ^6.0.0
file_selector: ^1.0.3
path_provider: ^2.1.5
pdfrx: ^1.3.5
pdf: ^3.10.8
dev_dependencies:
flutter_test:

View File

@ -7,24 +7,196 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:pdf_signature/main.dart';
import 'package:pdf_signature/features/pdf/viewer.dart';
void main() {
testWidgets('Counter increments smoke test', (WidgetTester tester) async {
// Build our app and trigger a frame.
await tester.pumpWidget(const MyApp());
Future<void> _pumpWithOpenPdf(WidgetTester tester) async {
await tester.pumpWidget(
ProviderScope(
overrides: [
pdfProvider.overrideWith(
(ref) => PdfController()..openPicked(path: 'test.pdf'),
),
useMockViewerProvider.overrideWith((ref) => true),
],
child: const MaterialApp(home: PdfSignatureHomePage()),
),
);
await tester.pump();
}
// Verify that our counter starts at 0.
expect(find.text('0'), findsOneWidget);
expect(find.text('1'), findsNothing);
Future<void> _pumpWithOpenPdfAndSig(WidgetTester tester) async {
await tester.pumpWidget(
ProviderScope(
overrides: [
pdfProvider.overrideWith(
(ref) => PdfController()..openPicked(path: 'test.pdf'),
),
signatureProvider.overrideWith(
(ref) => SignatureController()..placeDefaultRect(),
),
useMockViewerProvider.overrideWith((ref) => true),
],
child: const MaterialApp(home: PdfSignatureHomePage()),
),
);
await tester.pump();
}
// Tap the '+' icon and trigger a frame.
await tester.tap(find.byIcon(Icons.add));
testWidgets('Open a PDF and navigate pages', (tester) async {
await _pumpWithOpenPdf(tester);
final pageInfo = find.byKey(const Key('lbl_page_info'));
expect(pageInfo, findsOneWidget);
expect((tester.widget<Text>(pageInfo)).data, 'Page 1/5');
await tester.tap(find.byKey(const Key('btn_next')));
await tester.pump();
expect((tester.widget<Text>(pageInfo)).data, 'Page 2/5');
await tester.tap(find.byKey(const Key('btn_prev')));
await tester.pump();
expect((tester.widget<Text>(pageInfo)).data, 'Page 1/5');
});
testWidgets('Jump to a specific page', (tester) async {
await _pumpWithOpenPdf(tester);
final goto = find.byKey(const Key('txt_goto'));
await tester.enterText(goto, '4');
await tester.testTextInput.receiveAction(TextInputAction.done);
await tester.pump();
final pageInfo = find.byKey(const Key('lbl_page_info'));
expect((tester.widget<Text>(pageInfo)).data, 'Page 4/5');
});
testWidgets('Select a page for signing', (tester) async {
await _pumpWithOpenPdf(tester);
await tester.tap(find.byKey(const Key('btn_mark_signing')));
await tester.pump();
// signature actions appear (picker-based now)
expect(find.byKey(const Key('btn_load_signature_picker')), findsOneWidget);
});
testWidgets('Import a signature image', (tester) async {
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();
// Verify that our counter has incremented.
expect(find.text('0'), findsNothing);
expect(find.text('1'), findsOneWidget);
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 {
await _pumpWithOpenPdfAndSig(tester);
await tester.tap(find.byKey(const Key('btn_mark_signing')));
await tester.pump();
final overlay = find.byKey(const Key('signature_overlay'));
final posBefore = tester.getTopLeft(overlay);
// drag the overlay
await tester.drag(overlay, const Offset(30, -20));
await tester.pump();
final posAfter = tester.getTopLeft(overlay);
// Allow equality in case clamped at edges
expect(posAfter.dx >= posBefore.dx, isTrue);
expect(posAfter.dy <= posBefore.dy, isTrue);
// resize via handle
final handle = find.byKey(const Key('signature_handle'));
final sizeBefore = tester.getSize(overlay);
await tester.drag(handle, const Offset(40, 40));
await tester.pump();
final sizeAfter = tester.getSize(overlay);
expect(sizeAfter.width >= sizeBefore.width, isTrue);
expect(sizeAfter.height >= sizeBefore.height, isTrue);
});
testWidgets('Lock aspect ratio while resizing', (tester) async {
await _pumpWithOpenPdfAndSig(tester);
await tester.tap(find.byKey(const Key('btn_mark_signing')));
await tester.pump();
final overlay = find.byKey(const Key('signature_overlay'));
final sizeBefore = tester.getSize(overlay);
final aspect = sizeBefore.width / sizeBefore.height;
await tester.tap(find.byKey(const Key('chk_aspect_lock')));
await tester.pump();
await tester.drag(
find.byKey(const Key('signature_handle')),
const Offset(60, 10),
);
await tester.pump();
final sizeAfter = tester.getSize(overlay);
final newAspect = (sizeAfter.width / sizeAfter.height);
expect(
(newAspect - aspect).abs() < 0.15,
isTrue,
); // approximately preserved
});
testWidgets('Background removal and adjustments controls change state', (
tester,
) async {
await _pumpWithOpenPdfAndSig(tester);
await tester.tap(find.byKey(const Key('btn_mark_signing')));
await tester.pump();
// toggle bg removal
await tester.tap(find.byKey(const Key('swt_bg_removal')));
await tester.pump();
// move sliders
await tester.drag(
find.byKey(const Key('sld_contrast')),
const Offset(50, 0),
);
await tester.drag(
find.byKey(const Key('sld_brightness')),
const Offset(-50, 0),
);
await tester.pump();
// basic smoke: overlay still present
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')));
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();
await tester.tap(find.byKey(const Key('btn_canvas_confirm')));
await tester.pumpAndSettle();
// Overlay present with drawn strokes painter
expect(find.byKey(const Key('signature_overlay')), findsOneWidget);
});
}