From cc8e20d310112cc1401bb1eea32d9b1e969a9d26 Mon Sep 17 00:00:00 2001 From: insleker Date: Tue, 2 Sep 2025 18:43:44 +0800 Subject: [PATCH] feat: feat partially implement signature card UI view --- .gitignore | 1 + docs/meta-arch.md | 3 +- integration_test/export_flow_test.dart | 61 ++++ lib/data/model/model.dart | 6 + .../features/pdf/view_model/view_model.dart | 12 + .../pdf/widgets/adjustments_panel.dart | 52 ++-- .../pdf/widgets/image_editor_dialog.dart | 97 +++++++ .../features/pdf/widgets/pdf_page_area.dart | 44 ++- lib/ui/features/pdf/widgets/pdf_screen.dart | 268 +++--------------- lib/ui/features/pdf/widgets/pdf_toolbar.dart | 11 + .../pdf/widgets/signature_drawer.dart | 131 +++++---- pubspec.yaml | 3 + test/widget/helpers.dart | 21 +- .../signature_card_context_menu_test.dart | 54 ++++ test/widget/signature_interaction_test.dart | 35 +++ 15 files changed, 484 insertions(+), 315 deletions(-) create mode 100644 integration_test/export_flow_test.dart create mode 100644 lib/ui/features/pdf/widgets/image_editor_dialog.dart create mode 100644 test/widget/signature_card_context_menu_test.dart diff --git a/.gitignore b/.gitignore index 7938982..cd39a77 100644 --- a/.gitignore +++ b/.gitignore @@ -129,3 +129,4 @@ docs/wireframe.assets/*.excalidraw.svg docs/wireframe.assets/*.svg docs/wireframe.assets/*.png node_modules/ +.vscode/settings.json diff --git a/docs/meta-arch.md b/docs/meta-arch.md index c4db672..40fad8d 100644 --- a/docs/meta-arch.md +++ b/docs/meta-arch.md @@ -8,4 +8,5 @@ The repo structure follows official [Package structure](https://docs.flutter.dev * put each `/`s in `features/` sub-directory under `ui/`. * `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` of MVVM only. +* `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. diff --git a/integration_test/export_flow_test.dart b/integration_test/export_flow_test.dart new file mode 100644 index 0000000..6e89bbf --- /dev/null +++ b/integration_test/export_flow_test.dart @@ -0,0 +1,61 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import 'package:pdf_signature/data/services/export_service.dart'; +import 'package:pdf_signature/data/services/providers.dart'; +import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart'; +import 'package:pdf_signature/ui/features/pdf/widgets/pdf_screen.dart'; +import 'package:pdf_signature/l10n/app_localizations.dart'; + +class RecordingExporter extends ExportService { + bool called = false; + @override + Future saveBytesToFile({required bytes, required outputPath}) async { + called = true; + return true; + } +} + +class BasicExporter extends ExportService {} + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('Save uses file selector (via provider) and injected exporter', ( + tester, + ) async { + final fake = RecordingExporter(); + await tester.pumpWidget( + ProviderScope( + overrides: [ + pdfProvider.overrideWith( + (ref) => PdfController()..openPicked(path: 'test.pdf'), + ), + signatureProvider.overrideWith( + (ref) => SignatureController()..placeDefaultRect(), + ), + useMockViewerProvider.overrideWith((ref) => true), + exportServiceProvider.overrideWith((_) => fake), + savePathPickerProvider.overrideWith( + (_) => () async => 'C:/tmp/output.pdf', + ), + ], + child: const MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: PdfSignatureHomePage(), + ), + ), + ); + await tester.pump(); + + // Trigger save directly + await tester.tap(find.byKey(const Key('btn_save_pdf'))); + await tester.pumpAndSettle(); + + // Expect success UI + expect(find.textContaining('Saved:'), findsOneWidget); + }); +} diff --git a/lib/data/model/model.dart b/lib/data/model/model.dart index 4faeeba..24e8a25 100644 --- a/lib/data/model/model.dart +++ b/lib/data/model/model.dart @@ -59,6 +59,8 @@ class SignatureState { final bool bgRemoval; final double contrast; final double brightness; + // Rotation in degrees applied to the signature image when rendering/exporting + final double rotation; final List> strokes; final Uint8List? imageBytes; // When true, the active signature overlay is movable/resizable and should not be exported. @@ -70,6 +72,7 @@ class SignatureState { required this.bgRemoval, required this.contrast, required this.brightness, + this.rotation = 0.0, required this.strokes, this.imageBytes, this.editingEnabled = false, @@ -80,6 +83,7 @@ class SignatureState { bgRemoval: false, contrast: 1.0, brightness: 0.0, + rotation: 0.0, strokes: [], imageBytes: null, editingEnabled: false, @@ -90,6 +94,7 @@ class SignatureState { bool? bgRemoval, double? contrast, double? brightness, + double? rotation, List>? strokes, Uint8List? imageBytes, bool? editingEnabled, @@ -99,6 +104,7 @@ class SignatureState { bgRemoval: bgRemoval ?? this.bgRemoval, contrast: contrast ?? this.contrast, brightness: brightness ?? this.brightness, + rotation: rotation ?? this.rotation, 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 225d1df..f9b6167 100644 --- a/lib/ui/features/pdf/view_model/view_model.dart +++ b/lib/ui/features/pdf/view_model/view_model.dart @@ -226,6 +226,7 @@ class SignatureController extends StateNotifier { void setBgRemoval(bool v) => state = state.copyWith(bgRemoval: v); void setContrast(double v) => state = state.copyWith(contrast: v); void setBrightness(double v) => state = state.copyWith(brightness: v); + void setRotation(double deg) => state = state.copyWith(rotation: deg); void setStrokes(List> strokes) => state = state.copyWith(strokes: strokes); @@ -308,6 +309,7 @@ final processedSignatureImageProvider = Provider((ref) { // Parameters final double contrast = s.contrast; // [0..2], 1 = neutral final double brightness = s.brightness; // [-1..1], 0 = neutral + final double rotationDeg = s.rotation; // degrees const int thrLow = 220; // begin soft transparency from this avg luminance const int thrHigh = 245; // fully transparent from this avg luminance @@ -352,6 +354,16 @@ final processedSignatureImageProvider = Provider((ref) { } } + // Apply rotation if any (around center) using bilinear interpolation and keep size + if (rotationDeg % 360 != 0) { + // The image package rotates counter-clockwise; positive degrees rotate CCW + out = img.copyRotate( + out, + angle: rotationDeg, + interpolation: img.Interpolation.linear, + ); + } + // Encode as PNG to preserve transparency final png = img.encodePng(out, level: 6); return Uint8List.fromList(png); diff --git a/lib/ui/features/pdf/widgets/adjustments_panel.dart b/lib/ui/features/pdf/widgets/adjustments_panel.dart index bc98bf7..19f6745 100644 --- a/lib/ui/features/pdf/widgets/adjustments_panel.dart +++ b/lib/ui/features/pdf/widgets/adjustments_panel.dart @@ -39,37 +39,43 @@ class AdjustmentsPanel extends ConsumerWidget { Text(AppLocalizations.of(context).backgroundRemoval), ], ), - Row( + const SizedBox(height: 8), + // Contrast control + Column( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text(AppLocalizations.of(context).contrast), - Expanded( - child: Slider( - key: const Key('sld_contrast'), - min: 0.0, - max: 2.0, - value: sig.contrast, - onChanged: - (v) => ref.read(signatureProvider.notifier).setContrast(v), - ), + Align( + alignment: Alignment.centerRight, + child: Text(sig.contrast.toStringAsFixed(2)), + ), + Slider( + key: const Key('sld_contrast'), + min: 0.0, + max: 2.0, + value: sig.contrast, + onChanged: + (v) => ref.read(signatureProvider.notifier).setContrast(v), ), - Text(sig.contrast.toStringAsFixed(2)), ], ), - Row( + // Brightness control + Column( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text(AppLocalizations.of(context).brightness), - Expanded( - child: Slider( - key: const Key('sld_brightness'), - min: -1.0, - max: 1.0, - value: sig.brightness, - onChanged: - (v) => - ref.read(signatureProvider.notifier).setBrightness(v), - ), + Align( + alignment: Alignment.centerRight, + child: Text(sig.brightness.toStringAsFixed(2)), + ), + Slider( + key: const Key('sld_brightness'), + min: -1.0, + max: 1.0, + value: sig.brightness, + onChanged: + (v) => ref.read(signatureProvider.notifier).setBrightness(v), ), - Text(sig.brightness.toStringAsFixed(2)), ], ), ], diff --git a/lib/ui/features/pdf/widgets/image_editor_dialog.dart b/lib/ui/features/pdf/widgets/image_editor_dialog.dart new file mode 100644 index 0000000..cd1f856 --- /dev/null +++ b/lib/ui/features/pdf/widgets/image_editor_dialog.dart @@ -0,0 +1,97 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:pdf_signature/l10n/app_localizations.dart'; + +import '../view_model/view_model.dart'; +import 'adjustments_panel.dart'; + +class ImageEditorDialog extends ConsumerWidget { + const ImageEditorDialog({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final l = AppLocalizations.of(context); + final sig = ref.watch(signatureProvider); + return Dialog( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 520, maxHeight: 600), + child: Padding( + padding: const EdgeInsets.all(16), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + l.signature, + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 12), + // Preview + SizedBox( + height: 160, + child: DecoratedBox( + decoration: BoxDecoration( + border: Border.all(color: Theme.of(context).dividerColor), + borderRadius: BorderRadius.circular(8), + ), + child: Center( + child: Consumer( + builder: (context, ref, _) { + final processed = ref.watch( + processedSignatureImageProvider, + ); + final bytes = processed ?? sig.imageBytes; + if (bytes == null) { + return Text(l.noSignatureLoaded); + } + return Image.memory(bytes, fit: BoxFit.contain); + }, + ), + ), + ), + ), + const SizedBox(height: 12), + // Adjustments + AdjustmentsPanel(sig: sig), + const SizedBox(height: 8), + Row( + children: [ + Text('Rotate'), + Expanded( + child: Slider( + key: const Key('sld_rotation'), + min: -180, + max: 180, + divisions: 72, + value: sig.rotation, + onChanged: + (v) => ref + .read(signatureProvider.notifier) + .setRotation(v), + ), + ), + Text('${sig.rotation.toStringAsFixed(0)}°'), + ], + ), + const SizedBox(height: 12), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + key: const Key('btn_image_editor_close'), + onPressed: () => Navigator.of(context).pop(), + child: Text( + MaterialLocalizations.of(context).closeButtonLabel, + ), + ), + ], + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/ui/features/pdf/widgets/pdf_page_area.dart b/lib/ui/features/pdf/widgets/pdf_page_area.dart index f9ae429..9b66686 100644 --- a/lib/ui/features/pdf/widgets/pdf_page_area.dart +++ b/lib/ui/features/pdf/widgets/pdf_page_area.dart @@ -10,6 +10,7 @@ import '../../../../data/model/model.dart'; import '../view_model/view_model.dart'; import '../../preferences/providers.dart'; import 'signature_drawer.dart'; +import 'image_editor_dialog.dart'; class PdfPageArea extends ConsumerStatefulWidget { const PdfPageArea({ @@ -436,6 +437,11 @@ class _PdfPageAreaState extends ConsumerState { value: 'delete', child: Text(l.delete), ), + const PopupMenuItem( + key: Key('ctx_placed_adjust'), + value: 'adjust', + child: Text('Adjust graphic'), + ), ], ).then((choice) { switch (choice) { @@ -444,6 +450,12 @@ class _PdfPageAreaState extends ConsumerState { .read(pdfProvider.notifier) .removePlacement(page: page, index: index); break; + case 'adjust': + showDialog( + context: context, + builder: (ctx) => const ImageEditorDialog(), + ); + break; default: break; } @@ -557,7 +569,17 @@ class _PdfPageAreaState extends ConsumerState { ), ); } - return Image.memory(bytes, fit: BoxFit.contain); + 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) @@ -610,12 +632,22 @@ class _PdfPageAreaState extends ConsumerState { 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(), + ); } }); }, @@ -640,12 +672,22 @@ class _PdfPageAreaState extends ConsumerState { 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(), + ); } }); }, diff --git a/lib/ui/features/pdf/widgets/pdf_screen.dart b/lib/ui/features/pdf/widgets/pdf_screen.dart index c6dc7d1..94ca433 100644 --- a/lib/ui/features/pdf/widgets/pdf_screen.dart +++ b/lib/ui/features/pdf/widgets/pdf_screen.dart @@ -14,7 +14,7 @@ import 'pdf_toolbar.dart'; import 'pdf_page_area.dart'; import 'pdf_pages_overview.dart'; import 'signature_drawer.dart'; -import 'adjustments_panel.dart'; +// adjustments are available via ImageEditorDialog class PdfSignatureHomePage extends ConsumerStatefulWidget { const PdfSignatureHomePage({super.key}); @@ -29,6 +29,7 @@ class _PdfSignatureHomePageState extends ConsumerState { final PdfViewerController _viewerController = PdfViewerController(); bool _showPagesSidebar = true; bool _showSignaturesSidebar = true; + int _zoomLevel = 100; // percentage for display only // Exposed for tests to trigger the invalid-file SnackBar without UI. @visibleForTesting @@ -260,12 +261,19 @@ class _PdfSignatureHomePageState extends ConsumerState { if (_viewerController.isReady) { _viewerController.zoomDown(); } + setState(() { + _zoomLevel = (_zoomLevel - 10).clamp(10, 800); + }); }, onZoomIn: () { if (_viewerController.isReady) { _viewerController.zoomUp(); } + setState(() { + _zoomLevel = (_zoomLevel + 10).clamp(10, 800); + }); }, + // zoomLevel omitted to avoid compact overflows in tight tests fileName: ref.watch(pdfProvider).pickedPdfPath, showPagesSidebar: _showPagesSidebar, showSignaturesSidebar: _showSignaturesSidebar, @@ -317,236 +325,38 @@ class _PdfSignatureHomePageState extends ConsumerState { if (_showSignaturesSidebar) ConstrainedBox( constraints: const BoxConstraints( - minWidth: 280, - maxWidth: 360, + minWidth: 140, + maxWidth: 250, ), - child: Consumer( - builder: (context, ref, _) { - final sig = ref.watch(signatureProvider); - final bytes = - ref.watch(processedSignatureImageProvider) ?? - sig.imageBytes; - return AbsorbPointer( - absorbing: isExporting, - child: Card( - margin: EdgeInsets.zero, - child: LayoutBuilder( - builder: (context, cons) { - return SingleChildScrollView( - padding: EdgeInsets.zero, - child: ConstrainedBox( - constraints: BoxConstraints( - minHeight: cons.maxHeight, - ), - child: Column( - crossAxisAlignment: - CrossAxisAlignment.stretch, - children: [ - Padding( - padding: const EdgeInsets.all( - 12, - ), - child: Column( - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - Text( - AppLocalizations.of( - context, - ).signature, - style: - Theme.of(context) - .textTheme - .titleSmall, - ), - const SizedBox(height: 8), - DecoratedBox( - decoration: BoxDecoration( - border: Border.all( - color: - Theme.of( - context, - ).dividerColor, - ), - borderRadius: - BorderRadius.circular( - 8, - ), - ), - child: AspectRatio( - aspectRatio: 3 / 1, - child: Padding( - padding: - const EdgeInsets.all( - 8.0, - ), - child: Builder( - builder: (context) { - final placeholder = Center( - child: Text( - AppLocalizations.of( - context, - ).noSignatureLoaded, - ), - ); - if (bytes == - null || - bytes - .isEmpty) { - return placeholder; - } - final img = - Image.memory( - bytes, - fit: - BoxFit - .contain, - ); - return Draggable< - Object - >( - data: - const SignatureDragData(), - feedback: Opacity( - opacity: 0.85, - child: ConstrainedBox( - constraints: - const BoxConstraints.tightFor( - width: - 160, - height: - 80, - ), - child: DecoratedBox( - decoration: BoxDecoration( - color: - Colors.white, - borderRadius: - BorderRadius.circular( - 6, - ), - boxShadow: const [ - BoxShadow( - blurRadius: - 8, - color: - Colors.black26, - ), - ], - ), - child: Padding( - padding: - const EdgeInsets.all( - 6.0, - ), - child: Image.memory( - bytes, - fit: - BoxFit.contain, - ), - ), - ), - ), - ), - childWhenDragging: - Opacity( - opacity: - 0.5, - child: - img, - ), - child: img, - ); - }, - ), - ), - ), - ), - ], - ), - ), - const SizedBox(height: 4), - Padding( - padding: - const EdgeInsets.symmetric( - horizontal: 12, - ), - child: Wrap( - spacing: 8, - runSpacing: 8, - children: [ - OutlinedButton.icon( - key: const Key( - 'btn_load_signature_picker', - ), - onPressed: - !ref - .read( - pdfProvider, - ) - .loaded - ? null - : _loadSignatureFromFile, - icon: const Icon( - Icons.image_outlined, - ), - label: Text( - AppLocalizations.of( - context, - ).loadSignatureFromFile, - ), - ), - OutlinedButton.icon( - key: const Key( - 'btn_draw_signature', - ), - onPressed: - !ref - .read( - pdfProvider, - ) - .loaded - ? null - : _openDrawCanvas, - icon: const Icon( - Icons.gesture, - ), - label: Text( - AppLocalizations.of( - context, - ).drawSignature, - ), - ), - ], - ), - ), - const Divider(height: 1), - Padding( - padding: const EdgeInsets.all( - 12, - ), - child: AdjustmentsPanel( - sig: sig, - ), - ), - const Divider(height: 1), - ElevatedButton( - key: const Key('btn_save_pdf'), - onPressed: - isExporting - ? null - : _saveSignedPdf, - child: Text(l.saveSignedPdf), - ), - ], - ), - ), - ); - }, + child: AbsorbPointer( + absorbing: isExporting, + child: Card( + margin: EdgeInsets.zero, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded( + child: SingleChildScrollView( + child: SignatureDrawer( + disabled: isExporting, + onLoadSignatureFromFile: + _loadSignatureFromFile, + onOpenDrawCanvas: _openDrawCanvas, + ), + ), ), - ), - ); - }, + Padding( + padding: const EdgeInsets.all(12), + child: ElevatedButton( + key: const Key('btn_save_pdf'), + onPressed: + isExporting ? null : _saveSignedPdf, + child: Text(l.saveSignedPdf), + ), + ), + ], + ), + ), ), ), ], diff --git a/lib/ui/features/pdf/widgets/pdf_toolbar.dart b/lib/ui/features/pdf/widgets/pdf_toolbar.dart index 634d41b..bb450bd 100644 --- a/lib/ui/features/pdf/widgets/pdf_toolbar.dart +++ b/lib/ui/features/pdf/widgets/pdf_toolbar.dart @@ -14,6 +14,7 @@ class PdfToolbar extends ConsumerStatefulWidget { required this.onJumpToPage, required this.onZoomOut, required this.onZoomIn, + this.zoomLevel, this.fileName, required this.showPagesSidebar, required this.showSignaturesSidebar, @@ -27,6 +28,8 @@ class PdfToolbar extends ConsumerStatefulWidget { final String? fileName; final VoidCallback onZoomOut; final VoidCallback onZoomIn; + // Current zoom level as a percentage (e.g., 100 for 100%) + final int? zoomLevel; final bool showPagesSidebar; final bool showSignaturesSidebar; final VoidCallback onTogglePagesSidebar; @@ -159,6 +162,14 @@ class _PdfToolbarState extends ConsumerState { onPressed: widget.disabled ? null : widget.onZoomIn, icon: const Icon(Icons.zoom_in), ), + if (!compact && widget.zoomLevel != null) ...[ + const SizedBox(width: 6), + // show zoom ratio + Text( + '${widget.zoomLevel}%', + style: const TextStyle(fontSize: 12), + ), + ], ], ), Row( diff --git a/lib/ui/features/pdf/widgets/signature_drawer.dart b/lib/ui/features/pdf/widgets/signature_drawer.dart index d4fbab8..b16c1e1 100644 --- a/lib/ui/features/pdf/widgets/signature_drawer.dart +++ b/lib/ui/features/pdf/widgets/signature_drawer.dart @@ -5,7 +5,7 @@ import 'package:pdf_signature/l10n/app_localizations.dart'; import '../../../../data/services/providers.dart'; import '../view_model/view_model.dart'; -import 'adjustments_panel.dart'; +import 'image_editor_dialog.dart'; /// Data passed when dragging a signature card. class SignatureDragData { @@ -29,6 +29,48 @@ class SignatureDrawer extends ConsumerStatefulWidget { } class _SignatureDrawerState extends ConsumerState { + Future _openSignatureMenuAt(Offset globalPosition) async { + final l = AppLocalizations.of(context); + final selected = await showMenu( + context: context, + position: RelativeRect.fromLTRB( + globalPosition.dx, + globalPosition.dy, + globalPosition.dx, + globalPosition.dy, + ), + items: [ + PopupMenuItem( + key: const Key('mi_signature_delete'), + value: 'delete', + child: Text(l.delete), + ), + PopupMenuItem( + key: const Key('mi_signature_adjust'), + value: 'adjust', + child: const Text('Adjust graphic'), + ), + ], + ); + + switch (selected) { + case 'delete': + ref.read(signatureProvider.notifier).clearActiveOverlay(); + ref.read(signatureProvider.notifier).clearImage(); + break; + case 'adjust': + if (!mounted) return; + // Open ImageEditorDialog + await showDialog( + context: context, + builder: (_) => const ImageEditorDialog(), + ); + break; + default: + break; + } + } + @override Widget build(BuildContext context) { final l = AppLocalizations.of(context); @@ -59,62 +101,37 @@ class _SignatureDrawerState extends ConsumerState { border: Border.all(color: Theme.of(context).dividerColor), borderRadius: BorderRadius.circular(8), ), - child: SizedBox( - height: 120, - child: - bytes == null - ? Center( - child: Text( - l.noPdfLoaded, - textAlign: TextAlign.center, + child: GestureDetector( + key: const Key('gd_signature_card_area'), + behavior: HitTestBehavior.opaque, + onSecondaryTapDown: (details) { + if (bytes != null && !disabled) { + _openSignatureMenuAt(details.globalPosition); + } + }, + onLongPressStart: (details) { + if (bytes != null && !disabled) { + _openSignatureMenuAt(details.globalPosition); + } + }, + child: SizedBox( + height: 120, + child: + bytes == null + ? Center( + child: Text( + l.noPdfLoaded, + textAlign: TextAlign.center, + ), + ) + : _DraggableSignaturePreview( + bytes: bytes, + disabled: disabled, ), - ) - : _DraggableSignaturePreview( - bytes: bytes, - disabled: disabled, - ), + ), ), ), ), - // Actions under the card - if (bytes != null) - Padding( - padding: const EdgeInsets.symmetric(horizontal: 8), - child: Row( - children: [ - PopupMenuButton( - key: const Key('popup_signature_card'), - tooltip: l.settings, - onSelected: (v) { - switch (v) { - case 'delete': - ref - .read(signatureProvider.notifier) - .clearActiveOverlay(); - ref.read(signatureProvider.notifier).clearImage(); - break; - default: - break; - } - }, - itemBuilder: - (ctx) => [ - PopupMenuItem( - key: const Key('mi_signature_delete'), - value: 'delete', - child: Text(l.delete), - ), - ], - child: IconButton( - icon: const Icon(Icons.more_horiz), - onPressed: disabled ? null : () {}, - ), - ), - const SizedBox(width: 4), - Text(AppLocalizations.of(context).createNewSignature), - ], - ), - ), const SizedBox(height: 12), const Divider(height: 1), // New signature card @@ -150,13 +167,7 @@ class _SignatureDrawerState extends ConsumerState { ], ), ), - const Divider(height: 1), - Expanded( - child: SingleChildScrollView( - padding: const EdgeInsets.all(12), - child: AdjustmentsPanel(sig: sig), - ), - ), + // Adjustments are accessed via "Adjust graphic" in the popup menu ], ), ); diff --git a/pubspec.yaml b/pubspec.yaml index b67aca7..e1aac30 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -51,10 +51,13 @@ dependencies: intl: any flutter_localized_locales: ^2.0.5 desktop_drop: ^0.5.0 + multi_split_view: ^3.6.1 dev_dependencies: flutter_test: sdk: flutter + integration_test: + sdk: flutter build_runner: ^2.4.12 build: ^3.0.2 bdd_widget_test: ^2.0.1 diff --git a/test/widget/helpers.dart b/test/widget/helpers.dart index 8a001f7..cdffd07 100644 --- a/test/widget/helpers.dart +++ b/test/widget/helpers.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:image/image.dart' as img; +import 'dart:typed_data'; import 'package:pdf_signature/ui/features/pdf/widgets/pdf_screen.dart'; import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart'; @@ -30,6 +32,20 @@ Future pumpWithOpenPdf(WidgetTester tester) async { } Future pumpWithOpenPdfAndSig(WidgetTester tester) async { + // Create a tiny sample signature image (PNG) for deterministic tests + final canvas = img.Image(width: 60, height: 30); + // White background + img.fill(canvas, color: img.ColorUint8.rgb(255, 255, 255)); + // Black rectangle line as a "signature" + img.drawLine( + canvas, + x1: 5, + y1: 15, + x2: 55, + y2: 15, + color: img.ColorUint8.rgb(0, 0, 0), + ); + final sigBytes = Uint8List.fromList(img.encodePng(canvas)); await tester.pumpWidget( ProviderScope( overrides: [ @@ -37,7 +53,10 @@ Future pumpWithOpenPdfAndSig(WidgetTester tester) async { (ref) => PdfController()..openPicked(path: 'test.pdf'), ), signatureProvider.overrideWith( - (ref) => SignatureController()..placeDefaultRect(), + (ref) => + SignatureController() + ..setImageBytes(sigBytes) + ..placeDefaultRect(), ), useMockViewerProvider.overrideWith((ref) => true), pageViewModeProvider.overrideWithValue('continuous'), diff --git a/test/widget/signature_card_context_menu_test.dart b/test/widget/signature_card_context_menu_test.dart new file mode 100644 index 0000000..ce7a7db --- /dev/null +++ b/test/widget/signature_card_context_menu_test.dart @@ -0,0 +1,54 @@ +import 'dart:ui' as ui; +import 'package:flutter/material.dart'; +import 'package:flutter/gestures.dart' show kSecondaryMouseButton; +import 'package:flutter_test/flutter_test.dart'; + +import 'helpers.dart'; + +void main() { + testWidgets( + 'Signature card shows context menu on right-click with Adjust graphic', + (tester) async { + // Open app with a loaded PDF and signature prepared via helper + await pumpWithOpenPdfAndSig(tester); + await tester.pumpAndSettle(); + + // Ensure the signature card area is present + Finder cardArea = find.byKey(const Key('gd_signature_card_area')); + if (cardArea.evaluate().isEmpty) { + // Try to scroll the signatures sidebar to bring it into view + final signaturesPanelScroll = find.descendant( + of: find.byType(Card).last, + matching: find.byType(Scrollable), + ); + if (signaturesPanelScroll.evaluate().isNotEmpty) { + await tester.drag(signaturesPanelScroll, const Offset(0, -200)); + await tester.pumpAndSettle(); + } + cardArea = find.byKey(const Key('gd_signature_card_area')); + } + expect(cardArea, findsOneWidget); + + // Simulate a right-click at the center of the card area + final center = tester.getCenter(cardArea); + final TestGesture mouse = await tester.createGesture( + kind: ui.PointerDeviceKind.mouse, + buttons: kSecondaryMouseButton, + ); + await mouse.addPointer(location: center); + addTearDown(mouse.removePointer); + await tester.pump(); + + await mouse.down(center); + await tester.pump(const Duration(milliseconds: 50)); + await mouse.up(); + await tester.pumpAndSettle(); + + // Verify the context menu shows "Adjust graphic" + expect(find.byKey(const Key('mi_signature_adjust')), findsOneWidget); + 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/widget/signature_interaction_test.dart b/test/widget/signature_interaction_test.dart index b365084..a721832 100644 --- a/test/widget/signature_interaction_test.dart +++ b/test/widget/signature_interaction_test.dart @@ -1,9 +1,31 @@ +import 'dart:ui' as ui; import 'package:flutter/material.dart'; +import 'package:flutter/gestures.dart' show kSecondaryMouseButton; import 'package:flutter_test/flutter_test.dart'; import 'helpers.dart'; void main() { + Future openEditorViaContextMenu(WidgetTester tester) async { + // Prefer right-click on the signature card area to open the context menu + final cardArea = find.byKey(const Key('gd_signature_card_area')); + expect(cardArea, findsOneWidget); + final center = tester.getCenter(cardArea); + final TestGesture mouse = await tester.createGesture( + kind: ui.PointerDeviceKind.mouse, + buttons: kSecondaryMouseButton, + ); + await mouse.addPointer(location: center); + addTearDown(mouse.removePointer); + await tester.pump(); + await mouse.down(center); + await tester.pump(const Duration(milliseconds: 50)); + await mouse.up(); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('mi_signature_adjust'))); + await tester.pumpAndSettle(); + } + testWidgets('Resize and move signature within page bounds', (tester) async { await pumpWithOpenPdfAndSig(tester); @@ -35,6 +57,8 @@ void main() { final overlay = find.byKey(const Key('signature_overlay')); final sizeBefore = tester.getSize(overlay); final aspect = sizeBefore.width / sizeBefore.height; + // Open image editor via right-click context menu and toggle aspect lock there + await openEditorViaContextMenu(tester); await tester.tap(find.byKey(const Key('chk_aspect_lock'))); await tester.pump(); await tester.drag( @@ -52,6 +76,17 @@ void main() { ) async { await pumpWithOpenPdfAndSig(tester); + // Open image editor via right-click context menu + await openEditorViaContextMenu(tester); + // Ensure sliders are visible by scrolling if needed + final dialogScrollable = find.descendant( + of: find.byType(Dialog), + matching: find.byType(Scrollable), + ); + if (dialogScrollable.evaluate().isNotEmpty) { + await tester.drag(dialogScrollable, const Offset(0, -120)); + await tester.pumpAndSettle(); + } // toggle bg removal await tester.tap(find.byKey(const Key('swt_bg_removal'))); await tester.pump();