Compare commits

...

3 Commits

32 changed files with 2000 additions and 1353 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

@ -6,7 +6,8 @@
"dart-code.flutter",
"lsaudon.l10nization", // quick translation gen
"oke331.flutter-l10n-helper", // show arb string
"gabbygreat.flutter-l10n-checker", // detect hard-coded strings
"gabbygreat.flutter-l10n-checker",
"pomdtr.excalidraw-editor", // detect hard-coded strings
// "joaopinacio.translate-me"
]
}

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

@ -607,436 +607,6 @@
"originalText": "search",
"autoResize": true,
"lineHeight": 1.35
},
{
"id": "xdjwEB-znvBqkgNWqei0e",
"type": "rectangle",
"x": 829.345613801518,
"y": 73.27856093258742,
"width": 109.23454710748254,
"height": 36.33306860750372,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "#ffffff",
"fillStyle": "hachure",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [
"a_nIXU9JKF6NQxqDn6778"
],
"frameId": null,
"index": "aK",
"roundness": null,
"seed": 1079291190,
"version": 165,
"versionNonce": 1774775286,
"isDeleted": false,
"boundElements": [],
"updated": 1756646729695,
"link": null,
"locked": false
},
{
"type": "rectangle",
"version": 769,
"versionNonce": 754187574,
"isDeleted": false,
"id": "7kiaSLfvSEdtaNYQBYwzh",
"fillStyle": "hachure",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"angle": 0,
"x": 841.4747836141609,
"y": 84.61996518660985,
"strokeColor": "#000000",
"backgroundColor": "#868e96",
"width": 22.637490885793227,
"height": 13.582494531475936,
"seed": 1458935414,
"groupIds": [
"s9zt_E4Q8I28ITXmLtTku",
"a_nIXU9JKF6NQxqDn6778"
],
"boundElements": [],
"updated": 1756646729695,
"link": null,
"locked": false,
"index": "aL",
"frameId": null,
"roundness": null
},
{
"type": "line",
"version": 786,
"versionNonce": 321578614,
"isDeleted": false,
"id": "CbsiEXCcAqMJ4HF8vzcR3",
"fillStyle": "hachure",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"angle": 0,
"x": 855.0572781456366,
"y": 84.61996518660985,
"strokeColor": "#000000",
"backgroundColor": "#868e96",
"width": 13.582494531475936,
"height": 4.5274981771586456,
"seed": 1411864502,
"groupIds": [
"s9zt_E4Q8I28ITXmLtTku",
"a_nIXU9JKF6NQxqDn6778"
],
"boundElements": [],
"updated": 1756646729695,
"link": null,
"locked": false,
"startBinding": null,
"endBinding": null,
"lastCommittedPoint": null,
"startArrowhead": null,
"endArrowhead": null,
"points": [
[
0,
0
],
[
-4.5274981771586456,
-4.5274981771586456
],
[
-13.582494531475934,
-4.527498177158644
],
[
-13.582494531475936,
-8.881784197001252e-16
],
[
0,
0
]
],
"index": "aM",
"frameId": null,
"roundness": null,
"polygon": false
},
{
"id": "qiIJiYioLPMG7pTYwhrg5",
"type": "text",
"x": 877.3865259161674,
"y": 77.10300187674156,
"width": 49.27995300292969,
"height": 27,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "#ffffff",
"fillStyle": "hachure",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [
"a_nIXU9JKF6NQxqDn6778"
],
"frameId": null,
"index": "aN",
"roundness": null,
"seed": 1611591926,
"version": 76,
"versionNonce": 2100471734,
"isDeleted": false,
"boundElements": [],
"updated": 1756646729695,
"link": null,
"locked": false,
"text": "Open",
"fontSize": 20,
"fontFamily": 6,
"textAlign": "left",
"verticalAlign": "top",
"containerId": null,
"originalText": "Open",
"autoResize": true,
"lineHeight": 1.35
},
{
"type": "rectangle",
"version": 447,
"versionNonce": 1428930594,
"isDeleted": false,
"id": "CvUvdJBdFi_9gk7DmBP3h",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"angle": 0,
"x": 1276.5699538308854,
"y": -105.41567571464518,
"strokeColor": "#000000",
"backgroundColor": "#868e96",
"width": 41.296072889060916,
"height": 9.620822332911771,
"seed": 324223266,
"groupIds": [
"FhaLGab-lElwTXWsop0jD",
"ALJlvV1mOFRZHOCbgoRYA"
],
"index": "aO",
"frameId": null,
"roundness": null,
"boundElements": [],
"updated": 1756647328403,
"link": null,
"locked": false
},
{
"type": "rectangle",
"version": 467,
"versionNonce": 734227426,
"isDeleted": false,
"id": "JST3yuxNBZUHRvRS5svAi",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"angle": 0,
"x": 1276.5699538308854,
"y": -120.86895124654592,
"strokeColor": "#000000",
"backgroundColor": "#868e96",
"width": 41.0018486675741,
"height": 9.620822332911771,
"seed": 458411234,
"groupIds": [
"FhaLGab-lElwTXWsop0jD",
"ALJlvV1mOFRZHOCbgoRYA"
],
"index": "aP",
"frameId": null,
"roundness": null,
"boundElements": [],
"updated": 1756647328404,
"link": null,
"locked": false
},
{
"type": "rectangle",
"version": 546,
"versionNonce": 1911529378,
"isDeleted": false,
"id": "BdE7Lsbj5rN-2Fbc_g8TY",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"angle": 0,
"x": 1276.3519181972783,
"y": -136.32222677844666,
"strokeColor": "#000000",
"backgroundColor": "#868e96",
"width": 40.89858173493463,
"height": 8.789494679651982,
"seed": 1525342370,
"groupIds": [
"FhaLGab-lElwTXWsop0jD",
"ALJlvV1mOFRZHOCbgoRYA"
],
"index": "aQ",
"frameId": null,
"roundness": null,
"boundElements": [],
"updated": 1756647328404,
"link": null,
"locked": false
},
{
"type": "text",
"version": 306,
"versionNonce": 1407146850,
"isDeleted": false,
"id": "aMLLNSOvXTWAD4qesWqN6",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"angle": 0,
"x": 1285.8386804713773,
"y": -86.87954484494927,
"strokeColor": "#000000",
"backgroundColor": "#868e96",
"width": 24.33822760138865,
"height": 12.945865745419493,
"seed": 1573551202,
"groupIds": [
"ALJlvV1mOFRZHOCbgoRYA"
],
"fontSize": 10.356692596335595,
"fontFamily": 1,
"text": "Menu",
"baseline": 18,
"textAlign": "left",
"verticalAlign": "top",
"index": "aR",
"frameId": null,
"roundness": {
"type": 2
},
"boundElements": [
{
"id": "Zq3EuupF1HmOWACV2oefy",
"type": "arrow"
}
],
"updated": 1756647328404,
"link": null,
"locked": false,
"containerId": null,
"originalText": "Menu",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "lOTebkqDHtT4BBQALESig",
"type": "rectangle",
"x": 790.384031749907,
"y": 36.693096342540855,
"width": 335.5208042689734,
"height": 109.54448154994424,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "dashed",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": "aS",
"roundness": {
"type": 3
},
"seed": 1783740578,
"version": 55,
"versionNonce": 1405945982,
"isDeleted": false,
"boundElements": [
{
"id": "Zq3EuupF1HmOWACV2oefy",
"type": "arrow"
}
],
"updated": 1756647293005,
"link": null,
"locked": false
},
{
"id": "Zq3EuupF1HmOWACV2oefy",
"type": "arrow",
"x": 1128.0299808310733,
"y": 66.13666497128354,
"width": 153.447595240714,
"height": 132.2939715962288,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "dashed",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": "aT",
"roundness": {
"type": 2
},
"seed": 581324706,
"version": 170,
"versionNonce": 410803618,
"isDeleted": false,
"boundElements": [
{
"type": "text",
"id": "f9MY_jyhZB6ng-Fp--Exn"
}
],
"updated": 1756647332367,
"link": null,
"locked": false,
"points": [
[
0,
0
],
[
153.447595240714,
-132.2939715962288
]
],
"lastCommittedPoint": null,
"startBinding": {
"elementId": "lOTebkqDHtT4BBQALESig",
"focus": 0.6098248915581349,
"gap": 2.241472516741169
},
"endBinding": {
"elementId": "aMLLNSOvXTWAD4qesWqN6",
"focus": 0,
"gap": 12.733143243255915
},
"startArrowhead": null,
"endArrowhead": "arrow",
"elbowed": false
},
{
"id": "f9MY_jyhZB6ng-Fp--Exn",
"type": "text",
"x": 1107.8638629851216,
"y": -25.010320826830863,
"width": 193.7798309326172,
"height": 50,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "dashed",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": "aU",
"roundness": null,
"seed": 1827590818,
"version": 61,
"versionNonce": 1563426338,
"isDeleted": false,
"boundElements": null,
"updated": 1756647330897,
"link": null,
"locked": false,
"text": "group to 1 symbol if\nscreen is thin",
"fontSize": 20,
"fontFamily": 5,
"textAlign": "center",
"verticalAlign": "middle",
"containerId": "Zq3EuupF1HmOWACV2oefy",
"originalText": "group to 1 symbol if screen is thin",
"autoResize": true,
"lineHeight": 1.25
}
],
"appState": {

View File

@ -810,165 +810,6 @@
"originalText": "Configure",
"autoResize": true,
"lineHeight": 1.35
},
{
"id": "iIDobnzWCl-gygCOsA73n",
"type": "rectangle",
"x": 576.109131998714,
"y": -56.43111661983278,
"width": 109.23454710748254,
"height": 36.33306860750372,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "#ffffff",
"fillStyle": "hachure",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [
"2xO--DSh2411Pyp1YG0B4"
],
"frameId": null,
"index": "ac",
"roundness": null,
"seed": 1897278824,
"version": 221,
"versionNonce": 536065304,
"isDeleted": false,
"boundElements": [],
"updated": 1756647186276,
"link": null,
"locked": false
},
{
"type": "rectangle",
"version": 825,
"versionNonce": 921522712,
"isDeleted": false,
"id": "rumws8Xb5KM1-COUn-SjA",
"fillStyle": "hachure",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"angle": 0,
"x": 588.2383018113569,
"y": -45.08971236581036,
"strokeColor": "#000000",
"backgroundColor": "#868e96",
"width": 22.637490885793227,
"height": 13.582494531475936,
"seed": 331104360,
"groupIds": [
"1FZGUtYp_0lg0mZX7lxmQ",
"2xO--DSh2411Pyp1YG0B4"
],
"boundElements": [],
"updated": 1756647186276,
"link": null,
"locked": false,
"index": "ad",
"frameId": null,
"roundness": null
},
{
"type": "line",
"version": 842,
"versionNonce": 1454067480,
"isDeleted": false,
"id": "l8Hqi6JegDC-MxE036F3N",
"fillStyle": "hachure",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"angle": 0,
"x": 601.8207963428326,
"y": -45.08971236581036,
"strokeColor": "#000000",
"backgroundColor": "#868e96",
"width": 13.582494531475936,
"height": 4.5274981771586456,
"seed": 1042805608,
"groupIds": [
"1FZGUtYp_0lg0mZX7lxmQ",
"2xO--DSh2411Pyp1YG0B4"
],
"boundElements": [],
"updated": 1756647186276,
"link": null,
"locked": false,
"startBinding": null,
"endBinding": null,
"lastCommittedPoint": null,
"startArrowhead": null,
"endArrowhead": null,
"points": [
[
0,
0
],
[
-4.5274981771586456,
-4.5274981771586456
],
[
-13.582494531475934,
-4.527498177158644
],
[
-13.582494531475936,
-8.881784197001252e-16
],
[
0,
0
]
],
"index": "ae",
"frameId": null,
"roundness": null,
"polygon": false
},
{
"id": "C8V0VrPmqft0_wEEXIh2G",
"type": "text",
"x": 624.1500441133635,
"y": -52.606675675678645,
"width": 49.27995300292969,
"height": 27,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "#ffffff",
"fillStyle": "hachure",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [
"2xO--DSh2411Pyp1YG0B4"
],
"frameId": null,
"index": "af",
"roundness": null,
"seed": 648365672,
"version": 132,
"versionNonce": 664906776,
"isDeleted": false,
"boundElements": [],
"updated": 1756647186276,
"link": null,
"locked": false,
"text": "Open",
"fontSize": 20,
"fontFamily": 6,
"textAlign": "left",
"verticalAlign": "top",
"containerId": null,
"originalText": "Open",
"autoResize": true,
"lineHeight": 1.35
}
],
"appState": {

File diff suppressed because it is too large Load Diff

View File

@ -16,7 +16,7 @@ Route: root
Design notes:
- Central drop zone with hint text: “Drag a PDF here or click to select”.
- Minimal top bar with app name and a gear icon for settings.
- Minimal top bar with app name and a "Configure" button with a gear icon for settings.
- Clean layout encouraging first action.
Illustration:
@ -29,7 +29,7 @@ Purpose: provide basic configuration before/after opening a PDF.
Route: root --> settings
Design notes:
- Opened via gear icon in the top bar.
- Opened via "Configure" button in the top bar.
- Modal with simple sections (e.g., General, Display).
- Primary action to save, secondary to cancel.
@ -39,13 +39,35 @@ Illustration:
## PDF opened
Purpose: view and navigate the PDF; prepare for signature placement.
Purpose: view and navigate the PDF; for signature placement.
Route: root --> opened
Design notes:
- Main canvas shows the current page.
- Navigation: previous/next page, zoom controls are placed in toolbar which is at top of main PDF canvas.
- Drag signature onto page.
- Top: A small toolbar sits at the top edge with file name text, open pdf file button, previous/next page widgets and zoom controls.
- On the far left of the toolbar there is a button that can turn the document pages overview sidebar on and off.
- On the far right of the toolbar there is a button that can turn the signature cards overview sidebar on and off.
- Navigation: Previous page, Next page, and a page number input (e.g., “2 / 10”) with jump-on-Enter.
- Zoom: Zoom out, Zoom level dropdown (percent), Zoom in, Fit width, Fit page, Reset zoom.
- Optional: Find/search within PDF (if supported by engine).
- Left pane: vertical strip of page thumbnails (e.g., page1, page2, page3). Clicking a thumbnail navigates to that page; the current page is visually indicated.
- Center: main PDF viewer shows the active page.
- wheel to scroll pages.
- Ctrl/Cmd + wheel to zoom.
- Right pane: signatures drawer displaying saved signatures as cards.
- able to drag and drop signature cards onto the PDF as placed signatures.
- Each signature card shows a preview.
- long tap/right-click will show menu with options to delete, adjust graphic of image.
- "adjust graphic" opens a simple image editor, which can remove backgrounds, Rotate (rotation handle).
- There is an empty card with "new signature" prompt and 2 buttons: "from file" and "draw".
- "from file" opens a file picker to select an image as a signature card.
- "draw" opens a simple drawing interface (draw canvas) to create a signature card.
- Interaction: drag a signature card from the right drawer onto the currently visible page to place it.
Signature controls (after placing on page):
- Select to show bounding box with resize handles and a small inline action bar.
- Actions: Move (drag), Resize (corner/side handles), Delete (trash icon or Delete key).
- Lock: Lock/Unlock position.
- Keyboard: Arrow keys to nudge (Shift for 10px); Shift-resize to keep aspect; Esc to cancel; Ctrl/Cmd+D to duplicate; Del/Backspace to delete.
Illustration:

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

@ -6,6 +6,7 @@ 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/welcome/widgets/welcome_screen.dart';
import 'ui/features/preferences/providers.dart';
import 'package:pdf_signature/ui/features/preferences/widgets/settings_screen.dart';
class MyApp extends StatelessWidget {
const MyApp({super.key});
@ -62,7 +63,27 @@ class MyApp extends StatelessWidget {
...AppLocalizations.localizationsDelegates,
LocaleNamesLocalizationsDelegate(),
],
home: const _RootHomeSwitcher(),
home: Builder(
builder:
(ctx) => Scaffold(
appBar: AppBar(
title: Text(AppLocalizations.of(ctx).appTitle),
actions: [
OutlinedButton.icon(
key: const Key('btn_appbar_settings'),
icon: const Icon(Icons.settings),
label: Text(AppLocalizations.of(ctx).settings),
onPressed:
() => showDialog<bool>(
context: ctx,
builder: (_) => const SettingsDialog(),
),
),
],
),
body: const _RootHomeSwitcher(),
),
),
);
},
);

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

@ -26,6 +26,7 @@
"longPressOrRightClickTheSignatureToConfirmOrDelete": "Drücken Sie lange oder klicken Sie mit der rechten Maustaste auf die Signatur, um sie zu bestätigen oder zu löschen.",
"next": "Weiter",
"noPdfLoaded": "Keine PDF-Datei geladen",
"noSignatureLoaded": "Keine Signatur geladen",
"nothingToSaveYet": "Noch nichts zu speichern",
"openPdf": "PDF öffnen...",
"pageInfo": "Seite {current}/{total}",

View File

@ -61,6 +61,8 @@
"@next": {},
"noPdfLoaded": "No PDF loaded",
"@noPdfLoaded": {},
"noSignatureLoaded": "No signature loaded",
"@noSignatureLoaded": {},
"nothingToSaveYet": "Nothing to save yet",
"@nothingToSaveYet": {},
"openPdf": "Open PDF...",

View File

@ -26,6 +26,7 @@
"longPressOrRightClickTheSignatureToConfirmOrDelete": "Pulse prolongadamente o haga clic derecho en la firma para confirmar o eliminar.",
"next": "Siguiente",
"noPdfLoaded": "No se ha cargado ningún PDF",
"noSignatureLoaded": "No se ha cargado ninguna firma",
"nothingToSaveYet": "Aún no hay nada que guardar",
"openPdf": "Abrir PDF...",
"pageInfo": "Página {current}/{total}",

View File

@ -26,6 +26,7 @@
"longPressOrRightClickTheSignatureToConfirmOrDelete": "Appuyez longuement ou cliquez droit sur la signature pour la confirmer ou la supprimer.",
"next": "Suivant",
"noPdfLoaded": "Aucun PDF chargé",
"noSignatureLoaded": "Aucune signature chargée",
"nothingToSaveYet": "Rien à enregistrer pour le moment",
"openPdf": "Ouvrir un PDF...",
"pageInfo": "Page {current}/{total}",

View File

@ -26,6 +26,7 @@
"longPressOrRightClickTheSignatureToConfirmOrDelete": "署名を長押しまたは右クリックして、確認または削除します。",
"next": "次へ",
"noPdfLoaded": "PDFが読み込まれていません",
"noSignatureLoaded": "署名は読み込まれていません",
"nothingToSaveYet": "まだ保存するものがありません",
"openPdf": "PDFを開く…",
"pageInfo": "ページ {current}/{total}",

View File

@ -26,6 +26,7 @@
"longPressOrRightClickTheSignatureToConfirmOrDelete": "서명을 길게 누르거나 마우스 오른쪽 버튼을 클릭하여 확인하거나 삭제합니다.",
"next": "다음",
"noPdfLoaded": "로드된 PDF 없음",
"noSignatureLoaded": "서명이 로드되지 않았습니다",
"nothingToSaveYet": "아직 저장할 내용이 없습니다.",
"openPdf": "PDF 열기...",
"pageInfo": "{current}/{total} 페이지",

View File

@ -26,6 +26,7 @@
"longPressOrRightClickTheSignatureToConfirmOrDelete": "Довго натисніть або клацніть правою кнопкою миші на підпис, щоб підтвердити або видалити.",
"next": "Далі",
"noPdfLoaded": "PDF не завантажено",
"noSignatureLoaded": "Не завантажено жодного підпису",
"nothingToSaveYet": "Ще нічого не потрібно зберігати",
"openPdf": "Відкрити PDF...",
"pageInfo": "Сторінка {current}/{total}",

View File

@ -27,6 +27,7 @@
"longPressOrRightClickTheSignatureToConfirmOrDelete": "長按或右鍵點擊簽名以確認或刪除。",
"next": "下一頁",
"noPdfLoaded": "尚未載入 PDF",
"noSignatureLoaded": "没有加载签名",
"nothingToSaveYet": "尚無可儲存的內容",
"openPdf": "開啟 PDF…",
"pageInfo": "第 {current}/{total} 頁",

View File

@ -26,6 +26,7 @@
"longPressOrRightClickTheSignatureToConfirmOrDelete": "长按或右键单击签名以确认或删除。",
"next": "下一步",
"noPdfLoaded": "未加载 PDF",
"noSignatureLoaded": "未加载签名",
"nothingToSaveYet": "尚无内容保存",
"openPdf": "打开 PDF...",
"pageInfo": "第 {current} 页 / 共 {total} 页",

View File

@ -27,6 +27,7 @@
"longPressOrRightClickTheSignatureToConfirmOrDelete": "長按或右鍵點擊簽名以確認或刪除。",
"next": "下一頁",
"noPdfLoaded": "尚未載入 PDF",
"noSignatureLoaded": "未載入任何簽名",
"nothingToSaveYet": "尚無可儲存的內容",
"openPdf": "開啟 PDF…",
"pageInfo": "第 {current}/{total} 頁",

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);
@ -251,6 +252,16 @@ class SignatureController extends StateNotifier<SignatureState> {
state = state.copyWith(editingEnabled: true);
}
void clearImage() {
state = state.copyWith(imageBytes: null, rect: null, editingEnabled: false);
}
void placeAtCenter(Offset center, {double width = 120, double height = 60}) {
Rect r = Rect.fromCenter(center: center, width: width, height: height);
r = _clampRectToPage(r);
state = state.copyWith(rect: r, editingEnabled: true);
}
// Confirm current signature: freeze editing and place it on the PDF as an immutable overlay.
// Returns the Rect placed, or null if no rect to confirm.
Rect? confirmCurrentSignature(WidgetRef ref) {
@ -298,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
@ -342,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

@ -9,21 +9,23 @@ import '../../../../data/services/providers.dart';
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({
super.key,
required this.pageSize,
this.controller,
required this.onDragSignature,
required this.onResizeSignature,
required this.onConfirmSignature,
required this.onClearActiveOverlay,
required this.onSelectPlaced,
this.viewerController,
});
final Size pageSize;
final TransformationController? controller;
final PdfViewerController? viewerController;
final ValueChanged<Offset> onDragSignature;
final ValueChanged<Offset> onResizeSignature;
final VoidCallback onConfirmSignature;
@ -35,7 +37,8 @@ class PdfPageArea extends ConsumerStatefulWidget {
class _PdfPageAreaState extends ConsumerState<PdfPageArea> {
final Map<int, GlobalKey> _pageKeys = {};
final PdfViewerController _viewerController = PdfViewerController();
late final PdfViewerController _viewerController =
widget.viewerController ?? PdfViewerController();
// Guards to avoid scroll feedback between provider and viewer
int? _programmaticTargetPage;
bool _suppressProviderListen = false;
@ -58,6 +61,8 @@ class _PdfPageAreaState extends ConsumerState<PdfPageArea> {
});
}
// No dispose required for PdfViewerController (managed by owner if any)
GlobalKey _pageKey(int page) => _pageKeys.putIfAbsent(
page,
() => GlobalKey(debugLabel: 'cont_page_$page'),
@ -216,7 +221,7 @@ class _PdfPageAreaState extends ConsumerState<PdfPageArea> {
}
});
}
return SingleChildScrollView(
final content = SingleChildScrollView(
key: const Key('pdf_continuous_mock_list'),
padding: const EdgeInsets.symmetric(vertical: 8),
child: Column(
@ -270,6 +275,7 @@ class _PdfPageAreaState extends ConsumerState<PdfPageArea> {
}),
),
);
return content;
},
);
}
@ -277,14 +283,14 @@ class _PdfPageAreaState extends ConsumerState<PdfPageArea> {
// Real continuous mode (pdfrx): copy example patterns
// https://github.com/espresso3389/pdfrx/blob/2cc32c1e2aa2a054602d20a5e7cf60bcc2d6a889/packages/pdfrx/example/viewer/lib/main.dart
if (pdf.pickedPdfPath != null && isContinuous) {
return PdfViewer.file(
final viewer = PdfViewer.file(
pdf.pickedPdfPath!,
controller: _viewerController,
params: PdfViewerParams(
pageAnchor: PdfPageAnchor.top,
keyHandlerParams: PdfViewerKeyHandlerParams(autofocus: true),
maxScale: 8,
// scrollByMouseWheel: 0.6,
scrollByMouseWheel: 0.6,
// Add overlay scroll thumbs (vertical on right, horizontal on bottom)
viewerOverlayBuilder:
(context, size, handleLinkTap) => [
@ -294,7 +300,7 @@ class _PdfPageAreaState extends ConsumerState<PdfPageArea> {
thumbSize: const Size(40, 24),
thumbBuilder:
(context, thumbSize, pageNumber, controller) => Container(
color: Colors.black.withOpacity(0.7),
color: Colors.black.withValues(alpha: 0.7),
child: Center(
child: Text(
pageNumber.toString(),
@ -309,7 +315,7 @@ class _PdfPageAreaState extends ConsumerState<PdfPageArea> {
thumbSize: const Size(40, 24),
thumbBuilder:
(context, thumbSize, pageNumber, controller) => Container(
color: Colors.black.withOpacity(0.7),
color: Colors.black.withValues(alpha: 0.7),
child: Center(
child: Text(
pageNumber.toString(),
@ -375,6 +381,34 @@ class _PdfPageAreaState extends ConsumerState<PdfPageArea> {
},
),
);
// Accept drops of signature card over the viewer
final drop = DragTarget<Object>(
onWillAcceptWithDetails: (details) => details.data is SignatureDragData,
onAcceptWithDetails: (details) {
// Map the local position to UI page coordinates of the visible page
final box = context.findRenderObject() as RenderBox?;
if (box == null) return;
final local = box.globalToLocal(details.offset);
final size = box.size;
// Assume drop targets the current visible page; compute relative center
final cx = (local.dx / size.width) * widget.pageSize.width;
final cy = (local.dy / size.height) * widget.pageSize.height;
ref.read(signatureProvider.notifier).placeAtCenter(Offset(cx, cy));
ref
.read(pdfProvider.notifier)
.setSignedPage(ref.read(pdfProvider).currentPage);
},
builder:
(context, candidateData, rejected) => Stack(
fit: StackFit.expand,
children: [
viewer,
if (candidateData.isNotEmpty)
Container(color: Colors.blue.withValues(alpha: 0.08)),
],
),
);
return drop;
}
return const SizedBox.shrink();
@ -403,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) {
@ -411,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;
}
@ -524,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)
@ -577,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(),
);
}
});
},
@ -607,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

@ -5,15 +5,16 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:pdf_signature/l10n/app_localizations.dart';
import 'package:printing/printing.dart' as printing;
import 'package:pdfrx/pdfrx.dart';
import '../../../../data/services/providers.dart';
import '../view_model/view_model.dart';
import 'draw_canvas.dart';
import 'pdf_toolbar.dart';
import 'pdf_page_area.dart';
import 'adjustments_panel.dart';
import 'pdf_pages_overview.dart';
import '../../preferences/widgets/settings_screen.dart';
import 'signature_drawer.dart';
// adjustments are available via ImageEditorDialog
class PdfSignatureHomePage extends ConsumerStatefulWidget {
const PdfSignatureHomePage({super.key});
@ -25,7 +26,10 @@ class PdfSignatureHomePage extends ConsumerStatefulWidget {
class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
static const Size _pageSize = SignatureController.pageSize;
final TransformationController _ivController = TransformationController();
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
@ -52,6 +56,8 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
ref.read(pdfProvider.notifier).jumpTo(page);
}
// Zoom is managed by pdfrx viewer (Ctrl +/- etc.). No custom zoom here.
Future<void> _loadSignatureFromFile() async {
final typeGroup = const fs.XTypeGroup(
label: 'Image',
@ -68,25 +74,7 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
}
}
void _createNewSignature() {
final sig = ref.read(signatureProvider.notifier);
if (ref.read(pdfProvider).loaded) {
sig.placeDefaultRect();
ref
.read(pdfProvider.notifier)
.setSignedPage(ref.read(pdfProvider).currentPage);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
AppLocalizations.of(
context,
).longPressOrRightClickTheSignatureToConfirmOrDelete,
),
duration: const Duration(seconds: 3),
),
);
}
}
// _createNewSignature was removed as the toolbar no longer exposes this action.
void _confirmSignature() {
ref.read(signatureProvider.notifier).confirmCurrentSignature(ref);
@ -250,7 +238,6 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
@override
void dispose() {
_ivController.dispose();
super.dispose();
}
@ -259,64 +246,69 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
final isExporting = ref.watch(exportingProvider);
final l = AppLocalizations.of(context);
return Scaffold(
appBar: AppBar(
title: Text(l.appTitle),
actions: [
IconButton(
key: const Key('btn_appbar_settings'),
tooltip: l.settings,
onPressed:
() => showDialog<bool>(
context: context,
builder: (_) => const SettingsDialog(),
),
icon: const Icon(Icons.settings),
),
],
),
body: Padding(
padding: const EdgeInsets.all(12),
child: Stack(
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
Column(
children: [
// Left: pages overview (thumbnails + navigation)
ConstrainedBox(
constraints: const BoxConstraints(
minWidth: 140,
maxWidth: 180,
),
child: Card(
margin: EdgeInsets.zero,
child: const PdfPagesOverview(),
),
// Full-width toolbar row
PdfToolbar(
disabled: isExporting,
onPickPdf: _pickPdf,
onJumpToPage: _jumpToPage,
onZoomOut: () {
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,
onTogglePagesSidebar:
() => setState(() {
_showPagesSidebar = !_showPagesSidebar;
}),
onToggleSignaturesSidebar:
() => setState(() {
_showSignaturesSidebar = !_showSignaturesSidebar;
}),
),
const SizedBox(width: 12),
const SizedBox(height: 8),
Expanded(
child: Column(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
PdfToolbar(
disabled: isExporting,
onOpenSettings:
() => showDialog<bool>(
context: context,
builder: (_) => const SettingsDialog(),
),
onPickPdf: _pickPdf,
onJumpToPage: _jumpToPage,
onSave: _saveSignedPdf,
onLoadSignatureFromFile: _loadSignatureFromFile,
onCreateSignature: _createNewSignature,
onOpenDrawCanvas: _openDrawCanvas,
),
const SizedBox(height: 8),
if (_showPagesSidebar)
ConstrainedBox(
constraints: const BoxConstraints(
minWidth: 140,
maxWidth: 180,
),
child: Card(
margin: EdgeInsets.zero,
child: const PdfPagesOverview(),
),
),
if (_showPagesSidebar) const SizedBox(width: 12),
Expanded(
child: AbsorbPointer(
absorbing: isExporting,
child: PdfPageArea(
pageSize: _pageSize,
controller: _ivController,
viewerController: _viewerController,
onDragSignature: _onDragSignature,
onResizeSignature: _onResizeSignature,
onConfirmSignature: _confirmSignature,
@ -329,108 +321,45 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
),
),
),
],
),
),
const SizedBox(width: 12),
ConstrainedBox(
constraints: const BoxConstraints(
minWidth: 280,
maxWidth: 360,
),
child: Consumer(
builder: (context, ref, _) {
final sig = ref.watch(signatureProvider);
if (sig.rect != null) {
return AbsorbPointer(
absorbing: isExporting,
child: Card(
margin: EdgeInsets.zero,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Signature preview
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: Consumer(
builder: (context, ref, _) {
final bytes =
ref.watch(
processedSignatureImageProvider,
) ??
sig.imageBytes;
if (bytes == null) {
return Center(
child: Text(
AppLocalizations.of(
context,
).noPdfLoaded,
),
);
}
return Image.memory(
bytes,
fit: BoxFit.contain,
);
},
),
),
),
),
],
),
),
const Divider(height: 1),
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.all(12),
child: AdjustmentsPanel(sig: sig),
),
),
],
),
if (_showSignaturesSidebar) const SizedBox(width: 12),
if (_showSignaturesSidebar)
ConstrainedBox(
constraints: const BoxConstraints(
minWidth: 140,
maxWidth: 250,
),
);
}
return Card(
margin: EdgeInsets.zero,
child: Center(
child: Padding(
padding: const EdgeInsets.all(12),
child: Text(
AppLocalizations.of(context).signature,
style: Theme.of(context).textTheme.bodyMedium,
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

@ -10,23 +10,30 @@ class PdfToolbar extends ConsumerStatefulWidget {
const PdfToolbar({
super.key,
required this.disabled,
required this.onOpenSettings,
required this.onPickPdf,
required this.onJumpToPage,
required this.onSave,
required this.onLoadSignatureFromFile,
required this.onCreateSignature,
required this.onOpenDrawCanvas,
required this.onZoomOut,
required this.onZoomIn,
this.zoomLevel,
this.fileName,
required this.showPagesSidebar,
required this.showSignaturesSidebar,
required this.onTogglePagesSidebar,
required this.onToggleSignaturesSidebar,
});
final bool disabled;
final VoidCallback onOpenSettings;
final VoidCallback onPickPdf;
final ValueChanged<int> onJumpToPage;
final VoidCallback onSave;
final VoidCallback onLoadSignatureFromFile;
final VoidCallback onCreateSignature;
final VoidCallback onOpenDrawCanvas;
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;
final VoidCallback onToggleSignaturesSidebar;
@override
ConsumerState<PdfToolbar> createState() => _PdfToolbarState();
@ -57,21 +64,34 @@ class _PdfToolbarState extends ConsumerState<PdfToolbar> {
return LayoutBuilder(
builder: (context, constraints) {
final bool compact = constraints.maxWidth < 260;
final double gotoWidth = compact ? 60 : 100;
return Wrap(
final double gotoWidth = 50;
// Center content of the toolbar
final center = Wrap(
spacing: 8,
runSpacing: 8,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
OutlinedButton(
key: const Key('btn_open_settings'),
onPressed: widget.disabled ? null : widget.onOpenSettings,
child: Text(l.settings),
),
OutlinedButton(
key: const Key('btn_open_pdf_picker'),
onPressed: widget.disabled ? null : widget.onPickPdf,
child: Text(l.openPdf),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.insert_drive_file, size: 18),
const SizedBox(width: 6),
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 220),
child: Text(
// if filename not null
widget.fileName != null
? widget.fileName!
: 'No file selected',
overflow: TextOverflow.ellipsis,
),
),
],
),
),
if (pdf.loaded) ...[
Row(
@ -86,6 +106,7 @@ class _PdfToolbarState extends ConsumerState<PdfToolbar> {
icon: const Icon(Icons.chevron_left),
tooltip: l.prev,
),
// Current page label
Text(pageInfo, key: const Key('lbl_page_info')),
IconButton(
key: const Key('btn_next'),
@ -96,36 +117,59 @@ class _PdfToolbarState extends ConsumerState<PdfToolbar> {
icon: const Icon(Icons.chevron_right),
tooltip: l.next,
),
],
),
Wrap(
spacing: 6,
runSpacing: 4,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
Text(l.goTo),
SizedBox(
width: gotoWidth,
child: TextField(
key: const Key('txt_goto'),
controller: _goToController,
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
enabled: !widget.disabled,
decoration: InputDecoration(
isDense: true,
hintText: '1..${pdf.pageCount}',
Wrap(
spacing: 6,
runSpacing: 4,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
Text(l.goTo),
SizedBox(
width: gotoWidth,
child: TextField(
key: const Key('txt_goto'),
controller: _goToController,
keyboardType: TextInputType.number,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
],
enabled: !widget.disabled,
decoration: InputDecoration(
isDense: true,
hintText: '1..${pdf.pageCount}',
),
onSubmitted: (_) => _submitGoTo(),
),
),
onSubmitted: (_) => _submitGoTo(),
),
if (!compact)
IconButton(
key: const Key('btn_goto_apply'),
tooltip: l.goTo,
icon: const Icon(Icons.arrow_forward),
onPressed: widget.disabled ? null : _submitGoTo,
),
],
),
if (!compact)
IconButton(
key: const Key('btn_goto_apply'),
tooltip: l.goTo,
icon: const Icon(Icons.arrow_forward),
onPressed: widget.disabled ? null : _submitGoTo,
const SizedBox(width: 8),
IconButton(
key: const Key('btn_zoom_out'),
tooltip: 'Zoom out',
onPressed: widget.disabled ? null : widget.onZoomOut,
icon: const Icon(Icons.zoom_out),
),
IconButton(
key: const Key('btn_zoom_in'),
tooltip: 'Zoom in',
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(
@ -156,38 +200,42 @@ class _PdfToolbarState extends ConsumerState<PdfToolbar> {
),
],
),
ElevatedButton(
key: const Key('btn_save_pdf'),
onPressed: widget.disabled ? null : widget.onSave,
child: Text(l.saveSignedPdf),
),
OutlinedButton(
key: const Key('btn_load_signature_picker'),
onPressed:
widget.disabled || !pdf.loaded
? null
: widget.onLoadSignatureFromFile,
child: Text(l.loadSignatureFromFile),
),
OutlinedButton(
key: const Key('btn_create_signature'),
onPressed:
widget.disabled || !pdf.loaded
? null
: widget.onCreateSignature,
child: Text(l.createNewSignature),
),
ElevatedButton(
key: const Key('btn_draw_signature'),
onPressed:
widget.disabled || !pdf.loaded
? null
: widget.onOpenDrawCanvas,
child: Text(l.drawSignature),
),
],
],
);
return Row(
children: [
IconButton(
key: const Key('btn_toggle_pages_sidebar'),
tooltip: 'Toggle pages overview',
onPressed: widget.disabled ? null : widget.onTogglePagesSidebar,
icon: Icon(
Icons.view_sidebar,
color:
widget.showPagesSidebar
? Theme.of(context).colorScheme.primary
: null,
),
),
const SizedBox(width: 8),
Expanded(child: center),
const SizedBox(width: 8),
IconButton(
key: const Key('btn_toggle_signatures_sidebar'),
tooltip: 'Toggle signatures drawer',
onPressed:
widget.disabled ? null : widget.onToggleSignaturesSidebar,
icon: Icon(
Icons.view_sidebar,
color:
widget.showSignaturesSidebar
? Theme.of(context).colorScheme.primary
: null,
),
),
],
);
},
);
}

View File

@ -0,0 +1,217 @@
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:pdf_signature/l10n/app_localizations.dart';
import '../../../../data/services/providers.dart';
import '../view_model/view_model.dart';
import 'image_editor_dialog.dart';
/// Data passed when dragging a signature card.
class SignatureDragData {
const SignatureDragData();
}
class SignatureDrawer extends ConsumerStatefulWidget {
const SignatureDrawer({
super.key,
required this.disabled,
required this.onLoadSignatureFromFile,
required this.onOpenDrawCanvas,
});
final bool disabled;
final VoidCallback onLoadSignatureFromFile;
final VoidCallback onOpenDrawCanvas;
@override
ConsumerState<SignatureDrawer> createState() => _SignatureDrawerState();
}
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);
final sig = ref.watch(signatureProvider);
final processed = ref.watch(processedSignatureImageProvider);
final bytes = processed ?? sig.imageBytes;
final isExporting = ref.watch(exportingProvider);
final disabled = widget.disabled || isExporting;
return Card(
margin: EdgeInsets.zero,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Header
Padding(
padding: const EdgeInsets.fromLTRB(12, 12, 12, 8),
child: Text(
l.signature,
style: Theme.of(context).textTheme.titleSmall,
),
),
// Existing signature card (draggable when bytes available)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: DecoratedBox(
decoration: BoxDecoration(
border: Border.all(color: Theme.of(context).dividerColor),
borderRadius: BorderRadius.circular(8),
),
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,
),
),
),
),
),
const SizedBox(height: 12),
const Divider(height: 1),
// New signature card
Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
l.createNewSignature,
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
OutlinedButton.icon(
key: const Key('btn_drawer_load_signature'),
onPressed:
disabled ? null : widget.onLoadSignatureFromFile,
icon: const Icon(Icons.image_outlined),
label: Text(l.loadSignatureFromFile),
),
OutlinedButton.icon(
key: const Key('btn_drawer_draw_signature'),
onPressed: disabled ? null : widget.onOpenDrawCanvas,
icon: const Icon(Icons.gesture),
label: Text(l.drawSignature),
),
],
),
],
),
),
// Adjustments are accessed via "Adjust graphic" in the popup menu
],
),
);
}
}
class _DraggableSignaturePreview extends StatelessWidget {
const _DraggableSignaturePreview({
required this.bytes,
required this.disabled,
});
final Uint8List bytes;
final bool disabled;
@override
Widget build(BuildContext context) {
final child = Padding(
padding: const EdgeInsets.all(8.0),
child: Image.memory(bytes, fit: BoxFit.contain),
);
if (disabled) return child;
return Draggable<SignatureDragData>(
data: const SignatureDragData(),
feedback: Opacity(
opacity: 0.8,
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: child),
child: child,
);
}
}

View File

@ -8,7 +8,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:pdf_signature/l10n/app_localizations.dart';
import '../../pdf/view_model/view_model.dart';
import '../../preferences/widgets/settings_screen.dart';
// Settings dialog is provided via global AppBar in MyApp
// Abstraction to make drop handling testable without constructing
// platform-specific DropItem types in widget tests.
@ -131,33 +131,19 @@ class _WelcomeScreenState extends ConsumerState<WelcomeScreen> {
),
color:
_dragging
? Theme.of(context).colorScheme.primary.withOpacity(0.05)
? Theme.of(
context,
).colorScheme.primary.withValues(alpha: 0.05)
: Colors.transparent,
),
child: content,
),
);
return Scaffold(
appBar: AppBar(
title: Text(l.appTitle),
actions: [
IconButton(
tooltip: l.settings,
onPressed:
() => showDialog<bool>(
context: context,
builder: (_) => const SettingsDialog(),
),
icon: const Icon(Icons.settings),
),
],
),
body: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 560),
child: dropZone,
),
return Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 560),
child: dropZone,
),
);
}

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