feat: add open pdf file, open signature picture
This commit is contained in:
parent
6c2b5f5d4e
commit
8ad34023cd
|
@ -119,3 +119,5 @@ app.*.symbols
|
|||
!/dev/ci/**/Gemfile.lock
|
||||
|
||||
docs/.*
|
||||
.vscode/tasks.json
|
||||
.vscode/launch.json
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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
|
||||
```
|
||||
|
||||
|
|
|
@ -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(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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)),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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(),
|
||||
);
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
123
lib/main.dart
123
lib/main.dart
|
@ -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());
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue