feat: feat partially implement signature card UI view

This commit is contained in:
insleker 2025-09-02 18:43:44 +08:00
parent db0912b12f
commit cc8e20d310
15 changed files with 484 additions and 315 deletions

1
.gitignore vendored
View File

@ -129,3 +129,4 @@ docs/wireframe.assets/*.excalidraw.svg
docs/wireframe.assets/*.svg
docs/wireframe.assets/*.png
node_modules/
.vscode/settings.json

View File

@ -8,4 +8,5 @@ The repo structure follows official [Package structure](https://docs.flutter.dev
* put each `<FEATURE NAME>/`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.

View File

@ -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<bool> 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);
});
}

View File

@ -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<List<Offset>> 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<List<Offset>>? 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,

View File

@ -226,6 +226,7 @@ class SignatureController extends StateNotifier<SignatureState> {
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<List<Offset>> strokes) =>
state = state.copyWith(strokes: strokes);
@ -308,6 +309,7 @@ final processedSignatureImageProvider = Provider<Uint8List?>((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<Uint8List?>((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);

View File

@ -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)),
],
),
],

View File

@ -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,
),
),
],
),
],
),
),
),
),
);
}
}

View File

@ -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<PdfPageArea> {
value: 'delete',
child: Text(l.delete),
),
const PopupMenuItem<String>(
key: Key('ctx_placed_adjust'),
value: 'adjust',
child: Text('Adjust graphic'),
),
],
).then((choice) {
switch (choice) {
@ -444,6 +450,12 @@ class _PdfPageAreaState extends ConsumerState<PdfPageArea> {
.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<PdfPageArea> {
),
);
}
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<PdfPageArea> {
value: 'delete',
child: Text(AppLocalizations.of(context).delete),
),
const PopupMenuItem<String>(
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<PdfPageArea> {
value: 'delete',
child: Text(AppLocalizations.of(context).delete),
),
const PopupMenuItem<String>(
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(),
);
}
});
},

View File

@ -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<PdfSignatureHomePage> {
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<PdfSignatureHomePage> {
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<PdfSignatureHomePage> {
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),
),
),
],
),
),
),
),
],

View File

@ -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<PdfToolbar> {
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(

View File

@ -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<SignatureDrawer> {
Future<void> _openSignatureMenuAt(Offset globalPosition) async {
final l = AppLocalizations.of(context);
final selected = await showMenu<String>(
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<SignatureDrawer> {
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<String>(
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<SignatureDrawer> {
],
),
),
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
],
),
);

View File

@ -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

View File

@ -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<void> pumpWithOpenPdf(WidgetTester tester) async {
}
Future<void> 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<void> 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'),

View File

@ -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.
},
);
}

View File

@ -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<void> 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();