Compare commits

...

21 Commits

Author SHA1 Message Date
insleker fdf0d1f7a9 feat: able to add multi signatures on document view 2025-09-03 20:55:16 +08:00
insleker 0a21045761 test: removed unused *.feature files 2025-09-03 19:12:27 +08:00
insleker 8507dcf6f5 feat: basic signature overlay on document implement 2025-09-03 18:05:34 +08:00
insleker f4bd486ad9 doces: add AGENTS.md for better hinting LLM 2025-09-03 17:03:16 +08:00
insleker 8e2599c0f8 refactor: split pdf_page_area.dart to multi smaller files 2025-09-03 14:08:35 +08:00
insleker 0969ec2931 feat: add multi split view to display sidebars 2025-09-02 23:08:58 +08:00
insleker 1acd95fc94 feat: modify layout of signature sidebar 2025-09-02 22:17:48 +08:00
insleker 51bf7ed979 Merge branch 'feat/wireframe' into feat/multi_signature_picture 2025-09-02 18:50:58 +08:00
insleker cc8e20d310 feat: feat partially implement signature card UI view 2025-09-02 18:43:44 +08:00
insleker 39ecf7c617 docs: update document to build web and linux 2025-09-02 18:28:09 +08:00
insleker 5ae266d008 Merge branch 'feat/util' into feat/multi_signature_picture 2025-09-02 18:24:26 +08:00
insleker eaaf943c87 feat: support linux build appimage 2025-09-02 18:07:21 +08:00
insleker db0912b12f feat: partially update UI view to new design 2025-09-02 15:19:36 +08:00
insleker d3df15d695 docs: update wireframe, introduce signature cards abstraction 2025-09-02 12:36:30 +08:00
insleker 947c0eef81 feat: partially implement mult_signature_picture 2025-09-02 11:33:46 +08:00
insleker df1bf27553 refactor: remove single-page mode and enforce continuous view across preferences and settings 2025-09-02 10:59:52 +08:00
insleker 51c2a403c4 docs: add multi signature picture in user stories and use cases 2025-09-02 10:35:44 +08:00
insleker fc6e56c9ee fix: continuous PageAreaView not scroll depend on overview 2025-09-01 17:08:18 +08:00
insleker eb19022572 test: fix tests steps implement 2025-09-01 10:20:57 +08:00
insleker ad37861303 feat: partially implement new UI design 2025-09-01 00:43:45 +08:00
insleker abbaf462e1 feat: add wireframe in docs 2025-08-31 21:40:53 +08:00
141 changed files with 8241 additions and 1356 deletions

9
.gitignore vendored
View File

@ -125,3 +125,12 @@ devtools_options.yaml
test/features/*_test.dart
**/app_localizations*.dart
.env
docs/wireframe.assets/*.excalidraw.svg
docs/wireframe.assets/*.svg
docs/wireframe.assets/*.png
node_modules/
AppDir/.DirIcon
AppDir/bundle/
appimage-build/
/*.AppImage
.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"
]
}

12
AGENTS.md Normal file
View File

@ -0,0 +1,12 @@
# AGENTS
Always read `README.md` and `docs/meta-arch.md` when new chat created.
Additionally read relevant files depends on task.
* If want to modify use cases (files at `test/features/*.feature`)
* read `docs/FRs.md`
* If want to modify code (implement or test) of `View` of MVVM (UI widget) (files at `lib/ui/features/*/widgets/*`)
* read `docs/wireframe.md`, `docs/NFRs.md`, `test/features/*.feature`
* If want to modify code (implement or test) of non-View e.g. `Model`, `View Model`, services...
* read `test/features/*.feature`, `docs/NFRs.md`

5
AppDir/AppRun Executable file
View File

@ -0,0 +1,5 @@
#!/bin/sh
export LD_LIBRARY_PATH="${APPDIR}/bundle/lib"
exec $APPDIR/bundle/pdf_signature "$@"

27
AppDir/pdf_signature-icon.svg Executable file
View File

@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg version="1.1" id="_x32_" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
viewBox="0 0 512 512" xml:space="preserve">
<style type="text/css">
.st0{fill:#000000;}
</style>
<g>
<path class="st0" d="M56.007,114.35c-5.535-5.539-14.51-5.539-20.045,0L4.148,146.159c-5.531,5.539-5.531,14.506,0,20.046
l20.622,20.621l51.859-51.855L56.007,114.35z"/>
<polygon class="st0" points="286.422,396.623 268.742,327.077 216.884,378.94 "/>
<path class="st0" d="M258.136,316.475L86.058,144.397L34.2,196.26l172.073,172.077L258.136,316.475z M87.468,166.56
l149.919,149.922l-11.784,11.78L75.684,178.348L87.468,166.56z"/>
<rect x="195.662" y="132.491" class="st0" width="29.356" height="28.017"/>
<rect x="195.662" y="200.693" class="st0" width="29.356" height="28.009"/>
<rect x="256.69" y="132.491" class="st0" width="173.056" height="28.017"/>
<rect x="256.69" y="200.693" class="st0" width="173.056" height="28.009"/>
<rect x="288.598" y="268.894" class="st0" width="141.148" height="28.01"/>
<path class="st0" d="M429.817,11.059H195.582c-45.32,0-82.182,36.858-82.182,82.179v32.726l30.427,30.435V93.238
c0-28.586,23.178-51.752,51.755-51.752h234.235c28.594,0,51.756,23.166,51.756,51.752v254.042h-80.097
c-23.822,0-43.124,19.318-43.124,43.132v80.101h-162.77c-28.578,0-51.755-23.166-51.755-51.752v-37.072l-6.234-1.587l6.234-6.235
v-22.202L113.4,321.239v97.522c0,45.313,36.862,82.179,82.182,82.179h162.77h12.598l8.917-8.913l123.224-123.224l8.909-8.912
v-12.61V93.238C512,47.917,475.138,11.059,429.817,11.059z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

7
AppDir/pdf_signature.desktop Executable file
View File

@ -0,0 +1,7 @@
[Desktop Entry]
Version=1.0
Type=Application
Name=pdf_signature
Exec=AppRun %U
Icon=pdf_signature-icon
Categories=Utility

View File

@ -1,28 +1,53 @@
# pdf_signature
A GUI app to create a signature on PDF page interactively.
A GUI app to create signatures on PDF pages interactively.
## Features
checkout [`docs/FRs.md`](docs/FRs.md)
## Build
## run
```bash
# flutter clean
# arb_translate
flutter pub get
# generate gherkin test
# > to generate gherkin test
flutter pub run build_runner build --delete-conflicting-outputs
# > to remove unused step definitions
# dart run tool/prune_unused_steps.dart --delete
# > to static analyze the code
flutter analyze
# > run unit tests and widget tests
flutter test
# > run integration tests
flutter test integration_test/ -d linux
# dart run tool/gen_view_wireframe_md.dart
# flutter pub run dead_code_analyzer
# run the app
flutter run
```
# run unit tests and widget tests
flutter test
### build
flutter build
For Windows
```bash
flutter build windows
# create windows installer
flutter pub run msix:create
```
For web
```bash
flutter build web
```
Open the `index.html` file in the `build/web` directory. Remove the `<base href="/">` to ensure proper routing on GitHub Pages.
For Linux
```bash
flutter build linux
cp -r build/linux/x64/release/bundle/ AppDir
appimagetool-x86_64.AppImage AppDir
```

View File

@ -12,6 +12,8 @@ include: package:flutter_lints/flutter.yaml
analyzer:
plugins:
- custom_lint
exclude:
- 'test/features/*_test.dart'
linter:
# The lint rules applied to this project can be customized in the
@ -27,6 +29,12 @@ linter:
rules:
# avoid_print: false # Uncomment to disable the `avoid_print` rule
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
unintended_html_in_doc_comment:
exclude:
- 'test/features/step/*.dart'
unnecessary_import:
exclude:
- 'test/features/step/*.dart'
# Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options

View File

@ -42,3 +42,7 @@
* role: user
* functionality: the ability to sign multiple locations within a PDF document
* benefit: documents requiring multiple signatures can be signed simultaneously
* name: [support multiple signature pictures](../test/features/support_multiple_signature_pictures.feature)
* role: user
* functionality: the ability to use different signature pictures for different signing locations
* benefit: close to real-world signing scenarios where every signature is not the same

View File

@ -1,4 +1,5 @@
# Non-Functional Requirements
* Package structure
* plz follow official [Package structure](https://docs.flutter.dev/app-architecture/case-study#package-structure) with a slight modification, put each `<FEATURE NAME>/`s in `features/` sub-directory under `ui/`.
* support multiple platforms (windows, linux, android, web)
* only FOSS libs can use
* should not exceed 350 lines of code per file

View File

@ -2,3 +2,23 @@
* [MVVM](https://docs.flutter.dev/app-architecture/guide)
## Package structure
The repo structure follows official [Package structure](https://docs.flutter.dev/app-architecture/case-study#package-structure) with slight modifications.
* 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` from MVVM of each component.
* `integration_test/` for integration tests. They should be volatile to follow UI layout changes.
## key dependencies
* [pdfrx](https://pub.dev/packages/pdfrx)
* [packages/pdfrx/example/viewer/lib/main.dart](https://github.com/espresso3389/pdfrx/blob/master/packages/pdfrx/example/viewer/lib/main.dart)
* When using pdfrx, developers should control view function e.g. zoom, scroll... by component of pdfrx e.g. `PdfViewer`, rather than introduce additional view.
* [PdfViewer could not be scrollable when nested inside SingleChildScrollView #27](https://github.com/espresso3389/pdfrx/issues/27)
* [How to zoom in PdfPageView #244](https://github.com/espresso3389/pdfrx/issues/244)
* So does overlay some widgets, they should be placed using the provided overlay builder.
* [Viewer Customization using Widget Overlay](https://pub.dev/documentation/pdfrx/latest/pdfrx/PdfViewerParams/viewerOverlayBuilder.html)
* [Per-page Customization using Widget Overlay](https://pub.dev/documentation/pdfrx/latest/pdfrx/PdfViewerParams/pageOverlaysBuilder.html)
* `pageOverlaysBuilder`

View File

@ -1,6 +1,4 @@
# Use cases
Use cases are derived from `FRs.md` (user stories) and `meta-arch.md`. Each Feature name matches the corresponding user story; scenarios focus on observable behavior without restating story details.
The Gherkin scenarios live in runnable BDD feature files under `test/features/`.
Use cases are derived from `FRs.md` (user stories) and `meta-arch.md`. Each Feature name matches the corresponding user story;
The use cases are written in runnable `Gherkin` BDD feature files at `test/features/*.feature`.

View File

@ -0,0 +1,619 @@
{
"type": "excalidraw",
"version": 2,
"source": "https://marketplace.visualstudio.com/items?itemName=pomdtr.excalidraw-editor",
"elements": [
{
"id": "topbar-rect",
"type": "rectangle",
"x": 100,
"y": 60,
"width": 1000,
"height": 60,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": "a1",
"roundness": null,
"seed": 102938475,
"version": 13,
"versionNonce": 1046349494,
"isDeleted": false,
"boundElements": [],
"updated": 1756623169996,
"link": null,
"locked": true
},
{
"id": "app-title",
"type": "text",
"x": 120,
"y": 78,
"width": 157.46356201171875,
"height": 32.400000000000006,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": "a2",
"roundness": null,
"seed": 29384756,
"version": 12,
"versionNonce": 1936113066,
"isDeleted": false,
"boundElements": [],
"updated": 1756626539328,
"link": null,
"locked": false,
"text": "PDF Signature",
"fontSize": 24,
"fontFamily": 6,
"textAlign": "left",
"verticalAlign": "top",
"containerId": null,
"originalText": "PDF Signature",
"autoResize": true,
"lineHeight": 1.35
},
{
"id": "gear-label",
"type": "text",
"x": 963.8866170247396,
"y": 77.8426585727268,
"width": 22.298202514648448,
"height": 32.400000000000006,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": "a3",
"roundness": null,
"seed": 657483920,
"version": 65,
"versionNonce": 1772931574,
"isDeleted": false,
"boundElements": [],
"updated": 1756626539329,
"link": null,
"locked": false,
"text": "⚙",
"fontSize": 24,
"fontFamily": 6,
"textAlign": "left",
"verticalAlign": "top",
"containerId": null,
"originalText": "⚙",
"autoResize": false,
"lineHeight": 1.35
},
{
"id": "dropzone",
"type": "rectangle",
"x": 333.73695373535156,
"y": 200,
"width": 646.2630462646484,
"height": 380,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "dashed",
"roughness": 2,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": "a4",
"roundness": null,
"seed": 1203948576,
"version": 60,
"versionNonce": 101404074,
"isDeleted": false,
"boundElements": [],
"updated": 1756623242751,
"link": null,
"locked": false
},
{
"id": "dropzone-text",
"type": "text",
"x": 472.91015625,
"y": 360.8137512207031,
"width": 330.1771240234375,
"height": 29.700000000000003,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": "a5",
"roundness": null,
"seed": 349857102,
"version": 49,
"versionNonce": 1627287722,
"isDeleted": false,
"boundElements": [],
"updated": 1756626554169,
"link": null,
"locked": false,
"text": "Drag a PDF here or click to select",
"fontSize": 22,
"fontFamily": 6,
"textAlign": "center",
"verticalAlign": "top",
"containerId": null,
"originalText": "Drag a PDF here or click to select",
"autoResize": true,
"lineHeight": 1.35
},
{
"id": "HDUAA1jeBtvEIyOKXpfoZ",
"type": "rectangle",
"x": 104.79165649414051,
"y": 132.72271728515625,
"width": 992.4998779296876,
"height": 609.8372192382812,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": "a7",
"roundness": null,
"seed": 1062493290,
"version": 216,
"versionNonce": 1679498294,
"isDeleted": false,
"boundElements": [],
"updated": 1756623165302,
"link": null,
"locked": true
},
{
"id": "seNguakcMVpYxvk1_bUwl",
"type": "rectangle",
"x": 117.97808983212423,
"y": 138.36198671280425,
"width": 186.65359497070312,
"height": 588.3137512207032,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": "a8",
"roundness": null,
"seed": 1121938218,
"version": 100,
"versionNonce": 68985642,
"isDeleted": false,
"boundElements": [],
"updated": 1756623709368,
"link": null,
"locked": true
},
{
"id": "MAi6lDHjaNHJBo71nAl6k",
"type": "rectangle",
"x": 954.10010250031,
"y": 71.06057985245275,
"width": 131.2138027615017,
"height": 39.35548782348633,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": "a9",
"roundness": null,
"seed": 419392886,
"version": 176,
"versionNonce": 1613190058,
"isDeleted": false,
"boundElements": [],
"updated": 1756623486247,
"link": null,
"locked": false
},
{
"id": "a9ckadf3R8Gt1Lj0hYIJ6",
"type": "text",
"x": 988.4383325728156,
"y": 79.27564977464226,
"width": 87.77992248535156,
"height": 27,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": "aC",
"roundness": null,
"seed": 1939498154,
"version": 54,
"versionNonce": 1142872874,
"isDeleted": false,
"boundElements": [],
"updated": 1756626539329,
"link": null,
"locked": false,
"text": "Configure",
"fontSize": 20,
"fontFamily": 6,
"textAlign": "left",
"verticalAlign": "top",
"containerId": null,
"originalText": "Configure",
"autoResize": true,
"lineHeight": 1.35
},
{
"id": "lvzip0DZjK1BX1vU0mU3a",
"type": "text",
"x": 127.02633848644416,
"y": 156.23160104146086,
"width": 125.17988586425781,
"height": 27,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": "aD",
"roundness": null,
"seed": 1730640234,
"version": 70,
"versionNonce": 909067382,
"isDeleted": false,
"boundElements": [],
"updated": 1756626539329,
"link": null,
"locked": false,
"text": "Recents PDFs",
"fontSize": 20,
"fontFamily": 6,
"textAlign": "left",
"verticalAlign": "top",
"containerId": null,
"originalText": "Recents PDFs",
"autoResize": true,
"lineHeight": 1.35
},
{
"id": "b6pbxZgWhjrgDKm1tXO6q",
"type": "rectangle",
"x": 139.80121370345802,
"y": 245.11180767937321,
"width": 144.32293701171875,
"height": 61.95306396484372,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": "aE",
"roundness": null,
"seed": 2064534762,
"version": 151,
"versionNonce": 356135274,
"isDeleted": false,
"boundElements": [
{
"type": "text",
"id": "PLmT2cpc8WPbVPqMhp1mj"
}
],
"updated": 1756623721330,
"link": null,
"locked": true
},
{
"id": "PLmT2cpc8WPbVPqMhp1mj",
"type": "text",
"x": 151.24275728255958,
"y": 262.5883396617951,
"width": 121.43984985351562,
"height": 27,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": "aEV",
"roundness": null,
"seed": 1997975402,
"version": 62,
"versionNonce": 2072704554,
"isDeleted": false,
"boundElements": [],
"updated": 1756626545173,
"link": null,
"locked": true,
"text": "Contracts.pdf",
"fontSize": 20,
"fontFamily": 6,
"textAlign": "center",
"verticalAlign": "middle",
"containerId": "b6pbxZgWhjrgDKm1tXO6q",
"originalText": "Contracts.pdf",
"autoResize": true,
"lineHeight": 1.35
},
{
"id": "ZwTarBKCbh_6-ai2BBxbd",
"type": "rectangle",
"x": 136.77459559364905,
"y": 332.55539084994604,
"width": 144.32293701171875,
"height": 61.95306396484372,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": "aF",
"roundness": null,
"seed": 1887190326,
"version": 183,
"versionNonce": 452484842,
"isDeleted": false,
"boundElements": [
{
"type": "text",
"id": "_6b2tHbyJ3kyxAsbpcuOQ"
}
],
"updated": 1756623723927,
"link": null,
"locked": true
},
{
"id": "_6b2tHbyJ3kyxAsbpcuOQ",
"type": "text",
"x": 167.46612391396155,
"y": 350.0319228323679,
"width": 82.93988037109375,
"height": 27,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": "aF8",
"roundness": null,
"seed": 55789302,
"version": 45,
"versionNonce": 258667510,
"isDeleted": false,
"boundElements": [],
"updated": 1756626547688,
"link": null,
"locked": true,
"text": "letter.pdf",
"fontSize": 20,
"fontFamily": 6,
"textAlign": "center",
"verticalAlign": "middle",
"containerId": "ZwTarBKCbh_6-ai2BBxbd",
"originalText": "letter.pdf",
"autoResize": true,
"lineHeight": 1.35
},
{
"id": "LZKe-Y7H7Hv_X4SY3R0Ps",
"type": "rectangle",
"x": 140.0471514747254,
"y": 426.35747418327946,
"width": 144.32293701171875,
"height": 64,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": "aG",
"roundness": null,
"seed": 519878826,
"version": 199,
"versionNonce": 1387376566,
"isDeleted": false,
"boundElements": [
{
"type": "text",
"id": "Cdjuphn_F45RGdoixJtbw"
}
],
"updated": 1756626549455,
"link": null,
"locked": true
},
{
"id": "Cdjuphn_F45RGdoixJtbw",
"type": "text",
"x": 146.3086947486512,
"y": 431.35747418327946,
"width": 131.7998504638672,
"height": 54,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": "aGV",
"roundness": null,
"seed": 512425590,
"version": 22,
"versionNonce": 776654506,
"isDeleted": false,
"boundElements": [],
"updated": 1756626549455,
"link": null,
"locked": true,
"text": "agrrements.pd\nf",
"fontSize": 20,
"fontFamily": 6,
"textAlign": "center",
"verticalAlign": "middle",
"containerId": "LZKe-Y7H7Hv_X4SY3R0Ps",
"originalText": "agrrements.pdf",
"autoResize": true,
"lineHeight": 1.35
},
{
"id": "clU9wDIu9F56jVex_8rtB",
"type": "rectangle",
"x": 128.37470003158302,
"y": 190.46775792893916,
"width": 160.67418077256946,
"height": 37,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": "aI",
"roundness": null,
"seed": 1511287414,
"version": 99,
"versionNonce": 2058216938,
"isDeleted": false,
"boundElements": [
{
"type": "text",
"id": "9PgedBB-dCbCj6yATTvA7"
}
],
"updated": 1756626539329,
"link": null,
"locked": false
},
{
"id": "9PgedBB-dCbCj6yATTvA7",
"type": "text",
"x": 179.26182398720368,
"y": 195.46775792893916,
"width": 58.899932861328125,
"height": 27,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": "aJ",
"roundness": null,
"seed": 199568682,
"version": 59,
"versionNonce": 1006323126,
"isDeleted": false,
"boundElements": [],
"updated": 1756626539329,
"link": null,
"locked": false,
"text": "search",
"fontSize": 20,
"fontFamily": 6,
"textAlign": "center",
"verticalAlign": "middle",
"containerId": "clU9wDIu9F56jVex_8rtB",
"originalText": "search",
"autoResize": true,
"lineHeight": 1.35
}
],
"appState": {
"gridSize": 20,
"gridStep": 5,
"gridModeEnabled": false,
"viewBackgroundColor": "#ffffff"
},
"files": {}
}

View File

@ -0,0 +1,822 @@
{
"type": "excalidraw",
"version": 2,
"source": "https://marketplace.visualstudio.com/items?itemName=pomdtr.excalidraw-editor",
"elements": [
{
"id": "RJI8QD55RACPAUCo2GKoo",
"type": "rectangle",
"x": -140.36046055385032,
"y": 11.486575452108639,
"width": 186.65359497070312,
"height": 588.3137512207032,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [
"zR9PoqVUXJn-p5Me-QWmK"
],
"frameId": null,
"index": "Zy",
"roundness": null,
"seed": 230239768,
"version": 198,
"versionNonce": 1765613558,
"isDeleted": false,
"boundElements": [],
"updated": 1756647250652,
"link": null,
"locked": false
},
{
"id": "VWdv1VE8pzad1EwZuyYgo",
"type": "text",
"x": -131.31221189953033,
"y": 29.35618978076525,
"width": 125.17988586425781,
"height": 27,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [
"zR9PoqVUXJn-p5Me-QWmK"
],
"frameId": null,
"index": "Zz",
"roundness": null,
"seed": 530295576,
"version": 153,
"versionNonce": 748520042,
"isDeleted": false,
"boundElements": [],
"updated": 1756647250652,
"link": null,
"locked": false,
"text": "Recents PDFs",
"fontSize": 20,
"fontFamily": 6,
"textAlign": "left",
"verticalAlign": "top",
"containerId": null,
"originalText": "Recents PDFs",
"autoResize": true,
"lineHeight": 1.35
},
{
"id": "WKRHjAMR0he0xqbtoR-bH",
"type": "rectangle",
"x": 0.024998256138360375,
"y": 98.93073527018237,
"width": 741.8097795758928,
"height": 474.05282317979203,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "#ffffff",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [
"nQmqS53zA9IffPy8AAZwV"
],
"frameId": null,
"index": "a0",
"roundness": null,
"seed": 1506495512,
"version": 111,
"versionNonce": 657893430,
"isDeleted": false,
"boundElements": [],
"updated": 1756647231508,
"link": null,
"locked": false
},
{
"id": "gtEzgCZGX1lFA4HjD_xY_",
"type": "text",
"x": 87.19040296469939,
"y": 121.50467923112484,
"width": 84.4264505231206,
"height": 30.474824347272346,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "#ffffff",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [
"nQmqS53zA9IffPy8AAZwV"
],
"frameId": null,
"index": "a1",
"roundness": null,
"seed": 532285720,
"version": 103,
"versionNonce": 1545526134,
"isDeleted": false,
"boundElements": [],
"updated": 1756647235527,
"link": null,
"locked": false,
"text": "Settings",
"fontSize": 22.57394396094248,
"fontFamily": 6,
"textAlign": "left",
"verticalAlign": "top",
"containerId": null,
"originalText": "Settings",
"autoResize": true,
"lineHeight": 1.35
},
{
"id": "pGsYJy75OHZYTrkjzSwnf",
"type": "text",
"x": 707.9738618906175,
"y": 115.86119324088922,
"width": 12.189847070657128,
"height": 27.42734191254511,
"angle": 0,
"strokeColor": "#111827",
"backgroundColor": "#ffffff",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [
"nQmqS53zA9IffPy8AAZwV"
],
"frameId": null,
"index": "a2",
"roundness": null,
"seed": 315172376,
"version": 103,
"versionNonce": 510082794,
"isDeleted": false,
"boundElements": [],
"updated": 1756647235527,
"link": null,
"locked": false,
"text": "×",
"fontSize": 20.31654956484823,
"fontFamily": 6,
"textAlign": "left",
"verticalAlign": "top",
"containerId": null,
"originalText": "×",
"autoResize": true,
"lineHeight": 1.35
},
{
"id": "blOqLsCAyD5v2UW23ped-",
"type": "text",
"x": 87.19040296469939,
"y": 166.65256715300978,
"width": 72.38740412180704,
"height": 27.42734191254511,
"angle": 0,
"strokeColor": "#111827",
"backgroundColor": "#ffffff",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [
"nQmqS53zA9IffPy8AAZwV"
],
"frameId": null,
"index": "a3",
"roundness": null,
"seed": 1877779224,
"version": 103,
"versionNonce": 1306524854,
"isDeleted": false,
"boundElements": [],
"updated": 1756647235527,
"link": null,
"locked": false,
"text": "General",
"fontSize": 20.31654956484823,
"fontFamily": 6,
"textAlign": "left",
"verticalAlign": "top",
"containerId": null,
"originalText": "General",
"autoResize": true,
"lineHeight": 1.35
},
{
"id": "de3wHTnwHHk_Sj716L0AD",
"type": "text",
"x": 109.76434692564192,
"y": 200.51348309442352,
"width": 84.84209960420075,
"height": 24.379859477817877,
"angle": 0,
"strokeColor": "#374151",
"backgroundColor": "#ffffff",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [
"nQmqS53zA9IffPy8AAZwV"
],
"frameId": null,
"index": "a4",
"roundness": null,
"seed": 899186712,
"version": 103,
"versionNonce": 2108936618,
"isDeleted": false,
"boundElements": [],
"updated": 1756647235527,
"link": null,
"locked": false,
"text": "Language:",
"fontSize": 18.059155168753982,
"fontFamily": 6,
"textAlign": "left",
"verticalAlign": "top",
"containerId": null,
"originalText": "Language:",
"autoResize": true,
"lineHeight": 1.35
},
{
"id": "u274PAAExIXPOY5MjR_Sl",
"type": "rectangle",
"x": 233.92103871082554,
"y": 191.48390551004653,
"width": 338.60915941413714,
"height": 36.118310337507964,
"angle": 0,
"strokeColor": "#6b7280",
"backgroundColor": "#ffffff",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [
"nQmqS53zA9IffPy8AAZwV"
],
"frameId": null,
"index": "a5",
"roundness": null,
"seed": 1942011160,
"version": 101,
"versionNonce": 1164637686,
"isDeleted": false,
"boundElements": [],
"updated": 1756647235527,
"link": null,
"locked": false
},
{
"id": "d97Kf0eOcwYJybE20fjfu",
"type": "text",
"x": 86.99933596770336,
"y": 269.5432989464112,
"width": 69.07583451216198,
"height": 27.42734191254511,
"angle": 0,
"strokeColor": "#111827",
"backgroundColor": "#ffffff",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [
"nQmqS53zA9IffPy8AAZwV"
],
"frameId": null,
"index": "a6",
"roundness": null,
"seed": 467229208,
"version": 112,
"versionNonce": 1398803562,
"isDeleted": false,
"boundElements": [],
"updated": 1756647235527,
"link": null,
"locked": false,
"text": "Display",
"fontSize": 20.31654956484823,
"fontFamily": 6,
"textAlign": "left",
"verticalAlign": "top",
"containerId": null,
"originalText": "Display",
"autoResize": true,
"lineHeight": 1.35
},
{
"id": "M1joCnME850ZafH3FL-E_",
"type": "text",
"x": 109.57327992864577,
"y": 303.40421488782493,
"width": 60.91367078245484,
"height": 24.379859477817877,
"angle": 0,
"strokeColor": "#374151",
"backgroundColor": "#ffffff",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [
"nQmqS53zA9IffPy8AAZwV"
],
"frameId": null,
"index": "a7",
"roundness": null,
"seed": 1356224280,
"version": 112,
"versionNonce": 465880886,
"isDeleted": false,
"boundElements": [],
"updated": 1756647235527,
"link": null,
"locked": false,
"text": "Theme:",
"fontSize": 18.059155168753982,
"fontFamily": 6,
"textAlign": "left",
"verticalAlign": "top",
"containerId": null,
"originalText": "Theme:",
"autoResize": true,
"lineHeight": 1.35
},
{
"id": "8Mp1jT3NSsibB3kogMubl",
"type": "rectangle",
"x": 233.9110668529509,
"y": 279.88530391848326,
"width": 338.60915941413714,
"height": 36.118310337507964,
"angle": 0,
"strokeColor": "#6b7280",
"backgroundColor": "#ffffff",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [
"nQmqS53zA9IffPy8AAZwV"
],
"frameId": null,
"index": "a8",
"roundness": null,
"seed": 925611032,
"version": 114,
"versionNonce": 87486250,
"isDeleted": false,
"boundElements": [],
"updated": 1756647235527,
"link": null,
"locked": false
},
{
"id": "P2kfltnFMgp1Hpns5eRsk",
"type": "text",
"x": 109.57327992864577,
"y": 337.2651308292386,
"width": 88.30944720085046,
"height": 24.379859477817877,
"angle": 0,
"strokeColor": "#374151",
"backgroundColor": "#ffffff",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [
"nQmqS53zA9IffPy8AAZwV"
],
"frameId": null,
"index": "a9",
"roundness": null,
"seed": 1154314520,
"version": 112,
"versionNonce": 1095921782,
"isDeleted": false,
"boundElements": [],
"updated": 1756647235527,
"link": null,
"locked": false,
"text": "Page view:",
"fontSize": 18.059155168753982,
"fontFamily": 6,
"textAlign": "left",
"verticalAlign": "top",
"containerId": null,
"originalText": "Page view:",
"autoResize": true,
"lineHeight": 1.35
},
{
"id": "vmM82c6vkYHi9E8_orBEx",
"type": "rectangle",
"x": 233.72997171382946,
"y": 328.23555324486165,
"width": 338.60915941413714,
"height": 36.118310337507964,
"angle": 0,
"strokeColor": "#6b7280",
"backgroundColor": "#ffffff",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [
"nQmqS53zA9IffPy8AAZwV"
],
"frameId": null,
"index": "aA",
"roundness": null,
"seed": 288329240,
"version": 110,
"versionNonce": 128154090,
"isDeleted": false,
"boundElements": [],
"updated": 1756647235527,
"link": null,
"locked": false
},
{
"id": "Q0v5ejctIV2msui0iDFEg",
"type": "rectangle",
"x": 414.5125903983653,
"y": 505.261726567147,
"width": 124.15669178518363,
"height": 40.63309912969646,
"angle": 0,
"strokeColor": "#1f2937",
"backgroundColor": "#ffffff",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [
"nQmqS53zA9IffPy8AAZwV"
],
"frameId": null,
"index": "aB",
"roundness": null,
"seed": 625347352,
"version": 101,
"versionNonce": 1373172150,
"isDeleted": false,
"boundElements": [],
"updated": 1756647235527,
"link": null,
"locked": false
},
{
"id": "QSD6mQUNvCKRLZtin0AHX",
"type": "text",
"x": 442.73002034954345,
"y": 514.291304151524,
"width": 55.13471219456543,
"height": 24.379859477817877,
"angle": 0,
"strokeColor": "#1f2937",
"backgroundColor": "#ffffff",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [
"nQmqS53zA9IffPy8AAZwV"
],
"frameId": null,
"index": "aC",
"roundness": null,
"seed": 1267001368,
"version": 103,
"versionNonce": 162573482,
"isDeleted": false,
"boundElements": [],
"updated": 1756647235527,
"link": null,
"locked": false,
"text": "Cancel",
"fontSize": 18.059155168753982,
"fontFamily": 6,
"textAlign": "left",
"verticalAlign": "top",
"containerId": null,
"originalText": "Cancel",
"autoResize": true,
"lineHeight": 1.35
},
{
"id": "fmP0hKBOaNa5Ge12TEwyD",
"type": "rectangle",
"x": 561.2432261444915,
"y": 505.261726567147,
"width": 146.7306357461261,
"height": 40.63309912969646,
"angle": 0,
"strokeColor": "#1f2937",
"backgroundColor": "#ffffff",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [
"nQmqS53zA9IffPy8AAZwV"
],
"frameId": null,
"index": "aD",
"roundness": null,
"seed": 1608525080,
"version": 101,
"versionNonce": 679299830,
"isDeleted": false,
"boundElements": [],
"updated": 1756647235527,
"link": null,
"locked": false
},
{
"id": "iXKBOvEPDEax0jv78PxKB",
"type": "text",
"x": 601.8763252741879,
"y": 514.291304151524,
"width": 39.54961113185798,
"height": 24.379859477817877,
"angle": 0,
"strokeColor": "#1f2937",
"backgroundColor": "#ffffff",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [
"nQmqS53zA9IffPy8AAZwV"
],
"frameId": null,
"index": "aE",
"roundness": null,
"seed": 533447192,
"version": 103,
"versionNonce": 554272618,
"isDeleted": false,
"boundElements": [],
"updated": 1756647235527,
"link": null,
"locked": false,
"text": "Save",
"fontSize": 18.059155168753982,
"fontFamily": 6,
"textAlign": "left",
"verticalAlign": "top",
"containerId": null,
"originalText": "Save",
"autoResize": true,
"lineHeight": 1.35
},
{
"id": "NjGNkNhtOARJpj03cEb51",
"type": "rectangle",
"x": -158.10173397972483,
"y": 0.8265845889135903,
"width": 992.4998779296876,
"height": 609.8372192382812,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": "aF",
"roundness": null,
"seed": 668606488,
"version": 100,
"versionNonce": 1234867048,
"isDeleted": false,
"boundElements": [],
"updated": 1756647200609,
"link": null,
"locked": false
},
{
"id": "bb7EhwFW0ROfjqxlOkqmm",
"type": "rectangle",
"x": -155.50977434430797,
"y": -69.53801908947162,
"width": 1000,
"height": 60,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [
"lHEbf-Drffn9NvXktYUuc"
],
"frameId": null,
"index": "aG",
"roundness": null,
"seed": 1825463576,
"version": 115,
"versionNonce": 1176442904,
"isDeleted": false,
"boundElements": [],
"updated": 1756647163877,
"link": null,
"locked": false
},
{
"id": "D19DG1HUQ57QlAvyGFl0V",
"type": "text",
"x": -135.50977434430797,
"y": -51.53801908947162,
"width": 157.46356201171875,
"height": 32.400000000000006,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [
"lHEbf-Drffn9NvXktYUuc"
],
"frameId": null,
"index": "aH",
"roundness": null,
"seed": 1647507992,
"version": 132,
"versionNonce": 1739871512,
"isDeleted": false,
"boundElements": [],
"updated": 1756647163877,
"link": null,
"locked": false,
"text": "PDF Signature",
"fontSize": 24,
"fontFamily": 6,
"textAlign": "left",
"verticalAlign": "top",
"containerId": null,
"originalText": "PDF Signature",
"autoResize": true,
"lineHeight": 1.35
},
{
"id": "PdeNL3GtypJzGH5vBfCOZ",
"type": "rectangle",
"x": 698.590328156002,
"y": -58.47743923701887,
"width": 131.2138027615017,
"height": 39.35548782348633,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [
"WavtKMDY86ikKkLfh5Q4r",
"lHEbf-Drffn9NvXktYUuc"
],
"frameId": null,
"index": "aI",
"roundness": null,
"seed": 1526462232,
"version": 123,
"versionNonce": 2107240984,
"isDeleted": false,
"boundElements": [],
"updated": 1756647163877,
"link": null,
"locked": false
},
{
"id": "RzeDdJRNeKB2rEjlgqsfH",
"type": "text",
"x": 708.3768426804317,
"y": -51.69536051674481,
"width": 22.298202514648448,
"height": 32.400000000000006,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [
"WavtKMDY86ikKkLfh5Q4r",
"lHEbf-Drffn9NvXktYUuc"
],
"frameId": null,
"index": "aJ",
"roundness": null,
"seed": 731730968,
"version": 125,
"versionNonce": 1589899032,
"isDeleted": false,
"boundElements": [],
"updated": 1756647163877,
"link": null,
"locked": false,
"text": "⚙",
"fontSize": 24,
"fontFamily": 6,
"textAlign": "left",
"verticalAlign": "top",
"containerId": null,
"originalText": "⚙",
"autoResize": false,
"lineHeight": 1.35
},
{
"id": "hdqpl5nHgrXR_nhbDZDQ-",
"type": "text",
"x": 732.9285582285077,
"y": -50.26236931482936,
"width": 87.77992248535156,
"height": 27,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [
"WavtKMDY86ikKkLfh5Q4r",
"lHEbf-Drffn9NvXktYUuc"
],
"frameId": null,
"index": "aK",
"roundness": null,
"seed": 2047545624,
"version": 125,
"versionNonce": 1671842840,
"isDeleted": false,
"boundElements": [],
"updated": 1756647163877,
"link": null,
"locked": false,
"text": "Configure",
"fontSize": 20,
"fontFamily": 6,
"textAlign": "left",
"verticalAlign": "top",
"containerId": null,
"originalText": "Configure",
"autoResize": true,
"lineHeight": 1.35
}
],
"appState": {
"gridSize": 20,
"gridStep": 5,
"gridModeEnabled": false,
"viewBackgroundColor": "#ffffff"
},
"files": {}
}

File diff suppressed because it is too large Load Diff

90
docs/wireframe.md Normal file
View File

@ -0,0 +1,90 @@
# Wireframes
This document contains the product wireframes drawn in Excalidraw. The editable sources live under `docs/wireframe.assets/*.excalidraw`. For easy viewing, we generate an SVG-based copy at `docs/.wireframe.md`.
<!--
Note: `.excalidraw.svg` is a special Excalidraw-flavored SVG. We keep `.excalidraw` as the editable source and export to `.svg` for documentation preview.
Refs:
- https://github.com/excalidraw/excalidraw
- https://github.com/excalidraw/svg-to-excalidraw
-->
## Welcome / First screen
Purpose: let the user open a PDF quickly via drag & drop or file picker.
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 "Configure" button with a gear icon for settings.
- Clean layout encouraging first action.
Illustration:
![](wireframe.assets/first_screen.excalidraw)
## Settings dialog
Purpose: provide basic configuration before/after opening a PDF.
Route: root --> settings
Design notes:
- Opened via "Configure" button in the top bar.
- Modal with simple sections (e.g., General, Display).
- Primary action to save, secondary to cancel.
Illustration:
![](wireframe.assets/with_configure_screen.excalidraw)
## PDF opened
Purpose: view and navigate the PDF; for signature placement.
Route: root --> opened
Design notes:
- 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:
![](wireframe.assets/with_pdf_opened.excalidraw)
---
## How to view and export
We keep links in this file pointing to `.excalidraw`. To preview the SVGs and generate `docs/.wireframe.md` with `.svg` links, run from repo root:
dart run tool/gen_view_wireframe_md.dart
This will:
- Copy `docs/wireframe.md` to `docs/.wireframe.md` and rewrite image links to `.svg`.
- Export any `*.excalidraw` under `docs/` to `*.svg` if they are new or modified.
## Next wireframes (optional)
- Save/Export result dialog and success state.

View File

@ -0,0 +1,11 @@
import 'package:flutter_test/flutter_test.dart';
// This file is intentionally skipped. The integrated E2E test lives in
// integration_test/export_flow_test.dart to avoid multiple app launches.
void main() {
testWidgets('skipped duplicate E2E (see export_flow_test.dart)', (
tester,
) async {
expect(true, isTrue);
}, skip: true);
}

View File

@ -0,0 +1,149 @@
import 'dart:typed_data';
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:image/image.dart' as img;
import 'package:pdf_signature/data/services/export_service.dart';
import 'package:pdf_signature/data/services/export_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;
}
}
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);
});
// Helper to build a simple in-memory PNG as a signature image
Uint8List _makeSig() {
final canvas = img.Image(width: 80, height: 40);
img.fill(canvas, color: img.ColorUint8.rgb(255, 255, 255));
img.drawLine(
canvas,
x1: 6,
y1: 20,
x2: 74,
y2: 20,
color: img.ColorUint8.rgb(0, 0, 0),
);
return Uint8List.fromList(img.encodePng(canvas));
}
testWidgets('E2E (integration): place and confirm keeps size', (
tester,
) async {
final sigBytes = _makeSig();
await tester.pumpWidget(
ProviderScope(
overrides: [
pdfProvider.overrideWith(
(ref) => PdfController()..openPicked(path: 'test.pdf'),
),
signatureLibraryProvider.overrideWith((ref) {
final c = SignatureLibraryController();
c.add(sigBytes, name: 'image');
return c;
}),
// Keep mock viewer for determinism on CI/desktop devices
useMockViewerProvider.overrideWithValue(true),
],
child: const MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: PdfSignatureHomePage(),
),
),
);
await tester.pumpAndSettle();
final card = find.byKey(const Key('gd_signature_card_area')).first;
await tester.tap(card);
await tester.pump();
final active = find.byKey(const Key('signature_overlay'));
expect(active, findsOneWidget);
final sizeBefore = tester.getSize(active);
await tester.ensureVisible(active);
await tester.pumpAndSettle();
// Programmatically simulate confirm: add placement with current rect and bound image, then clear active overlay.
final ctx = tester.element(find.byType(PdfSignatureHomePage));
final container = ProviderScope.containerOf(ctx);
final sigState = container.read(signatureProvider);
final r = sigState.rect!;
final Size pageSize = SignatureController.pageSize;
final normalized = Rect.fromLTWH(
(r.left / pageSize.width).clamp(0.0, 1.0),
(r.top / pageSize.height).clamp(0.0, 1.0),
(r.width / pageSize.width).clamp(0.0, 1.0),
(r.height / pageSize.height).clamp(0.0, 1.0),
);
final lib = container.read(signatureLibraryProvider);
final imageId = lib.isNotEmpty ? lib.first.id : 'default.png';
final pdf = container.read(pdfProvider);
container
.read(pdfProvider.notifier)
.addPlacement(page: pdf.currentPage, rect: normalized, image: imageId);
container.read(signatureProvider.notifier).clearActiveOverlay();
await tester.pumpAndSettle();
final placed = find.byKey(const Key('placed_signature_0'));
expect(placed, findsOneWidget);
final sizeAfter = tester.getSize(placed);
expect(
(sizeAfter.width - sizeBefore.width).abs() < sizeBefore.width * 0.15,
isTrue,
);
expect(
(sizeAfter.height - sizeBefore.height).abs() < sizeBefore.height * 0.15,
isTrue,
);
});
}

View File

@ -3,7 +3,10 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_localized_locales/flutter_localized_locales.dart';
import 'package:pdf_signature/l10n/app_localizations.dart';
import 'package:pdf_signature/ui/features/pdf/widgets/pdf_screen.dart';
import 'ui/features/preferences/providers.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 'data/services/preferences_providers.dart';
import 'package:pdf_signature/ui/features/preferences/widgets/settings_screen.dart';
class MyApp extends StatelessWidget {
const MyApp({super.key});
@ -60,7 +63,27 @@ class MyApp extends StatelessWidget {
...AppLocalizations.localizationsDelegates,
LocaleNamesLocalizationsDelegate(),
],
home: const PdfSignatureHomePage(),
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(),
),
),
);
},
);
@ -69,3 +92,16 @@ class MyApp extends StatelessWidget {
);
}
}
class _RootHomeSwitcher extends ConsumerWidget {
const _RootHomeSwitcher();
@override
Widget build(BuildContext context, WidgetRef ref) {
final pdf = ref.watch(pdfProvider);
if (!pdf.loaded) {
return const WelcomeScreen();
}
return const PdfSignatureHomePage();
}
}

View File

@ -10,6 +10,8 @@ class PdfState {
final int? signedPage;
// Multiple signature placements per page, stored as UI-space rects (e.g., 400x560)
final Map<int, List<Rect>> placementsByPage;
// For each placement, store the assigned image identifier (e.g., filename) in the same index order.
final Map<int, List<String>> placementImageByPage;
// UI state: selected placement index on the current page (if any)
final int? selectedPlacementIndex;
const PdfState({
@ -20,6 +22,7 @@ class PdfState {
this.pickedPdfBytes,
this.signedPage,
this.placementsByPage = const {},
this.placementImageByPage = const {},
this.selectedPlacementIndex,
});
factory PdfState.initial() => const PdfState(
@ -29,6 +32,7 @@ class PdfState {
pickedPdfBytes: null,
signedPage: null,
placementsByPage: {},
placementImageByPage: {},
selectedPlacementIndex: null,
);
PdfState copyWith({
@ -39,6 +43,7 @@ class PdfState {
Uint8List? pickedPdfBytes,
int? signedPage,
Map<int, List<Rect>>? placementsByPage,
Map<int, List<String>>? placementImageByPage,
int? selectedPlacementIndex,
}) => PdfState(
loaded: loaded ?? this.loaded,
@ -48,6 +53,7 @@ class PdfState {
pickedPdfBytes: pickedPdfBytes ?? this.pickedPdfBytes,
signedPage: signedPage ?? this.signedPage,
placementsByPage: placementsByPage ?? this.placementsByPage,
placementImageByPage: placementImageByPage ?? this.placementImageByPage,
selectedPlacementIndex:
selectedPlacementIndex ?? this.selectedPlacementIndex,
);
@ -59,8 +65,12 @@ 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;
// The ID of the signature asset the current overlay is based on (from library)
final String? assetId;
// When true, the active signature overlay is movable/resizable and should not be exported.
// When false, the overlay is confirmed (unmovable) and eligible for export.
final bool editingEnabled;
@ -70,8 +80,10 @@ class SignatureState {
required this.bgRemoval,
required this.contrast,
required this.brightness,
this.rotation = 0.0,
required this.strokes,
this.imageBytes,
this.assetId,
this.editingEnabled = false,
});
factory SignatureState.initial() => const SignatureState(
@ -80,8 +92,10 @@ class SignatureState {
bgRemoval: false,
contrast: 1.0,
brightness: 0.0,
rotation: 0.0,
strokes: [],
imageBytes: null,
assetId: null,
editingEnabled: false,
);
SignatureState copyWith({
@ -90,8 +104,10 @@ class SignatureState {
bool? bgRemoval,
double? contrast,
double? brightness,
double? rotation,
List<List<Offset>>? strokes,
Uint8List? imageBytes,
String? assetId,
bool? editingEnabled,
}) => SignatureState(
rect: rect ?? this.rect,
@ -99,8 +115,10 @@ 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,
assetId: assetId ?? this.assetId,
editingEnabled: editingEnabled ?? this.editingEnabled,
);
}

View File

@ -33,6 +33,8 @@ class ExportService {
required Size uiPageSize,
required Uint8List? signatureImageBytes,
Map<int, List<Rect>>? placementsByPage,
Map<int, List<String>>? placementImageByPage,
Map<String, Uint8List>? libraryBytes,
double targetDpi = 144.0,
}) async {
// print(
@ -53,6 +55,8 @@ class ExportService {
uiPageSize: uiPageSize,
signatureImageBytes: signatureImageBytes,
placementsByPage: placementsByPage,
placementImageByPage: placementImageByPage,
libraryBytes: libraryBytes,
targetDpi: targetDpi,
);
if (bytes == null) return false;
@ -73,6 +77,8 @@ class ExportService {
required Size uiPageSize,
required Uint8List? signatureImageBytes,
Map<int, List<Rect>>? placementsByPage,
Map<int, List<String>>? placementImageByPage,
Map<String, Uint8List>? libraryBytes,
double targetDpi = 144.0,
}) async {
final out = pw.Document(version: pdf.PdfVersion.pdf_1_4, compress: false);
@ -100,6 +106,10 @@ class ExportService {
hasMulti
? (placementsByPage[pageIndex] ?? const <Rect>[])
: const <Rect>[];
final pageImageIds =
hasMulti
? (placementImageByPage?[pageIndex] ?? const <String>[])
: const <String>[];
final shouldStampSingle =
!hasMulti &&
signedPage != null &&
@ -107,12 +117,7 @@ class ExportService {
signatureRectUi != null &&
signatureImageBytes != null &&
signatureImageBytes.isNotEmpty;
final shouldStampMulti =
hasMulti &&
pagePlacements.isNotEmpty &&
signatureImageBytes != null &&
signatureImageBytes.isNotEmpty;
if (shouldStampSingle || shouldStampMulti) {
if (shouldStampSingle) {
try {
sigImgObj = pw.MemoryImage(signatureImageBytes);
} catch (_) {
@ -139,35 +144,52 @@ class ExportService {
),
),
];
if (sigImgObj != null) {
if (hasMulti && pagePlacements.isNotEmpty) {
for (final r in pagePlacements) {
final left = r.left / uiPageSize.width * widthPts;
final top = r.top / uiPageSize.height * heightPts;
final w = r.width / uiPageSize.width * widthPts;
final h = r.height / uiPageSize.height * heightPts;
children.add(
pw.Positioned(
left: left,
top: top,
child: pw.Image(sigImgObj, width: w, height: h),
),
);
}
} else if (shouldStampSingle) {
final r = signatureRectUi;
// Multi-placement stamping: per-placement image from libraryBytes
if (hasMulti && pagePlacements.isNotEmpty) {
for (var i = 0; i < pagePlacements.length; i++) {
final r = pagePlacements[i];
final left = r.left / uiPageSize.width * widthPts;
final top = r.top / uiPageSize.height * heightPts;
final w = r.width / uiPageSize.width * widthPts;
final h = r.height / uiPageSize.height * heightPts;
children.add(
pw.Positioned(
left: left,
top: top,
child: pw.Image(sigImgObj, width: w, height: h),
),
);
Uint8List? bytes;
if (i < pageImageIds.length) {
final id = pageImageIds[i];
bytes = libraryBytes?[id];
}
bytes ??=
signatureImageBytes; // fallback to single image if provided
if (bytes != null && bytes.isNotEmpty) {
pw.MemoryImage? imgObj;
try {
imgObj = pw.MemoryImage(bytes);
} catch (_) {
imgObj = null;
}
if (imgObj != null) {
children.add(
pw.Positioned(
left: left,
top: top,
child: pw.Image(imgObj, width: w, height: h),
),
);
}
}
}
} else if (shouldStampSingle && sigImgObj != null) {
final r = signatureRectUi;
final left = r.left / uiPageSize.width * widthPts;
final top = r.top / uiPageSize.height * heightPts;
final w = r.width / uiPageSize.width * widthPts;
final h = r.height / uiPageSize.height * heightPts;
children.add(
pw.Positioned(
left: left,
top: top,
child: pw.Image(sigImgObj, width: w, height: h),
),
);
}
return pw.Stack(children: children);
},
@ -187,6 +209,10 @@ class ExportService {
(placementsByPage != null && placementsByPage.isNotEmpty);
final pagePlacements =
hasMulti ? (placementsByPage[1] ?? const <Rect>[]) : const <Rect>[];
final pageImageIds =
hasMulti
? (placementImageByPage?[1] ?? const <String>[])
: const <String>[];
final shouldStampSingle =
!hasMulti &&
signedPage != null &&
@ -194,12 +220,7 @@ class ExportService {
signatureRectUi != null &&
signatureImageBytes != null &&
signatureImageBytes.isNotEmpty;
final shouldStampMulti =
hasMulti &&
pagePlacements.isNotEmpty &&
signatureImageBytes != null &&
signatureImageBytes.isNotEmpty;
if (shouldStampSingle || shouldStampMulti) {
if (shouldStampSingle) {
try {
// If it's already PNG, keep as-is to preserve alpha; otherwise decode/encode PNG
final asStr = String.fromCharCodes(signatureImageBytes.take(8));
@ -232,35 +253,66 @@ class ExportService {
color: pdf.PdfColors.white,
),
];
if (sigImgObj != null) {
if (hasMulti && pagePlacements.isNotEmpty) {
for (final r in pagePlacements) {
final left = r.left / uiPageSize.width * widthPts;
final top = r.top / uiPageSize.height * heightPts;
final w = r.width / uiPageSize.width * widthPts;
final h = r.height / uiPageSize.height * heightPts;
children.add(
pw.Positioned(
left: left,
top: top,
child: pw.Image(sigImgObj, width: w, height: h),
),
);
}
} else if (shouldStampSingle) {
final r = signatureRectUi;
// Multi-placement stamping on fallback page
if (hasMulti && pagePlacements.isNotEmpty) {
for (var i = 0; i < pagePlacements.length; i++) {
final r = pagePlacements[i];
final left = r.left / uiPageSize.width * widthPts;
final top = r.top / uiPageSize.height * heightPts;
final w = r.width / uiPageSize.width * widthPts;
final h = r.height / uiPageSize.height * heightPts;
children.add(
pw.Positioned(
left: left,
top: top,
child: pw.Image(sigImgObj, width: w, height: h),
),
);
Uint8List? bytes;
if (i < pageImageIds.length) {
final id = pageImageIds[i];
bytes = libraryBytes?[id];
}
bytes ??=
signatureImageBytes; // fallback to single image if provided
if (bytes != null && bytes.isNotEmpty) {
pw.MemoryImage? imgObj;
try {
// Ensure PNG for transparency if not already
final asStr = String.fromCharCodes(bytes.take(8));
final isPng =
bytes.length > 8 &&
bytes[0] == 0x89 &&
asStr.startsWith('\u0089PNG');
if (isPng) {
imgObj = pw.MemoryImage(bytes);
} else {
final decoded = img.decodeImage(bytes);
if (decoded != null) {
final png = img.encodePng(decoded, level: 6);
imgObj = pw.MemoryImage(Uint8List.fromList(png));
}
}
} catch (_) {
imgObj = null;
}
if (imgObj != null) {
children.add(
pw.Positioned(
left: left,
top: top,
child: pw.Image(imgObj, width: w, height: h),
),
);
}
}
}
} else if (shouldStampSingle && sigImgObj != null) {
final r = signatureRectUi;
final left = r.left / uiPageSize.width * widthPts;
final top = r.top / uiPageSize.height * heightPts;
final w = r.width / uiPageSize.width * widthPts;
final h = r.height / uiPageSize.height * heightPts;
children.add(
pw.Positioned(
left: left,
top: top,
child: pw.Image(sigImgObj, width: w, height: h),
),
);
}
return pw.Stack(children: children);
},

View File

@ -28,6 +28,7 @@ Set<String> _supportedTags() {
// Keys
const _kTheme = 'theme'; // 'light'|'dark'|'system'
const _kLanguage = 'language'; // BCP-47 tag like 'en', 'zh-TW', 'es'
const _kPageView = 'page_view'; // now only 'continuous'
String _normalizeLanguageTag(String tag) {
final tags = _supportedTags();
@ -64,13 +65,22 @@ String _normalizeLanguageTag(String tag) {
class PreferencesState {
final String theme; // 'light' | 'dark' | 'system'
final String language; // 'en' | 'zh-TW' | 'es'
const PreferencesState({required this.theme, required this.language});
final String pageView; // only 'continuous'
const PreferencesState({
required this.theme,
required this.language,
required this.pageView,
});
PreferencesState copyWith({String? theme, String? language}) =>
PreferencesState(
theme: theme ?? this.theme,
language: language ?? this.language,
);
PreferencesState copyWith({
String? theme,
String? language,
String? pageView,
}) => PreferencesState(
theme: theme ?? this.theme,
language: language ?? this.language,
pageView: pageView ?? this.pageView,
);
}
class PreferencesNotifier extends StateNotifier<PreferencesState> {
@ -84,6 +94,7 @@ class PreferencesNotifier extends StateNotifier<PreferencesState> {
WidgetsBinding.instance.platformDispatcher.locale
.toLanguageTag(),
),
pageView: prefs.getString(_kPageView) ?? 'continuous',
),
) {
// normalize language to supported/fallback
@ -101,6 +112,11 @@ class PreferencesNotifier extends StateNotifier<PreferencesState> {
state = state.copyWith(language: normalized);
prefs.setString(_kLanguage, normalized);
}
final pageViewValid = {'continuous'};
if (!pageViewValid.contains(state.pageView)) {
state = state.copyWith(pageView: 'continuous');
prefs.setString(_kPageView, 'continuous');
}
}
Future<void> setTheme(String theme) async {
@ -120,9 +136,21 @@ class PreferencesNotifier extends StateNotifier<PreferencesState> {
final device =
WidgetsBinding.instance.platformDispatcher.locale.toLanguageTag();
final normalized = _normalizeLanguageTag(device);
state = PreferencesState(theme: 'system', language: normalized);
state = PreferencesState(
theme: 'system',
language: normalized,
pageView: 'continuous',
);
await prefs.setString(_kTheme, 'system');
await prefs.setString(_kLanguage, normalized);
await prefs.setString(_kPageView, 'continuous');
}
Future<void> setPageView(String pageView) async {
final valid = {'continuous'};
if (!valid.contains(pageView)) return;
state = state.copyWith(pageView: pageView);
await prefs.setString(_kPageView, pageView);
}
}
@ -145,6 +173,8 @@ final preferencesProvider =
return PreferencesNotifier(prefs);
});
// pageViewModeProvider removed; the app always runs in continuous mode.
/// Derive the active ThemeMode based on preference and platform brightness
final themeModeProvider = Provider<ThemeMode>((ref) {
final prefs = ref.watch(preferencesProvider);

View File

@ -2,11 +2,14 @@
"appTitle": "PDF-Signatur",
"backgroundRemoval": "Hintergrund entfernen",
"brightness": "Helligkeit",
"cancel": "Abbrechen",
"clear": "Löschen",
"close": "Schließen",
"confirm": "Bestätigen",
"contrast": "Kontrast",
"createNewSignature": "Neue Signatur erstellen",
"delete": "Löschen",
"display": "Anzeige",
"downloadStarted": "Download gestartet",
"dpi": "DPI:",
"drawSignature": "Signatur zeichnen",
@ -14,6 +17,7 @@
"exportingPleaseWait": "Exportiere… Bitte warten",
"failedToGeneratePdf": "PDF konnte nicht generiert werden",
"failedToSavePdf": "PDF konnte nicht gespeichert werden",
"general": "Allgemein",
"goTo": "Gehe zu:",
"invalidOrUnsupportedFile": "Ungültige oder nicht unterstützte Datei",
"language": "Sprache",
@ -22,11 +26,15 @@
"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}",
"pageView": "Seitenansicht",
"pageViewContinuous": "Kontinuierlich",
"prev": "Vorherige",
"resetToDefaults": "Auf Standardwerte zurücksetzen",
"save": "Speichern",
"savedWithPath": "Gespeichert: {path}",
"saveSignedPdf": "Signiertes PDF speichern",
"settings": "Einstellungen",

View File

@ -6,8 +6,12 @@
"@backgroundRemoval": {},
"brightness": "Brightness",
"@brightness": {},
"cancel": "Cancel",
"@cancel": {},
"clear": "Clear",
"@clear": {},
"close": "Close",
"@close": {},
"confirm": "Confirm",
"@confirm": {},
"contrast": "Contrast",
@ -16,6 +20,8 @@
"@createNewSignature": {},
"delete": "Delete",
"@delete": {},
"display": "Display",
"@display": {},
"downloadStarted": "Download started",
"@downloadStarted": {},
"dpi": "DPI:",
@ -37,6 +43,8 @@
"@failedToGeneratePdf": {},
"failedToSavePdf": "Failed to save PDF",
"@failedToSavePdf": {},
"general": "General",
"@general": {},
"goTo": "Go to:",
"@goTo": {},
"invalidOrUnsupportedFile": "Invalid or unsupported file",
@ -53,6 +61,8 @@
"@next": {},
"noPdfLoaded": "No PDF loaded",
"@noPdfLoaded": {},
"noSignatureLoaded": "No signature loaded",
"@noSignatureLoaded": {},
"nothingToSaveYet": "Nothing to save yet",
"@nothingToSaveYet": {},
"openPdf": "Open PDF...",
@ -69,10 +79,16 @@
}
}
},
"pageView": "Page view",
"@pageView": {},
"pageViewContinuous": "Continuous",
"@pageViewContinuous": {},
"prev": "Prev",
"@prev": {},
"resetToDefaults": "Reset to defaults",
"@resetToDefaults": {},
"save": "Save",
"@save": {},
"savedWithPath": "Saved: {path}",
"@savedWithPath": {
"description": "Snackbar text showing where file saved",

View File

@ -2,11 +2,14 @@
"appTitle": "Firma PDF",
"backgroundRemoval": "Eliminar fondo",
"brightness": "Brillo",
"cancel": "Cancelar",
"clear": "Limpiar",
"close": "Cerrar",
"confirm": "Confirmar",
"contrast": "Contraste",
"createNewSignature": "Crear nueva firma",
"delete": "Eliminar",
"display": "Pantalla",
"downloadStarted": "Descarga iniciada",
"dpi": "DPI:",
"drawSignature": "Dibujar firma",
@ -14,6 +17,7 @@
"exportingPleaseWait": "Exportando... Por favor, espere",
"failedToGeneratePdf": "No se pudo generar el PDF",
"failedToSavePdf": "No se pudo guardar el PDF",
"general": "General",
"goTo": "Ir a:",
"invalidOrUnsupportedFile": "Archivo inválido o no compatible",
"language": "Idioma",
@ -22,11 +26,15 @@
"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}",
"pageView": "Vista de página",
"pageViewContinuous": "Continuo",
"prev": "Anterior",
"resetToDefaults": "Restablecer valores predeterminados",
"save": "Guardar",
"savedWithPath": "Guardado: {path}",
"saveSignedPdf": "Guardar PDF firmado",
"settings": "Ajustes",

View File

@ -2,11 +2,14 @@
"appTitle": "Signature PDF",
"backgroundRemoval": "Suppression de l'arrière-plan",
"brightness": "Luminosité",
"cancel": "Annuler",
"clear": "Effacer",
"close": "Fermer",
"confirm": "Confirmer",
"contrast": "Contraste",
"createNewSignature": "Créer une nouvelle signature",
"delete": "Supprimer",
"display": "Affichage",
"downloadStarted": "Téléchargement commencé",
"dpi": "DPI :",
"drawSignature": "Dessiner une signature",
@ -14,6 +17,7 @@
"exportingPleaseWait": "Exportation… Veuillez patienter",
"failedToGeneratePdf": "Échec de la génération du PDF",
"failedToSavePdf": "Échec de l'enregistrement du PDF",
"general": "Général",
"goTo": "Aller à :",
"invalidOrUnsupportedFile": "Fichier invalide ou non pris en charge",
"language": "Langue",
@ -22,11 +26,15 @@
"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}",
"pageView": "Affichage de la page",
"pageViewContinuous": "Continu",
"prev": "Précédent",
"resetToDefaults": "Rétablir les valeurs par défaut",
"save": "Enregistrer",
"savedWithPath": "Enregistré : {path}",
"saveSignedPdf": "Enregistrer le PDF signé",
"settings": "Paramètres",

View File

@ -2,11 +2,14 @@
"appTitle": "PDF署名",
"backgroundRemoval": "背景除去",
"brightness": "明るさ",
"cancel": "キャンセル",
"clear": "クリア",
"close": "閉じる",
"confirm": "確認",
"contrast": "コントラスト",
"createNewSignature": "新しい署名を作成",
"delete": "削除",
"display": "表示",
"downloadStarted": "ダウンロード開始",
"dpi": "DPI",
"drawSignature": "署名をかく",
@ -14,6 +17,7 @@
"exportingPleaseWait": "エクスポート中…お待ちください",
"failedToGeneratePdf": "PDFの生成に失敗しました",
"failedToSavePdf": "PDFの保存に失敗しました",
"general": "一般",
"goTo": "移動:",
"invalidOrUnsupportedFile": "無効なファイルまたはサポートされていないファイル",
"language": "言語",
@ -22,11 +26,15 @@
"longPressOrRightClickTheSignatureToConfirmOrDelete": "署名を長押しまたは右クリックして、確認または削除します。",
"next": "次へ",
"noPdfLoaded": "PDFが読み込まれていません",
"noSignatureLoaded": "署名は読み込まれていません",
"nothingToSaveYet": "まだ保存するものがありません",
"openPdf": "PDFを開く…",
"pageInfo": "ページ {current}/{total}",
"pageView": "ページ表示",
"pageViewContinuous": "連続",
"prev": "前へ",
"resetToDefaults": "デフォルトに戻す",
"save": "保存",
"savedWithPath": "保存しました:{path}",
"saveSignedPdf": "署名済みPDFを保存",
"settings": "設定",

View File

@ -2,11 +2,14 @@
"appTitle": "PDF 서명",
"backgroundRemoval": "배경 제거",
"brightness": "밝기",
"cancel": "취소",
"clear": "지우기",
"close": "닫기",
"confirm": "확인",
"contrast": "대비",
"createNewSignature": "새 서명 만들기",
"delete": "삭제",
"display": "표시",
"downloadStarted": "다운로드 시작됨",
"dpi": "DPI:",
"drawSignature": "서명 그리기",
@ -14,6 +17,7 @@
"exportingPleaseWait": "내보내는 중... 잠시 기다려주세요",
"failedToGeneratePdf": "PDF 생성 실패",
"failedToSavePdf": "PDF 저장 실패",
"general": "일반",
"goTo": "이동:",
"invalidOrUnsupportedFile": "잘못된 파일이거나 지원되지 않는 파일입니다.",
"language": "언어",
@ -22,11 +26,15 @@
"longPressOrRightClickTheSignatureToConfirmOrDelete": "서명을 길게 누르거나 마우스 오른쪽 버튼을 클릭하여 확인하거나 삭제합니다.",
"next": "다음",
"noPdfLoaded": "로드된 PDF 없음",
"noSignatureLoaded": "서명이 로드되지 않았습니다",
"nothingToSaveYet": "아직 저장할 내용이 없습니다.",
"openPdf": "PDF 열기...",
"pageInfo": "{current}/{total} 페이지",
"pageView": "페이지 보기",
"pageViewContinuous": "연속",
"prev": "이전",
"resetToDefaults": "기본값으로 재설정",
"save": "저장",
"savedWithPath": "{path}에 저장됨",
"saveSignedPdf": "서명된 PDF 저장",
"settings": "설정",

View File

@ -2,11 +2,14 @@
"appTitle": "Підпис PDF",
"backgroundRemoval": "Видалення фону",
"brightness": "Яскравість",
"cancel": "Скасувати",
"clear": "Очистити",
"close": "Закрити",
"confirm": "Підтвердити",
"contrast": "Контрастність",
"createNewSignature": "Створити новий підпис",
"delete": "Видалити",
"display": "Відображення",
"downloadStarted": "Завантаження розпочато",
"dpi": "DPI:",
"drawSignature": "Намалювати підпис",
@ -14,6 +17,7 @@
"exportingPleaseWait": "Експортування... Зачекайте",
"failedToGeneratePdf": "Не вдалося створити PDF",
"failedToSavePdf": "Не вдалося зберегти PDF",
"general": "Загальні",
"goTo": "Перейти до:",
"invalidOrUnsupportedFile": "Недійсний або непідтримуваний файл",
"language": "Мова",
@ -22,11 +26,15 @@
"longPressOrRightClickTheSignatureToConfirmOrDelete": "Довго натисніть або клацніть правою кнопкою миші на підпис, щоб підтвердити або видалити.",
"next": "Далі",
"noPdfLoaded": "PDF не завантажено",
"noSignatureLoaded": "Не завантажено жодного підпису",
"nothingToSaveYet": "Ще нічого не потрібно зберігати",
"openPdf": "Відкрити PDF...",
"pageInfo": "Сторінка {current}/{total}",
"pageView": "Перегляд сторінки",
"pageViewContinuous": "Безперервний",
"prev": "Попередня",
"resetToDefaults": "Скинути до значень за замовчуванням",
"save": "Зберегти",
"savedWithPath": "Збережено: {path}",
"saveSignedPdf": "Зберегти підписаний PDF",
"settings": "Налаштування",

View File

@ -3,11 +3,14 @@
"appTitle": "PDF 簽名",
"backgroundRemoval": "去除背景",
"brightness": "亮度",
"cancel": "取消",
"clear": "清除",
"close": "關閉",
"confirm": "確認",
"contrast": "對比",
"createNewSignature": "建立新簽名",
"delete": "刪除",
"display": "顯示",
"downloadStarted": "已開始下載",
"dpi": "DPI",
"drawSignature": "手寫簽名",
@ -15,6 +18,7 @@
"exportingPleaseWait": "匯出中…請稍候",
"failedToGeneratePdf": "產生 PDF 失敗",
"failedToSavePdf": "儲存 PDF 失敗",
"general": "一般",
"goTo": "前往:",
"invalidOrUnsupportedFile": "無效或不支援的檔案",
"language": "語言",
@ -23,11 +27,15 @@
"longPressOrRightClickTheSignatureToConfirmOrDelete": "長按或右鍵點擊簽名以確認或刪除。",
"next": "下一頁",
"noPdfLoaded": "尚未載入 PDF",
"noSignatureLoaded": "没有加载签名",
"nothingToSaveYet": "尚無可儲存的內容",
"openPdf": "開啟 PDF…",
"pageInfo": "第 {current}/{total} 頁",
"pageView": "頁面檢視",
"pageViewContinuous": "連續",
"prev": "上一頁",
"resetToDefaults": "重設為預設值",
"save": "儲存",
"savedWithPath": "已儲存:{path}",
"saveSignedPdf": "儲存已簽名 PDF",
"settings": "設定",

View File

@ -2,11 +2,14 @@
"appTitle": "PDF 签名",
"backgroundRemoval": "背景移除",
"brightness": "亮度",
"cancel": "取消",
"clear": "清除",
"close": "关闭",
"confirm": "确认",
"contrast": "对比度",
"createNewSignature": "创建新的签名",
"delete": "删除",
"display": "显示",
"downloadStarted": "下载已开始",
"dpi": "DPI",
"drawSignature": "绘制签名",
@ -14,6 +17,7 @@
"exportingPleaseWait": "正在导出... 请稍候",
"failedToGeneratePdf": "PDF 生成失败",
"failedToSavePdf": "PDF 保存失败",
"general": "常规",
"goTo": "跳转到:",
"invalidOrUnsupportedFile": "无效或不支持的文件",
"language": "语言",
@ -22,11 +26,15 @@
"longPressOrRightClickTheSignatureToConfirmOrDelete": "长按或右键单击签名以确认或删除。",
"next": "下一步",
"noPdfLoaded": "未加载 PDF",
"noSignatureLoaded": "未加载签名",
"nothingToSaveYet": "尚无内容保存",
"openPdf": "打开 PDF...",
"pageInfo": "第 {current} 页 / 共 {total} 页",
"pageView": "分页浏览",
"pageViewContinuous": "连续",
"prev": "上一页",
"resetToDefaults": "恢复默认值",
"save": "保存",
"savedWithPath": "已保存:{path}",
"saveSignedPdf": "保存已签名的 PDF",
"settings": "设置",

View File

@ -3,11 +3,14 @@
"appTitle": "PDF 簽名",
"backgroundRemoval": "去除背景",
"brightness": "亮度",
"cancel": "取消",
"clear": "清除",
"close": "關閉",
"confirm": "確認",
"contrast": "對比",
"createNewSignature": "建立新簽名",
"delete": "刪除",
"display": "顯示",
"downloadStarted": "已開始下載",
"dpi": "DPI",
"drawSignature": "手寫簽名",
@ -15,6 +18,7 @@
"exportingPleaseWait": "匯出中…請稍候",
"failedToGeneratePdf": "產生 PDF 失敗",
"failedToSavePdf": "儲存 PDF 失敗",
"general": "一般",
"goTo": "前往:",
"invalidOrUnsupportedFile": "無效或不支援的檔案",
"language": "語言",
@ -23,11 +27,15 @@
"longPressOrRightClickTheSignatureToConfirmOrDelete": "長按或右鍵點擊簽名以確認或刪除。",
"next": "下一頁",
"noPdfLoaded": "尚未載入 PDF",
"noSignatureLoaded": "未載入任何簽名",
"nothingToSaveYet": "尚無可儲存的內容",
"openPdf": "開啟 PDF…",
"pageInfo": "第 {current}/{total} 頁",
"pageView": "頁面檢視",
"pageViewContinuous": "連續",
"prev": "上一頁",
"resetToDefaults": "重設為預設值",
"save": "儲存",
"savedWithPath": "已儲存:{path}",
"saveSignedPdf": "儲存已簽名 PDF",
"settings": "設定",

View File

@ -0,0 +1,14 @@
import 'package:flutter/widgets.dart';
import 'package:pdf_signature/l10n/app_localizations.dart';
/// Centralized accessors for context menu labels to avoid duplication.
class MenuLabels {
static String confirm(BuildContext context) =>
AppLocalizations.of(context).confirm;
static String delete(BuildContext context) =>
AppLocalizations.of(context).delete;
// Not yet localized in l10n; keep here for single source of truth.
static String adjustGraphic(BuildContext context) => 'Adjust graphic';
}

View File

@ -1,8 +1,8 @@
import 'dart:math' as math;
import 'dart:math';
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 'package:image/image.dart' as img;
import '../../../../data/model/model.dart';
@ -11,6 +11,7 @@ class PdfController extends StateNotifier<PdfState> {
PdfController() : super(PdfState.initial());
static const int samplePageCount = 5;
@visibleForTesting
void openSample() {
state = state.copyWith(
loaded: true,
@ -19,6 +20,7 @@ class PdfController extends StateNotifier<PdfState> {
pickedPdfPath: null,
signedPage: null,
placementsByPage: {},
placementImageByPage: {},
selectedPlacementIndex: null,
);
}
@ -36,6 +38,7 @@ class PdfController extends StateNotifier<PdfState> {
pickedPdfBytes: bytes,
signedPage: null,
placementsByPage: {},
placementImageByPage: {},
selectedPlacementIndex: null,
);
}
@ -62,15 +65,29 @@ class PdfController extends StateNotifier<PdfState> {
state = state.copyWith(pageCount: count.clamp(1, 9999));
}
// Multiple-signature helpers
void addPlacement({required int page, required Rect rect}) {
// Multiple-signature helpers (rects are stored in normalized fractions 0..1
// relative to the page size: left/top/width/height are all 0..1)
void addPlacement({
required int page,
required Rect rect,
String image = 'default.png',
}) {
if (!state.loaded) return;
final p = page.clamp(1, state.pageCount);
final map = Map<int, List<Rect>>.from(state.placementsByPage);
final list = List<Rect>.from(map[p] ?? const []);
list.add(rect);
map[p] = list;
state = state.copyWith(placementsByPage: map, selectedPlacementIndex: null);
// Sync image mapping list
final imgMap = Map<int, List<String>>.from(state.placementImageByPage);
final imgList = List<String>.from(imgMap[p] ?? const []);
imgList.add(image);
imgMap[p] = imgList;
state = state.copyWith(
placementsByPage: map,
placementImageByPage: imgMap,
selectedPlacementIndex: null,
);
}
void removePlacement({required int page, required int index}) {
@ -80,18 +97,44 @@ class PdfController extends StateNotifier<PdfState> {
final list = List<Rect>.from(map[p] ?? const []);
if (index >= 0 && index < list.length) {
list.removeAt(index);
// Sync image mapping
final imgMap = Map<int, List<String>>.from(state.placementImageByPage);
final imgList = List<String>.from(imgMap[p] ?? const []);
if (index >= 0 && index < imgList.length) {
imgList.removeAt(index);
}
if (list.isEmpty) {
map.remove(p);
imgMap.remove(p);
} else {
map[p] = list;
imgMap[p] = imgList;
}
state = state.copyWith(
placementsByPage: map,
placementImageByPage: imgMap,
selectedPlacementIndex: null,
);
}
}
// Update the rect of an existing placement on a page.
void updatePlacementRect({
required int page,
required int index,
required Rect rect,
}) {
if (!state.loaded) return;
final p = page.clamp(1, state.pageCount);
final map = Map<int, List<Rect>>.from(state.placementsByPage);
final list = List<Rect>.from(map[p] ?? const []);
if (index >= 0 && index < list.length) {
list[index] = rect;
map[p] = list;
state = state.copyWith(placementsByPage: map);
}
}
List<Rect> placementsOn(int page) {
return List<Rect>.from(state.placementsByPage[page] ?? const []);
}
@ -116,12 +159,59 @@ class PdfController extends StateNotifier<PdfState> {
if (idx == null) return;
removePlacement(page: state.currentPage, index: idx);
}
// NOTE: Programmatic reassignment of images has been removed.
// Convenience to get image name for a placement
String? imageOfPlacement({required int page, required int index}) {
final list = state.placementImageByPage[page] ?? const [];
if (index < 0 || index >= list.length) return null;
return list[index];
}
}
final pdfProvider = StateNotifierProvider<PdfController, PdfState>(
(ref) => PdfController(),
);
/// A simple library of signature images available to the user in the sidebar.
class SignatureAsset {
final String id; // unique id
final Uint8List bytes;
final String? name; // optional display name (e.g., filename)
const SignatureAsset({required this.id, required this.bytes, this.name});
}
class SignatureLibraryController extends StateNotifier<List<SignatureAsset>> {
SignatureLibraryController() : super(const []);
String add(Uint8List bytes, {String? name}) {
// Always add a new asset (allow duplicates). This lets users create multiple cards
// even when loading the same image repeatedly for different adjustments/usages.
if (bytes.isEmpty) return '';
final id = DateTime.now().microsecondsSinceEpoch.toString();
state = List.of(state)
..add(SignatureAsset(id: id, bytes: bytes, name: name));
return id;
}
void remove(String id) {
state = state.where((a) => a.id != id).toList(growable: false);
}
SignatureAsset? byId(String id) {
for (final a in state) {
if (a.id == id) return a;
}
return null;
}
}
final signatureLibraryProvider =
StateNotifierProvider<SignatureLibraryController, List<SignatureAsset>>(
(ref) => SignatureLibraryController(),
);
class SignatureController extends StateNotifier<SignatureState> {
SignatureController() : super(SignatureState.initial());
static const Size pageSize = Size(400, 560);
@ -130,11 +220,15 @@ class SignatureController extends StateNotifier<SignatureState> {
state = SignatureState.initial();
}
@visibleForTesting
void placeDefaultRect() {
final w = 120.0, h = 60.0;
state = state.copyWith(
rect: Rect.fromCenter(
center: Offset(pageSize.width / 2, pageSize.height * 0.75),
center: Offset(
(pageSize.width / 2) * (Random().nextDouble() * 1.5 + 1),
(pageSize.height / 2) * (Random().nextDouble() * 1.5 + 1),
),
width: w,
height: h,
),
@ -155,10 +249,10 @@ class SignatureController extends StateNotifier<SignatureState> {
}
void setInvalidSelected(BuildContext context) {
final l = AppLocalizations.of(context);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(l.invalidOrUnsupportedFile)));
// Fallback message without localization to keep core logic testable
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Invalid or unsupported file')),
);
}
void drag(Offset delta) {
@ -226,6 +320,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);
@ -243,7 +338,7 @@ class SignatureController extends StateNotifier<SignatureState> {
}
void setImageBytes(Uint8List bytes) {
state = state.copyWith(imageBytes: bytes);
state = state.copyWith(imageBytes: bytes, assetId: null);
if (state.rect == null) {
placeDefaultRect();
}
@ -251,6 +346,25 @@ class SignatureController extends StateNotifier<SignatureState> {
state = state.copyWith(editingEnabled: true);
}
// Select image from the shared signature library
void setImageFromLibrary({required String assetId}) {
state = state.copyWith(assetId: assetId);
if (state.rect == null) {
placeDefaultRect();
}
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) {
@ -259,7 +373,38 @@ class SignatureController extends StateNotifier<SignatureState> {
// Place onto the current page
final pdf = ref.read(pdfProvider);
if (!pdf.loaded) return null;
ref.read(pdfProvider.notifier).addPlacement(page: pdf.currentPage, rect: r);
// Convert UI-space rect (400x560) to normalized rect
final Size pageSize = SignatureController.pageSize;
final normalized = Rect.fromLTWH(
(r.left / pageSize.width).clamp(0.0, 1.0),
(r.top / pageSize.height).clamp(0.0, 1.0),
(r.width / pageSize.width).clamp(0.0, 1.0),
(r.height / pageSize.height).clamp(0.0, 1.0),
);
// Determine the image id to bind at placement time
String id = state.assetId ?? '';
if (id.isEmpty) {
final bytes =
ref.read(processedSignatureImageProvider) ?? state.imageBytes;
if (bytes != null && bytes.isNotEmpty) {
id = ref
.read(signatureLibraryProvider.notifier)
.add(bytes, name: 'image');
} else {
id = 'default.png';
}
}
ref
.read(pdfProvider.notifier)
.addPlacement(page: pdf.currentPage, rect: normalized, image: id);
// Newly placed index is the last one on the page
final idx =
(ref.read(pdfProvider).placementsByPage[pdf.currentPage]?.length ?? 1) -
1;
// Auto-select the newly placed item so the red box appears
if (idx >= 0) {
ref.read(pdfProvider.notifier).selectPlacement(idx);
}
// Freeze editing: keep rect for preview but disable interaction
state = state.copyWith(editingEnabled: false);
return r;
@ -281,7 +426,19 @@ final signatureProvider =
/// Returns null if no image is loaded. The output is a PNG to preserve alpha.
final processedSignatureImageProvider = Provider<Uint8List?>((ref) {
final s = ref.watch(signatureProvider);
final bytes = s.imageBytes;
// If active overlay is based on a library asset, pull its bytes
Uint8List? bytes;
if (s.assetId != null) {
final lib = ref.watch(signatureLibraryProvider);
for (final a in lib) {
if (a.id == s.assetId) {
bytes = a.bytes;
break;
}
}
} else {
bytes = s.imageBytes;
}
if (bytes == null || bytes.isEmpty) return null;
// Decode (supports PNG/JPEG, etc.)
@ -298,6 +455,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 +500,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

@ -15,7 +15,10 @@ class AdjustmentsPanel extends ConsumerWidget {
return Column(
key: const Key('adjustments_panel'),
children: [
Row(
Wrap(
spacing: 8,
runSpacing: 8,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
Checkbox(
key: const Key('chk_aspect_lock'),
@ -36,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

@ -0,0 +1,11 @@
import 'package:flutter/material.dart';
import 'pdf_pages_overview.dart';
class PagesSidebar extends StatelessWidget {
const PagesSidebar({super.key});
@override
Widget build(BuildContext context) {
return Card(margin: EdgeInsets.zero, child: const PdfPagesOverview());
}
}

View File

@ -0,0 +1,115 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:pdf_signature/l10n/app_localizations.dart';
import '../../../../data/services/export_providers.dart';
import 'pdf_page_overlays.dart';
/// Mocked continuous viewer for tests or platforms without real viewer.
class PdfMockContinuousList extends ConsumerWidget {
const PdfMockContinuousList({
super.key,
required this.pageSize,
required this.count,
required this.pageKeyBuilder,
required this.scrollToPage,
this.onDragSignature,
this.onResizeSignature,
this.onConfirmSignature,
this.onClearActiveOverlay,
this.onSelectPlaced,
this.pendingPage,
this.clearPending,
});
final Size pageSize;
final int count;
final GlobalKey Function(int page) pageKeyBuilder;
final void Function(int page) scrollToPage;
final int? pendingPage;
final VoidCallback? clearPending;
final ValueChanged<Offset>? onDragSignature;
final ValueChanged<Offset>? onResizeSignature;
final VoidCallback? onConfirmSignature;
final VoidCallback? onClearActiveOverlay;
final ValueChanged<int?>? onSelectPlaced;
@override
Widget build(BuildContext context, WidgetRef ref) {
if (pendingPage != null) {
WidgetsBinding.instance.addPostFrameCallback((_) {
final p = pendingPage;
if (p != null) {
clearPending?.call();
scheduleMicrotask(() => scrollToPage(p));
}
});
}
return SingleChildScrollView(
key: const Key('pdf_continuous_mock_list'),
padding: const EdgeInsets.symmetric(vertical: 8),
child: Column(
children: List.generate(count, (idx) {
final pageNum = idx + 1;
return Center(
child: Padding(
key: pageKeyBuilder(pageNum),
padding: const EdgeInsets.symmetric(vertical: 8),
child: AspectRatio(
aspectRatio: pageSize.width / pageSize.height,
child: Stack(
key: ValueKey('page_stack_$pageNum'),
children: [
Container(
color: Colors.grey.shade200,
child: Center(
child: Builder(
builder: (ctx) {
String label;
try {
label = AppLocalizations.of(
ctx,
).pageInfo(pageNum, count);
} catch (_) {
label = 'Page $pageNum of $count';
}
return Text(
label,
style: const TextStyle(
fontSize: 24,
color: Colors.black54,
),
);
},
),
),
),
Consumer(
builder: (context, ref, _) {
final visible = ref.watch(signatureVisibilityProvider);
return visible
? PdfPageOverlays(
pageSize: pageSize,
pageNumber: pageNum,
onDragSignature: onDragSignature,
onResizeSignature: onResizeSignature,
onConfirmSignature: onConfirmSignature,
onClearActiveOverlay: onClearActiveOverlay,
onSelectPlaced: onSelectPlaced,
)
: const SizedBox.shrink();
},
),
],
),
),
),
);
}),
),
);
}
}

View File

@ -1,14 +1,15 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:pdf_signature/l10n/app_localizations.dart';
import 'package:pdfrx/pdfrx.dart';
import '../../../../data/services/providers.dart';
import '../../../../data/model/model.dart';
import '../../../../data/services/export_providers.dart';
import '../view_model/view_model.dart';
import 'signature_drag_data.dart';
import 'pdf_mock_continuous_list.dart';
import 'pdf_page_overlays.dart';
class PdfPageArea extends ConsumerWidget {
class PdfPageArea extends ConsumerStatefulWidget {
const PdfPageArea({
super.key,
required this.pageSize,
@ -17,357 +18,353 @@ class PdfPageArea extends ConsumerWidget {
required this.onConfirmSignature,
required this.onClearActiveOverlay,
required this.onSelectPlaced,
this.viewerController,
});
final Size pageSize;
final PdfViewerController? viewerController;
final ValueChanged<Offset> onDragSignature;
final ValueChanged<Offset> onResizeSignature;
final VoidCallback onConfirmSignature;
final VoidCallback onClearActiveOverlay;
final ValueChanged<int?> onSelectPlaced;
@override
ConsumerState<PdfPageArea> createState() => _PdfPageAreaState();
}
Future<void> _showContextMenuForPlaced({
required BuildContext context,
required WidgetRef ref,
required Offset globalPos,
required int index,
}) async {
onSelectPlaced(index);
final choice = await showMenu<String>(
context: context,
position: RelativeRect.fromLTRB(
globalPos.dx,
globalPos.dy,
globalPos.dx,
globalPos.dy,
),
items: [
PopupMenuItem<String>(
key: Key('ctx_delete_signature'),
value: 'delete',
child: Text(AppLocalizations.of(context).delete),
),
],
);
if (choice == 'delete') {
final currentPage = ref.read(pdfProvider).currentPage;
ref
.read(pdfProvider.notifier)
.removePlacement(page: currentPage, index: index);
}
class _PdfPageAreaState extends ConsumerState<PdfPageArea> {
final Map<int, GlobalKey> _pageKeys = {};
late final PdfViewerController _viewerController =
widget.viewerController ?? PdfViewerController();
// Guards to avoid scroll feedback between provider and viewer
int? _programmaticTargetPage;
bool _suppressProviderListen = false;
int? _visiblePage; // last page reported by viewer
int? _pendingPage; // pending target for mock ensureVisible retry
int _scrollRetryCount = 0;
static const int _maxScrollRetries = 50;
@override
void initState() {
super.initState();
// If app starts in continuous mode with a loaded PDF, ensure the viewer
// is instructed to align to the provider's current page once ready.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
final pdf = ref.read(pdfProvider);
if (pdf.pickedPdfPath != null && pdf.loaded) {
_scrollToPage(pdf.currentPage);
}
});
}
// No dispose required for PdfViewerController (managed by owner if any)
GlobalKey _pageKey(int page) => _pageKeys.putIfAbsent(
page,
() => GlobalKey(debugLabel: 'cont_page_$page'),
);
void _scrollToPage(int page) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
final pdf = ref.read(pdfProvider);
const isContinuous = true;
// Real continuous: drive via PdfViewerController
if (pdf.pickedPdfPath != null && isContinuous) {
if (_viewerController.isReady) {
_programmaticTargetPage = page;
// print("[DEBUG] viewerController Scrolling to page $page");
_viewerController.goToPage(
pageNumber: page,
anchor: PdfPageAnchor.top,
);
// Fallback: if no onPageChanged arrives (e.g., same page), don't block future jumps
// Use post-frame callbacks to avoid scheduling timers in tests.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
if (_programmaticTargetPage == page) {
_programmaticTargetPage = null;
}
});
});
_pendingPage = null;
_scrollRetryCount = 0;
} else {
_pendingPage = page;
if (_scrollRetryCount < _maxScrollRetries) {
_scrollRetryCount += 1;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
final p = _pendingPage;
if (p == null) return;
_scrollToPage(p);
});
}
}
return;
}
// print("[DEBUG] Mock Scrolling to page $page");
// Mock continuous: try ensureVisible on the page container
final ctx = _pageKey(page).currentContext;
if (ctx != null) {
try {
final scrollable = Scrollable.of(ctx);
final position = scrollable.position;
final targetBox = ctx.findRenderObject() as RenderBox?;
final scrollBox = scrollable.context.findRenderObject() as RenderBox?;
if (targetBox != null && scrollBox != null) {
final offsetInViewport = targetBox.localToGlobal(
Offset.zero,
ancestor: scrollBox,
);
final desiredTop = scrollBox.size.height * 0.1;
final newPixels =
(position.pixels + offsetInViewport.dy - desiredTop)
.clamp(position.minScrollExtent, position.maxScrollExtent)
.toDouble();
position.jumpTo(newPixels);
return;
}
} catch (_) {
// Fallback to ensureVisible if any calculation fails
Scrollable.ensureVisible(
ctx,
alignment: 0.1,
duration: Duration.zero,
curve: Curves.linear,
);
return;
}
return;
}
_pendingPage = page;
if (_scrollRetryCount < _maxScrollRetries) {
_scrollRetryCount += 1;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
final p = _pendingPage;
if (p == null) return;
_scrollToPage(p);
});
}
});
}
@override
Widget build(BuildContext context, WidgetRef ref) {
Widget build(BuildContext context) {
final pdf = ref.watch(pdfProvider);
const pageViewMode = 'continuous';
// React to provider currentPage changes (e.g., user tapped overview)
ref.listen(pdfProvider, (prev, next) {
if (_suppressProviderListen) return;
if ((prev?.currentPage != next.currentPage)) {
final target = next.currentPage;
// If we're already navigating to this target, ignore; otherwise allow new target.
if (_programmaticTargetPage != null &&
_programmaticTargetPage == target) {
return;
}
// Only navigate if target differs from what viewer shows
if (_visiblePage != target) {
_scrollToPage(target);
}
}
});
// No page view mode switching; always continuous.
if (!pdf.loaded) {
return Center(child: Text(AppLocalizations.of(context).noPdfLoaded));
// In tests, AppLocalizations delegate may not be injected; fallback.
String text;
try {
text = AppLocalizations.of(context).noPdfLoaded;
} catch (_) {
text = 'No PDF loaded';
}
return Center(child: Text(text));
}
final useMock = ref.watch(useMockViewerProvider);
if (useMock) {
return Center(
child: AspectRatio(
aspectRatio: pageSize.width / pageSize.height,
child: Stack(
key: const Key('page_stack'),
children: [
Container(
key: ValueKey('pdf_page_view_${pdf.currentPage}'),
color: Colors.grey.shade200,
child: Center(
child: Text(
AppLocalizations.of(
context,
).pageInfo(pdf.currentPage, pdf.pageCount),
style: const TextStyle(fontSize: 24, color: Colors.black54),
),
),
),
final isContinuous = pageViewMode == 'continuous';
// Mock continuous: ListView with prebuilt children, no controller
if (useMock && isContinuous) {
final count = pdf.pageCount > 0 ? pdf.pageCount : 1;
return PdfMockContinuousList(
pageSize: widget.pageSize,
count: count,
pageKeyBuilder: _pageKey,
scrollToPage: _scrollToPage,
pendingPage: _pendingPage,
clearPending: () {
_pendingPage = null;
_scrollRetryCount = 0;
},
onDragSignature: (delta) => widget.onDragSignature(delta),
onResizeSignature: (delta) => widget.onResizeSignature(delta),
onConfirmSignature: widget.onConfirmSignature,
onClearActiveOverlay: widget.onClearActiveOverlay,
onSelectPlaced: widget.onSelectPlaced,
);
}
// 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) {
final viewer = PdfViewer.file(
pdf.pickedPdfPath!,
controller: _viewerController,
params: PdfViewerParams(
pageAnchor: PdfPageAnchor.top,
keyHandlerParams: PdfViewerKeyHandlerParams(autofocus: true),
maxScale: 8,
scrollByMouseWheel: 0.6,
// Render signature overlays on each page via pdfrx pageOverlaysBuilder
pageOverlaysBuilder: (context, pageRect, page) {
return [
Consumer(
builder: (context, ref, _) {
final sig = ref.watch(signatureProvider);
final visible = ref.watch(signatureVisibilityProvider);
return visible
? _buildPageOverlays(context, ref, sig)
: const SizedBox.shrink();
},
),
],
),
),
);
}
if (pdf.pickedPdfPath != null) {
return PdfDocumentViewBuilder.file(
pdf.pickedPdfPath!,
builder: (context, document) {
if (document == null) {
return const Center(child: CircularProgressIndicator());
}
final pages = document.pages;
final pageNum = pdf.currentPage.clamp(1, pages.length);
final page = pages[pageNum - 1];
final aspect = page.width / page.height;
if (pdf.pageCount != pages.length) {
WidgetsBinding.instance.addPostFrameCallback((_) {
ref.read(pdfProvider.notifier).setPageCount(pages.length);
});
}
return Center(
child: AspectRatio(
aspectRatio: aspect,
child: Stack(
key: const Key('page_stack'),
children: [
PdfPageView(
key: ValueKey('pdf_page_view_$pageNum'),
document: document,
pageNumber: pageNum,
alignment: Alignment.center,
),
Consumer(
builder: (context, ref, _) {
final sig = ref.watch(signatureProvider);
final visible = ref.watch(signatureVisibilityProvider);
return visible
? _buildPageOverlays(context, ref, sig)
: const SizedBox.shrink();
},
),
],
),
),
);
},
);
}
return const SizedBox.shrink();
}
Widget _buildPageOverlays(
BuildContext context,
WidgetRef ref,
SignatureState sig,
) {
final pdf = ref.watch(pdfProvider);
final current = pdf.currentPage;
final placed = pdf.placementsByPage[current] ?? const <Rect>[];
final widgets = <Widget>[];
for (int i = 0; i < placed.length; i++) {
final r = placed[i];
widgets.add(
_buildSignatureOverlay(
context,
ref,
sig,
r,
interactive: false,
placedIndex: i,
),
);
}
if (sig.rect != null &&
sig.editingEnabled &&
(pdf.signedPage == null || pdf.signedPage == current)) {
widgets.add(
_buildSignatureOverlay(context, ref, sig, sig.rect!, interactive: true),
);
}
return Stack(children: widgets);
}
Widget _buildSignatureOverlay(
BuildContext context,
WidgetRef ref,
SignatureState sig,
Rect r, {
bool interactive = true,
int? placedIndex,
}) {
return LayoutBuilder(
builder: (context, constraints) {
final scaleX = constraints.maxWidth / pageSize.width;
final scaleY = constraints.maxHeight / pageSize.height;
final left = r.left * scaleX;
final top = r.top * scaleY;
final width = r.width * scaleX;
final height = r.height * scaleY;
return Stack(
children: [
Positioned(
left: left,
top: top,
width: width,
height: height,
child: Builder(
builder: (context) {
final selectedIdx =
ref.read(pdfProvider).selectedPlacementIndex;
final bool isPlaced = placedIndex != null;
final bool isSelected =
isPlaced && selectedIdx == placedIndex;
final Color borderColor =
isPlaced ? Colors.red : Colors.indigo;
final double borderWidth =
isPlaced ? (isSelected ? 3.0 : 2.0) : 2.0;
Widget content = DecoratedBox(
decoration: BoxDecoration(
color: Color.fromRGBO(
0,
0,
0,
0.05 + math.min(0.25, (sig.contrast - 1.0).abs()),
if (!visible) return const SizedBox.shrink();
return Align(
alignment: Alignment.topLeft,
child: SizedBox(
width: pageRect.width,
height: pageRect.height,
child: PdfPageOverlays(
pageSize: widget.pageSize,
pageNumber: page.pageNumber,
onDragSignature:
(delta) => widget.onDragSignature(delta),
onResizeSignature:
(delta) => widget.onResizeSignature(delta),
onConfirmSignature: widget.onConfirmSignature,
onClearActiveOverlay: widget.onClearActiveOverlay,
onSelectPlaced: widget.onSelectPlaced,
),
border: Border.all(
color: borderColor,
width: borderWidth,
),
),
child: Stack(
children: [
Consumer(
builder: (context, ref, _) {
final processed = ref.watch(
processedSignatureImageProvider,
);
final bytes = processed ?? sig.imageBytes;
if (bytes == null) {
return Center(
child: Text(
AppLocalizations.of(context).signature,
),
);
}
return Image.memory(bytes, fit: BoxFit.contain);
},
),
if (interactive)
Positioned(
right: 0,
bottom: 0,
child: GestureDetector(
key: const Key('signature_handle'),
behavior: HitTestBehavior.opaque,
onPanUpdate:
(d) => onResizeSignature(
Offset(
d.delta.dx / scaleX,
d.delta.dy / scaleY,
),
),
child: const Icon(Icons.open_in_full, size: 20),
),
),
],
),
);
if (interactive && sig.editingEnabled) {
content = GestureDetector(
key: const Key('signature_overlay'),
behavior: HitTestBehavior.opaque,
onPanStart: (_) {},
onPanUpdate:
(d) => onDragSignature(
Offset(d.delta.dx / scaleX, d.delta.dy / scaleY),
),
onSecondaryTapDown: (d) {
final pos = d.globalPosition;
showMenu<String>(
context: context,
position: RelativeRect.fromLTRB(
pos.dx,
pos.dy,
pos.dx,
pos.dy,
),
items: [
PopupMenuItem<String>(
key: Key('ctx_active_confirm'),
value: 'confirm',
child: Text(AppLocalizations.of(context).confirm),
),
PopupMenuItem<String>(
key: Key('ctx_active_delete'),
value: 'delete',
child: Text(AppLocalizations.of(context).delete),
),
],
).then((choice) {
if (choice == 'confirm') {
onConfirmSignature();
} else if (choice == 'delete') {
onClearActiveOverlay();
}
});
},
onLongPressStart: (d) {
final pos = d.globalPosition;
showMenu<String>(
context: context,
position: RelativeRect.fromLTRB(
pos.dx,
pos.dy,
pos.dx,
pos.dy,
),
items: [
PopupMenuItem<String>(
key: Key('ctx_active_confirm_lp'),
value: 'confirm',
child: Text(AppLocalizations.of(context).confirm),
),
PopupMenuItem<String>(
key: Key('ctx_active_delete_lp'),
value: 'delete',
child: Text(AppLocalizations.of(context).delete),
),
],
).then((choice) {
if (choice == 'confirm') {
onConfirmSignature();
} else if (choice == 'delete') {
onClearActiveOverlay();
}
});
},
child: content,
);
} else {
content = GestureDetector(
key: Key('placed_signature_${placedIndex ?? 'x'}'),
behavior: HitTestBehavior.opaque,
onTap: () => onSelectPlaced(placedIndex),
onSecondaryTapDown: (d) {
if (placedIndex != null) {
_showContextMenuForPlaced(
context: context,
ref: ref,
globalPos: d.globalPosition,
index: placedIndex,
);
}
},
onLongPressStart: (d) {
if (placedIndex != null) {
_showContextMenuForPlaced(
context: context,
ref: ref,
globalPos: d.globalPosition,
index: placedIndex,
);
}
},
child: content,
);
}
return content;
},
),
];
},
// Add overlay scroll thumbs (vertical on right, horizontal on bottom)
viewerOverlayBuilder:
(context, size, handleLinkTap) => [
PdfViewerScrollThumb(
controller: _viewerController,
orientation: ScrollbarOrientation.right,
thumbSize: const Size(40, 24),
thumbBuilder:
(context, thumbSize, pageNumber, controller) => Container(
color: Colors.black.withValues(alpha: 0.7),
child: Center(
child: Text(
pageNumber.toString(),
style: const TextStyle(color: Colors.white),
),
),
),
),
PdfViewerScrollThumb(
controller: _viewerController,
orientation: ScrollbarOrientation.bottom,
thumbSize: const Size(40, 24),
thumbBuilder:
(context, thumbSize, pageNumber, controller) => Container(
color: Colors.black.withValues(alpha: 0.7),
child: Center(
child: Text(
pageNumber.toString(),
style: const TextStyle(color: Colors.white),
),
),
),
),
],
onViewerReady: (doc, controller) {
if (pdf.pageCount != doc.pages.length) {
ref.read(pdfProvider.notifier).setPageCount(doc.pages.length);
}
final target = _pendingPage ?? pdf.currentPage;
_pendingPage = null;
_scrollRetryCount = 0;
// Defer navigation to the next frame to ensure controller state is fully ready.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
_scrollToPage(target);
});
},
onPageChanged: (n) {
if (n == null) return;
_visiblePage = n;
// Programmatic navigation: wait until target reached
if (_programmaticTargetPage != null) {
if (n == _programmaticTargetPage) {
if (n != ref.read(pdfProvider).currentPage) {
_suppressProviderListen = true;
ref.read(pdfProvider.notifier).jumpTo(n);
WidgetsBinding.instance.addPostFrameCallback((_) {
_suppressProviderListen = false;
});
}
_programmaticTargetPage = null;
}
return;
}
// User scroll -> reflect page to provider without re-triggering scroll
if (n != ref.read(pdfProvider).currentPage) {
_suppressProviderListen = true;
ref.read(pdfProvider.notifier).jumpTo(n);
WidgetsBinding.instance.addPostFrameCallback((_) {
_suppressProviderListen = false;
});
}
},
),
);
// 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;
final data = details.data;
if (data is SignatureDragData && data.assetId != null) {
// Set current overlay to use this asset
ref
.read(signatureProvider.notifier)
.setImageFromLibrary(assetId: data.assetId!);
}
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();
}
}
// Zoom controls removed with single-page mode; continuous viewer manages zoom.

View File

@ -0,0 +1,80 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../view_model/view_model.dart';
import 'signature_overlay.dart';
/// Builds all overlays for a given page: placed signatures and the active one.
class PdfPageOverlays extends ConsumerWidget {
const PdfPageOverlays({
super.key,
required this.pageSize,
required this.pageNumber,
this.onDragSignature,
this.onResizeSignature,
this.onConfirmSignature,
this.onClearActiveOverlay,
this.onSelectPlaced,
});
final Size pageSize;
final int pageNumber;
final ValueChanged<Offset>? onDragSignature;
final ValueChanged<Offset>? onResizeSignature;
final VoidCallback? onConfirmSignature;
final VoidCallback? onClearActiveOverlay;
final ValueChanged<int?>? onSelectPlaced;
@override
Widget build(BuildContext context, WidgetRef ref) {
final pdf = ref.watch(pdfProvider);
final sig = ref.watch(signatureProvider);
final placed = pdf.placementsByPage[pageNumber] ?? const <Rect>[];
final widgets = <Widget>[];
for (int i = 0; i < placed.length; i++) {
final r = placed[i]; // stored as normalized 0..1 of page size
final uiRect = Rect.fromLTWH(
r.left * pageSize.width,
r.top * pageSize.height,
r.width * pageSize.width,
r.height * pageSize.height,
);
widgets.add(
SignatureOverlay(
pageSize: pageSize,
rect: uiRect,
sig: sig,
pageNumber: pageNumber,
interactive: false,
placedIndex: i,
onSelectPlaced: onSelectPlaced,
),
);
}
final showActive =
sig.rect != null &&
sig.editingEnabled &&
(pdf.signedPage == null || pdf.signedPage == pageNumber) &&
pdf.currentPage == pageNumber;
if (showActive) {
widgets.add(
SignatureOverlay(
pageSize: pageSize,
rect: sig.rect!,
sig: sig,
pageNumber: pageNumber,
interactive: true,
onDragSignature: onDragSignature,
onResizeSignature: onResizeSignature,
onConfirmSignature: onConfirmSignature,
onClearActiveOverlay: onClearActiveOverlay,
),
);
}
return Stack(children: widgets);
}
}

View File

@ -0,0 +1,95 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:pdfrx/pdfrx.dart';
import '../../../../data/services/export_providers.dart';
import '../view_model/view_model.dart';
class PdfPagesOverview extends ConsumerWidget {
const PdfPagesOverview({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final pdf = ref.watch(pdfProvider);
final useMock = ref.watch(useMockViewerProvider);
final theme = Theme.of(context);
if (!pdf.loaded) return const SizedBox.shrink();
Widget buildList(int pageCount, {Widget Function(int i)? item}) {
return ListView.separated(
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 8),
itemCount: pageCount,
separatorBuilder: (_, _) => const SizedBox(height: 8),
itemBuilder: (context, index) {
final pageNumber = index + 1;
final isSelected = pdf.currentPage == pageNumber;
return InkWell(
onTap: () => ref.read(pdfProvider.notifier).jumpTo(pageNumber),
child: DecoratedBox(
decoration: BoxDecoration(
color:
isSelected
? theme.colorScheme.primaryContainer
: theme.cardColor,
borderRadius: BorderRadius.circular(8),
border: Border.all(
color:
isSelected
? theme.colorScheme.primary
: theme.dividerColor,
),
),
child: Padding(
padding: const EdgeInsets.all(6),
child: AspectRatio(
aspectRatio: 1 / 1.4142, // A4 portrait approx
child: ClipRRect(
borderRadius: BorderRadius.circular(4),
child:
item != null
? item(index)
: Center(child: Text('$pageNumber')),
),
),
),
),
);
},
);
}
if (useMock) {
final count = pdf.pageCount == 0 ? 1 : pdf.pageCount;
return buildList(count);
}
if (pdf.pickedPdfPath != null) {
return PdfDocumentViewBuilder.file(
pdf.pickedPdfPath!,
builder: (context, document) {
if (document == null) {
return const Center(child: CircularProgressIndicator());
}
final pages = document.pages;
if (pdf.pageCount != pages.length) {
WidgetsBinding.instance.addPostFrameCallback((_) {
ref.read(pdfProvider.notifier).setPageCount(pages.length);
});
}
return buildList(
pages.length,
item:
(i) => PdfPageView(
document: document,
pageNumber: i + 1,
alignment: Alignment.center,
),
);
},
);
}
return const SizedBox.shrink();
}
}

View File

@ -5,14 +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 'package:multi_split_view/multi_split_view.dart';
import '../../../../data/services/providers.dart';
import '../../../../data/services/export_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 '../../preferences/widgets/settings_screen.dart';
import 'pages_sidebar.dart';
import 'signatures_sidebar.dart';
class PdfSignatureHomePage extends ConsumerStatefulWidget {
const PdfSignatureHomePage({super.key});
@ -24,6 +26,21 @@ class PdfSignatureHomePage extends ConsumerStatefulWidget {
class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
static const Size _pageSize = SignatureController.pageSize;
final PdfViewerController _viewerController = PdfViewerController();
bool _showPagesSidebar = true;
bool _showSignaturesSidebar = true;
int _zoomLevel = 100; // percentage for display only
// Split view controller to manage resizable sidebars without remounting the center area.
late final MultiSplitViewController _splitController;
late final List<Area> _areas;
double _lastPagesWidth = 160;
double _lastSignaturesWidth = 220;
// Configurable sidebar constraints
final double _pagesMin = 100;
final double _pagesMax = 250;
final double _signaturesMin = 140;
final double _signaturesMax = 250;
// Exposed for tests to trigger the invalid-file SnackBar without UI.
@visibleForTesting
@ -50,49 +67,24 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
ref.read(pdfProvider.notifier).jumpTo(page);
}
// mark-for-signing removed; no toggle needed
Future<void> _loadSignatureFromFile() async {
Future<Uint8List?> _loadSignatureFromFile() async {
final typeGroup = const fs.XTypeGroup(
label: 'Image',
extensions: ['png', 'jpg', 'jpeg', 'webp'],
);
final file = await fs.openFile(acceptedTypeGroups: [typeGroup]);
if (file == null) return;
if (file == null) return null;
final bytes = await file.readAsBytes();
final sig = ref.read(signatureProvider.notifier);
sig.setImageBytes(bytes);
// When a signature is added, set the current page as signed.
final p = ref.read(pdfProvider);
if (p.loaded) {
ref.read(pdfProvider.notifier).setSignedPage(p.currentPage);
}
}
void _createNewSignature() {
// Create a movable signature (draft) that won't be exported until confirmed
final sig = ref.read(signatureProvider.notifier);
if (ref.read(pdfProvider).loaded) {
sig.placeDefaultRect();
ref
.read(pdfProvider.notifier)
.setSignedPage(ref.read(pdfProvider).currentPage);
// Hint: how to confirm/delete via context menu
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
AppLocalizations.of(
context,
).longPressOrRightClickTheSignatureToConfirmOrDelete,
),
duration: Duration(seconds: 3),
),
);
}
return bytes;
}
void _confirmSignature() {
// Confirm: make current signature immutable and eligible for export by placing it
ref.read(signatureProvider.notifier).confirmCurrentSignature(ref);
}
@ -108,7 +100,7 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
ref.read(pdfProvider.notifier).selectPlacement(index);
}
Future<void> _openDrawCanvas() async {
Future<Uint8List?> _openDrawCanvas() async {
final result = await showModalBottomSheet<Uint8List>(
context: context,
isScrollControlled: true,
@ -116,29 +108,26 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
builder: (_) => const DrawCanvas(),
);
if (result != null && result.isNotEmpty) {
// Use the drawn image as signature content
ref.read(signatureProvider.notifier).setImageBytes(result);
// Mark current page as signed when a signature is created
final p = ref.read(pdfProvider);
if (p.loaded) {
ref.read(pdfProvider.notifier).setSignedPage(p.currentPage);
}
}
return result;
}
Future<void> _saveSignedPdf() async {
// Set exporting state to show loading overlay and block interactions
ref.read(exportingProvider.notifier).state = true;
try {
final pdf = ref.read(pdfProvider);
final sig = ref.read(signatureProvider);
// Cache messenger before any awaits to avoid using BuildContext across async gaps.
final messenger = ScaffoldMessenger.of(context);
if (!pdf.loaded || sig.rect == null) {
messenger.showSnackBar(
SnackBar(
content: Text(AppLocalizations.of(context).nothingToSaveYet),
), // guard per use-case
),
);
return;
}
@ -148,11 +137,8 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
bool ok = false;
String? savedPath;
if (kIsWeb) {
// Web: prefer using picked bytes; share via Printing
Uint8List? src = pdf.pickedPdfBytes;
if (src == null) {
ok = false;
} else {
if (src != null) {
final processed = ref.read(processedSignatureImageProvider);
final bytes = await exporter.exportSignedPdfFromBytes(
srcBytes: src,
@ -161,6 +147,10 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
uiPageSize: SignatureController.pageSize,
signatureImageBytes: processed ?? sig.imageBytes,
placementsByPage: pdf.placementsByPage,
placementImageByPage: pdf.placementImageByPage,
libraryBytes: {
for (final a in ref.read(signatureLibraryProvider)) a.id: a.bytes,
},
targetDpi: targetDpi,
);
if (bytes != null) {
@ -173,12 +163,9 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
} catch (_) {
ok = false;
}
} else {
ok = false;
}
}
} else {
// Desktop/mobile: choose between bytes or file-based export
final pick = ref.read(savePathPickerProvider);
final path = await pick();
if (path == null || path.trim().isEmpty) return;
@ -193,22 +180,22 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
uiPageSize: SignatureController.pageSize,
signatureImageBytes: processed ?? sig.imageBytes,
placementsByPage: pdf.placementsByPage,
placementImageByPage: pdf.placementImageByPage,
libraryBytes: {
for (final a in ref.read(signatureLibraryProvider)) a.id: a.bytes,
},
targetDpi: targetDpi,
);
if (useMock) {
// In mock mode for tests, simulate success without file IO
ok = out != null;
} else if (out != null) {
ok = await exporter.saveBytesToFile(
bytes: out,
outputPath: fullPath,
);
} else {
ok = false;
}
} else if (pdf.pickedPdfPath != null) {
if (useMock) {
// Simulate success in mock
ok = true;
} else {
final processed = ref.read(processedSignatureImageProvider);
@ -220,15 +207,17 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
uiPageSize: SignatureController.pageSize,
signatureImageBytes: processed ?? sig.imageBytes,
placementsByPage: pdf.placementsByPage,
placementImageByPage: pdf.placementImageByPage,
libraryBytes: {
for (final a in ref.read(signatureLibraryProvider))
a.id: a.bytes,
},
targetDpi: targetDpi,
);
}
} else {
ok = false;
}
}
if (!kIsWeb) {
// Desktop/mobile: we had a concrete path
if (ok) {
messenger.showSnackBar(
SnackBar(
@ -245,7 +234,6 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
);
}
} else {
// Web: indicate whether we triggered a download dialog
if (ok) {
messenger.showSnackBar(
SnackBar(
@ -261,7 +249,6 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
}
}
} finally {
// Clear exporting state when finished or on error
ref.read(exportingProvider.notifier).state = false;
}
}
@ -271,61 +258,148 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
return name;
}
@override
void initState() {
super.initState();
// Build areas once with builders; keep these instances stable.
_areas = [
Area(
size: _lastPagesWidth,
min: _pagesMin,
max: _pagesMax,
builder:
(context, area) => Offstage(
offstage: !_showPagesSidebar,
child: const PagesSidebar(),
),
),
Area(
flex: 1,
builder:
(context, area) => RepaintBoundary(
child: PdfPageArea(
key: const ValueKey('pdf_page_area'),
pageSize: _pageSize,
viewerController: _viewerController,
onDragSignature: _onDragSignature,
onResizeSignature: _onResizeSignature,
onConfirmSignature: _confirmSignature,
onClearActiveOverlay:
() =>
ref
.read(signatureProvider.notifier)
.clearActiveOverlay(),
onSelectPlaced: _onSelectPlaced,
),
),
),
Area(
size: _lastSignaturesWidth,
min: _signaturesMin,
max: _signaturesMax,
builder:
(context, area) => Offstage(
offstage: !_showSignaturesSidebar,
child: SignaturesSidebar(
onLoadSignatureFromFile: _loadSignatureFromFile,
onOpenDrawCanvas: _openDrawCanvas,
onSave: _saveSignedPdf,
),
),
),
];
_splitController = MultiSplitViewController(areas: _areas);
// Apply initial collapse if needed
_applySidebarVisibility();
}
@override
void dispose() {
_splitController.dispose();
super.dispose();
}
void _applySidebarVisibility() {
// Left pages sidebar
final left = _splitController.areas[0];
if (_showPagesSidebar) {
left.max = _pagesMax;
left.min = _pagesMin;
left.size = _lastPagesWidth.clamp(_pagesMin, _pagesMax);
} else {
_lastPagesWidth = left.size ?? _lastPagesWidth;
left.min = 0;
left.max = 1;
left.size = 1; // effectively hidden
}
// Right signatures sidebar
final right = _splitController.areas[2];
if (_showSignaturesSidebar) {
right.max = _signaturesMax;
right.min = _signaturesMin;
right.size = _lastSignaturesWidth.clamp(_signaturesMin, _signaturesMax);
} else {
_lastSignaturesWidth = right.size ?? _lastSignaturesWidth;
right.min = 0;
right.max = 1;
right.size = 1;
}
}
@override
Widget build(BuildContext context) {
final isExporting = ref.watch(exportingProvider);
final l = AppLocalizations.of(context);
return Scaffold(
appBar: AppBar(title: Text(l.appTitle)),
body: Padding(
padding: const EdgeInsets.all(12),
child: Stack(
children: [
Column(
children: [
// Full-width toolbar row
PdfToolbar(
disabled: isExporting,
onOpenSettings: () {
Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const SettingsScreen()),
);
},
onPickPdf: _pickPdf,
onJumpToPage: _jumpToPage,
onSave: _saveSignedPdf,
onLoadSignatureFromFile: _loadSignatureFromFile,
onCreateSignature: _createNewSignature,
onOpenDrawCanvas: _openDrawCanvas,
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: _zoomLevel,
fileName: ref.watch(pdfProvider).pickedPdfPath,
showPagesSidebar: _showPagesSidebar,
showSignaturesSidebar: _showSignaturesSidebar,
onTogglePagesSidebar:
() => setState(() {
_showPagesSidebar = !_showPagesSidebar;
_applySidebarVisibility();
}),
onToggleSignaturesSidebar:
() => setState(() {
_showSignaturesSidebar = !_showSignaturesSidebar;
_applySidebarVisibility();
}),
),
const SizedBox(height: 8),
Expanded(
child: AbsorbPointer(
absorbing: isExporting,
child: PdfPageArea(
pageSize: _pageSize,
onDragSignature: _onDragSignature,
onResizeSignature: _onResizeSignature,
onConfirmSignature: _confirmSignature,
onClearActiveOverlay:
() =>
ref
.read(signatureProvider.notifier)
.clearActiveOverlay(),
onSelectPlaced: _onSelectPlaced,
),
child: MultiSplitView(
controller: _splitController,
axis: Axis.horizontal,
),
),
Consumer(
builder: (context, ref, _) {
final sig = ref.watch(signatureProvider);
return sig.rect != null
? AbsorbPointer(
absorbing: isExporting,
child: AdjustmentsPanel(sig: sig),
)
: const SizedBox.shrink();
},
),
],
),
if (isExporting)
@ -336,8 +410,8 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
CircularProgressIndicator(),
SizedBox(height: 12),
const CircularProgressIndicator(),
const SizedBox(height: 12),
Text(
l.exportingPleaseWait,
style: const TextStyle(color: Colors.white),

View File

@ -1,143 +1,236 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:pdf_signature/l10n/app_localizations.dart';
import '../../../../data/services/providers.dart';
import '../../../../data/services/export_providers.dart';
import '../view_model/view_model.dart';
class PdfToolbar extends ConsumerWidget {
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
Widget build(BuildContext context, WidgetRef ref) {
ConsumerState<PdfToolbar> createState() => _PdfToolbarState();
}
class _PdfToolbarState extends ConsumerState<PdfToolbar> {
final TextEditingController _goToController = TextEditingController();
@override
void dispose() {
_goToController.dispose();
super.dispose();
}
void _submitGoTo() {
final v = _goToController.text.trim();
final n = int.tryParse(v);
if (n != null) widget.onJumpToPage(n);
}
@override
Widget build(BuildContext context) {
final pdf = ref.watch(pdfProvider);
final dpi = ref.watch(exportDpiProvider);
final l = AppLocalizations.of(context);
final pageInfo = l.pageInfo(pdf.currentPage, pdf.pageCount);
return Wrap(
spacing: 8,
runSpacing: 8,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
OutlinedButton(
key: const Key('btn_open_settings'),
onPressed: disabled ? null : onOpenSettings,
child: Text(l.settings),
),
OutlinedButton(
key: const Key('btn_open_pdf_picker'),
onPressed: disabled ? null : onPickPdf,
child: Text(l.openPdf),
),
if (pdf.loaded) ...[
Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
key: const Key('btn_prev'),
onPressed:
disabled ? null : () => onJumpToPage(pdf.currentPage - 1),
icon: const Icon(Icons.chevron_left),
tooltip: l.prev,
return LayoutBuilder(
builder: (context, constraints) {
final bool compact = constraints.maxWidth < 260;
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_pdf_picker'),
onPressed: widget.disabled ? null : widget.onPickPdf,
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,
),
),
],
),
Text(pageInfo, key: const Key('lbl_page_info')),
IconButton(
key: const Key('btn_next'),
onPressed:
disabled ? null : () => onJumpToPage(pdf.currentPage + 1),
icon: const Icon(Icons.chevron_right),
tooltip: l.next,
),
],
),
Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(l.goTo),
SizedBox(
width: 60,
child: TextField(
key: const Key('txt_goto'),
keyboardType: TextInputType.number,
enabled: !disabled,
onSubmitted: (v) {
final n = int.tryParse(v);
if (n != null) onJumpToPage(n);
},
),
),
],
),
Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(l.dpi),
const SizedBox(width: 8),
DropdownButton<double>(
key: const Key('ddl_export_dpi'),
value: dpi,
items:
const [96.0, 144.0, 200.0, 300.0]
.map(
(v) => DropdownMenuItem(
value: v,
child: Text(v.toStringAsFixed(0)),
),
if (pdf.loaded) ...[
Wrap(
spacing: 8,
children: [
IconButton(
key: const Key('btn_prev'),
onPressed:
widget.disabled
? null
: () => widget.onJumpToPage(pdf.currentPage - 1),
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'),
onPressed:
widget.disabled
? null
: () => widget.onJumpToPage(pdf.currentPage + 1),
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}',
),
)
.toList(),
onChanged:
disabled
? null
: (v) {
if (v != null) {
ref.read(exportDpiProvider.notifier).state = v;
}
},
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,
),
],
),
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),
),
Text(
//if not null
widget.zoomLevel != null ? '${widget.zoomLevel}%' : '',
style: const TextStyle(fontSize: 12),
),
IconButton(
key: const Key('btn_zoom_in'),
tooltip: 'Zoom in',
onPressed: widget.disabled ? null : widget.onZoomIn,
icon: const Icon(Icons.zoom_in),
),
SizedBox(width: 6),
// show zoom ratio
Text(l.dpi),
const SizedBox(width: 8),
DropdownButton<double>(
key: const Key('ddl_export_dpi'),
value: dpi,
items:
const [96.0, 144.0, 200.0, 300.0]
.map(
(v) => DropdownMenuItem(
value: v,
child: Text(v.toStringAsFixed(0)),
),
)
.toList(),
onChanged:
widget.disabled
? null
: (v) {
if (v != null) {
ref.read(exportDpiProvider.notifier).state = v;
}
},
),
],
),
],
),
ElevatedButton(
key: const Key('btn_save_pdf'),
onPressed: disabled ? null : onSave,
child: Text(l.saveSignedPdf),
),
OutlinedButton(
key: const Key('btn_load_signature_picker'),
onPressed: disabled || !pdf.loaded ? null : onLoadSignatureFromFile,
child: Text(l.loadSignatureFromFile),
),
OutlinedButton(
key: const Key('btn_create_signature'),
onPressed: disabled || !pdf.loaded ? null : onCreateSignature,
child: Text(l.createNewSignature),
),
ElevatedButton(
key: const Key('btn_draw_signature'),
onPressed: disabled || !pdf.loaded ? null : 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,154 @@
import 'package:flutter/material.dart';
import '../view_model/view_model.dart';
import 'signature_drag_data.dart';
import '../../../common/menu_labels.dart';
class SignatureCard extends StatelessWidget {
const SignatureCard({
super.key,
required this.asset,
required this.disabled,
required this.onDelete,
this.onTap,
this.onAdjust,
this.useCurrentBytesForDrag = false,
});
final SignatureAsset asset;
final bool disabled;
final VoidCallback onDelete;
final VoidCallback? onTap;
final VoidCallback? onAdjust;
final bool useCurrentBytesForDrag;
@override
Widget build(BuildContext context) {
final img = Image.memory(asset.bytes, fit: BoxFit.contain);
Widget base = SizedBox(
width: 96,
height: 64,
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Stack(
children: [
Positioned.fill(
child: DecoratedBox(
decoration: BoxDecoration(
border: Border.all(color: Theme.of(context).dividerColor),
borderRadius: BorderRadius.circular(8),
),
child: Padding(padding: const EdgeInsets.all(6), child: img),
),
),
Positioned(
right: 0,
top: 0,
child: IconButton(
icon: const Icon(Icons.close, size: 16),
onPressed: disabled ? null : onDelete,
tooltip: 'Remove',
padding: const EdgeInsets.all(2),
),
),
],
),
),
);
Widget child = onTap != null ? InkWell(onTap: onTap, child: base) : base;
// Add context menu for adjust/delete on right-click or long-press
child = GestureDetector(
key: const Key('gd_signature_card_area'),
behavior: HitTestBehavior.opaque,
onSecondaryTapDown:
disabled
? null
: (details) async {
final selected = await showMenu<String>(
context: context,
position: RelativeRect.fromLTRB(
details.globalPosition.dx,
details.globalPosition.dy,
details.globalPosition.dx,
details.globalPosition.dy,
),
items: [
PopupMenuItem(
key: const Key('mi_signature_adjust'),
value: 'adjust',
child: Text(MenuLabels.adjustGraphic(context)),
),
PopupMenuItem(
key: const Key('mi_signature_delete'),
value: 'delete',
child: Text(MenuLabels.delete(context)),
),
],
);
if (selected == 'adjust') {
onAdjust?.call();
} else if (selected == 'delete') {
onDelete();
}
},
onLongPressStart:
disabled
? null
: (details) async {
final selected = await showMenu<String>(
context: context,
position: RelativeRect.fromLTRB(
details.globalPosition.dx,
details.globalPosition.dy,
details.globalPosition.dx,
details.globalPosition.dy,
),
items: [
PopupMenuItem(
key: const Key('mi_signature_adjust'),
value: 'adjust',
child: Text(MenuLabels.adjustGraphic(context)),
),
PopupMenuItem(
key: const Key('mi_signature_delete'),
value: 'delete',
child: Text(MenuLabels.delete(context)),
),
],
);
if (selected == 'adjust') {
onAdjust?.call();
} else if (selected == 'delete') {
onDelete();
}
},
child: child,
);
if (disabled) return child;
return Draggable<SignatureDragData>(
data:
useCurrentBytesForDrag
? const SignatureDragData()
: SignatureDragData(assetId: asset.id),
feedback: Opacity(
opacity: 0.9,
child: ConstrainedBox(
constraints: const BoxConstraints.tightFor(width: 160, height: 100),
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(asset.bytes, fit: BoxFit.contain),
),
),
),
),
childWhenDragging: Opacity(opacity: 0.5, child: child),
child: child,
);
}
}

View File

@ -0,0 +1,4 @@
class SignatureDragData {
final String? assetId; // null means use current processed signature
const SignatureDragData({this.assetId});
}

View File

@ -0,0 +1,181 @@
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/export_providers.dart';
import '../view_model/view_model.dart';
import 'image_editor_dialog.dart';
import 'signature_card.dart';
/// Data for drag-and-drop is in signature_drag_data.dart
class SignatureDrawer extends ConsumerStatefulWidget {
const SignatureDrawer({
super.key,
required this.disabled,
required this.onLoadSignatureFromFile,
required this.onOpenDrawCanvas,
});
final bool disabled;
// Return the loaded bytes (if any) so we can add the exact image to the library immediately.
final Future<Uint8List?> Function() onLoadSignatureFromFile;
// Return the drawn bytes (if any) so we can add it to the library immediately.
final Future<Uint8List?> Function() onOpenDrawCanvas;
@override
ConsumerState<SignatureDrawer> createState() => _SignatureDrawerState();
}
class _SignatureDrawerState extends ConsumerState<SignatureDrawer> {
@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 library = ref.watch(signatureLibraryProvider);
final isExporting = ref.watch(exportingProvider);
final disabled = widget.disabled || isExporting;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (library.isNotEmpty) ...[
for (final a in library) ...[
Card(
margin: EdgeInsets.zero,
child: Padding(
padding: const EdgeInsets.all(12),
child: SignatureCard(
key: ValueKey('sig_card_${a.id}'),
asset: a,
disabled: disabled,
onDelete:
() => ref
.read(signatureLibraryProvider.notifier)
.remove(a.id),
onAdjust: () async {
ref
.read(signatureProvider.notifier)
.setImageFromLibrary(assetId: a.id);
if (!mounted) return;
await showDialog(
context: context,
builder: (_) => const ImageEditorDialog(),
);
},
onTap: () {
// Never reassign placed signatures via tap; only set active overlay source
ref
.read(signatureProvider.notifier)
.setImageFromLibrary(assetId: a.id);
},
),
),
),
const SizedBox(height: 12),
],
],
if (library.isEmpty)
Card(
margin: EdgeInsets.zero,
child: Padding(
padding: const EdgeInsets.all(12),
child:
bytes == null
? Text(l.noSignatureLoaded)
: SignatureCard(
asset: SignatureAsset(id: '', bytes: bytes, name: ''),
disabled: disabled,
useCurrentBytesForDrag: true,
onDelete: () {
ref
.read(signatureProvider.notifier)
.clearActiveOverlay();
ref.read(signatureProvider.notifier).clearImage();
},
onAdjust: () async {
if (!mounted) return;
await showDialog(
context: context,
builder: (_) => const ImageEditorDialog(),
);
},
),
),
),
Card(
margin: EdgeInsets.zero,
child: 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
: () async {
final loaded =
await widget.onLoadSignatureFromFile();
final b =
loaded ??
ref.read(processedSignatureImageProvider) ??
ref.read(signatureProvider).imageBytes;
if (b != null) {
final id = ref
.read(signatureLibraryProvider.notifier)
.add(b, name: 'image');
ref
.read(signatureProvider.notifier)
.setImageFromLibrary(assetId: id);
}
},
icon: const Icon(Icons.image_outlined),
label: Text(l.loadSignatureFromFile),
),
OutlinedButton.icon(
key: const Key('btn_drawer_draw_signature'),
onPressed:
disabled
? null
: () async {
final drawn = await widget.onOpenDrawCanvas();
final b =
drawn ??
ref.read(processedSignatureImageProvider) ??
ref.read(signatureProvider).imageBytes;
if (b != null) {
final id = ref
.read(signatureLibraryProvider.notifier)
.add(b, name: 'drawing');
ref
.read(signatureProvider.notifier)
.setImageFromLibrary(assetId: id);
}
},
icon: const Icon(Icons.gesture),
label: Text(l.drawSignature),
),
],
),
],
),
),
),
],
);
}
}

View File

@ -0,0 +1,286 @@
import 'dart:math' as math;
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/model/model.dart';
import '../view_model/view_model.dart';
import 'image_editor_dialog.dart';
import '../../../common/menu_labels.dart';
/// Renders a single signature overlay (either interactive or placed) on a page.
class SignatureOverlay extends ConsumerWidget {
const SignatureOverlay({
super.key,
required this.pageSize,
required this.rect,
required this.sig,
required this.pageNumber,
this.interactive = true,
this.placedIndex,
this.onDragSignature,
this.onResizeSignature,
this.onConfirmSignature,
this.onClearActiveOverlay,
this.onSelectPlaced,
});
final Size pageSize;
final Rect rect;
final SignatureState sig;
final int pageNumber;
final bool interactive;
final int? placedIndex;
// Callbacks used by interactive overlay
final ValueChanged<Offset>? onDragSignature;
final ValueChanged<Offset>? onResizeSignature;
final VoidCallback? onConfirmSignature;
final VoidCallback? onClearActiveOverlay;
// Callback for selecting a placed overlay
final ValueChanged<int?>? onSelectPlaced;
@override
Widget build(BuildContext context, WidgetRef ref) {
return LayoutBuilder(
builder: (context, constraints) {
final scaleX = constraints.maxWidth / pageSize.width;
final scaleY = constraints.maxHeight / pageSize.height;
final left = rect.left * scaleX;
final top = rect.top * scaleY;
final width = rect.width * scaleX;
final height = rect.height * scaleY;
return Stack(
children: [
Positioned(
left: left,
top: top,
width: width,
height: height,
child: _buildContent(context, ref, scaleX, scaleY),
),
],
);
},
);
}
Widget _buildContent(
BuildContext context,
WidgetRef ref,
double scaleX,
double scaleY,
) {
final selectedIdx = ref.read(pdfProvider).selectedPlacementIndex;
final bool isPlaced = placedIndex != null;
final bool isSelected = isPlaced && selectedIdx == placedIndex;
final Color borderColor = isPlaced ? Colors.red : Colors.indigo;
final double borderWidth = isPlaced ? (isSelected ? 3.0 : 2.0) : 2.0;
Widget content = DecoratedBox(
decoration: BoxDecoration(
color: Color.fromRGBO(
0,
0,
0,
0.05 + math.min(0.25, (sig.contrast - 1.0).abs()),
),
border: Border.all(color: borderColor, width: borderWidth),
),
child: Stack(
children: [
_SignatureImage(
interactive: interactive,
placedIndex: placedIndex,
pageNumber: pageNumber,
sig: sig,
),
if (interactive)
Positioned(
right: 0,
bottom: 0,
child: GestureDetector(
key: const Key('signature_handle'),
behavior: HitTestBehavior.opaque,
onPanUpdate:
(d) => onResizeSignature?.call(
Offset(d.delta.dx / scaleX, d.delta.dy / scaleY),
),
child: const Icon(Icons.open_in_full, size: 20),
),
),
],
),
);
if (interactive && sig.editingEnabled) {
content = GestureDetector(
key: const Key('signature_overlay'),
behavior: HitTestBehavior.opaque,
onPanStart: (_) {},
onPanUpdate:
(d) => onDragSignature?.call(
Offset(d.delta.dx / scaleX, d.delta.dy / scaleY),
),
onSecondaryTapDown: (d) => _showActiveMenu(context, d.globalPosition),
onLongPressStart: (d) => _showActiveMenu(context, d.globalPosition),
child: content,
);
} else {
content = GestureDetector(
key: Key('placed_signature_${placedIndex ?? 'x'}'),
behavior: HitTestBehavior.opaque,
onTap: () => onSelectPlaced?.call(placedIndex),
onSecondaryTapDown: (d) {
if (placedIndex != null) {
_showPlacedMenu(context, ref, d.globalPosition);
}
},
onLongPressStart: (d) {
if (placedIndex != null) {
_showPlacedMenu(context, ref, d.globalPosition);
}
},
child: content,
);
}
return content;
}
void _showActiveMenu(BuildContext context, Offset globalPos) {
showMenu<String>(
context: context,
position: RelativeRect.fromLTRB(
globalPos.dx,
globalPos.dy,
globalPos.dx,
globalPos.dy,
),
items: [
PopupMenuItem<String>(
key: const Key('ctx_active_confirm'),
value: 'confirm',
child: Text(MenuLabels.confirm(context)),
),
PopupMenuItem<String>(
key: const Key('ctx_active_delete'),
value: 'delete',
child: Text(MenuLabels.delete(context)),
),
PopupMenuItem<String>(
key: const Key('ctx_active_adjust'),
value: 'adjust',
child: Text(MenuLabels.adjustGraphic(context)),
),
],
).then((choice) {
if (choice == 'confirm') {
onConfirmSignature?.call();
} else if (choice == 'delete') {
onClearActiveOverlay?.call();
} else if (choice == 'adjust') {
showDialog(context: context, builder: (_) => const ImageEditorDialog());
}
});
}
void _showPlacedMenu(BuildContext context, WidgetRef ref, Offset globalPos) {
showMenu<String>(
context: context,
position: RelativeRect.fromLTRB(
globalPos.dx,
globalPos.dy,
globalPos.dx,
globalPos.dy,
),
items: [
PopupMenuItem<String>(
key: const Key('ctx_placed_delete'),
value: 'delete',
child: Text(MenuLabels.delete(context)),
),
PopupMenuItem<String>(
key: const Key('ctx_placed_adjust'),
value: 'adjust',
child: Text(MenuLabels.adjustGraphic(context)),
),
],
).then((choice) {
switch (choice) {
case 'delete':
if (placedIndex != null) {
ref
.read(pdfProvider.notifier)
.removePlacement(page: pageNumber, index: placedIndex!);
}
break;
case 'adjust':
showDialog(
context: context,
builder: (ctx) => const ImageEditorDialog(),
);
break;
default:
break;
}
});
}
}
class _SignatureImage extends ConsumerWidget {
const _SignatureImage({
required this.interactive,
required this.placedIndex,
required this.pageNumber,
required this.sig,
});
final bool interactive;
final int? placedIndex;
final int pageNumber;
final SignatureState sig;
@override
Widget build(BuildContext context, WidgetRef ref) {
Uint8List? bytes;
if (interactive) {
final processed = ref.watch(processedSignatureImageProvider);
bytes = processed ?? sig.imageBytes;
} else if (placedIndex != null) {
// Use the image assigned to this placement
final imgId = ref
.read(pdfProvider)
.placementImageByPage[pageNumber]
?.elementAt(placedIndex!);
if (imgId != null) {
final lib = ref.watch(signatureLibraryProvider);
for (final a in lib) {
if (a.id == imgId) {
bytes = a.bytes;
break;
}
}
}
// Fallback to current processed
bytes ??= ref.read(processedSignatureImageProvider) ?? sig.imageBytes;
}
if (bytes == null) {
String label;
try {
label = AppLocalizations.of(context).signature;
} catch (_) {
label = 'Signature';
}
return Center(child: Text(label));
}
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;
}
}

View File

@ -0,0 +1,54 @@
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/export_providers.dart';
import 'signature_drawer.dart';
class SignaturesSidebar extends ConsumerWidget {
const SignaturesSidebar({
super.key,
required this.onLoadSignatureFromFile,
required this.onOpenDrawCanvas,
required this.onSave,
});
final Future<Uint8List?> Function() onLoadSignatureFromFile;
final Future<Uint8List?> Function() onOpenDrawCanvas;
final VoidCallback onSave;
@override
Widget build(BuildContext context, WidgetRef ref) {
final l = AppLocalizations.of(context);
final isExporting = ref.watch(exportingProvider);
return AbsorbPointer(
absorbing: isExporting,
child: Card(
margin: EdgeInsets.zero,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
child: SingleChildScrollView(
child: SignatureDrawer(
disabled: isExporting,
onLoadSignatureFromFile: onLoadSignatureFromFile,
onOpenDrawCanvas: onOpenDrawCanvas,
),
),
),
Padding(
padding: const EdgeInsets.all(12),
child: ElevatedButton(
key: const Key('btn_save_pdf'),
onPressed: isExporting ? null : onSave,
child: Text(l.saveSignedPdf),
),
),
],
),
),
);
}
}

View File

@ -1,114 +1,178 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:pdf_signature/l10n/app_localizations.dart';
import '../providers.dart';
import '../../../../data/services/preferences_providers.dart';
class SettingsScreen extends ConsumerWidget {
const SettingsScreen({super.key});
class SettingsDialog extends ConsumerStatefulWidget {
const SettingsDialog({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final prefs = ref.watch(preferencesProvider);
ConsumerState<SettingsDialog> createState() => _SettingsDialogState();
}
class _SettingsDialogState extends ConsumerState<SettingsDialog> {
String? _theme;
String? _language;
// Page view removed; continuous-only
@override
void initState() {
super.initState();
final prefs = ref.read(preferencesProvider);
_theme = prefs.theme;
_language = prefs.language;
// pageView no longer configurable (continuous-only)
}
@override
Widget build(BuildContext context) {
final l = AppLocalizations.of(context);
return Scaffold(
appBar: AppBar(title: Text(l.settings)),
body: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(l.theme, style: const TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
DropdownButton<String>(
key: const Key('ddl_theme'),
value: prefs.theme,
items: [
DropdownMenuItem(value: 'light', child: Text(l.themeLight)),
DropdownMenuItem(value: 'dark', child: Text(l.themeDark)),
DropdownMenuItem(value: 'system', child: Text(l.themeSystem)),
],
onChanged:
(v) =>
v == null
? null
: ref.read(preferencesProvider.notifier).setTheme(v),
),
const SizedBox(height: 16),
Text(
l.language,
style: const TextStyle(fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
ref
.watch(languageAutonymsProvider)
.when(
loading:
() => const SizedBox(
height: 48,
child: Center(child: CircularProgressIndicator()),
),
error:
(_, __) => DropdownButton<String>(
key: const Key('ddl_language'),
value: prefs.language,
items:
AppLocalizations.supportedLocales.map((loc) {
final tag = toLanguageTag(loc);
return DropdownMenuItem<String>(
value: tag,
child: Text(tag),
);
}).toList(),
onChanged:
(v) =>
v == null
? null
: ref
.read(preferencesProvider.notifier)
.setLanguage(v),
),
data: (names) {
final items =
AppLocalizations.supportedLocales
.map((loc) => toLanguageTag(loc))
.toList()
..sort();
return DropdownButton<String>(
key: const Key('ddl_language'),
value: prefs.language,
items:
items
.map(
(tag) => DropdownMenuItem<String>(
value: tag,
child: Text(names[tag] ?? tag),
),
)
.toList(),
onChanged:
(v) =>
v == null
? null
: ref
.read(preferencesProvider.notifier)
.setLanguage(v),
);
},
),
const Spacer(),
Align(
alignment: Alignment.bottomRight,
child: OutlinedButton(
key: const Key('btn_reset_defaults'),
onPressed:
() =>
ref
.read(preferencesProvider.notifier)
.resetToDefaults(),
child: Text(l.resetToDefaults),
return Dialog(
insetPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 24),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 720),
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
l.settings,
style: Theme.of(context).textTheme.titleLarge,
),
),
IconButton(
tooltip: l.close,
onPressed: () => Navigator.of(context).pop(false),
icon: const Icon(Icons.close),
),
],
),
),
],
const SizedBox(height: 12),
Text(l.general, style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
Row(
children: [
SizedBox(width: 140, child: Text('${l.language}:')),
const SizedBox(width: 8),
Expanded(
child: ref
.watch(languageAutonymsProvider)
.when(
loading:
() => const SizedBox(
height: 48,
child: Center(
child: CircularProgressIndicator(),
),
),
error: (_, _) {
final items =
AppLocalizations.supportedLocales
.map((loc) => toLanguageTag(loc))
.toList()
..sort();
return DropdownButton<String>(
key: const Key('ddl_language'),
isExpanded: true,
value: _language,
items:
items
.map(
(tag) => DropdownMenuItem(
value: tag,
child: Text(tag),
),
)
.toList(),
onChanged: (v) => setState(() => _language = v),
);
},
data: (names) {
final items =
AppLocalizations.supportedLocales
.map((loc) => toLanguageTag(loc))
.toList()
..sort();
return DropdownButton<String>(
key: const Key('ddl_language'),
isExpanded: true,
value: _language,
items:
items
.map(
(tag) => DropdownMenuItem<String>(
value: tag,
child: Text(names[tag] ?? tag),
),
)
.toList(),
onChanged: (v) => setState(() => _language = v),
);
},
),
),
],
),
const SizedBox(height: 16),
Text(l.display, style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
Row(
children: [
SizedBox(width: 140, child: Text('${l.theme}:')),
const SizedBox(width: 8),
Expanded(
child: DropdownButton<String>(
key: const Key('ddl_theme'),
isExpanded: true,
value: _theme,
items: [
DropdownMenuItem(
value: 'light',
child: Text(l.themeLight),
),
DropdownMenuItem(
value: 'dark',
child: Text(l.themeDark),
),
DropdownMenuItem(
value: 'system',
child: Text(l.themeSystem),
),
],
onChanged: (v) => setState(() => _theme = v),
),
),
],
),
// Page view setting removed (continuous-only)
const SizedBox(height: 24),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
OutlinedButton(
onPressed: () => Navigator.of(context).pop(false),
child: Text(l.cancel),
),
const SizedBox(width: 8),
FilledButton(
onPressed: () async {
final n = ref.read(preferencesProvider.notifier);
if (_theme != null) await n.setTheme(_theme!);
if (_language != null) await n.setLanguage(_language!);
// pageView not configurable anymore
if (mounted) Navigator.of(context).pop(true);
},
child: Text(l.save),
),
],
),
],
),
),
),
);

View File

@ -0,0 +1,150 @@
import 'dart:typed_data';
import 'package:desktop_drop/desktop_drop.dart';
import 'package:file_selector/file_selector.dart' as fs;
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:pdf_signature/l10n/app_localizations.dart';
import '../../pdf/view_model/view_model.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.
abstract class DropReadable {
String get name;
String? get path; // may be null on some platforms
Future<Uint8List> readAsBytes();
}
class _DropReadableFromDesktop implements DropReadable {
final DropItemFile inner;
_DropReadableFromDesktop(this.inner);
@override
String get name => inner.name;
@override
String? get path => inner.path;
@override
Future<Uint8List> readAsBytes() => inner.readAsBytes();
}
// Allow injecting Riverpod's read function from either WidgetRef or ProviderContainer
typedef Reader = T Function<T>(ProviderListenable<T> provider);
// Select first .pdf file (case-insensitive) or fall back to first entry.
Future<void> handleDroppedFiles(
Reader read,
Iterable<DropReadable> files,
) async {
if (files.isEmpty) return;
final pdf = files.firstWhere(
(f) => (f.name.toLowerCase()).endsWith('.pdf'),
orElse: () => files.first,
);
Uint8List? bytes;
try {
bytes = await pdf.readAsBytes();
} catch (_) {
bytes = null;
}
final String path = pdf.path ?? pdf.name;
read(pdfProvider.notifier).openPicked(path: path, bytes: bytes);
read(signatureProvider.notifier).resetForNewPage();
}
class WelcomeScreen extends ConsumerStatefulWidget {
const WelcomeScreen({super.key});
@override
ConsumerState<WelcomeScreen> createState() => _WelcomeScreenState();
}
class _WelcomeScreenState extends ConsumerState<WelcomeScreen> {
bool _dragging = false;
Future<void> _pickPdf() async {
final typeGroup = const fs.XTypeGroup(label: 'PDF', extensions: ['pdf']);
final file = await fs.openFile(acceptedTypeGroups: [typeGroup]);
if (file != null) {
Uint8List? bytes;
try {
bytes = await file.readAsBytes();
} catch (_) {
bytes = null;
}
ref.read(pdfProvider.notifier).openPicked(path: file.path, bytes: bytes);
ref.read(signatureProvider.notifier).resetForNewPage();
}
}
@override
Widget build(BuildContext context) {
final l = AppLocalizations.of(context);
final content = Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.picture_as_pdf,
size: 64,
color: Theme.of(context).hintColor,
),
const SizedBox(height: 12),
Text(
l.noPdfLoaded,
style: Theme.of(context).textTheme.titleMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
OutlinedButton.icon(
key: const Key('btn_open_pdf_welcome'),
onPressed: _pickPdf,
icon: const Icon(Icons.folder_open),
label: Text(l.openPdf),
),
],
);
// Use desktop_drop on desktop and mobile; web drag&drop not handled here
final dropZone = DropTarget(
enable: !kIsWeb,
onDragEntered: (_) => setState(() => _dragging = true),
onDragExited: (_) => setState(() => _dragging = false),
onDragDone: (details) async {
final desktopFiles = details.files.whereType<DropItemFile>();
final adapters = desktopFiles.map<DropReadable>(
(f) => _DropReadableFromDesktop(f),
);
await handleDroppedFiles(ref.read, adapters);
},
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
border: Border.all(
color:
_dragging
? Theme.of(context).colorScheme.primary
: Theme.of(context).dividerColor,
width: 2,
),
color:
_dragging
? Theme.of(
context,
).colorScheme.primary.withValues(alpha: 0.05)
: Colors.transparent,
),
child: content,
),
);
return Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 560),
child: dropZone,
),
);
}
}

View File

@ -50,10 +50,14 @@ dependencies:
sdk: flutter
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
@ -68,6 +72,7 @@ dev_dependencies:
flutter_lints: ^6.0.0
msix: ^3.16.12
json_serializable: ^6.11.0
dead_code_analyzer: ^1.1.0
# For information on the generic Dart part of this file, see the
# following page: https://dart.dev/tools/pub/pubspec

View File

@ -4,23 +4,23 @@ Feature: App preferences
Given the settings screen is open
When the user selects the "<theme>" theme
Then the app UI updates to use the "<theme>" theme
And the preference {theme} is saved as {"<theme>"}
And the preference {'theme'} is saved as <theme>
Examples:
| theme |
| light |
| dark |
| system |
| 'light' |
| 'dark' |
| 'system' |
Scenario Outline: Choose a language and apply it immediately
Given the settings screen is open
When the user selects a supported language "<language>"
Then all visible texts are displayed in "<language>"
And the preference {language} is saved as {"<language>"}
And the preference {'language'} is saved as <language>
Examples:
| language |
| en |
| zh-TW |
| es |
| 'en' |
| 'zh-TW' |
| 'es' |

View File

@ -5,9 +5,12 @@ Feature: internationalizing
Then the language is set to the device locale
Scenario: Invalid stored language falls back to the device locale
Given stored preferences contain theme {sepia} and language {xx}
Given stored preferences contain theme {"sepia"} and language {"xx"}
When the app launches
Then the language falls back to the device locale
Scenario: Supported languages are available
Then the app supports languages {en, zh-TW, es}
Then the app supports languages
| 'en' |
| 'zh-TW' |
| 'es' |

View File

@ -1,12 +1,52 @@
Feature: PDF browser
Background:
Given a sample multi-page PDF (5 pages) is available
Scenario: Open a PDF and navigate pages
Given a PDF document is available
When the user opens the document
Then the first page is displayed
And the user can move to the next or previous page
And the page label shows "Page {1} of {5}"
Scenario: Jump to a specific page
Given a multi-page PDF is open
When the user selects a specific page number
Then that page is displayed
Scenario: Jump to a specific page by typing Enter
Given the document is open
When the user types {3} into the Go to input and presses Enter
Then page {3} is displayed
And the page label shows "Page {3} of {5}"
And the left pages overview highlights page {3}
Scenario: Jump to a specific page using the Apply button
Given the document is open
When the user types {4} into the Go to input
And the user clicks the Go to apply button
Then page {4} is displayed
And the page label shows "Page {4} of {5}"
Scenario: Navigate via page thumbnails
Given the document is open
When the user clicks the thumbnail for page {2}
Then page {2} is displayed
And the page label shows "Page {2} of {5}"
Scenario: Continuous mode scrolls target page into view on jump
Given the document is open
And the Page view mode is set to Continuous
When the user jumps to page {5}
Then page {5} becomes visible in the scroll area
And the left pages overview highlights page {5}
Scenario: Go to clamps out-of-range inputs to valid bounds
Given the document is open
When the user enters {0} into the Go to input and applies it
Then page {1} is displayed
And the page label shows "Page {1} of {5}"
When the user enters {99} into the Go to input and applies it
Then the last page is displayed (page {5})
And the page label shows "Page {5} of {5}"
Scenario: Go to is disabled when no PDF is loaded
Given no document is open
Then the Go to input cannot be used

View File

@ -1,29 +0,0 @@
Feature: PDF state logic
Scenario: openPicked loads document and initializes state
Given a new provider container
When I openPicked with path {'test.pdf'} and pageCount {7}
Then pdf state is loaded {true}
And pdf picked path is {'test.pdf'}
And pdf page count is {7}
And pdf current page is {1}
And pdf marked for signing is {false}
Scenario: jumpTo clamps within page boundaries
Given a new provider container
And a pdf is open with path {'test.pdf'} and pageCount {5}
When I jumpTo {10}
Then pdf current page is {5}
When I jumpTo {0}
Then pdf current page is {1}
When I jumpTo {3}
Then pdf current page is {3}
Scenario: setPageCount updates count without toggling other flags
Given a new provider container
And a pdf is open with path {'test.pdf'} and pageCount {2}
When I toggle mark
And I set page count {9}
Then pdf page count is {9}
And pdf state is loaded {true}
And pdf marked for signing is {true}

View File

@ -8,9 +8,9 @@ Feature: remember preferences
Examples:
| theme | language |
| dark | en |
| light | zh-TW |
| system | es |
| 'dark' | 'en' |
| 'light' | 'zh-TW' |
| 'system' | 'es' |
Scenario: Follow system appearance when theme is set to system
Given the user selects the "system" theme

View File

@ -1,35 +0,0 @@
Feature: Signature state logic
Scenario: placeDefaultRect centers a reasonable default rect
Given a new provider container
Then signature rect is null
When I place default signature rect
Then signature rect left >= {0}
And signature rect top >= {0}
And signature rect right <= {400}
And signature rect bottom <= {560}
And signature rect width > {50}
And signature rect height > {20}
Scenario: drag clamps to canvas bounds
Given a new provider container
And a default signature rect is placed
When I drag signature by {Offset(10000, -10000)}
Then signature rect left >= {0}
And signature rect top >= {0}
And signature rect right <= {400}
And signature rect bottom <= {560}
And signature rect moved from center
Scenario: resize respects aspect lock and clamps
Given a new provider container
And a default signature rect is placed
And aspect lock is {true}
When I resize signature by {Offset(1000, 1000)}
Then signature aspect ratio is preserved within {0.05}
And signature rect left >= {0}
And signature rect top >= {0}
And signature rect right <= {400}
And signature rect bottom <= {560}

View File

@ -1,64 +1,6 @@
import 'dart:typed_data';
import 'dart:ui' show Rect, Size;
import 'dart:io';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '_world.dart';
// A lightweight fake exporter to avoid platform rasterization in tests.
class FakeExportService {
Future<bool> exportSignedPdfFromFile({
required String inputPath,
required String outputPath,
required int? signedPage,
required Rect? signatureRectUi,
required Size uiPageSize,
required Uint8List? signatureImageBytes,
double targetDpi = 144.0,
}) async {
final bytes = await exportSignedPdfFromBytes(
srcBytes: Uint8List.fromList([0x25, 0x50, 0x44, 0x46]),
signedPage: signedPage,
signatureRectUi: signatureRectUi,
uiPageSize: uiPageSize,
signatureImageBytes: signatureImageBytes,
targetDpi: targetDpi,
);
if (bytes == null) return false;
try {
final file = File(outputPath);
await file.writeAsBytes(bytes, flush: true);
return true;
} catch (_) {
return false;
}
}
Future<Uint8List?> exportSignedPdfFromBytes({
required Uint8List srcBytes,
required int? signedPage,
required Rect? signatureRectUi,
required Size uiPageSize,
required Uint8List? signatureImageBytes,
double targetDpi = 144.0,
}) async {
// Return a deterministic tiny PDF-like byte array
final header = <int>[0x25, 0x50, 0x44, 0x46, 0x2D]; // %PDF-
final payload = <int>[...srcBytes.take(4)];
final sigFlag =
(signatureRectUi != null &&
signatureImageBytes != null &&
signatureImageBytes.isNotEmpty)
? 1
: 0;
final meta = <int>[
sigFlag,
uiPageSize.width.toInt() & 0xFF,
uiPageSize.height.toInt() & 0xFF,
];
return Uint8List.fromList([...header, ...payload, ...meta]);
}
}
ProviderContainer getOrCreateContainer() {
if (TestWorld.container != null) return TestWorld.container!;
final container = ProviderContainer();

View File

@ -34,3 +34,7 @@ const zh = _Token('zh');
const TW = _Token('TW');
const theme = _Token('theme');
const language = _Token('language');
// Additional tokens used by i18n tests
const sepia = _Token('sepia');
const xx = _Token('xx');

View File

@ -21,6 +21,7 @@ class TestWorld {
// Generic flags/values
static int? selectedPage;
static int? pendingGoTo; // for simulating typed Go To value across steps
// Preferences & settings
static Map<String, String> prefs = {};
@ -30,6 +31,10 @@ class TestWorld {
static String? currentTheme; // actual UI theme applied: 'light' | 'dark'
static String? currentLanguage; // 'en' | 'zh-TW' | 'es'
static bool settingsOpen = false;
// Signature image name loaded via steps (e.g., 'alice.png')
static String? currentImageName;
// Counters for steps that are called multiple times without params
static int placeFromPictureCallCount = 0;
static void reset() {
prevCenter = null;
@ -41,6 +46,7 @@ class TestWorld {
exportInProgress = false;
nothingToSaveAttempt = false;
selectedPage = null;
pendingGoTo = null;
// Preferences
prefs = {};
@ -50,5 +56,7 @@ class TestWorld {
currentTheme = null;
currentLanguage = null;
settingsOpen = false;
currentImageName = null;
placeFromPictureCallCount = 0;
}
}

View File

@ -1,11 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart';
import '_world.dart';
/// Usage: a default signature rect is placed
Future<void> aDefaultSignatureRectIsPlaced(WidgetTester tester) async {
final c = TestWorld.container!;
c.read(signatureProvider.notifier).placeDefaultRect();
// remember center for movement checks
TestWorld.prevCenter = c.read(signatureProvider).rect!.center;
}

View File

@ -1,15 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '_world.dart';
/// Usage: a new provider container
Future<void> aNewProviderContainer(WidgetTester tester) async {
// Ensure a fresh world per scenario
TestWorld.container?.dispose();
TestWorld.reset();
TestWorld.container = ProviderContainer();
addTearDown(() {
TestWorld.container?.dispose();
TestWorld.container = null;
});
}

View File

@ -29,15 +29,3 @@ Future<void> aPdfIsOpenAndContainsMultiplePlacedSignaturesAcrossPages(
container.read(pdfProvider.notifier).setSignedPage(1);
container.read(signatureProvider.notifier).placeDefaultRect();
}
/// Usage: all placed signatures appear on their corresponding pages in the output
Future<void> allPlacedSignaturesAppearOnTheirCorrespondingPagesInTheOutput(
WidgetTester tester,
) async {
// In this logic-level test suite, we simply assert that placements exist
// on multiple pages and that a simulated export has bytes.
final container = TestWorld.container ?? ProviderContainer();
expect(container.read(pdfProvider.notifier).placementsOn(1), isNotEmpty);
expect(container.read(pdfProvider.notifier).placementsOn(4), isNotEmpty);
expect(TestWorld.lastExportBytes, isNotNull);
}

View File

@ -1,13 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart';
import '_world.dart';
/// Usage: a pdf is open with path {'test.pdf'} and pageCount {5}
Future<void> aPdfIsOpenWithPathAndPagecount(
WidgetTester tester,
String path,
int pageCount,
) async {
final c = TestWorld.container!;
c.read(pdfProvider.notifier).openPicked(path: path, pageCount: pageCount);
}

View File

@ -0,0 +1,14 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart';
import '_world.dart';
/// Usage: a sample multi-page PDF (5 pages) is available
Future<void> aSampleMultipagePdf5PagesIsAvailable(WidgetTester tester) async {
final container = TestWorld.container ?? ProviderContainer();
TestWorld.container = container;
// Open a mock document with 5 pages
container
.read(pdfProvider.notifier)
.openPicked(path: 'mock.pdf', pageCount: 5);
}

View File

@ -17,4 +17,11 @@ Future<void> aSignatureImageIsSelected(WidgetTester tester) async {
.setImageBytes(Uint8List.fromList([1, 2, 3]));
// Allow provider scheduler to process queued updates fully
await tester.pumpAndSettle();
// Extra pump with a non-zero duration to flush zero-delay timers
await tester.pump(const Duration(milliseconds: 1));
// Teardown to avoid pending timers from Riverpod's scheduler
addTearDown(() {
TestWorld.container?.dispose();
TestWorld.container = null;
});
}

View File

@ -1,20 +1,24 @@
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter/material.dart';
import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart';
import '_world.dart';
/// Usage: the user navigates to page 3 and places another signature
Future<void> theUserNavigatesToPage3AndPlacesAnotherSignature(
WidgetTester tester,
) async {
/// Usage: a signature is placed on page {2}
Future<void> aSignatureIsPlacedOnPage(WidgetTester tester, num page) async {
final container = TestWorld.container ?? ProviderContainer();
TestWorld.container = container;
container.read(pdfProvider.notifier).jumpTo(3);
container
.read(pdfProvider.notifier)
.openPicked(path: 'mock.pdf', pageCount: 6);
// Ensure image and rect
container
.read(signatureProvider.notifier)
.setImageBytes(Uint8List.fromList([1, 2, 3]));
container.read(signatureProvider.notifier).placeDefaultRect();
final rect = container.read(signatureProvider).rect!;
container.read(pdfProvider.notifier).addPlacement(page: 3, rect: rect);
final Rect r = container.read(signatureProvider).rect!;
container
.read(pdfProvider.notifier)
.addPlacement(page: page.toInt(), rect: r, image: 'default.png');
}

View File

@ -1,40 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter/material.dart';
import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart';
import '_world.dart';
/// Usage: a signature is placed on page 2
Future<void> aSignatureIsPlacedOnPage2(WidgetTester tester) async {
final container = TestWorld.container ?? ProviderContainer();
TestWorld.container = container;
container
.read(pdfProvider.notifier)
.openPicked(path: 'mock.pdf', pageCount: 8);
container
.read(pdfProvider.notifier)
.addPlacement(page: 2, rect: const Rect.fromLTWH(50, 100, 80, 40));
}
/// Usage: the user navigates to page 5 and places another signature
Future<void> theUserNavigatesToPage5AndPlacesAnotherSignature(
WidgetTester tester,
) async {
final container = TestWorld.container ?? ProviderContainer();
container.read(pdfProvider.notifier).jumpTo(5);
container
.read(pdfProvider.notifier)
.addPlacement(page: 5, rect: const Rect.fromLTWH(60, 120, 80, 40));
}
/// Usage: the signature on page 2 remains
Future<void> theSignatureOnPage2Remains(WidgetTester tester) async {
final container = TestWorld.container ?? ProviderContainer();
expect(container.read(pdfProvider.notifier).placementsOn(2), isNotEmpty);
}
/// Usage: the signature on page 5 is shown on page 5
Future<void> theSignatureOnPage5IsShownOnPage5(WidgetTester tester) async {
final container = TestWorld.container ?? ProviderContainer();
expect(container.read(pdfProvider.notifier).placementsOn(5), isNotEmpty);
}

View File

@ -0,0 +1,18 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart';
import '_world.dart';
/// Usage: adjusting one instance does not affect the others
Future<void> adjustingOneInstanceDoesNotAffectTheOthers(
WidgetTester tester,
) async {
final container = TestWorld.container ?? ProviderContainer();
final before = container.read(pdfProvider.notifier).placementsOn(2);
expect(before.length, greaterThanOrEqualTo(2));
final modified = before[0].translate(5, 0).inflate(3);
container.read(pdfProvider.notifier).removePlacement(page: 2, index: 0);
container.read(pdfProvider.notifier).addPlacement(page: 2, rect: modified);
final after = container.read(pdfProvider.notifier).placementsOn(2);
expect(after.any((r) => r == before[1]), isTrue);
}

View File

@ -0,0 +1,17 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart';
import '_world.dart';
/// Usage: all placed signatures appear on their corresponding pages in the output
Future<void> allPlacedSignaturesAppearOnTheirCorrespondingPagesInTheOutput(
WidgetTester tester,
) async {
final container = TestWorld.container ?? ProviderContainer();
expect(container.read(pdfProvider.notifier).placementsOn(1), isNotEmpty);
// One of 4 or 5 depending on scenario
final p4 = container.read(pdfProvider.notifier).placementsOn(4);
final p5 = container.read(pdfProvider.notifier).placementsOn(5);
expect(p4.isNotEmpty || p5.isNotEmpty, isTrue);
expect(TestWorld.lastExportBytes, isNotNull);
}

View File

@ -1,14 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart';
import '_world.dart';
/// Usage: aspect lock is {true}
Future<void> aspectLockIs(WidgetTester tester, bool value) async {
final c = TestWorld.container!;
// snapshot current aspect for later validation
final r = c.read(signatureProvider).rect;
if (r != null) {
TestWorld.prevAspect = r.width / r.height;
}
c.read(signatureProvider.notifier).toggleAspect(value);
}

View File

@ -0,0 +1,21 @@
import 'dart:ui';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart';
import '_world.dart';
/// Usage: dragging or resizing one does not change the other
Future<void> draggingOrResizingOneDoesNotChangeTheOther(
WidgetTester tester,
) async {
final container = TestWorld.container ?? ProviderContainer();
final list = container.read(pdfProvider.notifier).placementsOn(1);
expect(list.length, greaterThanOrEqualTo(2));
final before = List<Rect>.from(list.take(2));
// Simulate changing the first only
final changed = before[0].inflate(5);
container.read(pdfProvider.notifier).removePlacement(page: 1, index: 0);
container.read(pdfProvider.notifier).addPlacement(page: 1, rect: changed);
final after = container.read(pdfProvider.notifier).placementsOn(1);
expect(after.any((r) => r == before[1]), isTrue);
}

View File

@ -0,0 +1,19 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart';
import '_world.dart';
/// Usage: each signature can be dragged and resized independently
Future<void> eachSignatureCanBeDraggedAndResizedIndependently(
WidgetTester tester,
) async {
final container = TestWorld.container ?? ProviderContainer();
final list = container.read(pdfProvider.notifier).placementsOn(1);
expect(list.length, greaterThanOrEqualTo(2));
// Independence is modeled by distinct rects; ensure not equal and both within page
expect(list[0], isNot(equals(list[1])));
for (final r in list.take(2)) {
expect(r.left, greaterThanOrEqualTo(0));
expect(r.top, greaterThanOrEqualTo(0));
}
}

View File

@ -1,9 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart';
import '_world.dart';
/// Usage: I drag signature by {Offset(10000, -10000)}
Future<void> iDragSignatureBy(WidgetTester tester, Offset delta) async {
final c = TestWorld.container!;
c.read(signatureProvider.notifier).drag(delta);
}

View File

@ -1,9 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart';
import '_world.dart';
/// Usage: I jumpTo {10}
Future<void> iJumpto(WidgetTester tester, int page) async {
final c = TestWorld.container!;
c.read(pdfProvider.notifier).jumpTo(page);
}

View File

@ -1,13 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart';
import '_world.dart';
/// Usage: I openPicked with path {'test.pdf'} and pageCount {7}
Future<void> iOpenpickedWithPathAndPagecount(
WidgetTester tester,
String path,
int pageCount,
) async {
final c = TestWorld.container!;
c.read(pdfProvider.notifier).openPicked(path: path, pageCount: pageCount);
}

View File

@ -1,9 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart';
import '_world.dart';
/// Usage: I place default signature rect
Future<void> iPlaceDefaultSignatureRect(WidgetTester tester) async {
final c = TestWorld.container!;
c.read(signatureProvider.notifier).placeDefaultRect();
}

View File

@ -1,9 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart';
import '_world.dart';
/// Usage: I resize signature by {Offset(1000, 1000)}
Future<void> iResizeSignatureBy(WidgetTester tester, Offset delta) async {
final c = TestWorld.container!;
c.read(signatureProvider.notifier).resize(delta);
}

View File

@ -1,9 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart';
import '_world.dart';
/// Usage: I set page count {9}
Future<void> iSetPageCount(WidgetTester tester, int count) async {
final c = TestWorld.container!;
c.read(pdfProvider.notifier).setPageCount(count);
}

View File

@ -1,8 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import '_world.dart';
/// Usage: I toggle mark
Future<void> iToggleMark(WidgetTester tester) async {
// Feature removed; no-op for backward-compatible tests
TestWorld.container; // keep reference to avoid unused warnings
}

View File

@ -0,0 +1,17 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart';
import '_world.dart';
/// Usage: identical signature instances appear in each location
Future<void> identicalSignatureInstancesAppearInEachLocation(
WidgetTester tester,
) async {
final container = TestWorld.container ?? ProviderContainer();
TestWorld.container = container;
final state = container.read(pdfProvider);
final p2 = state.placementsByPage[2] ?? const [];
final p4 = state.placementsByPage[4] ?? const [];
expect(p2.length, greaterThanOrEqualTo(2));
expect(p4.length, greaterThanOrEqualTo(1));
}

View File

@ -0,0 +1,10 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '_world.dart';
/// Usage: no document is open
Future<void> noDocumentIsOpen(WidgetTester tester) async {
// Reset to a fresh container with initial provider state
TestWorld.container?.dispose();
TestWorld.container = ProviderContainer();
}

View File

@ -3,8 +3,9 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart';
import '_world.dart';
/// Usage: the user selects a specific page number
Future<void> theUserSelectsASpecificPageNumber(WidgetTester tester) async {
/// Usage: only the selected signature is removed
Future<void> onlyTheSelectedSignatureIsRemoved(WidgetTester tester) async {
final container = TestWorld.container ?? ProviderContainer();
container.read(pdfProvider.notifier).jumpTo(3);
final list = container.read(pdfProvider.notifier).placementsOn(1);
expect(list.length, 2);
}

View File

@ -0,0 +1,7 @@
import 'package:flutter_test/flutter_test.dart';
/// Usage: other page content remains unaltered
Future<void> otherPageContentRemainsUnaltered(WidgetTester tester) async {
// Logic-level test: We do not rasterize or mutate other content in this layer.
expect(true, isTrue);
}

View File

@ -0,0 +1,14 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart';
import '_world.dart';
/// Usage: page {5} becomes visible in the scroll area
Future<void> pageBecomesVisibleInTheScrollArea(
WidgetTester tester,
num param1,
) async {
final page = param1.toInt();
final c = TestWorld.container ?? ProviderContainer();
expect(c.read(pdfProvider).currentPage, page);
}

View File

@ -0,0 +1,11 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart';
import '_world.dart';
/// Usage: page {1} is displayed
Future<void> pageIsDisplayed(WidgetTester tester, num param1) async {
final expected = param1.toInt();
final c = TestWorld.container ?? ProviderContainer();
expect(c.read(pdfProvider).currentPage, expected);
}

View File

@ -1,9 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart';
import '_world.dart';
/// Usage: pdf current page is {1}
Future<void> pdfCurrentPageIs(WidgetTester tester, int expected) async {
final c = TestWorld.container!;
expect(c.read(pdfProvider).currentPage, expected);
}

View File

@ -1,7 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
/// Usage: pdf marked for signing is {false}
Future<void> pdfMarkedForSigningIs(WidgetTester tester, bool expected) async {
// Feature removed; assert expectation is false for backward compatibility
expect(expected, false);
}

View File

@ -1,9 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart';
import '_world.dart';
/// Usage: pdf page count is {7}
Future<void> pdfPageCountIs(WidgetTester tester, int expected) async {
final c = TestWorld.container!;
expect(c.read(pdfProvider).pageCount, expected);
}

View File

@ -1,10 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart';
import '_world.dart';
/// Usage: pdf picked path is {'test.pdf'}
Future<void> pdfPickedPathIs(WidgetTester tester, String expected) async {
final c = TestWorld.container!;
final s = c.read(pdfProvider);
expect(s.pickedPdfPath, expected);
}

View File

@ -1,9 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart';
import '_world.dart';
/// Usage: pdf state is loaded {true}
Future<void> pdfStateIsLoaded(WidgetTester tester, bool expected) async {
final c = TestWorld.container!;
expect(c.read(pdfProvider).loaded, expected);
}

View File

@ -1,20 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart';
import '_world.dart';
/// Usage: signature aspect ratio is preserved within {0.05}
Future<void> signatureAspectRatioIsPreservedWithin(
WidgetTester tester,
num tolerance,
) async {
final c = TestWorld.container!;
final r = c.read(signatureProvider).rect!;
final before = TestWorld.prevAspect;
if (before == null) {
// save and pass
TestWorld.prevAspect = r.width / r.height;
return;
}
final after = r.width / r.height;
expect((after - before).abs(), lessThanOrEqualTo(tolerance.toDouble()));
}

View File

@ -1,10 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart';
import '_world.dart';
/// Usage: signature rect bottom <= {560}
Future<void> signatureRectBottom(WidgetTester tester, num maxBottom) async {
final c = TestWorld.container!;
final r = c.read(signatureProvider).rect!;
expect(r.bottom, lessThanOrEqualTo(maxBottom.toDouble()));
}

View File

@ -1,10 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart';
import '_world.dart';
/// Usage: signature rect height > {20}
Future<void> signatureRectHeight(WidgetTester tester, num minHeight) async {
final c = TestWorld.container!;
final r = c.read(signatureProvider).rect!;
expect(r.height, greaterThan(minHeight.toDouble()));
}

View File

@ -1,9 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart';
import '_world.dart';
/// Usage: signature rect is null
Future<void> signatureRectIsNull(WidgetTester tester) async {
final c = TestWorld.container!;
expect(c.read(signatureProvider).rect, isNull);
}

View File

@ -1,10 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart';
import '_world.dart';
/// Usage: signature rect left >= {0}
Future<void> signatureRectLeft(WidgetTester tester, num minLeft) async {
final c = TestWorld.container!;
final r = c.read(signatureProvider).rect!;
expect(r.left, greaterThanOrEqualTo(minLeft.toDouble()));
}

View File

@ -1,12 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart';
import '_world.dart';
/// Usage: signature rect moved from center
Future<void> signatureRectMovedFromCenter(WidgetTester tester) async {
final c = TestWorld.container!;
final prev = TestWorld.prevCenter;
final now = c.read(signatureProvider).rect!.center;
expect(prev, isNotNull);
expect(now, isNot(equals(prev)));
}

View File

@ -1,10 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart';
import '_world.dart';
/// Usage: signature rect right <= {400}
Future<void> signatureRectRight(WidgetTester tester, num maxRight) async {
final c = TestWorld.container!;
final r = c.read(signatureProvider).rect!;
expect(r.right, lessThanOrEqualTo(maxRight.toDouble()));
}

View File

@ -1,10 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart';
import '_world.dart';
/// Usage: signature rect top >= {0}
Future<void> signatureRectTop(WidgetTester tester, num minTop) async {
final c = TestWorld.container!;
final r = c.read(signatureProvider).rect!;
expect(r.top, greaterThanOrEqualTo(minTop.toDouble()));
}

Some files were not shown because too many files have changed in this diff Show More