import 'dart:ui' as ui; import 'package:flutter/material.dart'; import 'package:flutter/gestures.dart' show kSecondaryMouseButton; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_box_transform/flutter_box_transform.dart'; import 'package:image/image.dart' as img; import 'package:pdf_signature/ui/features/pdf/widgets/signature_overlay.dart'; import 'package:pdf_signature/ui/features/pdf/view_model/pdf_view_model.dart'; import 'package:pdf_signature/data/repositories/document_repository.dart'; import 'package:pdf_signature/domain/models/model.dart'; import 'package:pdf_signature/l10n/app_localizations.dart'; void main() { late ProviderContainer container; late SignatureAsset testAsset; setUp(() { // Create a test signature asset final canvas = img.Image(width: 60, height: 30); img.fill(canvas, color: img.ColorUint8.rgb(255, 255, 255)); img.drawLine( canvas, x1: 5, y1: 15, x2: 55, y2: 15, color: img.ColorUint8.rgb(0, 0, 0), ); final bytes = img.encodePng(canvas); testAsset = SignatureAsset(bytes: bytes, name: 'test_signature.png'); container = ProviderContainer( overrides: [ documentRepositoryProvider.overrideWith( (ref) => DocumentStateNotifier()..openSample(), ), pdfViewModelProvider.overrideWith( (ref) => PdfViewModel(ref, useMockViewer: true), ), ], ); }); tearDown(() { container.dispose(); }); group('SignatureOverlay', () { testWidgets('shows red border when unlocked', (tester) async { // Add a signature placement container .read(documentRepositoryProvider.notifier) .addPlacement( page: 1, rect: const Rect.fromLTWH(0.1, 0.1, 0.2, 0.1), asset: testAsset, ); await tester.pumpWidget( UncontrolledProviderScope( container: container, child: MaterialApp( localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, home: Scaffold( body: SignatureOverlay( pageSize: const Size(400, 560), rect: const Rect.fromLTWH(0.1, 0.1, 0.2, 0.1), placement: SignaturePlacement( rect: const Rect.fromLTWH(0.1, 0.1, 0.2, 0.1), asset: testAsset, ), placedIndex: 0, pageNumber: 1, ), ), ), ), ); await tester.pumpAndSettle(); // Find the signature border DecoratedBox (with thicker border) final transformableBox = find.byType(TransformableBox); final allDecoratedBoxes = find.descendant( of: transformableBox, matching: find.byType(DecoratedBox), ); // Find the one with the thicker border (width 2.0) which is the signature border DecoratedBox? signatureBorderBox; for (final finder in allDecoratedBoxes.evaluate()) { final widget = finder.widget as DecoratedBox; final decoration = widget.decoration; if (decoration is BoxDecoration && decoration.border is Border && (decoration.border as Border).top.width == 2.0) { signatureBorderBox = widget; break; } } expect(signatureBorderBox, isNotNull); expect( (signatureBorderBox!.decoration as BoxDecoration).border, isA().having( (border) => border.top.color, 'border color', Colors.red, ), ); }); testWidgets('shows green border when locked', (tester) async { // Add and lock a signature placement container .read(documentRepositoryProvider.notifier) .addPlacement( page: 1, rect: const Rect.fromLTWH(0.1, 0.1, 0.2, 0.1), asset: testAsset, ); container .read(pdfViewModelProvider.notifier) .lockPlacement(page: 1, index: 0); await tester.pumpWidget( UncontrolledProviderScope( container: container, child: MaterialApp( localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, home: Scaffold( body: SignatureOverlay( pageSize: const Size(400, 560), rect: const Rect.fromLTWH(0.1, 0.1, 0.2, 0.1), placement: SignaturePlacement( rect: const Rect.fromLTWH(0.1, 0.1, 0.2, 0.1), asset: testAsset, ), placedIndex: 0, pageNumber: 1, ), ), ), ), ); await tester.pumpAndSettle(); // Find the signature border DecoratedBox (with thicker border) final transformableBox = find.byType(TransformableBox); final allDecoratedBoxes = find.descendant( of: transformableBox, matching: find.byType(DecoratedBox), ); // Find the one with the thicker border (width 2.0) which is the signature border DecoratedBox? signatureBorderBox; for (final finder in allDecoratedBoxes.evaluate()) { final widget = finder.widget as DecoratedBox; final decoration = widget.decoration; if (decoration is BoxDecoration && decoration.border is Border && (decoration.border as Border).top.width == 2.0) { signatureBorderBox = widget; break; } } expect(signatureBorderBox, isNotNull); final decoratedBoxWidget = signatureBorderBox!; expect( (decoratedBoxWidget.decoration as BoxDecoration).border, isA().having( (border) => border.top.color, 'border color', Colors.green, ), ); }); testWidgets('shows context menu on right-click', (tester) async { // Add a signature placement container .read(documentRepositoryProvider.notifier) .addPlacement( page: 1, rect: const Rect.fromLTWH(0.1, 0.1, 0.2, 0.1), asset: testAsset, ); await tester.pumpWidget( UncontrolledProviderScope( container: container, child: MaterialApp( localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, home: Scaffold( body: SignatureOverlay( pageSize: const Size(400, 560), rect: const Rect.fromLTWH(0.1, 0.1, 0.2, 0.1), placement: SignaturePlacement( rect: const Rect.fromLTWH(0.1, 0.1, 0.2, 0.1), asset: testAsset, ), placedIndex: 0, pageNumber: 1, ), ), ), ), ); await tester.pumpAndSettle(); // Find the TransformableBox which contains our overlay final transformableBox = find.byType(TransformableBox); expect(transformableBox, findsOneWidget); // Simulate right-click on the signature overlay final center = tester.getCenter(transformableBox); 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 context menu appears with lock option expect(find.byKey(const Key('mi_placement_lock')), findsOneWidget); expect(find.byKey(const Key('mi_placement_delete')), findsOneWidget); }); testWidgets('lock menu item shows "Lock (Confirm)" when unlocked', ( tester, ) async { // Add a signature placement (unlocked by default) container .read(documentRepositoryProvider.notifier) .addPlacement( page: 1, rect: const Rect.fromLTWH(0.1, 0.1, 0.2, 0.1), asset: testAsset, ); await tester.pumpWidget( UncontrolledProviderScope( container: container, child: MaterialApp( localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, home: Scaffold( body: SignatureOverlay( pageSize: const Size(400, 560), rect: const Rect.fromLTWH(0.1, 0.1, 0.2, 0.1), placement: SignaturePlacement( rect: const Rect.fromLTWH(0.1, 0.1, 0.2, 0.1), asset: testAsset, ), placedIndex: 0, pageNumber: 1, ), ), ), ), ); await tester.pumpAndSettle(); // Simulate right-click final transformableBox = find.byType(TransformableBox); final center = tester.getCenter(transformableBox); 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(); // Check that menu shows "Lock (Confirm)" for unlocked state final lockMenuItem = find.byKey(const Key('mi_placement_lock')); expect(lockMenuItem, findsOneWidget); final popupMenuItem = tester.widget>(lockMenuItem); expect(popupMenuItem.value, 'lock'); }); testWidgets('lock menu item shows "Unlock" when locked', (tester) async { // Add and lock a signature placement container .read(documentRepositoryProvider.notifier) .addPlacement( page: 1, rect: const Rect.fromLTWH(0.1, 0.1, 0.2, 0.1), asset: testAsset, ); container .read(pdfViewModelProvider.notifier) .lockPlacement(page: 1, index: 0); await tester.pumpWidget( UncontrolledProviderScope( container: container, child: MaterialApp( localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, home: Scaffold( body: SignatureOverlay( pageSize: const Size(400, 560), rect: const Rect.fromLTWH(0.1, 0.1, 0.2, 0.1), placement: SignaturePlacement( rect: const Rect.fromLTWH(0.1, 0.1, 0.2, 0.1), asset: testAsset, ), placedIndex: 0, pageNumber: 1, ), ), ), ), ); await tester.pumpAndSettle(); // Simulate right-click final transformableBox = find.byType(TransformableBox); final center = tester.getCenter(transformableBox); 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(); // Check that menu shows "Unlock" for locked state final lockMenuItem = find.byKey(const Key('mi_placement_lock')); expect(lockMenuItem, findsOneWidget); final popupMenuItem = tester.widget>(lockMenuItem); expect(popupMenuItem.value, 'unlock'); }); testWidgets('shows green border when placement is locked via view model', ( tester, ) async { // Add a signature placement container .read(documentRepositoryProvider.notifier) .addPlacement( page: 1, rect: const Rect.fromLTWH(0.1, 0.1, 0.2, 0.1), asset: testAsset, ); await tester.pumpWidget( UncontrolledProviderScope( container: container, child: MaterialApp( localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, home: Scaffold( body: SignatureOverlay( pageSize: const Size(400, 560), rect: const Rect.fromLTWH(0.1, 0.1, 0.2, 0.1), placement: SignaturePlacement( rect: const Rect.fromLTWH(0.1, 0.1, 0.2, 0.1), asset: testAsset, ), placedIndex: 0, pageNumber: 1, ), ), ), ), ); await tester.pumpAndSettle(); // Initially should be unlocked (red border) final transformableBox = find.byType(TransformableBox); final allDecoratedBoxes = find.descendant( of: transformableBox, matching: find.byType(DecoratedBox), ); // Find the one with the thicker border (width 2.0) which is the signature border DecoratedBox? initialSignatureBorderBox; for (final finder in allDecoratedBoxes.evaluate()) { final widget = finder.widget as DecoratedBox; final decoration = widget.decoration; if (decoration is BoxDecoration && decoration.border is Border && (decoration.border as Border).top.width == 2.0) { initialSignatureBorderBox = widget; break; } } expect(initialSignatureBorderBox, isNotNull); expect( (initialSignatureBorderBox!.decoration as BoxDecoration).border, isA().having( (border) => border.top.color, 'border color', Colors.red, ), ); // Lock the placement via view model container .read(pdfViewModelProvider.notifier) .lockPlacement(page: 1, index: 0); await tester.pumpAndSettle(); // Should now be locked (green border) final allDecoratedBoxesAfter = find.descendant( of: transformableBox, matching: find.byType(DecoratedBox), ); // Find the one with the thicker border (width 2.0) which is the signature border DecoratedBox? updatedSignatureBorderBox; for (final finder in allDecoratedBoxesAfter.evaluate()) { final widget = finder.widget as DecoratedBox; final decoration = widget.decoration; if (decoration is BoxDecoration && decoration.border is Border && (decoration.border as Border).top.width == 2.0) { updatedSignatureBorderBox = widget; break; } } expect(updatedSignatureBorderBox, isNotNull); expect( (updatedSignatureBorderBox!.decoration as BoxDecoration).border, isA().having( (border) => border.top.color, 'border color', Colors.green, ), ); }); testWidgets('locked signature cannot be dragged or resized', ( tester, ) async { // Add and lock a signature placement container .read(documentRepositoryProvider.notifier) .addPlacement( page: 1, rect: const Rect.fromLTWH(0.1, 0.1, 0.2, 0.1), asset: testAsset, ); container .read(pdfViewModelProvider.notifier) .lockPlacement(page: 1, index: 0); await tester.pumpWidget( UncontrolledProviderScope( container: container, child: MaterialApp( localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, home: Scaffold( body: SignatureOverlay( pageSize: const Size(400, 560), rect: const Rect.fromLTWH(0.1, 0.1, 0.2, 0.1), placement: SignaturePlacement( rect: const Rect.fromLTWH(0.1, 0.1, 0.2, 0.1), asset: testAsset, ), placedIndex: 0, pageNumber: 1, ), ), ), ), ); await tester.pumpAndSettle(); // Verify the TransformableBox has onChanged set to null (disabled) final transformableBox = find.byType(TransformableBox); expect(transformableBox, findsOneWidget); // Since onChanged is null for locked placements, dragging should not work // This is tested implicitly by the fact that the onChanged callback is null // when isPlacementLocked returns true }); testWidgets('can unlock signature placement via context menu', ( tester, ) async { // Add and lock a signature placement container .read(documentRepositoryProvider.notifier) .addPlacement( page: 1, rect: const Rect.fromLTWH(0.1, 0.1, 0.2, 0.1), asset: testAsset, ); container .read(pdfViewModelProvider.notifier) .lockPlacement(page: 1, index: 0); await tester.pumpWidget( UncontrolledProviderScope( container: container, child: MaterialApp( localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, home: Scaffold( body: SignatureOverlay( pageSize: const Size(400, 560), rect: const Rect.fromLTWH(0.1, 0.1, 0.2, 0.1), placement: SignaturePlacement( rect: const Rect.fromLTWH(0.1, 0.1, 0.2, 0.1), asset: testAsset, ), placedIndex: 0, pageNumber: 1, ), ), ), ), ); await tester.pumpAndSettle(); // Simulate right-click and select unlock final transformableBox = find.byType(TransformableBox); final center = tester.getCenter(transformableBox); final TestGesture mouse = await tester.createGesture( kind: ui.PointerDeviceKind.mouse, buttons: kSecondaryMouseButton, ); await mouse.addPointer(location: center); addTearDown(mouse.removePointer); await tester.pump(); await tester.pumpAndSettle(); // Instead of trying to tap the menu, directly call unlock on the view model container .read(pdfViewModelProvider.notifier) .unlockPlacement(page: 1, index: 0); await tester.pumpAndSettle(); // Should now be unlocked (red border) final allDecoratedBoxesAfterUnlock = find.descendant( of: transformableBox, matching: find.byType(DecoratedBox), ); // Find the one with the thicker border (width 2.0) which is the signature border DecoratedBox? unlockedSignatureBorderBox; for (final finder in allDecoratedBoxesAfterUnlock.evaluate()) { final widget = finder.widget as DecoratedBox; final decoration = widget.decoration; if (decoration is BoxDecoration && decoration.border is Border && (decoration.border as Border).top.width == 2.0) { unlockedSignatureBorderBox = widget; break; } } expect(unlockedSignatureBorderBox, isNotNull); final updatedWidget = unlockedSignatureBorderBox!; expect( (updatedWidget.decoration as BoxDecoration).border, isA().having( (border) => border.top.color, 'border color', Colors.red, ), ); }); testWidgets('can delete signature placement via context menu', ( tester, ) async { // Add a signature placement container .read(documentRepositoryProvider.notifier) .addPlacement( page: 1, rect: const Rect.fromLTWH(0.1, 0.1, 0.2, 0.1), asset: testAsset, ); await tester.pumpWidget( UncontrolledProviderScope( container: container, child: MaterialApp( localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, home: Scaffold( body: SignatureOverlay( pageSize: const Size(400, 560), rect: const Rect.fromLTWH(0.1, 0.1, 0.2, 0.1), placement: SignaturePlacement( rect: const Rect.fromLTWH(0.1, 0.1, 0.2, 0.1), asset: testAsset, ), placedIndex: 0, pageNumber: 1, ), ), ), ), ); await tester.pumpAndSettle(); // Verify signature is initially present expect(find.byType(TransformableBox), findsOneWidget); // Simulate right-click and select delete final transformableBox = find.byType(TransformableBox); final center = tester.getCenter(transformableBox); 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(); // Tap the delete menu item await tester.tap(find.byKey(const Key('mi_placement_delete'))); await tester.pumpAndSettle(); // Check that the placement was removed from the repository final placements = container .read(documentRepositoryProvider.notifier) .placementsOn(1); expect(placements.length, 0); }); testWidgets('locked signature cannot be dragged or resized', ( tester, ) async { // Add and lock a signature placement container .read(documentRepositoryProvider.notifier) .addPlacement( page: 1, rect: const Rect.fromLTWH(0.1, 0.1, 0.2, 0.1), asset: testAsset, ); container .read(pdfViewModelProvider.notifier) .lockPlacement(page: 1, index: 0); await tester.pumpWidget( UncontrolledProviderScope( container: container, child: MaterialApp( localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, home: Scaffold( body: SignatureOverlay( pageSize: const Size(400, 560), rect: const Rect.fromLTWH(0.1, 0.1, 0.2, 0.1), placement: SignaturePlacement( rect: const Rect.fromLTWH(0.1, 0.1, 0.2, 0.1), asset: testAsset, ), placedIndex: 0, pageNumber: 1, ), ), ), ), ); await tester.pumpAndSettle(); // Verify the TransformableBox has onChanged set to null (disabled) final transformableBox = find.byType(TransformableBox); expect(transformableBox, findsOneWidget); // Since onChanged is null for locked placements, dragging should not work // This is tested implicitly by the fact that the onChanged callback is null // when isPlacementLocked returns true }); }); }