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/*.png
|
||||
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/`.
|
||||
* `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 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,
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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)),
|
||||
],
|
||||
),
|
||||
],
|
||||
|
|
|
@ -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 '../../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(),
|
||||
);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
|
|
@ -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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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
|
||||
],
|
||||
),
|
||||
);
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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'),
|
||||
|
|
|
@ -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/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();
|
||||
|
|
Loading…
Reference in New Issue