diff --git a/README.md b/README.md index 5c10f4d..335725a 100644 --- a/README.md +++ b/README.md @@ -10,8 +10,9 @@ checkout [`docs/FRs.md`](docs/FRs.md) ```bash # flutter clean -# arb_translate flutter pub get +# arb_translate +# flutter gen-l10n # > to generate gherkin test flutter pub run build_runner build --delete-conflicting-outputs # > to remove unused step definitions diff --git a/docs/wireframe.md b/docs/wireframe.md index 7c21a32..163a99c 100644 --- a/docs/wireframe.md +++ b/docs/wireframe.md @@ -56,8 +56,8 @@ Design notes: - Right pane: signatures drawer displaying saved signatures as cards. - able to drag and drop signature cards onto the PDF as placed signatures. - Each signature card shows a preview. - - long tap/right-click will show menu with options to delete, adjust graphic of image. - - "adjust graphic" opens a simple image editor, which can remove backgrounds, Rotate (rotation handle). + - long tap/right-click will show menu with options to delete, Adjust graphic of image. + - "Adjust graphic" opens a simple image editor, which can remove backgrounds, Rotate (rotation handle). - There is an empty card with "new signature" prompt and 2 buttons: "from file" and "draw". - "from file" opens a file picker to select an image as a signature card. - "draw" opens a simple drawing interface (draw canvas) to create a signature card. diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index c033e81..8779f9b 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -1,4 +1,5 @@ { + "adjustGraphic": "Grafik anpassen", "appTitle": "PDF-Signatur", "backgroundRemoval": "Hintergrund entfernen", "brightness": "Helligkeit", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 711c3c7..6a2b367 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1,5 +1,7 @@ { "@@locale": "en", + "adjustGraphic": "Adjust graphic", + "@adjustGraphic": {}, "appTitle": "PDF Signature", "@appTitle": {}, "backgroundRemoval": "Background removal", diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 5aef40c..b6ca17d 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -1,4 +1,5 @@ { + "adjustGraphic": "Ajustar gráfico", "appTitle": "Firma PDF", "backgroundRemoval": "Eliminar fondo", "brightness": "Brillo", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index fe793fa..ee948bb 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -1,4 +1,5 @@ { + "adjustGraphic": "Ajuster le graphique", "appTitle": "Signature PDF", "backgroundRemoval": "Suppression de l'arrière-plan", "brightness": "Luminosité", diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index 2476988..e6836c3 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -1,4 +1,5 @@ { + "adjustGraphic": "グラフィックを調整する", "appTitle": "PDF署名", "backgroundRemoval": "背景除去", "brightness": "明るさ", diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index 25081a2..d504da7 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -1,4 +1,5 @@ { + "adjustGraphic": "그래픽 조정", "appTitle": "PDF 서명", "backgroundRemoval": "배경 제거", "brightness": "밝기", diff --git a/lib/l10n/app_uk.arb b/lib/l10n/app_uk.arb index b287b01..3a165ae 100644 --- a/lib/l10n/app_uk.arb +++ b/lib/l10n/app_uk.arb @@ -1,4 +1,5 @@ { + "adjustGraphic": "Регулювати графіку", "appTitle": "Підпис PDF", "backgroundRemoval": "Видалення фону", "brightness": "Яскравість", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 4f7dc84..aefd187 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -1,5 +1,6 @@ { "@@locale": "zh", + "adjustGraphic": "調整圖形", "appTitle": "PDF 簽名", "backgroundRemoval": "去除背景", "brightness": "亮度", diff --git a/lib/l10n/app_zh_CN.arb b/lib/l10n/app_zh_CN.arb index 213bb1e..1df52d0 100644 --- a/lib/l10n/app_zh_CN.arb +++ b/lib/l10n/app_zh_CN.arb @@ -1,4 +1,5 @@ { + "adjustGraphic": "调整图形", "appTitle": "PDF 签名", "backgroundRemoval": "背景移除", "brightness": "亮度", diff --git a/lib/l10n/app_zh_TW.arb b/lib/l10n/app_zh_TW.arb index 1cbf855..561dda8 100644 --- a/lib/l10n/app_zh_TW.arb +++ b/lib/l10n/app_zh_TW.arb @@ -1,5 +1,6 @@ { "@@locale": "zh_TW", + "adjustGraphic": "調整圖形", "appTitle": "PDF 簽名", "backgroundRemoval": "去除背景", "brightness": "亮度", diff --git a/lib/ui/common/menu_labels.dart b/lib/ui/common/menu_labels.dart deleted file mode 100644 index eff4d1d..0000000 --- a/lib/ui/common/menu_labels.dart +++ /dev/null @@ -1,14 +0,0 @@ -import 'package:flutter/widgets.dart'; -import 'package:pdf_signature/l10n/app_localizations.dart'; - -/// Centralized accessors for context menu labels to avoid duplication. -class MenuLabels { - static String confirm(BuildContext context) => - AppLocalizations.of(context).confirm; - - static String delete(BuildContext context) => - AppLocalizations.of(context).delete; - - // Not yet localized in l10n; keep here for single source of truth. - static String adjustGraphic(BuildContext context) => 'Adjust graphic'; -} diff --git a/lib/ui/features/pdf/widgets/image_editor_dialog.dart b/lib/ui/features/pdf/widgets/image_editor_dialog.dart index a176aba..788fe85 100644 --- a/lib/ui/features/pdf/widgets/image_editor_dialog.dart +++ b/lib/ui/features/pdf/widgets/image_editor_dialog.dart @@ -51,9 +51,6 @@ class ImageEditorDialog extends ConsumerWidget { return RotatedSignatureImage( bytes: bytes, rotationDeg: sig.rotation, - enableAngleAwareScale: true, - fit: BoxFit.contain, - wrapInRepaintBoundary: true, ); }, ), diff --git a/lib/ui/features/pdf/widgets/signature_overlay.dart b/lib/ui/features/pdf/widgets/signature_overlay.dart index 3c82e76..e5fed3d 100644 --- a/lib/ui/features/pdf/widgets/signature_overlay.dart +++ b/lib/ui/features/pdf/widgets/signature_overlay.dart @@ -9,7 +9,6 @@ import '../../signature/view_model/signature_controller.dart'; import '../view_model/pdf_controller.dart'; import '../../signature/view_model/signature_library.dart'; import 'image_editor_dialog.dart'; -import '../../../common/menu_labels.dart'; import '../../signature/widgets/rotated_signature_image.dart'; /// Renders a single signature overlay (either interactive or placed) on a page. @@ -82,41 +81,52 @@ class SignatureOverlay extends ConsumerWidget { 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( - 0, - 0, - 0, - 0.05 + math.min(0.25, (sig.contrast - 1.0).abs()), - ), - border: Border.all(color: borderColor, width: borderWidth), - ), - child: Stack( - alignment: Alignment.center, - children: [ - _SignatureImage( - interactive: interactive, - placedIndex: placedIndex, - pageNumber: pageNumber, - sig: sig, - ), - if (interactive) - Positioned( - right: 0, - bottom: 0, - child: GestureDetector( - key: const Key('signature_handle'), - behavior: HitTestBehavior.opaque, - onPanUpdate: - (d) => onResizeSignature?.call( - Offset(d.delta.dx / scaleX, d.delta.dy / scaleY), - ), - child: const Icon(Icons.open_in_full, size: 20), - ), + // Instead of DecoratedBox, use a Stack to control layering + Widget content = Stack( + alignment: Alignment.center, + children: [ + // Background layer (semi-transparent color) + Positioned.fill( + child: Container( + color: Color.fromRGBO( + 0, + 0, + 0, + 0.05 + math.min(0.25, (sig.contrast - 1.0).abs()), ), - ], - ), + ), + ), + // Signature image layer + _SignatureImage( + interactive: interactive, + placedIndex: placedIndex, + pageNumber: pageNumber, + sig: sig, + ), + // Border layer (on top, using Positioned.fill with a transparent background) + Positioned.fill( + child: Container( + decoration: BoxDecoration( + border: Border.all(color: borderColor, width: borderWidth), + ), + ), + ), + // Resize handle (only for interactive mode, on top of everything) + if (interactive) + Positioned( + right: 0, + bottom: 0, + child: GestureDetector( + key: const Key('signature_handle'), + behavior: HitTestBehavior.opaque, + onPanUpdate: + (d) => onResizeSignature?.call( + Offset(d.delta.dx / scaleX, d.delta.dy / scaleY), + ), + child: const Icon(Icons.open_in_full, size: 20), + ), + ), + ], ); if (interactive) { @@ -128,8 +138,10 @@ class SignatureOverlay extends ConsumerWidget { (d) => onDragSignature?.call( Offset(d.delta.dx / scaleX, d.delta.dy / scaleY), ), - onSecondaryTapDown: (d) => _showActiveMenu(context, d.globalPosition), - onLongPressStart: (d) => _showActiveMenu(context, d.globalPosition), + onSecondaryTapDown: + (d) => _showActiveMenu(context, d.globalPosition, ref, null), + onLongPressStart: + (d) => _showActiveMenu(context, d.globalPosition, ref, null), child: content, ); } else { @@ -139,12 +151,12 @@ class SignatureOverlay extends ConsumerWidget { onTap: () => onSelectPlaced?.call(placedIndex), onSecondaryTapDown: (d) { if (placedIndex != null) { - _showPlacedMenu(context, ref, d.globalPosition); + _showActiveMenu(context, d.globalPosition, ref, placedIndex); } }, onLongPressStart: (d) { if (placedIndex != null) { - _showPlacedMenu(context, ref, d.globalPosition); + _showActiveMenu(context, d.globalPosition, ref, placedIndex); } }, child: content, @@ -153,7 +165,12 @@ class SignatureOverlay extends ConsumerWidget { return content; } - void _showActiveMenu(BuildContext context, Offset globalPos) { + void _showActiveMenu( + BuildContext context, + Offset globalPos, + WidgetRef ref, + int? placedIndex, + ) { showMenu( context: context, position: RelativeRect.fromLTRB( @@ -163,74 +180,44 @@ class SignatureOverlay extends ConsumerWidget { globalPos.dy, ), items: [ - PopupMenuItem( - key: const Key('ctx_active_confirm'), - value: 'confirm', - child: Text(MenuLabels.confirm(context)), - ), + // if not placed, show Adjust and Confirm option + if (placedIndex == null) ...[ + PopupMenuItem( + key: const Key('ctx_active_confirm'), + value: 'confirm', + child: Text(AppLocalizations.of(context).confirm), + ), + PopupMenuItem( + key: const Key('ctx_active_adjust'), + value: 'adjust', + child: Text(AppLocalizations.of(context).adjustGraphic), + ), + ], PopupMenuItem( key: const Key('ctx_active_delete'), value: 'delete', - child: Text(MenuLabels.delete(context)), - ), - PopupMenuItem( - key: const Key('ctx_active_adjust'), - value: 'adjust', - child: Text(MenuLabels.adjustGraphic(context)), + child: Text(AppLocalizations.of(context).delete), ), ], ).then((choice) { if (choice == 'confirm') { - onConfirmSignature?.call(); + if (placedIndex == null) { + onConfirmSignature?.call(); + } + // For placed, confirm does nothing } else if (choice == 'delete') { - onClearActiveOverlay?.call(); + if (placedIndex == null) { + onClearActiveOverlay?.call(); + } else { + ref + .read(pdfProvider.notifier) + .removePlacement(page: pageNumber, index: placedIndex); + } } else if (choice == 'adjust') { showDialog(context: context, builder: (_) => const ImageEditorDialog()); } }); } - - void _showPlacedMenu(BuildContext context, WidgetRef ref, Offset globalPos) { - showMenu( - context: context, - position: RelativeRect.fromLTRB( - globalPos.dx, - globalPos.dy, - globalPos.dx, - globalPos.dy, - ), - items: [ - PopupMenuItem( - key: const Key('ctx_placed_delete'), - value: 'delete', - child: Text(MenuLabels.delete(context)), - ), - PopupMenuItem( - key: const Key('ctx_placed_adjust'), - value: 'adjust', - child: Text(MenuLabels.adjustGraphic(context)), - ), - ], - ).then((choice) { - switch (choice) { - case 'delete': - if (placedIndex != null) { - ref - .read(pdfProvider.notifier) - .removePlacement(page: pageNumber, index: placedIndex!); - } - break; - case 'adjust': - showDialog( - context: context, - builder: (ctx) => const ImageEditorDialog(), - ); - break; - default: - break; - } - }); - } } class _SignatureImage extends ConsumerWidget { @@ -291,12 +278,6 @@ class _SignatureImage extends ConsumerWidget { rotationDeg = placementList[placedIndex!].rotationDeg; } } - return RotatedSignatureImage( - bytes: bytes, - rotationDeg: rotationDeg, - enableAngleAwareScale: interactive, - fit: BoxFit.contain, - wrapInRepaintBoundary: true, - ); + return RotatedSignatureImage(bytes: bytes, rotationDeg: rotationDeg); } } diff --git a/lib/ui/features/signature/widgets/rotated_signature_image.dart b/lib/ui/features/signature/widgets/rotated_signature_image.dart index 2643bf1..13e1be9 100644 --- a/lib/ui/features/signature/widgets/rotated_signature_image.dart +++ b/lib/ui/features/signature/widgets/rotated_signature_image.dart @@ -1,6 +1,7 @@ import 'dart:math' as math; import 'dart:typed_data'; import 'package:flutter/material.dart'; +import 'package:image/image.dart' as img; /// A lightweight widget to render signature bytes with rotation and an /// angle-aware scale-to-fit so the rotated image stays within its bounds. @@ -9,30 +10,18 @@ class RotatedSignatureImage extends StatefulWidget { super.key, required this.bytes, this.rotationDeg = 0.0, - this.enableAngleAwareScale = true, - this.fit = BoxFit.contain, - this.gaplessPlayback = true, this.filterQuality = FilterQuality.low, - this.wrapInRepaintBoundary = true, - this.alignment = Alignment.center, this.semanticLabel, - this.intrinsicAspectRatio, }); final Uint8List bytes; final double rotationDeg; - final bool enableAngleAwareScale; - final BoxFit fit; - final bool gaplessPlayback; final FilterQuality filterQuality; - final bool wrapInRepaintBoundary; - final AlignmentGeometry alignment; + final BoxFit fit = BoxFit.contain; + final bool gaplessPlayback = true; + final Alignment alignment = Alignment.center; + final bool wrapInRepaintBoundary = true; final String? semanticLabel; - // Optional: intrinsic aspect ratio (width / height). If provided, we compute - // an angle-aware scale for non-square images to ensure the rotated rectangle - // (W,H) fits back into its (W,H) bounds. If null, we attempt to derive it - // from the image stream; only fall back to the square heuristic if unknown. - final double? intrinsicAspectRatio; @override State createState() => _RotatedSignatureImageState(); @@ -60,20 +49,30 @@ class _RotatedSignatureImageState extends State { } } + void _setAspectRatio(double ar) { + if (mounted && _derivedAspectRatio != ar) { + setState(() => _derivedAspectRatio = ar); + } + } + void _resolveImage() { _unlisten(); - // Only derive AR if not provided - if (widget.intrinsicAspectRatio != null) return; + // Decode synchronously to get aspect ratio + final decoded = img.decodePng(widget.bytes); + if (decoded != null) { + final w = decoded.width; + final h = decoded.height; + if (w > 0 && h > 0) { + _setAspectRatio(w / h); + } + } final stream = _provider.resolve(createLocalImageConfiguration(context)); _stream = stream; _listener = ImageStreamListener((ImageInfo info, bool sync) { final w = info.image.width; final h = info.image.height; if (w > 0 && h > 0) { - final ar = w / h; - if (mounted && _derivedAspectRatio != ar) { - setState(() => _derivedAspectRatio = ar); - } + _setAspectRatio(w / h); } }); stream.addListener(_listener!); @@ -106,24 +105,20 @@ class _RotatedSignatureImageState extends State { ); if (angle != 0.0) { - if (widget.enableAngleAwareScale) { - final double c = math.cos(angle).abs(); - final double s = math.sin(angle).abs(); - final ar = widget.intrinsicAspectRatio ?? _derivedAspectRatio; - double scaleToFit; - if (ar != null && ar > 0) { - scaleToFit = math.min(ar / (ar * c + s), 1.0 / (ar * s + c)); - } else { - // Fallback: square approximation - scaleToFit = 1.0 / (c + s).clamp(1.0, double.infinity); - } - img = Transform.scale( - scale: scaleToFit, - child: Transform.rotate(angle: angle, child: img), - ); + final double c = math.cos(angle).abs(); + final double s = math.sin(angle).abs(); + final ar = _derivedAspectRatio; + double scaleToFit; + if (ar != null && ar > 0) { + scaleToFit = math.min(ar / (ar * c + s), 1.0 / (ar * s + c)); } else { - img = Transform.rotate(angle: angle, child: img); + // Fallback: square approximation + scaleToFit = 1.0 / (c + s).clamp(1.0, double.infinity); } + img = Transform.scale( + scale: scaleToFit, + child: Transform.rotate(angle: angle, child: img), + ); } if (!widget.wrapInRepaintBoundary) return img; diff --git a/lib/ui/features/signature/widgets/signature_card.dart b/lib/ui/features/signature/widgets/signature_card.dart index 255186d..00d586f 100644 --- a/lib/ui/features/signature/widgets/signature_card.dart +++ b/lib/ui/features/signature/widgets/signature_card.dart @@ -1,8 +1,8 @@ import 'package:flutter/material.dart'; import '../view_model/signature_library.dart'; import 'signature_drag_data.dart'; -import '../../../common/menu_labels.dart'; import 'rotated_signature_image.dart'; +import 'package:pdf_signature/l10n/app_localizations.dart'; class SignatureCard extends StatelessWidget { const SignatureCard({ @@ -30,9 +30,6 @@ class SignatureCard extends StatelessWidget { Widget img = RotatedSignatureImage( bytes: asset.bytes, rotationDeg: rotationDeg, - enableAngleAwareScale: true, - fit: BoxFit.contain, - wrapInRepaintBoundary: true, ); Widget base = SizedBox( width: 96, @@ -92,12 +89,12 @@ class SignatureCard extends StatelessWidget { PopupMenuItem( key: const Key('mi_signature_adjust'), value: 'adjust', - child: Text(MenuLabels.adjustGraphic(context)), + child: Text(AppLocalizations.of(context).adjustGraphic), ), PopupMenuItem( key: const Key('mi_signature_delete'), value: 'delete', - child: Text(MenuLabels.delete(context)), + child: Text(AppLocalizations.of(context).delete), ), ], ); @@ -123,12 +120,12 @@ class SignatureCard extends StatelessWidget { PopupMenuItem( key: const Key('mi_signature_adjust'), value: 'adjust', - child: Text(MenuLabels.adjustGraphic(context)), + child: Text(AppLocalizations.of(context).adjustGraphic), ), PopupMenuItem( key: const Key('mi_signature_delete'), value: 'delete', - child: Text(MenuLabels.delete(context)), + child: Text(AppLocalizations.of(context).delete), ), ], ); @@ -163,9 +160,6 @@ class SignatureCard extends StatelessWidget { child: RotatedSignatureImage( bytes: asset.bytes, rotationDeg: rotationDeg, - enableAngleAwareScale: true, - fit: BoxFit.contain, - wrapInRepaintBoundary: true, ), ), ), diff --git a/test/widget/signature_card_context_menu_test.dart b/test/widget/signature_card_context_menu_test.dart index ce7a7db..e09f519 100644 --- a/test/widget/signature_card_context_menu_test.dart +++ b/test/widget/signature_card_context_menu_test.dart @@ -46,6 +46,11 @@ void main() { // Verify the context menu shows "Adjust graphic" expect(find.byKey(const Key('mi_signature_adjust')), findsOneWidget); + + // before confirm, adjust must be visible + expect(find.text('Adjust graphic'), findsOneWidget); + + // after confirm, adjust must be visible expect(find.text('Adjust graphic'), findsOneWidget); // Do not proceed to open the dialog here; the goal is just to verify menu content. diff --git a/test/widgets/rotated_signature_image_test.dart b/test/widgets/rotated_signature_image_test.dart index 045428d..7042146 100644 --- a/test/widgets/rotated_signature_image_test.dart +++ b/test/widgets/rotated_signature_image_test.dart @@ -26,14 +26,7 @@ void main() { child: SizedBox( width: 200, height: 150, // same aspect as image bounds (4:3) - child: RotatedSignatureImage( - bytes: bytes, - rotationDeg: -90, - enableAngleAwareScale: true, - intrinsicAspectRatio: 4 / 3, - fit: BoxFit.contain, - wrapInRepaintBoundary: false, // make Transform visible - ), + child: RotatedSignatureImage(bytes: bytes, rotationDeg: -90), ), ), ),