diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 007a0d3..8874b30 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,5 +1,6 @@ { "recommendations": [ - "yzhang.markdown-all-in-one" + "yzhang.markdown-all-in-one", + "alexkrechik.cucumberautocomplete" ] } \ No newline at end of file diff --git a/lib/data/model/model.dart b/lib/data/model/model.dart index 9e9b511..96c6760 100644 --- a/lib/data/model/model.dart +++ b/lib/data/model/model.dart @@ -10,6 +10,8 @@ class PdfState { final int? signedPage; // Multiple signature placements per page, stored as UI-space rects (e.g., 400x560) final Map> 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>? 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> 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>? 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, ); } diff --git a/lib/ui/features/pdf/view_model/view_model.dart b/lib/ui/features/pdf/view_model/view_model.dart index fcaa356..8c400da 100644 --- a/lib/ui/features/pdf/view_model/view_model.dart +++ b/lib/ui/features/pdf/view_model/view_model.dart @@ -19,6 +19,7 @@ class PdfController extends StateNotifier { pickedPdfPath: null, signedPage: null, placementsByPage: {}, + selectedPlacementIndex: null, ); } @@ -35,23 +36,24 @@ class PdfController extends StateNotifier { 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 { final list = List.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 { } else { map[p] = list; } - state = state.copyWith(placementsByPage: map); + state = state.copyWith( + placementsByPage: map, + selectedPlacementIndex: null, + ); } } List placementsOn(int page) { return List.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( @@ -112,6 +138,7 @@ class SignatureController extends StateNotifier { width: w, height: h, ), + editingEnabled: true, ); } @@ -123,6 +150,7 @@ class SignatureController extends StateNotifier { width: w, height: h, ), + editingEnabled: true, ); } @@ -134,13 +162,13 @@ class SignatureController extends StateNotifier { } 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 { width: 140, height: 70, ), + editingEnabled: true, ); } @@ -218,6 +247,27 @@ class SignatureController extends StateNotifier { 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); } } diff --git a/lib/ui/features/pdf/widgets/pdf_screen.dart b/lib/ui/features/pdf/widgets/pdf_screen.dart index 4b91568..5e5ceb3 100644 --- a/lib/ui/features/pdf/widgets/pdf_screen.dart +++ b/lib/ui/features/pdf/widgets/pdf_screen.dart @@ -70,14 +70,29 @@ class _PdfSignatureHomePageState extends ConsumerState { } } - 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 { ref.read(signatureProvider.notifier).resize(delta); } + void _onSelectPlaced(int? index) { + ref.read(pdfProvider.notifier).selectPlacement(index); + } + + Future _showContextMenuForPlaced({ + required Offset globalPos, + required int index, + }) async { + // Opening the menu implicitly selects the item + _onSelectPlaced(index); + final choice = await showMenu( + context: context, + position: RelativeRect.fromLTRB( + globalPos.dx, + globalPos.dy, + globalPos.dx, + globalPos.dy, + ), + items: [ + const PopupMenuItem( + 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 _openDrawCanvas() async { final result = await showModalBottomSheet( context: context, @@ -416,21 +466,17 @@ class _PdfSignatureHomePageState extends ConsumerState { 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 { SignatureState sig, Rect r, { bool interactive = true, + int? placedIndex, }) { return LayoutBuilder( builder: (context, constraints) { @@ -558,6 +605,15 @@ class _PdfSignatureHomePageState extends ConsumerState { 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 { 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 { 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 { (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( + context: context, + position: RelativeRect.fromLTRB( + pos.dx, + pos.dy, + pos.dx, + pos.dy, + ), + items: [ + const PopupMenuItem( + key: Key('ctx_active_confirm'), + value: 'confirm', + child: Text('Confirm'), + ), + const PopupMenuItem( + 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( + context: context, + position: RelativeRect.fromLTRB( + pos.dx, + pos.dy, + pos.dx, + pos.dy, + ), + items: [ + const PopupMenuItem( + key: Key('ctx_active_confirm_lp'), + value: 'confirm', + child: Text('Confirm'), + ), + const PopupMenuItem( + 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 { final current = pdf.currentPage; final placed = pdf.placementsByPage[current] ?? const []; final widgets = []; - 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)); }