diff --git a/README.md b/README.md index 0b78121..b2b9663 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ flutter pub get flutter pub run build_runner build --delete-conflicting-outputs # dart run tool/prune_unused_steps.dart --delete # dart run tool/gen_view_wireframe_md.dart +# flutter pub run dead_code_analyzer # run the app flutter run diff --git a/analysis_options.yaml b/analysis_options.yaml index dbc49bd..f6150b4 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -12,6 +12,8 @@ include: package:flutter_lints/flutter.yaml analyzer: plugins: - custom_lint + exclude: + - 'test/features/*_test.dart' linter: # The lint rules applied to this project can be customized in the @@ -27,6 +29,12 @@ linter: rules: # avoid_print: false # Uncomment to disable the `avoid_print` rule # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule + unintended_html_in_doc_comment: + exclude: + - 'test/features/step/*.dart' + unnecessary_import: + exclude: + - 'test/features/step/*.dart' # Additional information about this file can be found at # https://dart.dev/guides/language/analysis-options diff --git a/docs/NFRs.md b/docs/NFRs.md index c7e77e3..599f805 100644 --- a/docs/NFRs.md +++ b/docs/NFRs.md @@ -2,4 +2,4 @@ * support multiple platforms (windows, linux, android, web) * only FOSS libs can use -* recommend no more than 300 lines of code per file +* should not exceed 350 lines of code per file diff --git a/docs/meta-arch.md b/docs/meta-arch.md index 40fad8d..6db0118 100644 --- a/docs/meta-arch.md +++ b/docs/meta-arch.md @@ -10,3 +10,13 @@ The repo structure follows official [Package structure](https://docs.flutter.dev * `test/features/` contains BDD unit tests for each feature. It focuses on pure logic, therefore will not access `View` but `ViewModel` and `Model`. * `test/widget/` contains UI widget(component) tests which focus on `View` from MVVM of each component. * `integration_test/` for integration tests. They should be volatile to follow UI layout changes. + +## key dependencies + +* [pdfrx](https://pub.dev/packages/pdfrx) + * [packages/pdfrx/example/viewer/lib/main.dart](https://github.com/espresso3389/pdfrx/blob/master/packages/pdfrx/example/viewer/lib/main.dart) + * When using pdfrx, developers should control view function e.g. zoom, scroll... by component of pdfrx e.g. `PdfViewer`, rather than introduce additional view. + * [PdfViewer could not be scrollable when nested inside SingleChildScrollView #27](https://github.com/espresso3389/pdfrx/issues/27) + * [How to zoom in PdfPageView #244](https://github.com/espresso3389/pdfrx/issues/244) + * So does overlay some widgets, they should be placed using the provided overlay builder. + * [Viewer Customization using Widget Overlay](https://pub.dev/documentation/pdfrx/latest/pdfrx/PdfViewerParams/viewerOverlayBuilder.html) diff --git a/integration_test/export_flow_test.dart b/integration_test/export_flow_test.dart index 3326aa2..9c67a12 100644 --- a/integration_test/export_flow_test.dart +++ b/integration_test/export_flow_test.dart @@ -18,8 +18,6 @@ class RecordingExporter extends ExportService { } } -class BasicExporter extends ExportService {} - void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index a43ed67..25b533f 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -32,7 +32,6 @@ "pageInfo": "Seite {current}/{total}", "pageView": "Seitenansicht", "pageViewContinuous": "Kontinuierlich", - "pageViewSingle": "Einzelne Seite", "prev": "Vorherige", "resetToDefaults": "Auf Standardwerte zurücksetzen", "save": "Speichern", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index e82cd40..9d95cf1 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -83,8 +83,6 @@ "@pageView": {}, "pageViewContinuous": "Continuous", "@pageViewContinuous": {}, - "pageViewSingle": "Single page", - "@pageViewSingle": {}, "prev": "Prev", "@prev": {}, "resetToDefaults": "Reset to defaults", diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 423b127..049c458 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -32,7 +32,6 @@ "pageInfo": "Página {current}/{total}", "pageView": "Vista de página", "pageViewContinuous": "Continuo", - "pageViewSingle": "Página única", "prev": "Anterior", "resetToDefaults": "Restablecer valores predeterminados", "save": "Guardar", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 3321eb1..355ac57 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -32,7 +32,6 @@ "pageInfo": "Page {current}/{total}", "pageView": "Affichage de la page", "pageViewContinuous": "Continu", - "pageViewSingle": "Page unique", "prev": "Précédent", "resetToDefaults": "Rétablir les valeurs par défaut", "save": "Enregistrer", diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index eb55c31..2ca0379 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -32,7 +32,6 @@ "pageInfo": "ページ {current}/{total}", "pageView": "ページ表示", "pageViewContinuous": "連続", - "pageViewSingle": "シングルページ", "prev": "前へ", "resetToDefaults": "デフォルトに戻す", "save": "保存", diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index 22e8834..380213c 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -32,7 +32,6 @@ "pageInfo": "{current}/{total} 페이지", "pageView": "페이지 보기", "pageViewContinuous": "연속", - "pageViewSingle": "단일 페이지", "prev": "이전", "resetToDefaults": "기본값으로 재설정", "save": "저장", diff --git a/lib/l10n/app_uk.arb b/lib/l10n/app_uk.arb index 23aea3a..e5f8cdf 100644 --- a/lib/l10n/app_uk.arb +++ b/lib/l10n/app_uk.arb @@ -32,7 +32,6 @@ "pageInfo": "Сторінка {current}/{total}", "pageView": "Перегляд сторінки", "pageViewContinuous": "Безперервний", - "pageViewSingle": "Одна сторінка", "prev": "Попередня", "resetToDefaults": "Скинути до значень за замовчуванням", "save": "Зберегти", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 426e4e7..3d25a34 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -33,7 +33,6 @@ "pageInfo": "第 {current}/{total} 頁", "pageView": "頁面檢視", "pageViewContinuous": "連續", - "pageViewSingle": "單頁", "prev": "上一頁", "resetToDefaults": "重設為預設值", "save": "儲存", diff --git a/lib/l10n/app_zh_CN.arb b/lib/l10n/app_zh_CN.arb index 9ec0a6e..e2c7b55 100644 --- a/lib/l10n/app_zh_CN.arb +++ b/lib/l10n/app_zh_CN.arb @@ -32,7 +32,6 @@ "pageInfo": "第 {current} 页 / 共 {total} 页", "pageView": "分页浏览", "pageViewContinuous": "连续", - "pageViewSingle": "单页", "prev": "上一页", "resetToDefaults": "恢复默认值", "save": "保存", diff --git a/lib/l10n/app_zh_TW.arb b/lib/l10n/app_zh_TW.arb index f41bbd4..5c804c7 100644 --- a/lib/l10n/app_zh_TW.arb +++ b/lib/l10n/app_zh_TW.arb @@ -33,7 +33,6 @@ "pageInfo": "第 {current}/{total} 頁", "pageView": "頁面檢視", "pageViewContinuous": "連續", - "pageViewSingle": "單頁", "prev": "上一頁", "resetToDefaults": "重設為預設值", "save": "儲存", diff --git a/lib/ui/common/menu_labels.dart b/lib/ui/common/menu_labels.dart new file mode 100644 index 0000000..eff4d1d --- /dev/null +++ b/lib/ui/common/menu_labels.dart @@ -0,0 +1,14 @@ +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/view_model/view_model.dart b/lib/ui/features/pdf/view_model/view_model.dart index 308524a..304fb4a 100644 --- a/lib/ui/features/pdf/view_model/view_model.dart +++ b/lib/ui/features/pdf/view_model/view_model.dart @@ -63,7 +63,8 @@ class PdfController extends StateNotifier { state = state.copyWith(pageCount: count.clamp(1, 9999)); } - // Multiple-signature helpers + // Multiple-signature helpers (rects are stored in normalized fractions 0..1 + // relative to the page size: left/top/width/height are all 0..1) void addPlacement({ required int page, required Rect rect, @@ -115,6 +116,23 @@ class PdfController extends StateNotifier { } } + // Update the rect of an existing placement on a page. + void updatePlacementRect({ + required int page, + required int index, + required Rect rect, + }) { + if (!state.loaded) return; + final p = page.clamp(1, state.pageCount); + final map = Map>.from(state.placementsByPage); + final list = List.from(map[p] ?? const []); + if (index >= 0 && index < list.length) { + list[index] = rect; + map[p] = list; + state = state.copyWith(placementsByPage: map); + } + } + List placementsOn(int page) { return List.from(state.placementsByPage[page] ?? const []); } @@ -364,7 +382,17 @@ class SignatureController extends StateNotifier { // 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); + // Convert UI-space rect (400x560) to normalized rect + final Size pageSize = SignatureController.pageSize; + final normalized = Rect.fromLTWH( + (r.left / pageSize.width).clamp(0.0, 1.0), + (r.top / pageSize.height).clamp(0.0, 1.0), + (r.width / pageSize.width).clamp(0.0, 1.0), + (r.height / pageSize.height).clamp(0.0, 1.0), + ); + ref + .read(pdfProvider.notifier) + .addPlacement(page: pdf.currentPage, rect: normalized); // Assign image id to this placement (last index) final idx = (ref.read(pdfProvider).placementsByPage[pdf.currentPage]?.length ?? 1) - @@ -384,6 +412,10 @@ class SignatureController extends StateNotifier { .read(pdfProvider.notifier) .assignImageToPlacement(page: pdf.currentPage, index: idx, image: id); } + // Auto-select the newly placed item so the red box appears + if (idx >= 0) { + ref.read(pdfProvider.notifier).selectPlacement(idx); + } // Freeze editing: keep rect for preview but disable interaction state = state.copyWith(editingEnabled: false); return r; diff --git a/lib/ui/features/pdf/widgets/pdf_mock_continuous_list.dart b/lib/ui/features/pdf/widgets/pdf_mock_continuous_list.dart new file mode 100644 index 0000000..2692d49 --- /dev/null +++ b/lib/ui/features/pdf/widgets/pdf_mock_continuous_list.dart @@ -0,0 +1,103 @@ +import 'dart:async'; +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 'pdf_page_overlays.dart'; + +/// Mocked continuous viewer for tests or platforms without real viewer. +class PdfMockContinuousList extends ConsumerWidget { + const PdfMockContinuousList({ + super.key, + required this.pageSize, + required this.count, + required this.pageKeyBuilder, + required this.scrollToPage, + this.onDragSignature, + this.onResizeSignature, + this.onConfirmSignature, + this.onClearActiveOverlay, + this.onSelectPlaced, + this.pendingPage, + this.clearPending, + }); + + final Size pageSize; + final int count; + final GlobalKey Function(int page) pageKeyBuilder; + final void Function(int page) scrollToPage; + final int? pendingPage; + final VoidCallback? clearPending; + + final ValueChanged? onDragSignature; + final ValueChanged? onResizeSignature; + final VoidCallback? onConfirmSignature; + final VoidCallback? onClearActiveOverlay; + final ValueChanged? onSelectPlaced; + + @override + Widget build(BuildContext context, WidgetRef ref) { + if (pendingPage != null) { + WidgetsBinding.instance.addPostFrameCallback((_) { + final p = pendingPage; + if (p != null) { + clearPending?.call(); + scheduleMicrotask(() => scrollToPage(p)); + } + }); + } + + return SingleChildScrollView( + key: const Key('pdf_continuous_mock_list'), + padding: const EdgeInsets.symmetric(vertical: 8), + child: Column( + children: List.generate(count, (idx) { + final pageNum = idx + 1; + return Center( + child: Padding( + key: pageKeyBuilder(pageNum), + padding: const EdgeInsets.symmetric(vertical: 8), + child: AspectRatio( + aspectRatio: pageSize.width / pageSize.height, + child: Stack( + key: ValueKey('page_stack_$pageNum'), + children: [ + Container( + color: Colors.grey.shade200, + child: Center( + child: Text( + AppLocalizations.of(context).pageInfo(pageNum, count), + style: const TextStyle( + fontSize: 24, + color: Colors.black54, + ), + ), + ), + ), + Consumer( + builder: (context, ref, _) { + final visible = ref.watch(signatureVisibilityProvider); + return visible + ? PdfPageOverlays( + pageSize: pageSize, + pageNumber: pageNum, + onDragSignature: onDragSignature, + onResizeSignature: onResizeSignature, + onConfirmSignature: onConfirmSignature, + onClearActiveOverlay: onClearActiveOverlay, + onSelectPlaced: onSelectPlaced, + ) + : const SizedBox.shrink(); + }, + ), + ], + ), + ), + ), + ); + }), + ), + ); + } +} diff --git a/lib/ui/features/pdf/widgets/pdf_page_area.dart b/lib/ui/features/pdf/widgets/pdf_page_area.dart index 89ad34a..2eb9bb2 100644 --- a/lib/ui/features/pdf/widgets/pdf_page_area.dart +++ b/lib/ui/features/pdf/widgets/pdf_page_area.dart @@ -1,17 +1,13 @@ -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/export_providers.dart'; -import '../../../../data/model/model.dart'; import '../view_model/view_model.dart'; import '../../../../data/services/preferences_providers.dart'; import 'signature_drag_data.dart'; -import 'image_editor_dialog.dart'; +import 'pdf_mock_continuous_list.dart'; class PdfPageArea extends ConsumerStatefulWidget { const PdfPageArea({ @@ -204,80 +200,21 @@ class _PdfPageAreaState extends ConsumerState { // Mock continuous: ListView with prebuilt children, no controller if (useMock && isContinuous) { final count = pdf.pageCount > 0 ? pdf.pageCount : 1; - return Builder( - builder: (ctx) { - // Defer processing of any pending jump until after the tree is mounted. - if (_pendingPage != null) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted) return; - final p = _pendingPage; - if (p != null) { - _pendingPage = null; - _scrollRetryCount = 0; - // Schedule via microtask to avoid test timers remaining pending - scheduleMicrotask(() { - if (!mounted) return; - _scrollToPage(p); - }); - } - }); - } - final content = SingleChildScrollView( - key: const Key('pdf_continuous_mock_list'), - padding: const EdgeInsets.symmetric(vertical: 8), - child: Column( - children: List.generate(count, (idx) { - final pageNum = idx + 1; - return Center( - child: Padding( - key: _pageKey(pageNum), - padding: const EdgeInsets.symmetric(vertical: 8), - child: AspectRatio( - aspectRatio: - widget.pageSize.width / widget.pageSize.height, - child: Stack( - key: ValueKey('page_stack_$pageNum'), - children: [ - Container( - color: Colors.grey.shade200, - child: Center( - child: Text( - AppLocalizations.of( - context, - ).pageInfo(pageNum, count), - style: const TextStyle( - fontSize: 24, - color: Colors.black54, - ), - ), - ), - ), - Consumer( - builder: (context, ref, _) { - final sig = ref.watch(signatureProvider); - final visible = ref.watch( - signatureVisibilityProvider, - ); - return visible - ? _buildPageOverlays( - context, - ref, - sig, - pageNum, - ) - : const SizedBox.shrink(); - }, - ), - ], - ), - ), - ), - ); - }), - ), - ); - return content; + return PdfMockContinuousList( + pageSize: widget.pageSize, + count: count, + pageKeyBuilder: _pageKey, + scrollToPage: _scrollToPage, + pendingPage: _pendingPage, + clearPending: () { + _pendingPage = null; + _scrollRetryCount = 0; }, + onDragSignature: (delta) => widget.onDragSignature(delta), + onResizeSignature: (delta) => widget.onResizeSignature(delta), + onConfirmSignature: widget.onConfirmSignature, + onClearActiveOverlay: widget.onClearActiveOverlay, + onSelectPlaced: widget.onSelectPlaced, ); } @@ -406,347 +343,6 @@ class _PdfPageAreaState extends ConsumerState { return const SizedBox.shrink(); } - - // Context menu for already placed signatures - void _showContextMenuForPlaced({ - required BuildContext context, - required WidgetRef ref, - required Offset globalPos, - required int index, - required int page, - }) { - final l = AppLocalizations.of(context); - 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(l.delete), - ), - const PopupMenuItem( - key: Key('ctx_placed_adjust'), - value: 'adjust', - child: Text('Adjust graphic'), - ), - ], - ).then((choice) { - switch (choice) { - case 'delete': - ref - .read(pdfProvider.notifier) - .removePlacement(page: page, index: index); - break; - case 'adjust': - showDialog( - context: context, - builder: (ctx) => const ImageEditorDialog(), - ); - break; - default: - break; - } - }); - } - - Widget _buildPageOverlays( - BuildContext context, - WidgetRef ref, - SignatureState sig, - int pageNumber, - ) { - final pdf = ref.watch(pdfProvider); - final placed = pdf.placementsByPage[pageNumber] ?? const []; - final widgets = []; - for (int i = 0; i < placed.length; i++) { - final r = placed[i]; - widgets.add( - _buildSignatureOverlay( - context, - ref, - sig, - r, - interactive: false, - placedIndex: i, - pageNumber: pageNumber, - ), - ); - } - // Only show the active (interactive) signature overlay on the current page - // in continuous mode, so tests can reliably find a single overlay. - if (sig.rect != null && - sig.editingEnabled && - (pdf.signedPage == null || pdf.signedPage == pageNumber) && - pdf.currentPage == pageNumber) { - widgets.add( - _buildSignatureOverlay( - context, - ref, - sig, - sig.rect!, - interactive: true, - pageNumber: pageNumber, - ), - ); - } - return Stack(children: widgets); - } - - Widget _buildSignatureOverlay( - BuildContext context, - WidgetRef ref, - SignatureState sig, - Rect r, { - bool interactive = true, - int? placedIndex, - required int pageNumber, - }) { - return LayoutBuilder( - builder: (context, constraints) { - final scaleX = constraints.maxWidth / widget.pageSize.width; - final scaleY = constraints.maxHeight / widget.pageSize.height; - final left = r.left * scaleX; - final top = r.top * scaleY; - final width = r.width * scaleX; - final height = r.height * scaleY; - - return Stack( - children: [ - Positioned( - left: left, - top: top, - width: width, - 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( - 0, - 0, - 0, - 0.05 + math.min(0.25, (sig.contrast - 1.0).abs()), - ), - border: Border.all( - color: borderColor, - width: borderWidth, - ), - ), - child: Stack( - children: [ - Consumer( - builder: (context, ref, _) { - 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( - AppLocalizations.of(context).signature, - ), - ); - } - Widget im = Image.memory( - bytes, - fit: BoxFit.contain, - ); - if (sig.rotation % 360 != 0) { - im = Transform.rotate( - angle: sig.rotation * math.pi / 180.0, - child: im, - ); - } - return im; - }, - ), - if (interactive) - Positioned( - right: 0, - bottom: 0, - child: GestureDetector( - key: const Key('signature_handle'), - behavior: HitTestBehavior.opaque, - onPanUpdate: - (d) => widget.onResizeSignature( - Offset( - d.delta.dx / scaleX, - d.delta.dy / scaleY, - ), - ), - child: const Icon(Icons.open_in_full, size: 20), - ), - ), - ], - ), - ); - if (interactive && sig.editingEnabled) { - content = GestureDetector( - key: const Key('signature_overlay'), - behavior: HitTestBehavior.opaque, - onPanStart: (_) {}, - onPanUpdate: - (d) => widget.onDragSignature( - Offset(d.delta.dx / scaleX, d.delta.dy / scaleY), - ), - onSecondaryTapDown: (d) { - final pos = d.globalPosition; - showMenu( - context: context, - position: RelativeRect.fromLTRB( - pos.dx, - pos.dy, - pos.dx, - pos.dy, - ), - items: [ - PopupMenuItem( - key: Key('ctx_active_confirm'), - value: 'confirm', - child: Text(AppLocalizations.of(context).confirm), - ), - PopupMenuItem( - key: Key('ctx_active_delete'), - value: 'delete', - child: Text(AppLocalizations.of(context).delete), - ), - const PopupMenuItem( - key: Key('ctx_active_adjust'), - value: 'adjust', - child: Text('Adjust graphic'), - ), - ], - ).then((choice) { - if (choice == 'confirm') { - widget.onConfirmSignature(); - } else if (choice == 'delete') { - widget.onClearActiveOverlay(); - } else if (choice == 'adjust') { - showDialog( - context: context, - builder: (_) => const ImageEditorDialog(), - ); - } - }); - }, - onLongPressStart: (d) { - final pos = d.globalPosition; - showMenu( - context: context, - position: RelativeRect.fromLTRB( - pos.dx, - pos.dy, - pos.dx, - pos.dy, - ), - items: [ - PopupMenuItem( - key: Key('ctx_active_confirm_lp'), - value: 'confirm', - child: Text(AppLocalizations.of(context).confirm), - ), - PopupMenuItem( - key: Key('ctx_active_delete_lp'), - value: 'delete', - child: Text(AppLocalizations.of(context).delete), - ), - const PopupMenuItem( - key: Key('ctx_active_adjust_lp'), - value: 'adjust', - child: Text('Adjust graphic'), - ), - ], - ).then((choice) { - if (choice == 'confirm') { - widget.onConfirmSignature(); - } else if (choice == 'delete') { - widget.onClearActiveOverlay(); - } else if (choice == 'adjust') { - showDialog( - context: context, - builder: (_) => const ImageEditorDialog(), - ); - } - }); - }, - child: content, - ); - } else { - content = GestureDetector( - key: Key('placed_signature_${placedIndex ?? 'x'}'), - behavior: HitTestBehavior.opaque, - onTap: () => widget.onSelectPlaced(placedIndex), - onSecondaryTapDown: (d) { - if (placedIndex != null) { - _showContextMenuForPlaced( - context: context, - ref: ref, - globalPos: d.globalPosition, - index: placedIndex, - page: pageNumber, - ); - } - }, - onLongPressStart: (d) { - if (placedIndex != null) { - _showContextMenuForPlaced( - context: context, - ref: ref, - globalPos: d.globalPosition, - index: placedIndex, - page: pageNumber, - ); - } - }, - child: content, - ); - } - return content; - }, - ), - ), - ], - ); - }, - ); - } } // Zoom controls removed with single-page mode; continuous viewer manages zoom. diff --git a/lib/ui/features/pdf/widgets/pdf_page_overlays.dart b/lib/ui/features/pdf/widgets/pdf_page_overlays.dart new file mode 100644 index 0000000..404a7db --- /dev/null +++ b/lib/ui/features/pdf/widgets/pdf_page_overlays.dart @@ -0,0 +1,73 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../view_model/view_model.dart'; +import 'signature_overlay.dart'; + +/// Builds all overlays for a given page: placed signatures and the active one. +class PdfPageOverlays extends ConsumerWidget { + const PdfPageOverlays({ + super.key, + required this.pageSize, + required this.pageNumber, + this.onDragSignature, + this.onResizeSignature, + this.onConfirmSignature, + this.onClearActiveOverlay, + this.onSelectPlaced, + }); + + final Size pageSize; + final int pageNumber; + final ValueChanged? onDragSignature; + final ValueChanged? onResizeSignature; + final VoidCallback? onConfirmSignature; + final VoidCallback? onClearActiveOverlay; + final ValueChanged? onSelectPlaced; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final pdf = ref.watch(pdfProvider); + final sig = ref.watch(signatureProvider); + final placed = pdf.placementsByPage[pageNumber] ?? const []; + final widgets = []; + + for (int i = 0; i < placed.length; i++) { + widgets.add( + SignatureOverlay( + pageSize: pageSize, + rect: placed[i], + sig: sig, + pageNumber: pageNumber, + interactive: false, + placedIndex: i, + onSelectPlaced: onSelectPlaced, + ), + ); + } + + final showActive = + sig.rect != null && + sig.editingEnabled && + (pdf.signedPage == null || pdf.signedPage == pageNumber) && + pdf.currentPage == pageNumber; + + if (showActive) { + widgets.add( + SignatureOverlay( + pageSize: pageSize, + rect: sig.rect!, + sig: sig, + pageNumber: pageNumber, + interactive: true, + onDragSignature: onDragSignature, + onResizeSignature: onResizeSignature, + onConfirmSignature: onConfirmSignature, + onClearActiveOverlay: onClearActiveOverlay, + ), + ); + } + + return Stack(children: widgets); + } +} diff --git a/lib/ui/features/pdf/widgets/pdf_pages_overview.dart b/lib/ui/features/pdf/widgets/pdf_pages_overview.dart index e14b337..a0b19bb 100644 --- a/lib/ui/features/pdf/widgets/pdf_pages_overview.dart +++ b/lib/ui/features/pdf/widgets/pdf_pages_overview.dart @@ -20,7 +20,7 @@ class PdfPagesOverview extends ConsumerWidget { return ListView.separated( padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 8), itemCount: pageCount, - separatorBuilder: (_, __) => const SizedBox(height: 8), + separatorBuilder: (_, _) => const SizedBox(height: 8), itemBuilder: (context, index) { final pageNumber = index + 1; final isSelected = pdf.currentPage == pageNumber; diff --git a/lib/ui/features/pdf/widgets/signature_card.dart b/lib/ui/features/pdf/widgets/signature_card.dart index 8e2537f..ccc818a 100644 --- a/lib/ui/features/pdf/widgets/signature_card.dart +++ b/lib/ui/features/pdf/widgets/signature_card.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import '../view_model/view_model.dart'; import 'signature_drag_data.dart'; +import '../../../common/menu_labels.dart'; class SignatureCard extends StatelessWidget { const SignatureCard({ @@ -69,16 +70,16 @@ class SignatureCard extends StatelessWidget { details.globalPosition.dx, details.globalPosition.dy, ), - items: const [ + items: [ PopupMenuItem( - key: Key('mi_signature_adjust'), + key: const Key('mi_signature_adjust'), value: 'adjust', - child: Text('Adjust graphic'), + child: Text(MenuLabels.adjustGraphic(context)), ), PopupMenuItem( - key: Key('mi_signature_delete'), + key: const Key('mi_signature_delete'), value: 'delete', - child: Text('Delete'), + child: Text(MenuLabels.delete(context)), ), ], ); @@ -100,16 +101,16 @@ class SignatureCard extends StatelessWidget { details.globalPosition.dx, details.globalPosition.dy, ), - items: const [ + items: [ PopupMenuItem( - key: Key('mi_signature_adjust'), + key: const Key('mi_signature_adjust'), value: 'adjust', - child: Text('Adjust graphic'), + child: Text(MenuLabels.adjustGraphic(context)), ), PopupMenuItem( - key: Key('mi_signature_delete'), + key: const Key('mi_signature_delete'), value: 'delete', - child: Text('Delete'), + child: Text(MenuLabels.delete(context)), ), ], ); diff --git a/lib/ui/features/pdf/widgets/signature_overlay.dart b/lib/ui/features/pdf/widgets/signature_overlay.dart new file mode 100644 index 0000000..6f0c806 --- /dev/null +++ b/lib/ui/features/pdf/widgets/signature_overlay.dart @@ -0,0 +1,280 @@ +import 'dart:math' as math; +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/model/model.dart'; +import '../view_model/view_model.dart'; +import 'image_editor_dialog.dart'; +import '../../../common/menu_labels.dart'; + +/// Renders a single signature overlay (either interactive or placed) on a page. +class SignatureOverlay extends ConsumerWidget { + const SignatureOverlay({ + super.key, + required this.pageSize, + required this.rect, + required this.sig, + required this.pageNumber, + this.interactive = true, + this.placedIndex, + this.onDragSignature, + this.onResizeSignature, + this.onConfirmSignature, + this.onClearActiveOverlay, + this.onSelectPlaced, + }); + + final Size pageSize; + final Rect rect; + final SignatureState sig; + final int pageNumber; + final bool interactive; + final int? placedIndex; + + // Callbacks used by interactive overlay + final ValueChanged? onDragSignature; + final ValueChanged? onResizeSignature; + final VoidCallback? onConfirmSignature; + final VoidCallback? onClearActiveOverlay; + // Callback for selecting a placed overlay + final ValueChanged? onSelectPlaced; + + @override + Widget build(BuildContext context, WidgetRef ref) { + return LayoutBuilder( + builder: (context, constraints) { + final scaleX = constraints.maxWidth / pageSize.width; + final scaleY = constraints.maxHeight / pageSize.height; + final left = rect.left * scaleX; + final top = rect.top * scaleY; + final width = rect.width * scaleX; + final height = rect.height * scaleY; + + return Stack( + children: [ + Positioned( + left: left, + top: top, + width: width, + height: height, + child: _buildContent(context, ref, scaleX, scaleY), + ), + ], + ); + }, + ); + } + + Widget _buildContent( + BuildContext context, + WidgetRef ref, + double scaleX, + double scaleY, + ) { + 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( + 0, + 0, + 0, + 0.05 + math.min(0.25, (sig.contrast - 1.0).abs()), + ), + border: Border.all(color: borderColor, width: borderWidth), + ), + child: Stack( + 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), + ), + ), + ], + ), + ); + + if (interactive && sig.editingEnabled) { + content = GestureDetector( + key: const Key('signature_overlay'), + behavior: HitTestBehavior.opaque, + onPanStart: (_) {}, + onPanUpdate: + (d) => onDragSignature?.call( + Offset(d.delta.dx / scaleX, d.delta.dy / scaleY), + ), + onSecondaryTapDown: (d) => _showActiveMenu(context, d.globalPosition), + onLongPressStart: (d) => _showActiveMenu(context, d.globalPosition), + child: content, + ); + } else { + content = GestureDetector( + key: Key('placed_signature_${placedIndex ?? 'x'}'), + behavior: HitTestBehavior.opaque, + onTap: () => onSelectPlaced?.call(placedIndex), + onSecondaryTapDown: (d) { + if (placedIndex != null) { + _showPlacedMenu(context, ref, d.globalPosition); + } + }, + onLongPressStart: (d) { + if (placedIndex != null) { + _showPlacedMenu(context, ref, d.globalPosition); + } + }, + child: content, + ); + } + return content; + } + + void _showActiveMenu(BuildContext context, Offset globalPos) { + showMenu( + context: context, + position: RelativeRect.fromLTRB( + globalPos.dx, + globalPos.dy, + globalPos.dx, + globalPos.dy, + ), + items: [ + PopupMenuItem( + key: const Key('ctx_active_confirm'), + value: 'confirm', + child: Text(MenuLabels.confirm(context)), + ), + 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)), + ), + ], + ).then((choice) { + if (choice == 'confirm') { + onConfirmSignature?.call(); + } else if (choice == 'delete') { + onClearActiveOverlay?.call(); + } 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 { + const _SignatureImage({ + required this.interactive, + required this.placedIndex, + required this.pageNumber, + required this.sig, + }); + + final bool interactive; + final int? placedIndex; + final int pageNumber; + final SignatureState sig; + + @override + Widget build(BuildContext context, WidgetRef ref) { + 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(AppLocalizations.of(context).signature)); + } + + Widget im = Image.memory(bytes, fit: BoxFit.contain); + if (sig.rotation % 360 != 0) { + im = Transform.rotate(angle: sig.rotation * math.pi / 180.0, child: im); + } + return im; + } +} diff --git a/lib/ui/features/preferences/widgets/settings_screen.dart b/lib/ui/features/preferences/widgets/settings_screen.dart index 9a453e9..08c388d 100644 --- a/lib/ui/features/preferences/widgets/settings_screen.dart +++ b/lib/ui/features/preferences/widgets/settings_screen.dart @@ -70,7 +70,7 @@ class _SettingsDialogState extends ConsumerState { child: CircularProgressIndicator(), ), ), - error: (_, __) { + error: (_, _) { final items = AppLocalizations.supportedLocales .map((loc) => toLanguageTag(loc)) diff --git a/pubspec.yaml b/pubspec.yaml index e1aac30..af6a439 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -72,6 +72,7 @@ dev_dependencies: flutter_lints: ^6.0.0 msix: ^3.16.12 json_serializable: ^6.11.0 + dead_code_analyzer: ^1.1.0 # For information on the generic Dart part of this file, see the # following page: https://dart.dev/tools/pub/pubspec diff --git a/test/features/step/_helpers.dart b/test/features/step/_helpers.dart index 94c06da..cb9d7ad 100644 --- a/test/features/step/_helpers.dart +++ b/test/features/step/_helpers.dart @@ -1,64 +1,6 @@ -import 'dart:typed_data'; -import 'dart:ui' show Rect, Size; -import 'dart:io'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '_world.dart'; -// A lightweight fake exporter to avoid platform rasterization in tests. -class FakeExportService { - Future exportSignedPdfFromFile({ - required String inputPath, - required String outputPath, - required int? signedPage, - required Rect? signatureRectUi, - required Size uiPageSize, - required Uint8List? signatureImageBytes, - double targetDpi = 144.0, - }) async { - final bytes = await exportSignedPdfFromBytes( - srcBytes: Uint8List.fromList([0x25, 0x50, 0x44, 0x46]), - signedPage: signedPage, - signatureRectUi: signatureRectUi, - uiPageSize: uiPageSize, - signatureImageBytes: signatureImageBytes, - targetDpi: targetDpi, - ); - if (bytes == null) return false; - try { - final file = File(outputPath); - await file.writeAsBytes(bytes, flush: true); - return true; - } catch (_) { - return false; - } - } - - Future exportSignedPdfFromBytes({ - required Uint8List srcBytes, - required int? signedPage, - required Rect? signatureRectUi, - required Size uiPageSize, - required Uint8List? signatureImageBytes, - double targetDpi = 144.0, - }) async { - // Return a deterministic tiny PDF-like byte array - final header = [0x25, 0x50, 0x44, 0x46, 0x2D]; // %PDF- - final payload = [...srcBytes.take(4)]; - final sigFlag = - (signatureRectUi != null && - signatureImageBytes != null && - signatureImageBytes.isNotEmpty) - ? 1 - : 0; - final meta = [ - sigFlag, - uiPageSize.width.toInt() & 0xFF, - uiPageSize.height.toInt() & 0xFF, - ]; - return Uint8List.fromList([...header, ...payload, ...meta]); - } -} - ProviderContainer getOrCreateContainer() { if (TestWorld.container != null) return TestWorld.container!; final container = ProviderContainer(); diff --git a/test/widget/export_flow_test.dart b/test/widget/export_flow_test.dart index 6d535c2..7620c66 100644 --- a/test/widget/export_flow_test.dart +++ b/test/widget/export_flow_test.dart @@ -17,8 +17,6 @@ class RecordingExporter extends ExportService { } } -class BasicExporter extends ExportService {} - void main() { testWidgets('Save uses file selector (via provider) and injected exporter', ( tester, diff --git a/test/widget/pdf_page_area_test.dart b/test/widget/pdf_page_area_test.dart new file mode 100644 index 0000000..bc3c549 --- /dev/null +++ b/test/widget/pdf_page_area_test.dart @@ -0,0 +1,104 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +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/export_providers.dart'; + +void main() { + testWidgets('placed signature stays attached on zoom (mock continuous)', ( + tester, + ) async { + const Size uiPageSize = Size(400, 560); + + // Test harness that exposes the ProviderContainer to mutate state + late ProviderContainer container; + Widget buildHarness({required double width}) { + return ProviderScope( + overrides: [ + // Force mock viewer for predictable layout; pageViewModeProvider already falls back to 'continuous' + useMockViewerProvider.overrideWithValue(true), + ], + child: Builder( + builder: (context) { + container = ProviderScope.containerOf(context); + return Directionality( + textDirection: TextDirection.ltr, + child: MaterialApp( + home: Scaffold( + body: Center( + child: SizedBox( + width: width, + // Keep aspect ratio consistent with uiPageSize + child: PdfPageArea( + pageSize: uiPageSize, + onDragSignature: (_) {}, + onResizeSignature: (_) {}, + onConfirmSignature: () {}, + onClearActiveOverlay: () {}, + onSelectPlaced: (_) {}, + ), + ), + ), + ), + ), + ); + }, + ), + ); + } + + // Initial pump at base width + await tester.pumpWidget(buildHarness(width: 480)); + + // Open sample and add a normalized placement to page 1 + container.read(pdfProvider.notifier).openSample(); + // One placement at (25% x, 50% y), size 10% x 10% + container + .read(pdfProvider.notifier) + .addPlacement( + page: 1, + rect: const Rect.fromLTWH(0.25, 0.50, 0.10, 0.10), + ); + + await tester.pumpAndSettle(); + + // Find the first page stack and the placed signature widget + final pageStackFinder = find.byKey(const ValueKey('page_stack_1')); + expect(pageStackFinder, findsOneWidget); + + final placedFinder = find.byKey(const Key('placed_signature_0')); + expect(placedFinder, findsOneWidget); + + final pageBox = tester.getRect(pageStackFinder); + final placedBox1 = tester.getRect(placedFinder); + + // Compute normalized position within the page container + final relX1 = (placedBox1.left - pageBox.left) / pageBox.width; + final relY1 = (placedBox1.top - pageBox.top) / pageBox.height; + + // Simulate zoom by doubling the available width + await tester.pumpWidget(buildHarness(width: 960)); + // Maintain state across rebuild + await tester.pumpAndSettle(); + + final pageBox2 = tester.getRect(pageStackFinder); + final placedBox2 = tester.getRect(placedFinder); + + final relX2 = (placedBox2.left - pageBox2.left) / pageBox2.width; + final relY2 = (placedBox2.top - pageBox2.top) / pageBox2.height; + + // The relative position should stay approximately the same + expect( + (relX2 - relX1).abs() < 0.01, + isTrue, + reason: 'X should remain attached', + ); + expect( + (relY2 - relY1).abs() < 0.01, + isTrue, + reason: 'Y should remain attached', + ); + }); +}