feat: adjust app pdf view for easy signature confirm
This commit is contained in:
parent
5b71b294ac
commit
98798123ae
|
@ -1,5 +1,6 @@
|
|||
{
|
||||
"recommendations": [
|
||||
"yzhang.markdown-all-in-one"
|
||||
"yzhang.markdown-all-in-one",
|
||||
"alexkrechik.cucumberautocomplete"
|
||||
]
|
||||
}
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue