feat: adjust app pdf view for easy signature confirm

This commit is contained in:
insleker 2025-08-30 00:15:06 +08:00
parent 5b71b294ac
commit 98798123ae
4 changed files with 250 additions and 30 deletions

View File

@ -1,5 +1,6 @@
{
"recommendations": [
"yzhang.markdown-all-in-one"
"yzhang.markdown-all-in-one",
"alexkrechik.cucumberautocomplete"
]
}

View File

@ -10,6 +10,8 @@ class PdfState {
final int? signedPage;
// Multiple signature placements per page, stored as UI-space rects (e.g., 400x560)
final Map<int, List<Rect>> placementsByPage;
// UI state: selected placement index on the current page (if any)
final int? selectedPlacementIndex;
const PdfState({
required this.loaded,
required this.pageCount,
@ -18,6 +20,7 @@ class PdfState {
this.pickedPdfBytes,
this.signedPage,
this.placementsByPage = const {},
this.selectedPlacementIndex,
});
factory PdfState.initial() => const PdfState(
loaded: false,
@ -26,6 +29,7 @@ class PdfState {
pickedPdfBytes: null,
signedPage: null,
placementsByPage: {},
selectedPlacementIndex: null,
);
PdfState copyWith({
bool? loaded,
@ -35,6 +39,7 @@ class PdfState {
Uint8List? pickedPdfBytes,
int? signedPage,
Map<int, List<Rect>>? placementsByPage,
int? selectedPlacementIndex,
}) => PdfState(
loaded: loaded ?? this.loaded,
pageCount: pageCount ?? this.pageCount,
@ -43,6 +48,10 @@ class PdfState {
pickedPdfBytes: pickedPdfBytes ?? this.pickedPdfBytes,
signedPage: signedPage ?? this.signedPage,
placementsByPage: placementsByPage ?? this.placementsByPage,
selectedPlacementIndex:
selectedPlacementIndex == null
? this.selectedPlacementIndex
: selectedPlacementIndex,
);
}
@ -54,6 +63,9 @@ class SignatureState {
final double brightness;
final List<List<Offset>> strokes;
final Uint8List? imageBytes;
// 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;
const SignatureState({
required this.rect,
required this.aspectLocked,
@ -62,6 +74,7 @@ class SignatureState {
required this.brightness,
required this.strokes,
this.imageBytes,
this.editingEnabled = false,
});
factory SignatureState.initial() => const SignatureState(
rect: null,
@ -71,6 +84,7 @@ class SignatureState {
brightness: 0.0,
strokes: [],
imageBytes: null,
editingEnabled: false,
);
SignatureState copyWith({
Rect? rect,
@ -80,6 +94,7 @@ class SignatureState {
double? brightness,
List<List<Offset>>? strokes,
Uint8List? imageBytes,
bool? editingEnabled,
}) => SignatureState(
rect: rect ?? this.rect,
aspectLocked: aspectLocked ?? this.aspectLocked,
@ -88,5 +103,6 @@ class SignatureState {
brightness: brightness ?? this.brightness,
strokes: strokes ?? this.strokes,
imageBytes: imageBytes ?? this.imageBytes,
editingEnabled: editingEnabled ?? this.editingEnabled,
);
}

View File

@ -19,6 +19,7 @@ class PdfController extends StateNotifier<PdfState> {
pickedPdfPath: null,
signedPage: null,
placementsByPage: {},
selectedPlacementIndex: null,
);
}
@ -35,23 +36,24 @@ class PdfController extends StateNotifier<PdfState> {
pickedPdfBytes: bytes,
signedPage: null,
placementsByPage: {},
selectedPlacementIndex: null,
);
}
void jumpTo(int page) {
if (!state.loaded) return;
final clamped = page.clamp(1, state.pageCount);
state = state.copyWith(currentPage: clamped);
state = state.copyWith(currentPage: clamped, selectedPlacementIndex: null);
}
// Set or clear the page that will receive the signature overlay.
void setSignedPage(int? page) {
if (!state.loaded) return;
if (page == null) {
state = state.copyWith(signedPage: null);
state = state.copyWith(signedPage: null, selectedPlacementIndex: null);
} else {
final clamped = page.clamp(1, state.pageCount);
state = state.copyWith(signedPage: clamped);
state = state.copyWith(signedPage: clamped, selectedPlacementIndex: null);
}
}
@ -68,7 +70,7 @@ class PdfController extends StateNotifier<PdfState> {
final list = List<Rect>.from(map[p] ?? const []);
list.add(rect);
map[p] = list;
state = state.copyWith(placementsByPage: map);
state = state.copyWith(placementsByPage: map, selectedPlacementIndex: null);
}
void removePlacement({required int page, required int index}) {
@ -83,13 +85,37 @@ class PdfController extends StateNotifier<PdfState> {
} else {
map[p] = list;
}
state = state.copyWith(placementsByPage: map);
state = state.copyWith(
placementsByPage: map,
selectedPlacementIndex: null,
);
}
}
List<Rect> placementsOn(int page) {
return List<Rect>.from(state.placementsByPage[page] ?? const []);
}
void selectPlacement(int? index) {
if (!state.loaded) return;
// Only allow valid index on current page; otherwise clear
if (index == null) {
state = state.copyWith(selectedPlacementIndex: null);
return;
}
final list = state.placementsByPage[state.currentPage] ?? const [];
if (index >= 0 && index < list.length) {
state = state.copyWith(selectedPlacementIndex: index);
} else {
state = state.copyWith(selectedPlacementIndex: null);
}
}
void deleteSelectedPlacement() {
final idx = state.selectedPlacementIndex;
if (idx == null) return;
removePlacement(page: state.currentPage, index: idx);
}
}
final pdfProvider = StateNotifierProvider<PdfController, PdfState>(
@ -112,6 +138,7 @@ class SignatureController extends StateNotifier<SignatureState> {
width: w,
height: h,
),
editingEnabled: true,
);
}
@ -123,6 +150,7 @@ class SignatureController extends StateNotifier<SignatureState> {
width: w,
height: h,
),
editingEnabled: true,
);
}
@ -134,13 +162,13 @@ class SignatureController extends StateNotifier<SignatureState> {
}
void drag(Offset delta) {
if (state.rect == null) return;
if (state.rect == null || !state.editingEnabled) return;
final moved = state.rect!.shift(delta);
state = state.copyWith(rect: _clampRectToPage(moved));
}
void resize(Offset delta) {
if (state.rect == null) return;
if (state.rect == null || !state.editingEnabled) return;
final r = state.rect!;
double newW = r.width + delta.dx;
double newH = r.height + delta.dy;
@ -210,6 +238,7 @@ class SignatureController extends StateNotifier<SignatureState> {
width: 140,
height: 70,
),
editingEnabled: true,
);
}
@ -218,6 +247,27 @@ class SignatureController extends StateNotifier<SignatureState> {
if (state.rect == null) {
placeDefaultRect();
}
// Mark as draft/editable when user just loaded image
state = state.copyWith(editingEnabled: true);
}
// Confirm current signature: freeze editing and place it on the PDF as an immutable overlay.
// Returns the Rect placed, or null if no rect to confirm.
Rect? confirmCurrentSignature(WidgetRef ref) {
final r = state.rect;
if (r == null) return null;
// Place onto the current page
final pdf = ref.read(pdfProvider);
if (!pdf.loaded) return null;
ref.read(pdfProvider.notifier).addPlacement(page: pdf.currentPage, rect: r);
// Freeze editing: keep rect for preview but disable interaction
state = state.copyWith(editingEnabled: false);
return r;
}
// Remove the active overlay (draft or confirmed preview) but keep image settings intact
void clearActiveOverlay() {
state = state.copyWith(rect: null, editingEnabled: false);
}
}

View File

@ -70,14 +70,29 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
}
}
void _placeCurrentSignatureOnPage() {
final pdf = ref.read(pdfProvider);
final sig = ref.read(signatureProvider);
if (!pdf.loaded || sig.rect == null) return;
ref
.read(pdfProvider.notifier)
.addPlacement(page: pdf.currentPage, rect: sig.rect!);
// Keep the active rect so the user can place multiple times if desired.
void _createNewSignature() {
// Create a movable signature (draft) that won't be exported until confirmed
final sig = ref.read(signatureProvider.notifier);
if (ref.read(pdfProvider).loaded) {
sig.placeDefaultRect();
ref
.read(pdfProvider.notifier)
.setSignedPage(ref.read(pdfProvider).currentPage);
// Hint: how to confirm/delete via context menu
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Long-press or right-click the signature to Confirm or Delete.',
),
duration: Duration(seconds: 3),
),
);
}
}
void _confirmSignature() {
// Confirm: make current signature immutable and eligible for export by placing it
ref.read(signatureProvider.notifier).confirmCurrentSignature(ref);
}
void _onDragSignature(Offset delta) {
@ -88,6 +103,41 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
ref.read(signatureProvider.notifier).resize(delta);
}
void _onSelectPlaced(int? index) {
ref.read(pdfProvider.notifier).selectPlacement(index);
}
Future<void> _showContextMenuForPlaced({
required Offset globalPos,
required int index,
}) async {
// Opening the menu implicitly selects the item
_onSelectPlaced(index);
final choice = await showMenu<String>(
context: context,
position: RelativeRect.fromLTRB(
globalPos.dx,
globalPos.dy,
globalPos.dx,
globalPos.dy,
),
items: [
const PopupMenuItem<String>(
key: Key('ctx_delete_signature'),
value: 'delete',
child: Text('Delete'),
),
],
);
if (choice == null) return;
if (choice == 'delete') {
final currentPage = ref.read(pdfProvider).currentPage;
ref
.read(pdfProvider.notifier)
.removePlacement(page: currentPage, index: index);
}
}
Future<void> _openDrawCanvas() async {
final result = await showModalBottomSheet<Uint8List>(
context: context,
@ -416,21 +466,17 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
onPressed: disabled || !pdf.loaded ? null : _loadSignatureFromFile,
child: Text(l.loadSignatureFromFile),
),
OutlinedButton(
key: const Key('btn_create_signature'),
onPressed: disabled || !pdf.loaded ? null : _createNewSignature,
child: const Text('Create new signature'),
),
ElevatedButton(
key: const Key('btn_draw_signature'),
onPressed: disabled || !pdf.loaded ? null : _openDrawCanvas,
child: Text(l.drawSignature),
),
OutlinedButton(
key: const Key('btn_place_signature'),
onPressed:
disabled ||
!pdf.loaded ||
ref.read(signatureProvider).rect == null
? null
: _placeCurrentSignatureOnPage,
child: const Text('Place on page'),
),
// Confirm and Delete are available via context menus
],
],
);
@ -539,6 +585,7 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
SignatureState sig,
Rect r, {
bool interactive = true,
int? placedIndex,
}) {
return LayoutBuilder(
builder: (context, constraints) {
@ -558,6 +605,15 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
height: height,
child: Builder(
builder: (context) {
final selectedIdx =
ref.read(pdfProvider).selectedPlacementIndex;
final bool isPlaced = placedIndex != null;
final bool isSelected =
isPlaced && selectedIdx == placedIndex;
final Color borderColor =
isPlaced ? Colors.red : Colors.indigo;
final double borderWidth =
isPlaced ? (isSelected ? 3.0 : 2.0) : 2.0;
Widget content = DecoratedBox(
decoration: BoxDecoration(
color: Color.fromRGBO(
@ -566,7 +622,10 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
0,
0.05 + math.min(0.25, (sig.contrast - 1.0).abs()),
),
border: Border.all(color: Colors.indigo, width: 2),
border: Border.all(
color: borderColor,
width: borderWidth,
),
),
child: Stack(
children: [
@ -603,10 +662,11 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
child: const Icon(Icons.open_in_full, size: 20),
),
),
// No inline buttons for placed overlays; use context menu instead
],
),
);
if (interactive) {
if (interactive && sig.editingEnabled) {
content = GestureDetector(
key: const Key('signature_overlay'),
behavior: HitTestBehavior.opaque,
@ -615,6 +675,95 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
(d) => _onDragSignature(
Offset(d.delta.dx / scaleX, d.delta.dy / scaleY),
),
onSecondaryTapDown: (d) {
// Context menu for active signature: confirm or delete draft (clear)
final pos = d.globalPosition;
showMenu<String>(
context: context,
position: RelativeRect.fromLTRB(
pos.dx,
pos.dy,
pos.dx,
pos.dy,
),
items: [
const PopupMenuItem<String>(
key: Key('ctx_active_confirm'),
value: 'confirm',
child: Text('Confirm'),
),
const PopupMenuItem<String>(
key: Key('ctx_active_delete'),
value: 'delete',
child: Text('Delete'),
),
],
).then((choice) {
if (choice == 'confirm') {
_confirmSignature();
} else if (choice == 'delete') {
ref
.read(signatureProvider.notifier)
.clearActiveOverlay();
}
});
},
onLongPressStart: (d) {
final pos = d.globalPosition;
showMenu<String>(
context: context,
position: RelativeRect.fromLTRB(
pos.dx,
pos.dy,
pos.dx,
pos.dy,
),
items: [
const PopupMenuItem<String>(
key: Key('ctx_active_confirm_lp'),
value: 'confirm',
child: Text('Confirm'),
),
const PopupMenuItem<String>(
key: Key('ctx_active_delete_lp'),
value: 'delete',
child: Text('Delete'),
),
],
).then((choice) {
if (choice == 'confirm') {
_confirmSignature();
} else if (choice == 'delete') {
ref
.read(signatureProvider.notifier)
.clearActiveOverlay();
}
});
},
child: content,
);
} else {
// For placed items: tap to select; long-press/right-click for context menu
content = GestureDetector(
key: Key('placed_signature_${placedIndex ?? 'x'}'),
behavior: HitTestBehavior.opaque,
onTap: () => _onSelectPlaced(placedIndex),
onSecondaryTapDown: (d) {
if (placedIndex != null) {
_showContextMenuForPlaced(
globalPos: d.globalPosition,
index: placedIndex,
);
}
},
onLongPressStart: (d) {
if (placedIndex != null) {
_showContextMenuForPlaced(
globalPos: d.globalPosition,
index: placedIndex,
);
}
},
child: content,
);
}
@ -633,11 +782,15 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
final current = pdf.currentPage;
final placed = pdf.placementsByPage[current] ?? const <Rect>[];
final widgets = <Widget>[];
for (final r in placed) {
widgets.add(_buildSignatureOverlay(sig, r, interactive: false));
for (int i = 0; i < placed.length; i++) {
final r = placed[i];
widgets.add(
_buildSignatureOverlay(sig, r, interactive: false, placedIndex: i),
);
}
// Show the active editing rect only on the selected (signed) page
if (sig.rect != null &&
sig.editingEnabled &&
(pdf.signedPage == null || pdf.signedPage == current)) {
widgets.add(_buildSignatureOverlay(sig, sig.rect!, interactive: true));
}