feat: feat partially implement signature card UI view
This commit is contained in:
parent
db0912b12f
commit
cc8e20d310
|
@ -129,3 +129,4 @@ docs/wireframe.assets/*.excalidraw.svg
|
||||||
docs/wireframe.assets/*.svg
|
docs/wireframe.assets/*.svg
|
||||||
docs/wireframe.assets/*.png
|
docs/wireframe.assets/*.png
|
||||||
node_modules/
|
node_modules/
|
||||||
|
.vscode/settings.json
|
||||||
|
|
|
@ -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/`.
|
* 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/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.
|
||||||
|
|
|
@ -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);
|
||||||
|
});
|
||||||
|
}
|
|
@ -59,6 +59,8 @@ class SignatureState {
|
||||||
final bool bgRemoval;
|
final bool bgRemoval;
|
||||||
final double contrast;
|
final double contrast;
|
||||||
final double brightness;
|
final double brightness;
|
||||||
|
// Rotation in degrees applied to the signature image when rendering/exporting
|
||||||
|
final double rotation;
|
||||||
final List<List<Offset>> strokes;
|
final List<List<Offset>> strokes;
|
||||||
final Uint8List? imageBytes;
|
final Uint8List? imageBytes;
|
||||||
// When true, the active signature overlay is movable/resizable and should not be exported.
|
// 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.bgRemoval,
|
||||||
required this.contrast,
|
required this.contrast,
|
||||||
required this.brightness,
|
required this.brightness,
|
||||||
|
this.rotation = 0.0,
|
||||||
required this.strokes,
|
required this.strokes,
|
||||||
this.imageBytes,
|
this.imageBytes,
|
||||||
this.editingEnabled = false,
|
this.editingEnabled = false,
|
||||||
|
@ -80,6 +83,7 @@ class SignatureState {
|
||||||
bgRemoval: false,
|
bgRemoval: false,
|
||||||
contrast: 1.0,
|
contrast: 1.0,
|
||||||
brightness: 0.0,
|
brightness: 0.0,
|
||||||
|
rotation: 0.0,
|
||||||
strokes: [],
|
strokes: [],
|
||||||
imageBytes: null,
|
imageBytes: null,
|
||||||
editingEnabled: false,
|
editingEnabled: false,
|
||||||
|
@ -90,6 +94,7 @@ class SignatureState {
|
||||||
bool? bgRemoval,
|
bool? bgRemoval,
|
||||||
double? contrast,
|
double? contrast,
|
||||||
double? brightness,
|
double? brightness,
|
||||||
|
double? rotation,
|
||||||
List<List<Offset>>? strokes,
|
List<List<Offset>>? strokes,
|
||||||
Uint8List? imageBytes,
|
Uint8List? imageBytes,
|
||||||
bool? editingEnabled,
|
bool? editingEnabled,
|
||||||
|
@ -99,6 +104,7 @@ class SignatureState {
|
||||||
bgRemoval: bgRemoval ?? this.bgRemoval,
|
bgRemoval: bgRemoval ?? this.bgRemoval,
|
||||||
contrast: contrast ?? this.contrast,
|
contrast: contrast ?? this.contrast,
|
||||||
brightness: brightness ?? this.brightness,
|
brightness: brightness ?? this.brightness,
|
||||||
|
rotation: rotation ?? this.rotation,
|
||||||
strokes: strokes ?? this.strokes,
|
strokes: strokes ?? this.strokes,
|
||||||
imageBytes: imageBytes ?? this.imageBytes,
|
imageBytes: imageBytes ?? this.imageBytes,
|
||||||
editingEnabled: editingEnabled ?? this.editingEnabled,
|
editingEnabled: editingEnabled ?? this.editingEnabled,
|
||||||
|
|
|
@ -226,6 +226,7 @@ class SignatureController extends StateNotifier<SignatureState> {
|
||||||
void setBgRemoval(bool v) => state = state.copyWith(bgRemoval: v);
|
void setBgRemoval(bool v) => state = state.copyWith(bgRemoval: v);
|
||||||
void setContrast(double v) => state = state.copyWith(contrast: v);
|
void setContrast(double v) => state = state.copyWith(contrast: v);
|
||||||
void setBrightness(double v) => state = state.copyWith(brightness: 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) =>
|
void setStrokes(List<List<Offset>> strokes) =>
|
||||||
state = state.copyWith(strokes: strokes);
|
state = state.copyWith(strokes: strokes);
|
||||||
|
@ -308,6 +309,7 @@ final processedSignatureImageProvider = Provider<Uint8List?>((ref) {
|
||||||
// Parameters
|
// Parameters
|
||||||
final double contrast = s.contrast; // [0..2], 1 = neutral
|
final double contrast = s.contrast; // [0..2], 1 = neutral
|
||||||
final double brightness = s.brightness; // [-1..1], 0 = 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 thrLow = 220; // begin soft transparency from this avg luminance
|
||||||
const int thrHigh = 245; // fully transparent 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
|
// Encode as PNG to preserve transparency
|
||||||
final png = img.encodePng(out, level: 6);
|
final png = img.encodePng(out, level: 6);
|
||||||
return Uint8List.fromList(png);
|
return Uint8List.fromList(png);
|
||||||
|
|
|
@ -39,37 +39,43 @@ class AdjustmentsPanel extends ConsumerWidget {
|
||||||
Text(AppLocalizations.of(context).backgroundRemoval),
|
Text(AppLocalizations.of(context).backgroundRemoval),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
Row(
|
const SizedBox(height: 8),
|
||||||
|
// Contrast control
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
Text(AppLocalizations.of(context).contrast),
|
Text(AppLocalizations.of(context).contrast),
|
||||||
Expanded(
|
Align(
|
||||||
child: Slider(
|
alignment: Alignment.centerRight,
|
||||||
key: const Key('sld_contrast'),
|
child: Text(sig.contrast.toStringAsFixed(2)),
|
||||||
min: 0.0,
|
),
|
||||||
max: 2.0,
|
Slider(
|
||||||
value: sig.contrast,
|
key: const Key('sld_contrast'),
|
||||||
onChanged:
|
min: 0.0,
|
||||||
(v) => ref.read(signatureProvider.notifier).setContrast(v),
|
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: [
|
children: [
|
||||||
Text(AppLocalizations.of(context).brightness),
|
Text(AppLocalizations.of(context).brightness),
|
||||||
Expanded(
|
Align(
|
||||||
child: Slider(
|
alignment: Alignment.centerRight,
|
||||||
key: const Key('sld_brightness'),
|
child: Text(sig.brightness.toStringAsFixed(2)),
|
||||||
min: -1.0,
|
),
|
||||||
max: 1.0,
|
Slider(
|
||||||
value: sig.brightness,
|
key: const Key('sld_brightness'),
|
||||||
onChanged:
|
min: -1.0,
|
||||||
(v) =>
|
max: 1.0,
|
||||||
ref.read(signatureProvider.notifier).setBrightness(v),
|
value: sig.brightness,
|
||||||
),
|
onChanged:
|
||||||
|
(v) => ref.read(signatureProvider.notifier).setBrightness(v),
|
||||||
),
|
),
|
||||||
Text(sig.brightness.toStringAsFixed(2)),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
|
@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -10,6 +10,7 @@ import '../../../../data/model/model.dart';
|
||||||
import '../view_model/view_model.dart';
|
import '../view_model/view_model.dart';
|
||||||
import '../../preferences/providers.dart';
|
import '../../preferences/providers.dart';
|
||||||
import 'signature_drawer.dart';
|
import 'signature_drawer.dart';
|
||||||
|
import 'image_editor_dialog.dart';
|
||||||
|
|
||||||
class PdfPageArea extends ConsumerStatefulWidget {
|
class PdfPageArea extends ConsumerStatefulWidget {
|
||||||
const PdfPageArea({
|
const PdfPageArea({
|
||||||
|
@ -436,6 +437,11 @@ class _PdfPageAreaState extends ConsumerState<PdfPageArea> {
|
||||||
value: 'delete',
|
value: 'delete',
|
||||||
child: Text(l.delete),
|
child: Text(l.delete),
|
||||||
),
|
),
|
||||||
|
const PopupMenuItem<String>(
|
||||||
|
key: Key('ctx_placed_adjust'),
|
||||||
|
value: 'adjust',
|
||||||
|
child: Text('Adjust graphic'),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
).then((choice) {
|
).then((choice) {
|
||||||
switch (choice) {
|
switch (choice) {
|
||||||
|
@ -444,6 +450,12 @@ class _PdfPageAreaState extends ConsumerState<PdfPageArea> {
|
||||||
.read(pdfProvider.notifier)
|
.read(pdfProvider.notifier)
|
||||||
.removePlacement(page: page, index: index);
|
.removePlacement(page: page, index: index);
|
||||||
break;
|
break;
|
||||||
|
case 'adjust':
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => const ImageEditorDialog(),
|
||||||
|
);
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
break;
|
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)
|
if (interactive)
|
||||||
|
@ -610,12 +632,22 @@ class _PdfPageAreaState extends ConsumerState<PdfPageArea> {
|
||||||
value: 'delete',
|
value: 'delete',
|
||||||
child: Text(AppLocalizations.of(context).delete),
|
child: Text(AppLocalizations.of(context).delete),
|
||||||
),
|
),
|
||||||
|
const PopupMenuItem<String>(
|
||||||
|
key: Key('ctx_active_adjust'),
|
||||||
|
value: 'adjust',
|
||||||
|
child: Text('Adjust graphic'),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
).then((choice) {
|
).then((choice) {
|
||||||
if (choice == 'confirm') {
|
if (choice == 'confirm') {
|
||||||
widget.onConfirmSignature();
|
widget.onConfirmSignature();
|
||||||
} else if (choice == 'delete') {
|
} else if (choice == 'delete') {
|
||||||
widget.onClearActiveOverlay();
|
widget.onClearActiveOverlay();
|
||||||
|
} else if (choice == 'adjust') {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (_) => const ImageEditorDialog(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
@ -640,12 +672,22 @@ class _PdfPageAreaState extends ConsumerState<PdfPageArea> {
|
||||||
value: 'delete',
|
value: 'delete',
|
||||||
child: Text(AppLocalizations.of(context).delete),
|
child: Text(AppLocalizations.of(context).delete),
|
||||||
),
|
),
|
||||||
|
const PopupMenuItem<String>(
|
||||||
|
key: Key('ctx_active_adjust_lp'),
|
||||||
|
value: 'adjust',
|
||||||
|
child: Text('Adjust graphic'),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
).then((choice) {
|
).then((choice) {
|
||||||
if (choice == 'confirm') {
|
if (choice == 'confirm') {
|
||||||
widget.onConfirmSignature();
|
widget.onConfirmSignature();
|
||||||
} else if (choice == 'delete') {
|
} else if (choice == 'delete') {
|
||||||
widget.onClearActiveOverlay();
|
widget.onClearActiveOverlay();
|
||||||
|
} else if (choice == 'adjust') {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (_) => const ImageEditorDialog(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
|
@ -14,7 +14,7 @@ import 'pdf_toolbar.dart';
|
||||||
import 'pdf_page_area.dart';
|
import 'pdf_page_area.dart';
|
||||||
import 'pdf_pages_overview.dart';
|
import 'pdf_pages_overview.dart';
|
||||||
import 'signature_drawer.dart';
|
import 'signature_drawer.dart';
|
||||||
import 'adjustments_panel.dart';
|
// adjustments are available via ImageEditorDialog
|
||||||
|
|
||||||
class PdfSignatureHomePage extends ConsumerStatefulWidget {
|
class PdfSignatureHomePage extends ConsumerStatefulWidget {
|
||||||
const PdfSignatureHomePage({super.key});
|
const PdfSignatureHomePage({super.key});
|
||||||
|
@ -29,6 +29,7 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
|
||||||
final PdfViewerController _viewerController = PdfViewerController();
|
final PdfViewerController _viewerController = PdfViewerController();
|
||||||
bool _showPagesSidebar = true;
|
bool _showPagesSidebar = true;
|
||||||
bool _showSignaturesSidebar = true;
|
bool _showSignaturesSidebar = true;
|
||||||
|
int _zoomLevel = 100; // percentage for display only
|
||||||
|
|
||||||
// Exposed for tests to trigger the invalid-file SnackBar without UI.
|
// Exposed for tests to trigger the invalid-file SnackBar without UI.
|
||||||
@visibleForTesting
|
@visibleForTesting
|
||||||
|
@ -260,12 +261,19 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
|
||||||
if (_viewerController.isReady) {
|
if (_viewerController.isReady) {
|
||||||
_viewerController.zoomDown();
|
_viewerController.zoomDown();
|
||||||
}
|
}
|
||||||
|
setState(() {
|
||||||
|
_zoomLevel = (_zoomLevel - 10).clamp(10, 800);
|
||||||
|
});
|
||||||
},
|
},
|
||||||
onZoomIn: () {
|
onZoomIn: () {
|
||||||
if (_viewerController.isReady) {
|
if (_viewerController.isReady) {
|
||||||
_viewerController.zoomUp();
|
_viewerController.zoomUp();
|
||||||
}
|
}
|
||||||
|
setState(() {
|
||||||
|
_zoomLevel = (_zoomLevel + 10).clamp(10, 800);
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
// zoomLevel omitted to avoid compact overflows in tight tests
|
||||||
fileName: ref.watch(pdfProvider).pickedPdfPath,
|
fileName: ref.watch(pdfProvider).pickedPdfPath,
|
||||||
showPagesSidebar: _showPagesSidebar,
|
showPagesSidebar: _showPagesSidebar,
|
||||||
showSignaturesSidebar: _showSignaturesSidebar,
|
showSignaturesSidebar: _showSignaturesSidebar,
|
||||||
|
@ -317,236 +325,38 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
|
||||||
if (_showSignaturesSidebar)
|
if (_showSignaturesSidebar)
|
||||||
ConstrainedBox(
|
ConstrainedBox(
|
||||||
constraints: const BoxConstraints(
|
constraints: const BoxConstraints(
|
||||||
minWidth: 280,
|
minWidth: 140,
|
||||||
maxWidth: 360,
|
maxWidth: 250,
|
||||||
),
|
),
|
||||||
child: Consumer(
|
child: AbsorbPointer(
|
||||||
builder: (context, ref, _) {
|
absorbing: isExporting,
|
||||||
final sig = ref.watch(signatureProvider);
|
child: Card(
|
||||||
final bytes =
|
margin: EdgeInsets.zero,
|
||||||
ref.watch(processedSignatureImageProvider) ??
|
child: Column(
|
||||||
sig.imageBytes;
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
return AbsorbPointer(
|
children: [
|
||||||
absorbing: isExporting,
|
Expanded(
|
||||||
child: Card(
|
child: SingleChildScrollView(
|
||||||
margin: EdgeInsets.zero,
|
child: SignatureDrawer(
|
||||||
child: LayoutBuilder(
|
disabled: isExporting,
|
||||||
builder: (context, cons) {
|
onLoadSignatureFromFile:
|
||||||
return SingleChildScrollView(
|
_loadSignatureFromFile,
|
||||||
padding: EdgeInsets.zero,
|
onOpenDrawCanvas: _openDrawCanvas,
|
||||||
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),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
),
|
Padding(
|
||||||
);
|
padding: const EdgeInsets.all(12),
|
||||||
},
|
child: ElevatedButton(
|
||||||
|
key: const Key('btn_save_pdf'),
|
||||||
|
onPressed:
|
||||||
|
isExporting ? null : _saveSignedPdf,
|
||||||
|
child: Text(l.saveSignedPdf),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
|
@ -14,6 +14,7 @@ class PdfToolbar extends ConsumerStatefulWidget {
|
||||||
required this.onJumpToPage,
|
required this.onJumpToPage,
|
||||||
required this.onZoomOut,
|
required this.onZoomOut,
|
||||||
required this.onZoomIn,
|
required this.onZoomIn,
|
||||||
|
this.zoomLevel,
|
||||||
this.fileName,
|
this.fileName,
|
||||||
required this.showPagesSidebar,
|
required this.showPagesSidebar,
|
||||||
required this.showSignaturesSidebar,
|
required this.showSignaturesSidebar,
|
||||||
|
@ -27,6 +28,8 @@ class PdfToolbar extends ConsumerStatefulWidget {
|
||||||
final String? fileName;
|
final String? fileName;
|
||||||
final VoidCallback onZoomOut;
|
final VoidCallback onZoomOut;
|
||||||
final VoidCallback onZoomIn;
|
final VoidCallback onZoomIn;
|
||||||
|
// Current zoom level as a percentage (e.g., 100 for 100%)
|
||||||
|
final int? zoomLevel;
|
||||||
final bool showPagesSidebar;
|
final bool showPagesSidebar;
|
||||||
final bool showSignaturesSidebar;
|
final bool showSignaturesSidebar;
|
||||||
final VoidCallback onTogglePagesSidebar;
|
final VoidCallback onTogglePagesSidebar;
|
||||||
|
@ -159,6 +162,14 @@ class _PdfToolbarState extends ConsumerState<PdfToolbar> {
|
||||||
onPressed: widget.disabled ? null : widget.onZoomIn,
|
onPressed: widget.disabled ? null : widget.onZoomIn,
|
||||||
icon: const Icon(Icons.zoom_in),
|
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(
|
Row(
|
||||||
|
|
|
@ -5,7 +5,7 @@ import 'package:pdf_signature/l10n/app_localizations.dart';
|
||||||
|
|
||||||
import '../../../../data/services/providers.dart';
|
import '../../../../data/services/providers.dart';
|
||||||
import '../view_model/view_model.dart';
|
import '../view_model/view_model.dart';
|
||||||
import 'adjustments_panel.dart';
|
import 'image_editor_dialog.dart';
|
||||||
|
|
||||||
/// Data passed when dragging a signature card.
|
/// Data passed when dragging a signature card.
|
||||||
class SignatureDragData {
|
class SignatureDragData {
|
||||||
|
@ -29,6 +29,48 @@ class SignatureDrawer extends ConsumerStatefulWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
class _SignatureDrawerState extends ConsumerState<SignatureDrawer> {
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final l = AppLocalizations.of(context);
|
final l = AppLocalizations.of(context);
|
||||||
|
@ -59,62 +101,37 @@ class _SignatureDrawerState extends ConsumerState<SignatureDrawer> {
|
||||||
border: Border.all(color: Theme.of(context).dividerColor),
|
border: Border.all(color: Theme.of(context).dividerColor),
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
),
|
),
|
||||||
child: SizedBox(
|
child: GestureDetector(
|
||||||
height: 120,
|
key: const Key('gd_signature_card_area'),
|
||||||
child:
|
behavior: HitTestBehavior.opaque,
|
||||||
bytes == null
|
onSecondaryTapDown: (details) {
|
||||||
? Center(
|
if (bytes != null && !disabled) {
|
||||||
child: Text(
|
_openSignatureMenuAt(details.globalPosition);
|
||||||
l.noPdfLoaded,
|
}
|
||||||
textAlign: TextAlign.center,
|
},
|
||||||
|
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 SizedBox(height: 12),
|
||||||
const Divider(height: 1),
|
const Divider(height: 1),
|
||||||
// New signature card
|
// New signature card
|
||||||
|
@ -150,13 +167,7 @@ class _SignatureDrawerState extends ConsumerState<SignatureDrawer> {
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const Divider(height: 1),
|
// Adjustments are accessed via "Adjust graphic" in the popup menu
|
||||||
Expanded(
|
|
||||||
child: SingleChildScrollView(
|
|
||||||
padding: const EdgeInsets.all(12),
|
|
||||||
child: AdjustmentsPanel(sig: sig),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
@ -51,10 +51,13 @@ dependencies:
|
||||||
intl: any
|
intl: any
|
||||||
flutter_localized_locales: ^2.0.5
|
flutter_localized_locales: ^2.0.5
|
||||||
desktop_drop: ^0.5.0
|
desktop_drop: ^0.5.0
|
||||||
|
multi_split_view: ^3.6.1
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
|
integration_test:
|
||||||
|
sdk: flutter
|
||||||
build_runner: ^2.4.12
|
build_runner: ^2.4.12
|
||||||
build: ^3.0.2
|
build: ^3.0.2
|
||||||
bdd_widget_test: ^2.0.1
|
bdd_widget_test: ^2.0.1
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:flutter_test/flutter_test.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/widgets/pdf_screen.dart';
|
||||||
import 'package:pdf_signature/ui/features/pdf/view_model/view_model.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 {
|
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(
|
await tester.pumpWidget(
|
||||||
ProviderScope(
|
ProviderScope(
|
||||||
overrides: [
|
overrides: [
|
||||||
|
@ -37,7 +53,10 @@ Future<void> pumpWithOpenPdfAndSig(WidgetTester tester) async {
|
||||||
(ref) => PdfController()..openPicked(path: 'test.pdf'),
|
(ref) => PdfController()..openPicked(path: 'test.pdf'),
|
||||||
),
|
),
|
||||||
signatureProvider.overrideWith(
|
signatureProvider.overrideWith(
|
||||||
(ref) => SignatureController()..placeDefaultRect(),
|
(ref) =>
|
||||||
|
SignatureController()
|
||||||
|
..setImageBytes(sigBytes)
|
||||||
|
..placeDefaultRect(),
|
||||||
),
|
),
|
||||||
useMockViewerProvider.overrideWith((ref) => true),
|
useMockViewerProvider.overrideWith((ref) => true),
|
||||||
pageViewModeProvider.overrideWithValue('continuous'),
|
pageViewModeProvider.overrideWithValue('continuous'),
|
||||||
|
|
|
@ -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.
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
|
@ -1,9 +1,31 @@
|
||||||
|
import 'dart:ui' as ui;
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/gestures.dart' show kSecondaryMouseButton;
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
import 'helpers.dart';
|
import 'helpers.dart';
|
||||||
|
|
||||||
void main() {
|
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 {
|
testWidgets('Resize and move signature within page bounds', (tester) async {
|
||||||
await pumpWithOpenPdfAndSig(tester);
|
await pumpWithOpenPdfAndSig(tester);
|
||||||
|
|
||||||
|
@ -35,6 +57,8 @@ void main() {
|
||||||
final overlay = find.byKey(const Key('signature_overlay'));
|
final overlay = find.byKey(const Key('signature_overlay'));
|
||||||
final sizeBefore = tester.getSize(overlay);
|
final sizeBefore = tester.getSize(overlay);
|
||||||
final aspect = sizeBefore.width / sizeBefore.height;
|
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.tap(find.byKey(const Key('chk_aspect_lock')));
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
await tester.drag(
|
await tester.drag(
|
||||||
|
@ -52,6 +76,17 @@ void main() {
|
||||||
) async {
|
) async {
|
||||||
await pumpWithOpenPdfAndSig(tester);
|
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
|
// toggle bg removal
|
||||||
await tester.tap(find.byKey(const Key('swt_bg_removal')));
|
await tester.tap(find.byKey(const Key('swt_bg_removal')));
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
|
Loading…
Reference in New Issue