Compare commits

...

2 Commits

23 changed files with 719 additions and 346 deletions

View File

@ -4,7 +4,7 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:pdf_signature/data/services/export_service.dart';
import 'package:pdf_signature/data/services/providers.dart';
import 'package:pdf_signature/data/services/export_providers.dart';
import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart';
import 'package:pdf_signature/ui/features/pdf/widgets/pdf_screen.dart';
import 'package:pdf_signature/l10n/app_localizations.dart';

View File

@ -5,7 +5,7 @@ import 'package:pdf_signature/l10n/app_localizations.dart';
import 'package:pdf_signature/ui/features/pdf/widgets/pdf_screen.dart';
import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart';
import 'package:pdf_signature/ui/features/welcome/widgets/welcome_screen.dart';
import 'ui/features/preferences/providers.dart';
import 'data/services/preferences_providers.dart';
import 'package:pdf_signature/ui/features/preferences/widgets/settings_screen.dart';
class MyApp extends StatelessWidget {

View File

@ -69,6 +69,8 @@ class SignatureState {
final double rotation;
final List<List<Offset>> strokes;
final Uint8List? imageBytes;
// The ID of the signature asset the current overlay is based on (from library)
final String? assetId;
// When true, the active signature overlay is movable/resizable and should not be exported.
// When false, the overlay is confirmed (unmovable) and eligible for export.
final bool editingEnabled;
@ -81,6 +83,7 @@ class SignatureState {
this.rotation = 0.0,
required this.strokes,
this.imageBytes,
this.assetId,
this.editingEnabled = false,
});
factory SignatureState.initial() => const SignatureState(
@ -92,6 +95,7 @@ class SignatureState {
rotation: 0.0,
strokes: [],
imageBytes: null,
assetId: null,
editingEnabled: false,
);
SignatureState copyWith({
@ -103,6 +107,7 @@ class SignatureState {
double? rotation,
List<List<Offset>>? strokes,
Uint8List? imageBytes,
String? assetId,
bool? editingEnabled,
}) => SignatureState(
rect: rect ?? this.rect,
@ -113,6 +118,7 @@ class SignatureState {
rotation: rotation ?? this.rotation,
strokes: strokes ?? this.strokes,
imageBytes: imageBytes ?? this.imageBytes,
assetId: assetId ?? this.assetId,
editingEnabled: editingEnabled ?? this.editingEnabled,
);
}

View File

@ -33,6 +33,8 @@ class ExportService {
required Size uiPageSize,
required Uint8List? signatureImageBytes,
Map<int, List<Rect>>? placementsByPage,
Map<int, List<String>>? placementImageByPage,
Map<String, Uint8List>? libraryBytes,
double targetDpi = 144.0,
}) async {
// print(
@ -53,6 +55,8 @@ class ExportService {
uiPageSize: uiPageSize,
signatureImageBytes: signatureImageBytes,
placementsByPage: placementsByPage,
placementImageByPage: placementImageByPage,
libraryBytes: libraryBytes,
targetDpi: targetDpi,
);
if (bytes == null) return false;
@ -73,6 +77,8 @@ class ExportService {
required Size uiPageSize,
required Uint8List? signatureImageBytes,
Map<int, List<Rect>>? placementsByPage,
Map<int, List<String>>? placementImageByPage,
Map<String, Uint8List>? libraryBytes,
double targetDpi = 144.0,
}) async {
final out = pw.Document(version: pdf.PdfVersion.pdf_1_4, compress: false);
@ -100,6 +106,10 @@ class ExportService {
hasMulti
? (placementsByPage[pageIndex] ?? const <Rect>[])
: const <Rect>[];
final pageImageIds =
hasMulti
? (placementImageByPage?[pageIndex] ?? const <String>[])
: const <String>[];
final shouldStampSingle =
!hasMulti &&
signedPage != null &&
@ -107,12 +117,7 @@ class ExportService {
signatureRectUi != null &&
signatureImageBytes != null &&
signatureImageBytes.isNotEmpty;
final shouldStampMulti =
hasMulti &&
pagePlacements.isNotEmpty &&
signatureImageBytes != null &&
signatureImageBytes.isNotEmpty;
if (shouldStampSingle || shouldStampMulti) {
if (shouldStampSingle) {
try {
sigImgObj = pw.MemoryImage(signatureImageBytes);
} catch (_) {
@ -139,35 +144,52 @@ class ExportService {
),
),
];
if (sigImgObj != null) {
if (hasMulti && pagePlacements.isNotEmpty) {
for (final r in pagePlacements) {
final left = r.left / uiPageSize.width * widthPts;
final top = r.top / uiPageSize.height * heightPts;
final w = r.width / uiPageSize.width * widthPts;
final h = r.height / uiPageSize.height * heightPts;
children.add(
pw.Positioned(
left: left,
top: top,
child: pw.Image(sigImgObj, width: w, height: h),
),
);
}
} else if (shouldStampSingle) {
final r = signatureRectUi;
// Multi-placement stamping: per-placement image from libraryBytes
if (hasMulti && pagePlacements.isNotEmpty) {
for (var i = 0; i < pagePlacements.length; i++) {
final r = pagePlacements[i];
final left = r.left / uiPageSize.width * widthPts;
final top = r.top / uiPageSize.height * heightPts;
final w = r.width / uiPageSize.width * widthPts;
final h = r.height / uiPageSize.height * heightPts;
children.add(
pw.Positioned(
left: left,
top: top,
child: pw.Image(sigImgObj, width: w, height: h),
),
);
Uint8List? bytes;
if (i < pageImageIds.length) {
final id = pageImageIds[i];
bytes = libraryBytes?[id];
}
bytes ??=
signatureImageBytes; // fallback to single image if provided
if (bytes != null && bytes.isNotEmpty) {
pw.MemoryImage? imgObj;
try {
imgObj = pw.MemoryImage(bytes);
} catch (_) {
imgObj = null;
}
if (imgObj != null) {
children.add(
pw.Positioned(
left: left,
top: top,
child: pw.Image(imgObj, width: w, height: h),
),
);
}
}
}
} else if (shouldStampSingle && sigImgObj != null) {
final r = signatureRectUi;
final left = r.left / uiPageSize.width * widthPts;
final top = r.top / uiPageSize.height * heightPts;
final w = r.width / uiPageSize.width * widthPts;
final h = r.height / uiPageSize.height * heightPts;
children.add(
pw.Positioned(
left: left,
top: top,
child: pw.Image(sigImgObj, width: w, height: h),
),
);
}
return pw.Stack(children: children);
},
@ -187,6 +209,10 @@ class ExportService {
(placementsByPage != null && placementsByPage.isNotEmpty);
final pagePlacements =
hasMulti ? (placementsByPage[1] ?? const <Rect>[]) : const <Rect>[];
final pageImageIds =
hasMulti
? (placementImageByPage?[1] ?? const <String>[])
: const <String>[];
final shouldStampSingle =
!hasMulti &&
signedPage != null &&
@ -194,12 +220,7 @@ class ExportService {
signatureRectUi != null &&
signatureImageBytes != null &&
signatureImageBytes.isNotEmpty;
final shouldStampMulti =
hasMulti &&
pagePlacements.isNotEmpty &&
signatureImageBytes != null &&
signatureImageBytes.isNotEmpty;
if (shouldStampSingle || shouldStampMulti) {
if (shouldStampSingle) {
try {
// If it's already PNG, keep as-is to preserve alpha; otherwise decode/encode PNG
final asStr = String.fromCharCodes(signatureImageBytes.take(8));
@ -232,35 +253,66 @@ class ExportService {
color: pdf.PdfColors.white,
),
];
if (sigImgObj != null) {
if (hasMulti && pagePlacements.isNotEmpty) {
for (final r in pagePlacements) {
final left = r.left / uiPageSize.width * widthPts;
final top = r.top / uiPageSize.height * heightPts;
final w = r.width / uiPageSize.width * widthPts;
final h = r.height / uiPageSize.height * heightPts;
children.add(
pw.Positioned(
left: left,
top: top,
child: pw.Image(sigImgObj, width: w, height: h),
),
);
}
} else if (shouldStampSingle) {
final r = signatureRectUi;
// Multi-placement stamping on fallback page
if (hasMulti && pagePlacements.isNotEmpty) {
for (var i = 0; i < pagePlacements.length; i++) {
final r = pagePlacements[i];
final left = r.left / uiPageSize.width * widthPts;
final top = r.top / uiPageSize.height * heightPts;
final w = r.width / uiPageSize.width * widthPts;
final h = r.height / uiPageSize.height * heightPts;
children.add(
pw.Positioned(
left: left,
top: top,
child: pw.Image(sigImgObj, width: w, height: h),
),
);
Uint8List? bytes;
if (i < pageImageIds.length) {
final id = pageImageIds[i];
bytes = libraryBytes?[id];
}
bytes ??=
signatureImageBytes; // fallback to single image if provided
if (bytes != null && bytes.isNotEmpty) {
pw.MemoryImage? imgObj;
try {
// Ensure PNG for transparency if not already
final asStr = String.fromCharCodes(bytes.take(8));
final isPng =
bytes.length > 8 &&
bytes[0] == 0x89 &&
asStr.startsWith('\u0089PNG');
if (isPng) {
imgObj = pw.MemoryImage(bytes);
} else {
final decoded = img.decodeImage(bytes);
if (decoded != null) {
final png = img.encodePng(decoded, level: 6);
imgObj = pw.MemoryImage(Uint8List.fromList(png));
}
}
} catch (_) {
imgObj = null;
}
if (imgObj != null) {
children.add(
pw.Positioned(
left: left,
top: top,
child: pw.Image(imgObj, width: w, height: h),
),
);
}
}
}
} else if (shouldStampSingle && sigImgObj != null) {
final r = signatureRectUi;
final left = r.left / uiPageSize.width * widthPts;
final top = r.top / uiPageSize.height * heightPts;
final w = r.width / uiPageSize.width * widthPts;
final h = r.height / uiPageSize.height * heightPts;
children.add(
pw.Positioned(
left: left,
top: top,
child: pw.Image(sigImgObj, width: w, height: h),
),
);
}
return pw.Stack(children: children);
},

View File

@ -169,6 +169,44 @@ final pdfProvider = StateNotifierProvider<PdfController, PdfState>(
(ref) => PdfController(),
);
/// A simple library of signature images available to the user in the sidebar.
class SignatureAsset {
final String id; // unique id
final Uint8List bytes;
final String? name; // optional display name (e.g., filename)
const SignatureAsset({required this.id, required this.bytes, this.name});
}
class SignatureLibraryController extends StateNotifier<List<SignatureAsset>> {
SignatureLibraryController() : super(const []);
String add(Uint8List bytes, {String? name}) {
// Always add a new asset (allow duplicates). This lets users create multiple cards
// even when loading the same image repeatedly for different adjustments/usages.
if (bytes.isEmpty) return '';
final id = DateTime.now().microsecondsSinceEpoch.toString();
state = List.of(state)
..add(SignatureAsset(id: id, bytes: bytes, name: name));
return id;
}
void remove(String id) {
state = state.where((a) => a.id != id).toList(growable: false);
}
SignatureAsset? byId(String id) {
for (final a in state) {
if (a.id == id) return a;
}
return null;
}
}
final signatureLibraryProvider =
StateNotifierProvider<SignatureLibraryController, List<SignatureAsset>>(
(ref) => SignatureLibraryController(),
);
class SignatureController extends StateNotifier<SignatureState> {
SignatureController() : super(SignatureState.initial());
static const Size pageSize = Size(400, 560);
@ -291,7 +329,7 @@ class SignatureController extends StateNotifier<SignatureState> {
}
void setImageBytes(Uint8List bytes) {
state = state.copyWith(imageBytes: bytes);
state = state.copyWith(imageBytes: bytes, assetId: null);
if (state.rect == null) {
placeDefaultRect();
}
@ -299,6 +337,15 @@ class SignatureController extends StateNotifier<SignatureState> {
state = state.copyWith(editingEnabled: true);
}
// Select image from the shared signature library
void setImageFromLibrary({required String assetId}) {
state = state.copyWith(assetId: assetId);
if (state.rect == null) {
placeDefaultRect();
}
state = state.copyWith(editingEnabled: true);
}
void clearImage() {
state = state.copyWith(imageBytes: null, rect: null, editingEnabled: false);
}
@ -318,6 +365,25 @@ class SignatureController extends StateNotifier<SignatureState> {
final pdf = ref.read(pdfProvider);
if (!pdf.loaded) return null;
ref.read(pdfProvider.notifier).addPlacement(page: pdf.currentPage, rect: r);
// Assign image id to this placement (last index)
final idx =
(ref.read(pdfProvider).placementsByPage[pdf.currentPage]?.length ?? 1) -
1;
String? id = state.assetId;
if (id == null) {
final bytes =
ref.read(processedSignatureImageProvider) ?? state.imageBytes;
if (bytes != null) {
id = ref
.read(signatureLibraryProvider.notifier)
.add(bytes, name: 'image');
}
}
if (id != null && id.isNotEmpty && idx >= 0) {
ref
.read(pdfProvider.notifier)
.assignImageToPlacement(page: pdf.currentPage, index: idx, image: id);
}
// Freeze editing: keep rect for preview but disable interaction
state = state.copyWith(editingEnabled: false);
return r;
@ -339,7 +405,19 @@ final signatureProvider =
/// Returns null if no image is loaded. The output is a PNG to preserve alpha.
final processedSignatureImageProvider = Provider<Uint8List?>((ref) {
final s = ref.watch(signatureProvider);
final bytes = s.imageBytes;
// If active overlay is based on a library asset, pull its bytes
Uint8List? bytes;
if (s.assetId != null) {
final lib = ref.watch(signatureLibraryProvider);
for (final a in lib) {
if (a.id == s.assetId) {
bytes = a.bytes;
break;
}
}
} else {
bytes = s.imageBytes;
}
if (bytes == null || bytes.isEmpty) return null;
// Decode (supports PNG/JPEG, etc.)

View File

@ -0,0 +1,11 @@
import 'package:flutter/material.dart';
import 'pdf_pages_overview.dart';
class PagesSidebar extends StatelessWidget {
const PagesSidebar({super.key});
@override
Widget build(BuildContext context) {
return Card(margin: EdgeInsets.zero, child: const PdfPagesOverview());
}
}

View File

@ -1,15 +1,16 @@
import 'dart:math' as math;
import 'dart:async';
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:pdf_signature/l10n/app_localizations.dart';
import 'package:pdfrx/pdfrx.dart';
import '../../../../data/services/providers.dart';
import '../../../../data/services/export_providers.dart';
import '../../../../data/model/model.dart';
import '../view_model/view_model.dart';
import '../../preferences/providers.dart';
import 'signature_drawer.dart';
import '../../../../data/services/preferences_providers.dart';
import 'signature_drag_data.dart';
import 'image_editor_dialog.dart';
class PdfPageArea extends ConsumerStatefulWidget {
@ -332,25 +333,10 @@ class _PdfPageAreaState extends ConsumerState<PdfPageArea> {
final target = _pendingPage ?? pdf.currentPage;
_pendingPage = null;
_scrollRetryCount = 0;
_programmaticTargetPage = target;
controller.goToPage(pageNumber: target, anchor: PdfPageAnchor.top);
// Fallback: if the viewer doesn't emit onPageChanged (e.g., already at target),
// ensure we don't keep blocking provider-driven jumps.
// Defer navigation to the next frame to ensure controller state is fully ready.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
if (_programmaticTargetPage == target) {
_programmaticTargetPage = null;
}
});
});
// Also ensure a scroll attempt is queued in case current state suppressed earlier.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
if (_visiblePage != ref.read(pdfProvider).currentPage) {
_scrollToPage(ref.read(pdfProvider).currentPage);
}
_scrollToPage(target);
});
},
onPageChanged: (n) {
@ -393,6 +379,13 @@ class _PdfPageAreaState extends ConsumerState<PdfPageArea> {
// Assume drop targets the current visible page; compute relative center
final cx = (local.dx / size.width) * widget.pageSize.width;
final cy = (local.dy / size.height) * widget.pageSize.height;
final data = details.data;
if (data is SignatureDragData && data.assetId != null) {
// Set current overlay to use this asset
ref
.read(signatureProvider.notifier)
.setImageFromLibrary(assetId: data.assetId!);
}
ref.read(signatureProvider.notifier).placeAtCenter(Offset(cx, cy));
ref
.read(pdfProvider.notifier)
@ -558,10 +551,32 @@ class _PdfPageAreaState extends ConsumerState<PdfPageArea> {
children: [
Consumer(
builder: (context, ref, _) {
final processed = ref.watch(
processedSignatureImageProvider,
);
final bytes = processed ?? sig.imageBytes;
Uint8List? bytes;
if (interactive) {
final processed = ref.watch(
processedSignatureImageProvider,
);
bytes = processed ?? sig.imageBytes;
} else if (placedIndex != null) {
// Use the image assigned to this placement
final imgId = ref
.read(pdfProvider)
.placementImageByPage[pageNumber]
?.elementAt(placedIndex);
if (imgId != null) {
final lib = ref.watch(signatureLibraryProvider);
for (final a in lib) {
if (a.id == imgId) {
bytes = a.bytes;
break;
}
}
}
// Fallback to current processed
bytes ??=
ref.read(processedSignatureImageProvider) ??
sig.imageBytes;
}
if (bytes == null) {
return Center(
child: Text(

View File

@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:pdfrx/pdfrx.dart';
import '../../../../data/services/providers.dart';
import '../../../../data/services/export_providers.dart';
import '../view_model/view_model.dart';
class PdfPagesOverview extends ConsumerWidget {

View File

@ -6,15 +6,15 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:pdf_signature/l10n/app_localizations.dart';
import 'package:printing/printing.dart' as printing;
import 'package:pdfrx/pdfrx.dart';
import 'package:multi_split_view/multi_split_view.dart';
import '../../../../data/services/providers.dart';
import '../../../../data/services/export_providers.dart';
import '../view_model/view_model.dart';
import 'draw_canvas.dart';
import 'pdf_toolbar.dart';
import 'pdf_page_area.dart';
import 'pdf_pages_overview.dart';
import 'signature_drawer.dart';
// adjustments are available via ImageEditorDialog
import 'pages_sidebar.dart';
import 'signatures_sidebar.dart';
class PdfSignatureHomePage extends ConsumerStatefulWidget {
const PdfSignatureHomePage({super.key});
@ -31,6 +31,17 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
bool _showSignaturesSidebar = true;
int _zoomLevel = 100; // percentage for display only
// Split view controller to manage resizable sidebars without remounting the center area.
late final MultiSplitViewController _splitController;
late final List<Area> _areas;
double _lastPagesWidth = 160;
double _lastSignaturesWidth = 220;
// Configurable sidebar constraints
final double _pagesMin = 100;
final double _pagesMax = 250;
final double _signaturesMin = 140;
final double _signaturesMax = 250;
// Exposed for tests to trigger the invalid-file SnackBar without UI.
@visibleForTesting
void debugShowInvalidSignatureSnackBar() {
@ -56,15 +67,13 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
ref.read(pdfProvider.notifier).jumpTo(page);
}
// Zoom is managed by pdfrx viewer (Ctrl +/- etc.). No custom zoom here.
Future<void> _loadSignatureFromFile() async {
Future<Uint8List?> _loadSignatureFromFile() async {
final typeGroup = const fs.XTypeGroup(
label: 'Image',
extensions: ['png', 'jpg', 'jpeg', 'webp'],
);
final file = await fs.openFile(acceptedTypeGroups: [typeGroup]);
if (file == null) return;
if (file == null) return null;
final bytes = await file.readAsBytes();
final sig = ref.read(signatureProvider.notifier);
sig.setImageBytes(bytes);
@ -72,10 +81,9 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
if (p.loaded) {
ref.read(pdfProvider.notifier).setSignedPage(p.currentPage);
}
return bytes;
}
// _createNewSignature was removed as the toolbar no longer exposes this action.
void _confirmSignature() {
ref.read(signatureProvider.notifier).confirmCurrentSignature(ref);
}
@ -92,7 +100,7 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
ref.read(pdfProvider.notifier).selectPlacement(index);
}
Future<void> _openDrawCanvas() async {
Future<Uint8List?> _openDrawCanvas() async {
final result = await showModalBottomSheet<Uint8List>(
context: context,
isScrollControlled: true,
@ -106,6 +114,7 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
ref.read(pdfProvider.notifier).setSignedPage(p.currentPage);
}
}
return result;
}
Future<void> _saveSignedPdf() async {
@ -138,6 +147,10 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
uiPageSize: SignatureController.pageSize,
signatureImageBytes: processed ?? sig.imageBytes,
placementsByPage: pdf.placementsByPage,
placementImageByPage: pdf.placementImageByPage,
libraryBytes: {
for (final a in ref.read(signatureLibraryProvider)) a.id: a.bytes,
},
targetDpi: targetDpi,
);
if (bytes != null) {
@ -167,6 +180,10 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
uiPageSize: SignatureController.pageSize,
signatureImageBytes: processed ?? sig.imageBytes,
placementsByPage: pdf.placementsByPage,
placementImageByPage: pdf.placementImageByPage,
libraryBytes: {
for (final a in ref.read(signatureLibraryProvider)) a.id: a.bytes,
},
targetDpi: targetDpi,
);
if (useMock) {
@ -190,6 +207,11 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
uiPageSize: SignatureController.pageSize,
signatureImageBytes: processed ?? sig.imageBytes,
placementsByPage: pdf.placementsByPage,
placementImageByPage: pdf.placementImageByPage,
libraryBytes: {
for (final a in ref.read(signatureLibraryProvider))
a.id: a.bytes,
},
targetDpi: targetDpi,
);
}
@ -236,11 +258,94 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
return name;
}
@override
void initState() {
super.initState();
// Build areas once with builders; keep these instances stable.
_areas = [
Area(
size: _lastPagesWidth,
min: _pagesMin,
max: _pagesMax,
builder:
(context, area) => Offstage(
offstage: !_showPagesSidebar,
child: const PagesSidebar(),
),
),
Area(
flex: 1,
builder:
(context, area) => RepaintBoundary(
child: PdfPageArea(
key: const ValueKey('pdf_page_area'),
pageSize: _pageSize,
viewerController: _viewerController,
onDragSignature: _onDragSignature,
onResizeSignature: _onResizeSignature,
onConfirmSignature: _confirmSignature,
onClearActiveOverlay:
() =>
ref
.read(signatureProvider.notifier)
.clearActiveOverlay(),
onSelectPlaced: _onSelectPlaced,
),
),
),
Area(
size: _lastSignaturesWidth,
min: _signaturesMin,
max: _signaturesMax,
builder:
(context, area) => Offstage(
offstage: !_showSignaturesSidebar,
child: SignaturesSidebar(
onLoadSignatureFromFile: _loadSignatureFromFile,
onOpenDrawCanvas: _openDrawCanvas,
onSave: _saveSignedPdf,
),
),
),
];
_splitController = MultiSplitViewController(areas: _areas);
// Apply initial collapse if needed
_applySidebarVisibility();
}
@override
void dispose() {
_splitController.dispose();
super.dispose();
}
void _applySidebarVisibility() {
// Left pages sidebar
final left = _splitController.areas[0];
if (_showPagesSidebar) {
left.max = _pagesMax;
left.min = _pagesMin;
left.size = _lastPagesWidth.clamp(_pagesMin, _pagesMax);
} else {
_lastPagesWidth = left.size ?? _lastPagesWidth;
left.min = 0;
left.max = 1;
left.size = 1; // effectively hidden
}
// Right signatures sidebar
final right = _splitController.areas[2];
if (_showSignaturesSidebar) {
right.max = _signaturesMax;
right.min = _signaturesMin;
right.size = _lastSignaturesWidth.clamp(_signaturesMin, _signaturesMax);
} else {
_lastSignaturesWidth = right.size ?? _lastSignaturesWidth;
right.min = 0;
right.max = 1;
right.size = 1;
}
}
@override
Widget build(BuildContext context) {
final isExporting = ref.watch(exportingProvider);
@ -273,93 +378,26 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
_zoomLevel = (_zoomLevel + 10).clamp(10, 800);
});
},
// zoomLevel omitted to avoid compact overflows in tight tests
zoomLevel: _zoomLevel,
fileName: ref.watch(pdfProvider).pickedPdfPath,
showPagesSidebar: _showPagesSidebar,
showSignaturesSidebar: _showSignaturesSidebar,
onTogglePagesSidebar:
() => setState(() {
_showPagesSidebar = !_showPagesSidebar;
_applySidebarVisibility();
}),
onToggleSignaturesSidebar:
() => setState(() {
_showSignaturesSidebar = !_showSignaturesSidebar;
_applySidebarVisibility();
}),
),
const SizedBox(height: 8),
Expanded(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (_showPagesSidebar)
ConstrainedBox(
constraints: const BoxConstraints(
minWidth: 140,
maxWidth: 180,
),
child: Card(
margin: EdgeInsets.zero,
child: const PdfPagesOverview(),
),
),
if (_showPagesSidebar) const SizedBox(width: 12),
Expanded(
child: AbsorbPointer(
absorbing: isExporting,
child: PdfPageArea(
pageSize: _pageSize,
viewerController: _viewerController,
onDragSignature: _onDragSignature,
onResizeSignature: _onResizeSignature,
onConfirmSignature: _confirmSignature,
onClearActiveOverlay:
() =>
ref
.read(signatureProvider.notifier)
.clearActiveOverlay(),
onSelectPlaced: _onSelectPlaced,
),
),
),
if (_showSignaturesSidebar) const SizedBox(width: 12),
if (_showSignaturesSidebar)
ConstrainedBox(
constraints: const BoxConstraints(
minWidth: 140,
maxWidth: 250,
),
child: AbsorbPointer(
absorbing: isExporting,
child: Card(
margin: EdgeInsets.zero,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
child: SingleChildScrollView(
child: SignatureDrawer(
disabled: isExporting,
onLoadSignatureFromFile:
_loadSignatureFromFile,
onOpenDrawCanvas: _openDrawCanvas,
),
),
),
Padding(
padding: const EdgeInsets.all(12),
child: ElevatedButton(
key: const Key('btn_save_pdf'),
onPressed:
isExporting ? null : _saveSignedPdf,
child: Text(l.saveSignedPdf),
),
),
],
),
),
),
),
],
child: MultiSplitView(
controller: _splitController,
axis: Axis.horizontal,
),
),
],

View File

@ -3,7 +3,7 @@ import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:pdf_signature/l10n/app_localizations.dart';
import '../../../../data/services/providers.dart';
import '../../../../data/services/export_providers.dart';
import '../view_model/view_model.dart';
class PdfToolbar extends ConsumerStatefulWidget {
@ -94,8 +94,8 @@ class _PdfToolbarState extends ConsumerState<PdfToolbar> {
),
),
if (pdf.loaded) ...[
Row(
mainAxisSize: MainAxisSize.min,
Wrap(
spacing: 8,
children: [
IconButton(
key: const Key('btn_prev'),
@ -156,25 +156,19 @@ class _PdfToolbarState extends ConsumerState<PdfToolbar> {
onPressed: widget.disabled ? null : widget.onZoomOut,
icon: const Icon(Icons.zoom_out),
),
Text(
//if not null
widget.zoomLevel != null ? '${widget.zoomLevel}%' : '',
style: const TextStyle(fontSize: 12),
),
IconButton(
key: const Key('btn_zoom_in'),
tooltip: 'Zoom in',
onPressed: widget.disabled ? null : widget.onZoomIn,
icon: const Icon(Icons.zoom_in),
),
if (!compact && widget.zoomLevel != null) ...[
const SizedBox(width: 6),
// show zoom ratio
Text(
'${widget.zoomLevel}%',
style: const TextStyle(fontSize: 12),
),
],
],
),
Row(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(width: 6),
// show zoom ratio
Text(l.dpi),
const SizedBox(width: 8),
DropdownButton<double>(

View File

@ -0,0 +1,153 @@
import 'package:flutter/material.dart';
import '../view_model/view_model.dart';
import 'signature_drag_data.dart';
class SignatureCard extends StatelessWidget {
const SignatureCard({
super.key,
required this.asset,
required this.disabled,
required this.onDelete,
this.onTap,
this.onAdjust,
this.useCurrentBytesForDrag = false,
});
final SignatureAsset asset;
final bool disabled;
final VoidCallback onDelete;
final VoidCallback? onTap;
final VoidCallback? onAdjust;
final bool useCurrentBytesForDrag;
@override
Widget build(BuildContext context) {
final img = Image.memory(asset.bytes, fit: BoxFit.contain);
Widget base = SizedBox(
width: 96,
height: 64,
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Stack(
children: [
Positioned.fill(
child: DecoratedBox(
decoration: BoxDecoration(
border: Border.all(color: Theme.of(context).dividerColor),
borderRadius: BorderRadius.circular(8),
),
child: Padding(padding: const EdgeInsets.all(6), child: img),
),
),
Positioned(
right: 0,
top: 0,
child: IconButton(
icon: const Icon(Icons.close, size: 16),
onPressed: disabled ? null : onDelete,
tooltip: 'Remove',
padding: const EdgeInsets.all(2),
),
),
],
),
),
);
Widget child = onTap != null ? InkWell(onTap: onTap, child: base) : base;
// Add context menu for adjust/delete on right-click or long-press
child = GestureDetector(
key: const Key('gd_signature_card_area'),
behavior: HitTestBehavior.opaque,
onSecondaryTapDown:
disabled
? null
: (details) async {
final selected = await showMenu<String>(
context: context,
position: RelativeRect.fromLTRB(
details.globalPosition.dx,
details.globalPosition.dy,
details.globalPosition.dx,
details.globalPosition.dy,
),
items: const [
PopupMenuItem(
key: Key('mi_signature_adjust'),
value: 'adjust',
child: Text('Adjust graphic'),
),
PopupMenuItem(
key: Key('mi_signature_delete'),
value: 'delete',
child: Text('Delete'),
),
],
);
if (selected == 'adjust') {
onAdjust?.call();
} else if (selected == 'delete') {
onDelete();
}
},
onLongPressStart:
disabled
? null
: (details) async {
final selected = await showMenu<String>(
context: context,
position: RelativeRect.fromLTRB(
details.globalPosition.dx,
details.globalPosition.dy,
details.globalPosition.dx,
details.globalPosition.dy,
),
items: const [
PopupMenuItem(
key: Key('mi_signature_adjust'),
value: 'adjust',
child: Text('Adjust graphic'),
),
PopupMenuItem(
key: Key('mi_signature_delete'),
value: 'delete',
child: Text('Delete'),
),
],
);
if (selected == 'adjust') {
onAdjust?.call();
} else if (selected == 'delete') {
onDelete();
}
},
child: child,
);
if (disabled) return child;
return Draggable<SignatureDragData>(
data:
useCurrentBytesForDrag
? const SignatureDragData()
: SignatureDragData(assetId: asset.id),
feedback: Opacity(
opacity: 0.9,
child: ConstrainedBox(
constraints: const BoxConstraints.tightFor(width: 160, height: 100),
child: DecoratedBox(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(6),
boxShadow: const [
BoxShadow(blurRadius: 8, color: Colors.black26),
],
),
child: Padding(
padding: const EdgeInsets.all(6.0),
child: Image.memory(asset.bytes, fit: BoxFit.contain),
),
),
),
),
childWhenDragging: Opacity(opacity: 0.5, child: child),
child: child,
);
}
}

View File

@ -0,0 +1,4 @@
class SignatureDragData {
final String? assetId; // null means use current processed signature
const SignatureDragData({this.assetId});
}

View File

@ -3,14 +3,12 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:pdf_signature/l10n/app_localizations.dart';
import '../../../../data/services/providers.dart';
import '../../../../data/services/export_providers.dart';
import '../view_model/view_model.dart';
import 'image_editor_dialog.dart';
import 'signature_card.dart';
/// Data passed when dragging a signature card.
class SignatureDragData {
const SignatureDragData();
}
/// Data for drag-and-drop is in signature_drag_data.dart
class SignatureDrawer extends ConsumerStatefulWidget {
const SignatureDrawer({
@ -21,121 +19,107 @@ class SignatureDrawer extends ConsumerStatefulWidget {
});
final bool disabled;
final VoidCallback onLoadSignatureFromFile;
final VoidCallback onOpenDrawCanvas;
// Return the loaded bytes (if any) so we can add the exact image to the library immediately.
final Future<Uint8List?> Function() onLoadSignatureFromFile;
// Return the drawn bytes (if any) so we can add it to the library immediately.
final Future<Uint8List?> Function() onOpenDrawCanvas;
@override
ConsumerState<SignatureDrawer> createState() => _SignatureDrawerState();
}
class _SignatureDrawerState extends ConsumerState<SignatureDrawer> {
Future<void> _openSignatureMenuAt(Offset globalPosition) async {
final l = AppLocalizations.of(context);
final selected = await showMenu<String>(
context: context,
position: RelativeRect.fromLTRB(
globalPosition.dx,
globalPosition.dy,
globalPosition.dx,
globalPosition.dy,
),
items: [
PopupMenuItem(
key: const Key('mi_signature_delete'),
value: 'delete',
child: Text(l.delete),
),
PopupMenuItem(
key: const Key('mi_signature_adjust'),
value: 'adjust',
child: const Text('Adjust graphic'),
),
],
);
switch (selected) {
case 'delete':
ref.read(signatureProvider.notifier).clearActiveOverlay();
ref.read(signatureProvider.notifier).clearImage();
break;
case 'adjust':
if (!mounted) return;
// Open ImageEditorDialog
await showDialog(
context: context,
builder: (_) => const ImageEditorDialog(),
);
break;
default:
break;
}
}
@override
Widget build(BuildContext context) {
final l = AppLocalizations.of(context);
final sig = ref.watch(signatureProvider);
final processed = ref.watch(processedSignatureImageProvider);
final bytes = processed ?? sig.imageBytes;
final library = ref.watch(signatureLibraryProvider);
final isExporting = ref.watch(exportingProvider);
final disabled = widget.disabled || isExporting;
return Card(
margin: EdgeInsets.zero,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Header
Padding(
padding: const EdgeInsets.fromLTRB(12, 12, 12, 8),
child: Text(
l.signature,
style: Theme.of(context).textTheme.titleSmall,
),
),
// Existing signature card (draggable when bytes available)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: DecoratedBox(
decoration: BoxDecoration(
border: Border.all(color: Theme.of(context).dividerColor),
borderRadius: BorderRadius.circular(8),
),
child: GestureDetector(
key: const Key('gd_signature_card_area'),
behavior: HitTestBehavior.opaque,
onSecondaryTapDown: (details) {
if (bytes != null && !disabled) {
_openSignatureMenuAt(details.globalPosition);
}
},
onLongPressStart: (details) {
if (bytes != null && !disabled) {
_openSignatureMenuAt(details.globalPosition);
}
},
child: SizedBox(
height: 120,
child:
bytes == null
? Center(
child: Text(
l.noPdfLoaded,
textAlign: TextAlign.center,
),
)
: _DraggableSignaturePreview(
bytes: bytes,
disabled: disabled,
),
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (library.isNotEmpty) ...[
for (final a in library) ...[
Card(
margin: EdgeInsets.zero,
child: Padding(
padding: const EdgeInsets.all(12),
child: SignatureCard(
key: ValueKey('sig_card_${a.id}'),
asset: a,
disabled: disabled,
onDelete:
() => ref
.read(signatureLibraryProvider.notifier)
.remove(a.id),
onAdjust: () async {
ref
.read(signatureProvider.notifier)
.setImageFromLibrary(assetId: a.id);
if (!mounted) return;
await showDialog(
context: context,
builder: (_) => const ImageEditorDialog(),
);
},
onTap: () {
final sel = ref.read(pdfProvider).selectedPlacementIndex;
final page = ref.read(pdfProvider).currentPage;
if (sel != null) {
ref
.read(pdfProvider.notifier)
.assignImageToPlacement(
page: page,
index: sel,
image: a.id,
);
} else {
ref
.read(signatureProvider.notifier)
.setImageFromLibrary(assetId: a.id);
}
},
),
),
),
const SizedBox(height: 12),
],
],
if (library.isEmpty)
Card(
margin: EdgeInsets.zero,
child: Padding(
padding: const EdgeInsets.all(12),
child:
bytes == null
? Text(l.noSignatureLoaded)
: SignatureCard(
asset: SignatureAsset(id: '', bytes: bytes, name: ''),
disabled: disabled,
useCurrentBytesForDrag: true,
onDelete: () {
ref
.read(signatureProvider.notifier)
.clearActiveOverlay();
ref.read(signatureProvider.notifier).clearImage();
},
onAdjust: () async {
if (!mounted) return;
await showDialog(
context: context,
builder: (_) => const ImageEditorDialog(),
);
},
),
),
),
const SizedBox(height: 12),
const Divider(height: 1),
// New signature card
Padding(
Card(
margin: EdgeInsets.zero,
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
@ -152,13 +136,41 @@ class _SignatureDrawerState extends ConsumerState<SignatureDrawer> {
OutlinedButton.icon(
key: const Key('btn_drawer_load_signature'),
onPressed:
disabled ? null : widget.onLoadSignatureFromFile,
disabled
? null
: () async {
final loaded =
await widget.onLoadSignatureFromFile();
final b =
loaded ??
ref.read(processedSignatureImageProvider) ??
ref.read(signatureProvider).imageBytes;
if (b != null) {
ref
.read(signatureLibraryProvider.notifier)
.add(b, name: 'image');
}
},
icon: const Icon(Icons.image_outlined),
label: Text(l.loadSignatureFromFile),
),
OutlinedButton.icon(
key: const Key('btn_drawer_draw_signature'),
onPressed: disabled ? null : widget.onOpenDrawCanvas,
onPressed:
disabled
? null
: () async {
final drawn = await widget.onOpenDrawCanvas();
final b =
drawn ??
ref.read(processedSignatureImageProvider) ??
ref.read(signatureProvider).imageBytes;
if (b != null) {
ref
.read(signatureLibraryProvider.notifier)
.add(b, name: 'drawing');
}
},
icon: const Icon(Icons.gesture),
label: Text(l.drawSignature),
),
@ -167,51 +179,8 @@ class _SignatureDrawerState extends ConsumerState<SignatureDrawer> {
],
),
),
// Adjustments are accessed via "Adjust graphic" in the popup menu
],
),
);
}
}
class _DraggableSignaturePreview extends StatelessWidget {
const _DraggableSignaturePreview({
required this.bytes,
required this.disabled,
});
final Uint8List bytes;
final bool disabled;
@override
Widget build(BuildContext context) {
final child = Padding(
padding: const EdgeInsets.all(8.0),
child: Image.memory(bytes, fit: BoxFit.contain),
);
if (disabled) return child;
return Draggable<SignatureDragData>(
data: const SignatureDragData(),
feedback: Opacity(
opacity: 0.8,
child: ConstrainedBox(
constraints: const BoxConstraints.tightFor(width: 160, height: 80),
child: DecoratedBox(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(6),
boxShadow: const [
BoxShadow(blurRadius: 8, color: Colors.black26),
],
),
child: Padding(
padding: const EdgeInsets.all(6.0),
child: Image.memory(bytes, fit: BoxFit.contain),
),
),
),
),
childWhenDragging: Opacity(opacity: 0.5, child: child),
child: child,
],
);
}
}

View File

@ -0,0 +1,54 @@
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:pdf_signature/l10n/app_localizations.dart';
import '../../../../data/services/export_providers.dart';
import 'signature_drawer.dart';
class SignaturesSidebar extends ConsumerWidget {
const SignaturesSidebar({
super.key,
required this.onLoadSignatureFromFile,
required this.onOpenDrawCanvas,
required this.onSave,
});
final Future<Uint8List?> Function() onLoadSignatureFromFile;
final Future<Uint8List?> Function() onOpenDrawCanvas;
final VoidCallback onSave;
@override
Widget build(BuildContext context, WidgetRef ref) {
final l = AppLocalizations.of(context);
final isExporting = ref.watch(exportingProvider);
return AbsorbPointer(
absorbing: isExporting,
child: Card(
margin: EdgeInsets.zero,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
child: SingleChildScrollView(
child: SignatureDrawer(
disabled: isExporting,
onLoadSignatureFromFile: onLoadSignatureFromFile,
onOpenDrawCanvas: onOpenDrawCanvas,
),
),
),
Padding(
padding: const EdgeInsets.all(12),
child: ElevatedButton(
key: const Key('btn_save_pdf'),
onPressed: isExporting ? null : onSave,
child: Text(l.saveSignedPdf),
),
),
],
),
),
);
}
}

View File

@ -1,7 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:pdf_signature/l10n/app_localizations.dart';
import '../providers.dart';
import '../../../../data/services/preferences_providers.dart';
class SettingsDialog extends ConsumerStatefulWidget {
const SettingsDialog({super.key});

View File

@ -1,4 +1,3 @@
import 'dart:ui';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart';

View File

@ -3,7 +3,7 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:pdf_signature/data/services/export_service.dart';
import 'package:pdf_signature/data/services/providers.dart';
import 'package:pdf_signature/data/services/export_providers.dart';
import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart';
import 'package:pdf_signature/ui/features/pdf/widgets/pdf_screen.dart';
import 'package:pdf_signature/l10n/app_localizations.dart';

View File

@ -6,9 +6,9 @@ import 'dart:typed_data';
import 'package:pdf_signature/ui/features/pdf/widgets/pdf_screen.dart';
import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart';
import 'package:pdf_signature/data/services/providers.dart';
import 'package:pdf_signature/data/services/export_providers.dart';
import 'package:pdf_signature/l10n/app_localizations.dart';
import 'package:pdf_signature/ui/features/preferences/providers.dart';
import 'package:pdf_signature/data/services/preferences_providers.dart';
Future<void> pumpWithOpenPdf(WidgetTester tester) async {
await tester.pumpWidget(

View File

@ -5,7 +5,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:pdf_signature/ui/features/pdf/widgets/pdf_screen.dart';
import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart';
import 'package:pdf_signature/data/model/model.dart';
import 'package:pdf_signature/data/services/providers.dart';
import 'package:pdf_signature/data/services/export_providers.dart';
import 'package:pdf_signature/l10n/app_localizations.dart';
class _TestPdfController extends PdfController {

View File

@ -4,10 +4,10 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:pdf_signature/ui/features/pdf/widgets/pdf_page_area.dart';
import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart';
import 'package:pdf_signature/data/services/providers.dart';
import 'package:pdf_signature/data/services/export_providers.dart';
import 'package:pdf_signature/l10n/app_localizations.dart';
import 'package:pdf_signature/data/model/model.dart';
import 'package:pdf_signature/ui/features/preferences/providers.dart';
import 'package:pdf_signature/data/services/preferences_providers.dart';
class _TestPdfController extends PdfController {
_TestPdfController() : super() {

View File

@ -4,10 +4,10 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:pdf_signature/ui/features/pdf/widgets/pdf_page_area.dart';
import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart';
import 'package:pdf_signature/data/services/providers.dart';
import 'package:pdf_signature/data/services/export_providers.dart';
import 'package:pdf_signature/l10n/app_localizations.dart';
import 'package:pdf_signature/data/model/model.dart';
import 'package:pdf_signature/ui/features/preferences/providers.dart';
import 'package:pdf_signature/data/services/preferences_providers.dart';
class _TestPdfController extends PdfController {
_TestPdfController() : super() {