diff --git a/.gitignore b/.gitignore index e1e0c24..57ccf8f 100644 --- a/.gitignore +++ b/.gitignore @@ -119,3 +119,5 @@ app.*.symbols !/dev/ci/**/Gemfile.lock docs/.* +.vscode/tasks.json +.vscode/launch.json diff --git a/docs/FRs.md b/docs/FRs.md index 30d9977..063f48a 100644 --- a/docs/FRs.md +++ b/docs/FRs.md @@ -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. diff --git a/docs/use_cases.md b/docs/use_cases.md index 7723112..330f96d 100644 --- a/docs/use_cases.md +++ b/docs/use_cases.md @@ -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 +``` + diff --git a/lib/app.dart b/lib/app.dart new file mode 100644 index 0000000..990fc95 --- /dev/null +++ b/lib/app.dart @@ -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(), + ), + ); + } +} diff --git a/lib/features/pdf/viewer.dart b/lib/features/pdf/viewer.dart new file mode 100644 index 0000000..2608a28 --- /dev/null +++ b/lib/features/pdf/viewer.dart @@ -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((_) => false); + +class PdfSignatureHomePage extends ConsumerStatefulWidget { + const PdfSignatureHomePage({super.key}); + + @override + ConsumerState createState() => + _PdfSignatureHomePageState(); +} + +class _PdfSignatureHomePageState extends ConsumerState { + static const Size _pageSize = SignatureController.pageSize; + final GlobalKey _captureKey = GlobalKey(); + + Future _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 _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 _openDrawCanvas() async { + final pdf = ref.read(pdfProvider); + if (!pdf.markedForSigning) return; + final current = ref.read(signatureProvider).strokes; + final result = await showModalBottomSheet>>( + context: context, + isScrollControlled: true, + builder: (_) => DrawCanvas(strokes: current), + ); + if (result != null) { + ref.read(signatureProvider.notifier).setStrokes(result); + ref.read(signatureProvider.notifier).ensureRectForStrokes(); + } + } + + Future _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)), + ], + ), + ], + ); + } +} diff --git a/lib/features/pdf/viewer_state.dart b/lib/features/pdf/viewer_state.dart new file mode 100644 index 0000000..a24eca8 --- /dev/null +++ b/lib/features/pdf/viewer_state.dart @@ -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 { + 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( + (ref) => PdfController(), +); + +class SignatureState { + final Rect? rect; + final bool aspectLocked; + final bool bgRemoval; + final double contrast; + final double brightness; + final List> 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>? 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 { + 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> 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( + (ref) => SignatureController(), + ); diff --git a/lib/features/pdf/viewer_widgets.dart b/lib/features/pdf/viewer_widgets.dart new file mode 100644 index 0000000..d74649d --- /dev/null +++ b/lib/features/pdf/viewer_widgets.dart @@ -0,0 +1,109 @@ +part of 'viewer.dart'; + +class DrawCanvas extends StatefulWidget { + const DrawCanvas({super.key, required this.strokes}); + final List> strokes; + + @override + State createState() => _DrawCanvasState(); +} + +class _DrawCanvasState extends State { + late List> _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> 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; +} diff --git a/lib/features/share/export_service.dart b/lib/features/share/export_service.dart new file mode 100644 index 0000000..4647ae3 --- /dev/null +++ b/lib/features/share/export_service.dart @@ -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 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; + } + } +} diff --git a/lib/main.dart b/lib/main.dart index 7b7f5b6..4019f18 100644 --- a/lib/main.dart +++ b/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 createState() => _MyHomePageState(); -} - -class _MyHomePageState extends State { - 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: [ - 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()); diff --git a/pubspec.yaml b/pubspec.yaml index b477bb7..5139310 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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: diff --git a/test/widget/widget_test.dart b/test/widget/widget_test.dart index 2209b13..bc07192 100644 --- a/test/widget/widget_test.dart +++ b/test/widget/widget_test.dart @@ -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 _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 _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(pageInfo)).data, 'Page 1/5'); + + await tester.tap(find.byKey(const Key('btn_next'))); + await tester.pump(); + expect((tester.widget(pageInfo)).data, 'Page 2/5'); + + await tester.tap(find.byKey(const Key('btn_prev'))); + await tester.pump(); + expect((tester.widget(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(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); }); }