Compare commits

..

45 Commits

Author SHA1 Message Date
insleker f133ecb17c chore: bump version to 1.1.0 and add changelog entry 2025-09-20 21:24:23 +08:00
insleker c7922cff23 refactor: migrate models to use Freezed for immutability and JSON support 2025-09-20 20:33:24 +08:00
insleker 771126d10c Merge branch 'feat/ui' 2025-09-20 19:33:24 +08:00
insleker 82d0c40e6a refactor: preferences repository to contain only 1 provicder 2025-09-20 19:31:27 +08:00
insleker 7032f22327 feat: add theme color selection in setting dialog 2025-09-20 19:06:10 +08:00
insleker 8197a352aa feat: add theme color selection
feat: drag-and-drop hints for signature cards
2025-09-20 18:37:49 +08:00
insleker bc524e958f refactor: use image object to replace bytes 2025-09-20 15:38:34 +08:00
insleker 81a352a513 feat: found root cause of slow image process is store them in bytes rather than image object 2025-09-19 21:55:59 +08:00
insleker 8daf5ea3ca fix: exported document doesn't scale, rotate signature correctly 2025-09-19 16:53:49 +08:00
insleker 5a03793b54 feat: remember uploaded file name on web and use it when downloading 2025-09-18 22:30:22 +08:00
insleker 0c38178502 refactor: ui_services.dart to PdfExportViewModel for export functionality 2025-09-18 21:31:30 +08:00
insleker eee75f6fdb feat: disable context menu in web 2025-09-18 21:05:40 +08:00
insleker 34f6abad32 Merge branch 'refactor/model' into feat/ui 2025-09-18 18:09:58 +08:00
insleker 0f7d840e48 feat: enhance PDF thumbnail navigation and selection logic 2025-09-18 18:08:33 +08:00
insleker a08f93e8d4 feat: support export functionality on web by download 2025-09-18 18:04:43 +08:00
insleker 41eea5f00c feat: change app icon 2025-09-18 16:30:57 +08:00
insleker 5ad4d6136f feat: add locking and unlocking functionality for signature placements 2025-09-18 14:44:47 +08:00
insleker 69d5a9a248 feat: implement signature drag-and-drop functionality and enhance PDF page overlays 2025-09-18 12:50:14 +08:00
insleker 2043bfc14c feat: enhance signature img processing performance 2025-09-18 00:14:56 +08:00
insleker feaf7aee9f refactor: update PDF view model and routing for improved session management 2025-09-17 20:46:11 +08:00
insleker 6652de28bf feat: add zoom level listener and scroll thumbs to PDF viewer 2025-09-17 17:03:07 +08:00
insleker 994c1b2569 fix: DrawCanvas create signatureCard functionality 2025-09-17 14:51:16 +08:00
insleker 26a0c93390 feat: implement image processing and caching in signatureCard
repository
2025-09-17 08:16:31 +08:00
insleker 80cf115ab3 feat: add background remove feature in image editor dialog 2025-09-15 20:09:27 +08:00
insleker 8f3039f99e fix: graphic adjust dialog has to show image preview 2025-09-12 22:44:00 +08:00
insleker 461c8f6ae5 feat: pass base test of viewmodel API migration 2025-09-12 21:40:00 +08:00
insleker 5549f08b4c feat: migrate pdf state to viewmodel abstraction 2025-09-12 18:59:27 +08:00
insleker 7336ca4d57 fix: thumbnail not shown actualy pdf page 2025-09-12 12:29:23 +08:00
insleker c82bb7fa2a feat: pass widget test 2025-09-12 08:19:03 +08:00
insleker 00e2e1deb4 feat: pass base test after document API change 2025-09-11 22:04:37 +08:00
insleker c46aca1331 feat: remove currentPage in Document model 2025-09-11 20:54:31 +08:00
insleker 545d3ad688 fix: signature card repository wrong API 2025-09-11 17:52:50 +08:00
insleker 4d2cd09adf feat: partially implement new view of UI 2025-09-11 00:13:47 +08:00
insleker 189bc7e6e6 feat: partially implement integration test 2025-09-10 22:17:36 +08:00
insleker f0a8e25890 feat: partially implement UI widget and implement test 2025-09-10 21:55:02 +08:00
insleker b0a3ff1f57 feat: partially implement new ui widget 2025-09-10 20:22:36 +08:00
insleker d9969e5ea5 refactor: remove unused import 2025-09-10 18:56:18 +08:00
insleker be7c1d4029 feat: implement new feature test 2025-09-10 18:21:11 +08:00
insleker e9cf4c30c1 feat: new implement of `/lib/data/repositories/` 2025-09-10 15:57:54 +08:00
insleker 948999fe8e feat: move `model.dart` to `/lib/domain/models/` 2025-09-10 13:17:31 +08:00
insleker c1b7824cbd feat: group provider into `/lib/data` 2025-09-10 00:37:47 +08:00
insleker 095e99f0a6 feat: pass feature test 2025-09-09 23:12:56 +08:00
insleker 21a0638bf0 feat: partially implement new feature test 2025-09-09 22:26:33 +08:00
insleker 380be43c05 feat: migrate to whole new data model and update relevant use
cases.
2025-09-09 21:44:54 +08:00
insleker fba880e1be fix: rotation and scale of placed signature on PDF are not sync 2025-09-08 20:28:14 +08:00
280 changed files with 8589 additions and 4643 deletions

4
.gitignore vendored
View File

@ -135,3 +135,7 @@ AppDir/bundle/
appimage-build/ appimage-build/
/*.AppImage /*.AppImage
.vscode/settings.json .vscode/settings.json
*.patch
*.freezed.dart
*.g.dart

View File

@ -6,7 +6,7 @@ Additionally read relevant files depends on task.
* If want to modify use cases (files at `test/features/*.feature`) * If want to modify use cases (files at `test/features/*.feature`)
* read [`FRs.md`](docs/FRs.md) * read [`FRs.md`](docs/FRs.md)
* If want to modify code (implement or test) of `View` of MVVM (UI widget) (files at `lib/ui/features/*/widgets/*`) * If want to modify code (implement or test) of `ViewModel`, `View` of MVVM (UI widget) (files in `lib/ui/features/*/widgets/*`)
* read [`wireframe.md`](docs/wireframe.md), [`NFRs.md`](docs/NFRs.md), `test/features/*.feature` * read [`wireframe.md`](docs/wireframe.md), [`NFRs.md`](docs/NFRs.md), `test/features/*.feature`
* If want to modify code (implement or test) of non-View e.g. `Model`, `View Model`, services... * If want to modify code (implement or test) of non-View e.g. `Model`, repositories, services...
* read `test/features/*.feature`, [`NFRs.md`](docs/NFRs.md) * read `test/features/*.feature`, [`NFRs.md`](docs/NFRs.md)

View File

@ -1,27 +1,52 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="UTF-8"?>
<svg
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
viewBox="0 0 64 64"
width="64"
height="64"
role="img"
aria-labelledby="title desc"
>
<title id="title">PDF Signature</title>
<desc id="desc">An app icon showing a PDF page with a folded corner and a handwritten signature.</desc>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"> <!-- Background tile -->
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools --> <rect x="4" y="4" width="56" height="56" rx="12" fill="#2563EB" />
<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"> <!-- Paper with folded corner -->
<style type="text/css"> <g>
.st0{fill:#000000;} <path
</style> d="M20 16h18l10 10v22c0 2.2-1.8 4-4 4H20c-2.2 0-4-1.8-4-4V20c0-2.2 1.8-4 4-4z"
<g> fill="#FFFFFF"
<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"/> <path
<polygon class="st0" points="286.422,396.623 268.742,327.077 216.884,378.94 "/> d="M38 16v8c0 3.3 2.7 6 6 6h8l-14-14z"
<path class="st0" d="M258.136,316.475L86.058,144.397L34.2,196.26l172.073,172.077L258.136,316.475z M87.468,166.56 fill="#F3F4F6"
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"/> </g>
<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"/> <!-- Signature stroke -->
<rect x="256.69" y="200.693" class="st0" width="173.056" height="28.009"/> <path
<rect x="288.598" y="268.894" class="st0" width="141.148" height="28.01"/> d="M18 42c3-2 6-2.2 8.5 0 2.5 2.2 4.8 1.8 8.2-1.2 3.4-3 6.9-5.3 9.4-2.8 1.2 1.2 0.5 3.2-1.2 3.6-3.5 0.9 3.3-6.8 6.4-4.6 2 1.4-1.5 6.7-4.8 7.8-4.6 1.6-10.9-0.6-13.8-0.6-4.4 0-7.5 2.4-12 2.8"
<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 fill="none"
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 stroke="#1F2937"
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 stroke-width="2.5"
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 stroke-linecap="round"
v-12.61V93.238C512,47.917,475.138,11.059,429.817,11.059z"/> stroke-linejoin="round"
</g> />
<!-- Subtle page shadow for depth (kept minimal for clarity) -->
<path
d="M20 16h18l10 10v1H44c-4.4 0-8-3.6-8-8v-3H20c-1.1 0-2 .9-2 2v0c0-1.1.9-2 2-2z"
fill="#000"
opacity=".05"
/>
<!-- Optional PDF label dots (very subtle) -->
<g fill="#E53935" opacity=".9">
<circle cx="24" cy="28" r="1" />
<circle cx="28" cy="28" r="1" />
<circle cx="32" cy="28" r="1" />
</g>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

9
CHANGELOG.md Normal file
View File

@ -0,0 +1,9 @@
## 1.1.0
* refactor to clear domain models
## 1.0.0
* basic implementation
* support localization

View File

@ -20,6 +20,7 @@ RUN --mount=type=cache,target=/root/.pub-cache \
rm -rf .dart_tool build && \ rm -rf .dart_tool build && \
flutter pub get && \ flutter pub get && \
flutter gen-l10n && \ flutter gen-l10n && \
flutter pub run build_runner build --delete-conflicting-outputs && \
flutter build web --release -O4 --wasm flutter build web --release -O4 --wasm
# Stage 2: Caddy (Alpine) to serve static files with SPA fallback # Stage 2: Caddy (Alpine) to serve static files with SPA fallback

View File

@ -10,8 +10,9 @@ checkout [`docs/FRs.md`](docs/FRs.md)
```bash ```bash
# flutter clean # flutter clean
# arb_translate
flutter pub get flutter pub get
# arb_translate
# flutter gen-l10n
# > to generate gherkin test # > to generate gherkin test
flutter pub run build_runner build --delete-conflicting-outputs flutter pub run build_runner build --delete-conflicting-outputs
# > to remove unused step definitions # > to remove unused step definitions
@ -22,6 +23,7 @@ flutter analyze
flutter test flutter test
# > run integration tests # > run integration tests
flutter test integration_test/ -d <device_id> flutter test integration_test/ -d <device_id>
# dart run tool/run_integration_tests.dart --device=linux
# dart run tool/gen_view_wireframe_md.dart # dart run tool/gen_view_wireframe_md.dart
# flutter pub run dead_code_analyzer # flutter pub run dead_code_analyzer

View File

@ -2,7 +2,7 @@
<application <application
android:label="pdf_signature" android:label="pdf_signature"
android:name="${applicationName}" android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"> android:icon="@mipmap/launcher_icon">
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -0,0 +1,52 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
viewBox="0 0 64 64"
width="64"
height="64"
role="img"
aria-labelledby="title desc"
>
<title id="title">PDF Signature</title>
<desc id="desc">An app icon showing a PDF page with a folded corner and a handwritten signature.</desc>
<!-- Background tile -->
<rect x="4" y="4" width="56" height="56" rx="12" fill="#2563EB" />
<!-- Paper with folded corner -->
<g>
<path
d="M20 16h18l10 10v22c0 2.2-1.8 4-4 4H20c-2.2 0-4-1.8-4-4V20c0-2.2 1.8-4 4-4z"
fill="#FFFFFF"
/>
<path
d="M38 16v8c0 3.3 2.7 6 6 6h8l-14-14z"
fill="#F3F4F6"
/>
</g>
<!-- Signature stroke -->
<path
d="M18 42c3-2 6-2.2 8.5 0 2.5 2.2 4.8 1.8 8.2-1.2 3.4-3 6.9-5.3 9.4-2.8 1.2 1.2 0.5 3.2-1.2 3.6-3.5 0.9 3.3-6.8 6.4-4.6 2 1.4-1.5 6.7-4.8 7.8-4.6 1.6-10.9-0.6-13.8-0.6-4.4 0-7.5 2.4-12 2.8"
fill="none"
stroke="#1F2937"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
<!-- Subtle page shadow for depth (kept minimal for clarity) -->
<path
d="M20 16h18l10 10v1H44c-4.4 0-8-3.6-8-8v-3H20c-1.1 0-2 .9-2 2v0c0-1.1.9-2 2-2z"
fill="#000"
opacity=".05"
/>
<!-- Optional PDF label dots (very subtle) -->
<g fill="#E53935" opacity=".9">
<circle cx="24" cy="28" r="1" />
<circle cx="28" cy="28" r="1" />
<circle cx="32" cy="28" r="1" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -1,8 +1,18 @@
targets: targets:
$default: $default:
sources: sources:
- integration_test/** - integration_test/** # By default, build runner will not generate code in the integration folder
- test/** - test/** # so we override paths for code generation here
- lib/** - lib/**
- $package$ - $package$
builders: builders:
bdd_widget_test|featureBuilder:
generate_for:
- test/**
- integration_test/**
freezed:
generate_for:
- lib/**
json_serializable:
generate_for:
- lib/**

View File

@ -2,25 +2,27 @@
## user stories ## user stories
The following user stories may not use formal terminology as [meta-arch.md](./meta-arch.md) and use cases(`test/*.feature`), but use oral descriptions.
* name: [PDF browser](../test/features/pdf_browser.feature) * name: [PDF browser](../test/features/pdf_browser.feature)
* role: user * role: user
* functionality: view and navigate PDF documents * functionality: view and navigate PDF documents
* benefit: select page to add signature * benefit: select page to add signature
* name: [load signature picture](../test/features/load_signature_picture.feature) * name: [load signature](../test/features/load_signature.feature)
* role: user * role: user
* functionality: load a signature picture file * functionality: load a signature asset file and create a signature card
* benefit: easily add signature to PDF * benefit: easily add signature to PDF
* name: [geometrically adjust signature picture](../test/features/geometrically_adjust_signature_picture.feature) * name: [geometrically adjust signature picture](../test/features/geometrically_adjust_signature_picture.feature)
* role: user * role: user
* functionality: adjust the size and position of the signature picture * functionality: adjust the scale, rotation and position of the signature placement on the PDF page
* benefit: ensure the signature fits well on the PDF page * benefit: ensure the signature fits well on the PDF page
* name: [graphically adjust signature picture](../test/features/graphically_adjust_signature_picture.feature) * name: [graphically adjust signature picture](../test/features/graphically_adjust_signature_picture.feature)
* role: user * role: user
* functionality: background removal, contrast adjustment... * functionality: background removal, contrast adjustment... to enhance the appearance of the signature asset within the signature card
* benefit: easily improve the appearance of the signature on the PDF without additional software. * benefit: easily improve the appearance of the signature on the PDF without additional software.
* name: [draw signature](../test/features/draw_signature.feature) * name: [draw signature](../test/features/draw_signature.feature)
* role: user * role: user
* functionality: draw a signature using mouse or touch input * functionality: draw a signature asset using mouse or touch input
* benefit: create a custom signature directly on the PDF if no pre-made signature is available. * benefit: create a custom signature directly on the PDF if no pre-made signature is available.
* name: [save signed PDF](../test/features/save_signed_pdf.feature) * name: [save signed PDF](../test/features/save_signed_pdf.feature)
* role: user * role: user
@ -28,7 +30,7 @@
* benefit: easily keep a copy of the signed document for records. * benefit: easily keep a copy of the signed document for records.
* name: [preferences for app](../test/features/app_preferences.feature) * name: [preferences for app](../test/features/app_preferences.feature)
* role: user * role: user
* functionality: configure app preferences such as `theme`, `language`. * functionality: configure app preferences such as `language`, `theme`, `theme-color`.
* benefit: customize the app experience to better fit user needs * benefit: customize the app experience to better fit user needs
* name: [remember preferences](../test/features/remember_preferences.feature) * name: [remember preferences](../test/features/remember_preferences.feature)
* role: user * role: user

View File

@ -3,3 +3,4 @@
* support multiple platforms (windows, linux, android, web) * support multiple platforms (windows, linux, android, web)
* only FOSS libs can use * only FOSS libs can use
* should not exceed 350 lines of code per file * should not exceed 350 lines of code per file
* Direct Passing is better than Singleton(e.g.Provider) especially for `view`, `viewModel`.

View File

@ -1,16 +1,84 @@
# meta archietecture # meta archietecture
* [MVVM](https://docs.flutter.dev/app-architecture/guide) * [MVVM](https://docs.flutter.dev/app-architecture/guide)
* [Data layer](https://docs.flutter.dev/app-architecture/case-study/data-layer)
* View ⇆ ViewModel ⇆ Repository ⇆ Service
* Model is used across.
## Package structure ## Package structure
The repo structure follows official [Package structure](https://docs.flutter.dev/app-architecture/case-study#package-structure) with slight modifications. The repo structure follows official [Package structure](https://docs.flutter.dev/app-architecture/case-study#package-structure).
```
lib
├─┬─ ui
│ ├─┬─ core
│ │ ├─┬─ ui
│ │ │ └─── <shared widgets>
│ │ └─── themes
│ └─┬─ <FEATURE NAME>
│ ├─┬─ view_model
│ │ └─── <view_model class>.dart
│ └─┬─ widgets
│ ├── <feature name>_screen.dart
│ └── <other widgets>
├─┬─ domain
│ └─┬─ models
│ └─── <model name>.dart
├─┬─ data
│ ├─┬─ repositories
│ │ └─── <repository class>.dart
│ ├─┬─ services
│ │ └─── <service class>.dart
│ └─┬─ model
│ └─── <api model class>.dart
├─── config
├─── utils
├─── routing
├─── main_staging.dart
├─── main_development.dart
└─── main.dart
// The test folder contains unit and widget tests
test
├─── data
├─── domain
├─── ui
└─── utils
// The testing folder contains mocks other classes need to execute tests
testing
├─── fakes
└─── models
```
But with slight modifications.
* put each `<FEATURE NAME>/`s in `features/` sub-directory under `ui/`. * put each `<FEATURE NAME>/`s in `features/` sub-directory under `ui/`.
* `test/features/` contains BDD unit tests for each feature. It focuses on pure logic, therefore will not access `View` but `ViewModel` and `Model`. * `test/features/` contains BDD unit tests for each feature. It focuses on pure logic, therefore will not access `View` but `ViewModel` and `Model`.
* `test/widget/` contains UI widget(component) tests which focus on `View` from MVVM of each component. * `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. * `integration_test/` for integration tests. They should be volatile to follow UI layout changes.
Some rule of thumb:
* global provider
* `<object>RepositoryProvider` only placed in `/lib/data/repositories/`, provide data to `/lib/ui`.
* `lib/data/services/*` should be stateless, and should only accessible by `Repository`.
## Abstraction
### terminology
* `signature asset`
* image file of a signature, stored in the device or cloud storage
* can drawing from canvas
* `signature card`
* template of signature placement
* It will include modifications such as brightness, contrast, background removal, rotation of the signature asset.
* `signature placement`
* placed modified signature asset from signature card on a specific position on a specific page of a specific PDF document
* `document`
* PDF document to be signed
## key dependencies ## key dependencies
* [pdfrx](https://pub.dev/packages/pdfrx) * [pdfrx](https://pub.dev/packages/pdfrx)
@ -22,3 +90,7 @@ The repo structure follows official [Package structure](https://docs.flutter.dev
* [Viewer Customization using Widget Overlay](https://pub.dev/documentation/pdfrx/latest/pdfrx/PdfViewerParams/viewerOverlayBuilder.html) * [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) * [Per-page Customization using Widget Overlay](https://pub.dev/documentation/pdfrx/latest/pdfrx/PdfViewerParams/pageOverlaysBuilder.html)
* `pageOverlaysBuilder` * `pageOverlaysBuilder`
* [image](https://pub.dev/packages/image)
* whole app use its image object as image representation.
* aware that minimize, encode/decode usage, because its has poor performance on web
* `ColorFilterGenerator` can not replace `adjustColor` due to custom background removal algorithm need at last stage. It is GPU based, and offscreen-render then readback is not ideal.

View File

@ -396,151 +396,13 @@
"link": null, "link": null,
"locked": false "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", "id": "fmP0hKBOaNa5Ge12TEwyD",
"type": "rectangle", "type": "rectangle",
"x": 561.2432261444915, "x": 561.2432261444915,
"y": 505.261726567147, "y": 509.59787769019385,
"width": 146.7306357461261, "width": 123.56657324612611,
"height": 40.63309912969646, "height": 36.296948006649586,
"angle": 0, "angle": 0,
"strokeColor": "#1f2937", "strokeColor": "#1f2937",
"backgroundColor": "#ffffff", "backgroundColor": "#ffffff",
@ -556,11 +418,11 @@
"index": "aD", "index": "aD",
"roundness": null, "roundness": null,
"seed": 1608525080, "seed": 1608525080,
"version": 101, "version": 114,
"versionNonce": 679299830, "versionNonce": 1580272529,
"isDeleted": false, "isDeleted": false,
"boundElements": [], "boundElements": [],
"updated": 1756647235527, "updated": 1758364887319,
"link": null, "link": null,
"locked": false "locked": false
}, },
@ -569,7 +431,7 @@
"type": "text", "type": "text",
"x": 601.8763252741879, "x": 601.8763252741879,
"y": 514.291304151524, "y": 514.291304151524,
"width": 39.54961113185798, "width": 45.983367919921875,
"height": 24.379859477817877, "height": 24.379859477817877,
"angle": 0, "angle": 0,
"strokeColor": "#1f2937", "strokeColor": "#1f2937",
@ -586,20 +448,20 @@
"index": "aE", "index": "aE",
"roundness": null, "roundness": null,
"seed": 533447192, "seed": 533447192,
"version": 103, "version": 111,
"versionNonce": 554272618, "versionNonce": 935775633,
"isDeleted": false, "isDeleted": false,
"boundElements": [], "boundElements": [],
"updated": 1756647235527, "updated": 1758364882876,
"link": null, "link": null,
"locked": false, "locked": false,
"text": "Save", "text": "Close",
"fontSize": 18.059155168753982, "fontSize": 18.059155168753982,
"fontFamily": 6, "fontFamily": 6,
"textAlign": "left", "textAlign": "left",
"verticalAlign": "top", "verticalAlign": "top",
"containerId": null, "containerId": null,
"originalText": "Save", "originalText": "Close",
"autoResize": true, "autoResize": true,
"lineHeight": 1.35 "lineHeight": 1.35
}, },

View File

@ -12,6 +12,7 @@ Refs:
## Welcome / First screen ## Welcome / First screen
Purpose: let the user open a PDF quickly via drag & drop or file picker. Purpose: let the user open a PDF quickly via drag & drop or file picker.
Route: root Route: root
Design notes: Design notes:
@ -29,9 +30,10 @@ Purpose: provide basic configuration before/after opening a PDF.
Route: root --> settings Route: root --> settings
Design notes: Design notes:
- Opened via "Configure" button in the top bar. - Opened via "Configure" button in the right of top bar.
- Modal with simple sections (e.g., General, Display). - Model with simple sections (e.g., General, Display).
- Primary action to save, secondary to cancel. - When select option, option will take effect immediately.
- A button to close the dialog and return to the previous screen.
Illustration: Illustration:
@ -56,11 +58,12 @@ Design notes:
- Right pane: signatures drawer displaying saved signatures as cards. - Right pane: signatures drawer displaying saved signatures as cards.
- able to drag and drop signature cards onto the PDF as placed signatures. - able to drag and drop signature cards onto the PDF as placed signatures.
- Each signature card shows a preview. - Each signature card shows a preview.
- long tap/right-click will show menu with options to delete, adjust graphic of image. - 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). - "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". - 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. - "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. - "draw" opens a simple drawing interface (draw canvas) to create a signature card.
- There is a button at bottom to export PDF with placed signatures.
- Interaction: drag a signature card from the right drawer onto the currently visible page to place it. - Interaction: drag a signature card from the right drawer onto the currently visible page to place it.
Signature controls (after placing on page): Signature controls (after placing on page):

Binary file not shown.

View File

@ -3,14 +3,22 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart'; import 'package:integration_test/integration_test.dart';
import 'package:image/image.dart' as img; import 'dart:io';
import 'package:file_selector/file_selector.dart' as fs;
import 'package:pdf_signature/data/services/export_service.dart'; import 'package:pdf_signature/data/services/export_service.dart';
import 'package:pdf_signature/data/services/export_providers.dart';
import 'package:pdf_signature/ui/features/signature/view_model/signature_library.dart'; import 'package:pdf_signature/data/repositories/signature_asset_repository.dart';
import 'package:pdf_signature/ui/features/signature/view_model/signature_controller.dart'; import 'package:pdf_signature/data/repositories/signature_card_repository.dart';
import 'package:pdf_signature/ui/features/pdf/view_model/pdf_controller.dart'; import 'package:pdf_signature/data/repositories/document_repository.dart';
import 'package:pdf_signature/domain/models/model.dart';
import 'package:pdf_signature/ui/features/pdf/view_model/pdf_view_model.dart';
import 'package:pdf_signature/ui/features/pdf/view_model/pdf_export_view_model.dart';
import 'package:pdf_signature/ui/features/pdf/widgets/pages_sidebar.dart';
import 'package:pdf_signature/ui/features/pdf/widgets/pdf_screen.dart'; import 'package:pdf_signature/ui/features/pdf/widgets/pdf_screen.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:image/image.dart' as img;
import 'package:pdf_signature/data/repositories/preferences_repository.dart';
import 'package:pdf_signature/l10n/app_localizations.dart'; import 'package:pdf_signature/l10n/app_localizations.dart';
class RecordingExporter extends ExportService { class RecordingExporter extends ExportService {
@ -22,6 +30,30 @@ class RecordingExporter extends ExportService {
} }
} }
// Lightweight fake exporter to avoid invoking heavy rasterization during tests
class LightweightExporter extends ExportService {
@override
Future<Uint8List?> exportSignedPdfFromBytes({
required Uint8List srcBytes,
required Size uiPageSize,
required Uint8List? signatureImageBytes,
Map<int, List<SignaturePlacement>>? placementsByPage,
Map<String, img.Image>? libraryImages,
double targetDpi = 144.0,
}) async {
// Return minimal non-empty bytes; content isn't used further in tests
return Uint8List.fromList([1, 2, 3]);
}
@override
Future<bool> saveBytesToFile({
required Uint8List bytes,
required String outputPath,
}) async {
return true;
}
}
void main() { void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized(); IntegrationTestWidgetsFlutterBinding.ensureInitialized();
@ -29,26 +61,42 @@ void main() {
tester, tester,
) async { ) async {
final fake = RecordingExporter(); final fake = RecordingExporter();
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
// For this test, we don't need the PDF bytes since it's not loaded
await tester.pumpWidget( await tester.pumpWidget(
ProviderScope( ProviderScope(
overrides: [ overrides: [
pdfProvider.overrideWith( preferencesRepositoryProvider.overrideWith(
(ref) => PdfController()..openPicked(path: 'test.pdf'), (ref) => PreferencesStateNotifier(prefs),
), ),
signatureProvider.overrideWith( documentRepositoryProvider.overrideWith(
(ref) => SignatureController()..placeDefaultRect(), (ref) => DocumentStateNotifier()..openPicked(pageCount: 3),
),
pdfViewModelProvider.overrideWith(
(ref) => PdfViewModel(ref, useMockViewer: false),
),
pdfExportViewModelProvider.overrideWith(
(ref) => PdfExportViewModel(
ref,
exporter: fake,
savePathPicker: () async {
final dir = Directory.systemTemp.createTempSync('pdfsig_');
return '${dir.path}/output.pdf';
},
), ),
useMockViewerProvider.overrideWith((ref) => true),
exportServiceProvider.overrideWith((_) => fake),
savePathPickerProvider.overrideWith(
(_) => () async => 'C:/tmp/output.pdf',
), ),
], ],
child: const MaterialApp( child: MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates, localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales, supportedLocales: AppLocalizations.supportedLocales,
locale: Locale('en'), locale: Locale('en'),
home: PdfSignatureHomePage(), home: PdfSignatureHomePage(
onPickPdf: () async {},
onClosePdf: () {},
currentFile: fs.XFile('test.pdf'),
),
), ),
), ),
); );
@ -81,26 +129,49 @@ void main() {
tester, tester,
) async { ) async {
final sigBytes = _makeSig(); final sigBytes = _makeSig();
final pdfBytes =
await File('integration_test/data/sample-local-pdf.pdf').readAsBytes();
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
await tester.pumpWidget( await tester.pumpWidget(
ProviderScope( ProviderScope(
overrides: [ overrides: [
pdfProvider.overrideWith( preferencesRepositoryProvider.overrideWith(
(ref) => PdfController()..openPicked(path: 'test.pdf'), (ref) => PreferencesStateNotifier(prefs),
), ),
signatureLibraryProvider.overrideWith((ref) { documentRepositoryProvider.overrideWith(
final c = SignatureLibraryController(); (ref) =>
c.add(sigBytes, name: 'image'); DocumentStateNotifier()
..openPicked(pageCount: 3, bytes: pdfBytes),
),
signatureAssetRepositoryProvider.overrideWith((ref) {
final c = SignatureAssetRepository();
c.addImage(img.decodeImage(sigBytes)!, name: 'image');
return c; return c;
}), }),
// Keep mock viewer for determinism on CI/desktop devices signatureCardRepositoryProvider.overrideWith((ref) {
useMockViewerProvider.overrideWithValue(true), final cardRepo = SignatureCardStateNotifier();
final asset = SignatureAsset(
sigImage: img.decodeImage(sigBytes)!,
name: 'image',
);
cardRepo.addWithAsset(asset, 0.0);
return cardRepo;
}),
pdfViewModelProvider.overrideWith(
(ref) => PdfViewModel(ref, useMockViewer: false),
),
], ],
child: const MaterialApp( child: MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates, localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales, supportedLocales: AppLocalizations.supportedLocales,
locale: Locale('en'), locale: Locale('en'),
home: PdfSignatureHomePage(), home: PdfSignatureHomePage(
onPickPdf: () async {},
onClosePdf: () {},
currentFile: fs.XFile('test.pdf'),
),
), ),
), ),
); );
@ -119,15 +190,18 @@ void main() {
// Programmatically simulate confirm: add placement with current rect and bound image, then clear active overlay. // Programmatically simulate confirm: add placement with current rect and bound image, then clear active overlay.
final ctx = tester.element(find.byType(PdfSignatureHomePage)); final ctx = tester.element(find.byType(PdfSignatureHomePage));
final container = ProviderScope.containerOf(ctx); final container = ProviderScope.containerOf(ctx);
final sigState = container.read(signatureProvider); final r = container.read(pdfViewModelProvider).activeRect!;
final r = sigState.rect!; final lib = container.read(signatureAssetRepositoryProvider);
final lib = container.read(signatureLibraryProvider); final asset = lib.isNotEmpty ? lib.first : null;
final imageId = lib.isNotEmpty ? lib.first.id : 'default.png'; final currentPage = container.read(pdfViewModelProvider).currentPage;
final pdf = container.read(pdfProvider);
container container
.read(pdfProvider.notifier) .read(documentRepositoryProvider.notifier)
.addPlacement(page: pdf.currentPage, rect: r, imageId: imageId); .addPlacement(page: currentPage, rect: r, asset: asset);
container.read(signatureProvider.notifier).clearActiveOverlay(); // Clear active overlay by hiding signatures temporarily
// Note: signatureVisibilityProvider was removed in migration
// container.read(signatureVisibilityProvider.notifier).state = false;
await tester.pump();
// container.read(signatureVisibilityProvider.notifier).state = true;
await tester.pumpAndSettle(); await tester.pumpAndSettle();
final placed = find.byKey(const Key('placed_signature_0')); final placed = find.byKey(const Key('placed_signature_0'));
@ -143,4 +217,265 @@ void main() {
isTrue, isTrue,
); );
}); });
// ---- PDF view interaction tests (merged from pdf_view_test.dart) ----
testWidgets('PDF View: programmatic page jumps reach last page', (
tester,
) async {
final pdfBytes =
await File('integration_test/data/sample-local-pdf.pdf').readAsBytes();
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
await tester.pumpWidget(
ProviderScope(
overrides: [
preferencesRepositoryProvider.overrideWith(
(ref) => PreferencesStateNotifier(prefs),
),
documentRepositoryProvider.overrideWith(
(ref) =>
DocumentStateNotifier()
..openPicked(pageCount: 3, bytes: pdfBytes),
),
pdfViewModelProvider.overrideWith(
(ref) => PdfViewModel(ref, useMockViewer: false),
),
],
child: MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
locale: Locale('en'),
home: PdfSignatureHomePage(
onPickPdf: () async {},
onClosePdf: () {},
currentFile: fs.XFile('test.pdf'),
),
),
),
);
await tester.pumpAndSettle();
final ctx = tester.element(find.byType(PdfSignatureHomePage));
final container = ProviderScope.containerOf(ctx);
expect(container.read(pdfViewModelProvider).currentPage, 1);
container.read(pdfViewModelProvider.notifier).jumpToPage(2);
await tester.pumpAndSettle();
expect(container.read(pdfViewModelProvider).currentPage, 2);
container.read(pdfViewModelProvider.notifier).jumpToPage(3);
await tester.pumpAndSettle();
expect(container.read(pdfViewModelProvider).currentPage, 3);
});
testWidgets('PDF View: zoom in/out', (tester) async {
final pdfBytes =
await File('integration_test/data/sample-local-pdf.pdf').readAsBytes();
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
await tester.pumpWidget(
ProviderScope(
overrides: [
preferencesRepositoryProvider.overrideWith(
(ref) => PreferencesStateNotifier(prefs),
),
documentRepositoryProvider.overrideWith(
(ref) =>
DocumentStateNotifier()
..openPicked(pageCount: 3, bytes: pdfBytes),
),
pdfViewModelProvider.overrideWith(
(ref) => PdfViewModel(ref, useMockViewer: false),
),
],
child: MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
locale: Locale('en'),
home: PdfSignatureHomePage(
onPickPdf: () async {},
onClosePdf: () {},
currentFile: fs.XFile('test.pdf'),
),
),
),
);
await tester.pumpAndSettle();
final pdfViewer = find.byKey(const ValueKey('pdf_page_area'));
expect(pdfViewer, findsOneWidget);
final center = tester.getCenter(pdfViewer);
final g1 = await tester.createGesture();
final g2 = await tester.createGesture();
await g1.down(center - const Offset(10, 0));
await g2.down(center + const Offset(10, 0));
await g1.moveTo(center - const Offset(20, 0));
await g2.moveTo(center + const Offset(20, 0));
await g1.up();
await g2.up();
await tester.pumpAndSettle();
expect(pdfViewer, findsOneWidget);
});
testWidgets('PDF View: jump to page by clicking thumbnail', (tester) async {
final pdfBytes =
await File('integration_test/data/sample-local-pdf.pdf').readAsBytes();
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
await tester.pumpWidget(
ProviderScope(
overrides: [
preferencesRepositoryProvider.overrideWith(
(ref) => PreferencesStateNotifier(prefs),
),
documentRepositoryProvider.overrideWith(
(ref) =>
DocumentStateNotifier()
..openPicked(pageCount: 3, bytes: pdfBytes),
),
pdfViewModelProvider.overrideWith(
(ref) => PdfViewModel(ref, useMockViewer: false),
),
],
child: MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
locale: Locale('en'),
home: PdfSignatureHomePage(
onPickPdf: () async {},
onClosePdf: () {},
currentFile: fs.XFile('test.pdf'),
),
),
),
);
await tester.pumpAndSettle();
final ctx = tester.element(find.byType(PdfSignatureHomePage));
final container = ProviderScope.containerOf(ctx);
expect(container.read(pdfViewModelProvider).currentPage, 1);
final pagesSidebar = find.byType(PagesSidebar);
expect(pagesSidebar, findsOneWidget);
// Scroll to make page 3 thumbnail visible
await tester.drag(pagesSidebar, const Offset(0, -300));
await tester.pumpAndSettle();
final page3Thumb = find.text('3');
expect(page3Thumb, findsOneWidget);
await tester.tap(page3Thumb);
await tester.pumpAndSettle();
expect(container.read(pdfViewModelProvider).currentPage, 3);
});
testWidgets('PDF View: thumbnails scroll and select', (tester) async {
final pdfBytes =
await File('integration_test/data/sample-local-pdf.pdf').readAsBytes();
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
await tester.pumpWidget(
ProviderScope(
overrides: [
preferencesRepositoryProvider.overrideWith(
(ref) => PreferencesStateNotifier(prefs),
),
documentRepositoryProvider.overrideWith(
(ref) =>
DocumentStateNotifier()
..openPicked(pageCount: 3, bytes: pdfBytes),
),
pdfViewModelProvider.overrideWith(
(ref) => PdfViewModel(ref, useMockViewer: false),
),
],
child: MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
locale: Locale('en'),
home: PdfSignatureHomePage(
onPickPdf: () async {},
onClosePdf: () {},
currentFile: fs.XFile('test.pdf'),
),
),
),
);
await tester.pumpAndSettle();
final ctx = tester.element(find.byType(PdfSignatureHomePage));
final container = ProviderScope.containerOf(ctx);
expect(container.read(pdfViewModelProvider).currentPage, 1);
final sidebar = find.byType(PagesSidebar);
expect(sidebar, findsOneWidget);
await tester.drag(sidebar, const Offset(0, -200));
await tester.pumpAndSettle();
expect(find.text('1'), findsOneWidget);
expect(container.read(pdfViewModelProvider).currentPage, 1);
await tester.tap(find.text('2'));
await tester.pumpAndSettle();
expect(container.read(pdfViewModelProvider).currentPage, 2);
});
testWidgets('PDF View: tap viewer after export does not crash', (
tester,
) async {
final pdfBytes =
await File('integration_test/data/sample-local-pdf.pdf').readAsBytes();
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
await tester.pumpWidget(
ProviderScope(
overrides: [
preferencesRepositoryProvider.overrideWith(
(ref) => PreferencesStateNotifier(prefs),
),
documentRepositoryProvider.overrideWith(
(ref) =>
DocumentStateNotifier()
..openPicked(pageCount: 3, bytes: pdfBytes),
),
pdfViewModelProvider.overrideWith(
(ref) => PdfViewModel(ref, useMockViewer: false),
),
pdfExportViewModelProvider.overrideWith(
(ref) => PdfExportViewModel(
ref,
exporter: LightweightExporter(),
savePathPicker: () async {
final dir = Directory.systemTemp.createTempSync(
'pdfsig_after_',
);
return '${dir.path}/output-after-export.pdf';
},
),
),
],
child: MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
locale: const Locale('en'),
home: PdfSignatureHomePage(
onPickPdf: () async {},
onClosePdf: () {},
currentFile: fs.XFile('test.pdf'),
),
),
),
);
await tester.pumpAndSettle();
// Trigger export
await tester.tap(find.byKey(const Key('btn_save_pdf')));
await tester.pumpAndSettle();
// Tap on the page area; should not crash
final pageArea = find.byKey(const ValueKey('pdf_page_area'));
expect(pageArea, findsOneWidget);
await tester.tap(pageArea);
await tester.pumpAndSettle();
// Still present and responsive
expect(pageArea, findsOneWidget);
});
} }

View File

@ -0,0 +1,342 @@
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 'dart:io';
import 'package:file_selector/file_selector.dart' as fs;
import 'package:pdf_signature/ui/features/pdf/widgets/pdf_screen.dart';
import 'package:pdf_signature/ui/features/pdf/widgets/pages_sidebar.dart';
import 'package:pdf_signature/ui/features/pdf/view_model/pdf_view_model.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:pdf_signature/data/repositories/preferences_repository.dart';
import 'package:pdf_signature/data/repositories/document_repository.dart';
import 'package:pdf_signature/l10n/app_localizations.dart';
/// It has known that sample-local-pdf.pdf has 3 pages.
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
testWidgets('PDF View: programmatic page jumps reach last page', (
tester,
) async {
final pdfBytes =
await File('integration_test/data/sample-local-pdf.pdf').readAsBytes();
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
await tester.pumpWidget(
ProviderScope(
overrides: [
preferencesRepositoryProvider.overrideWith(
(ref) => PreferencesStateNotifier(prefs),
),
documentRepositoryProvider.overrideWith(
(ref) =>
DocumentStateNotifier()
..openPicked(pageCount: 3, bytes: pdfBytes),
),
pdfViewModelProvider.overrideWith(
(ref) => PdfViewModel(ref, useMockViewer: false),
),
],
child: MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
locale: Locale('en'),
home: PdfSignatureHomePage(
onPickPdf: () async {},
onClosePdf: () {},
currentFile: fs.XFile('test.pdf'),
),
),
),
);
await tester.pumpAndSettle();
// Extra settle to avoid startup race when running with other integration tests.
await tester.pump(const Duration(milliseconds: 200));
final ctx = tester.element(find.byType(PdfSignatureHomePage));
final container = ProviderScope.containerOf(ctx);
final vm = container.read(pdfViewModelProvider);
expect(vm.currentPage, 1);
container.read(pdfViewModelProvider.notifier).jumpToPage(2);
await tester.pumpAndSettle();
await tester.pump(const Duration(milliseconds: 120));
expect(container.read(pdfViewModelProvider).currentPage, 2);
container.read(pdfViewModelProvider.notifier).jumpToPage(3);
await tester.pumpAndSettle();
await tester.pump(const Duration(milliseconds: 120));
expect(container.read(pdfViewModelProvider).currentPage, 3);
});
testWidgets('PDF View: zoom in/out', (tester) async {
final pdfBytes =
await File('integration_test/data/sample-local-pdf.pdf').readAsBytes();
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
await tester.pumpWidget(
ProviderScope(
overrides: [
preferencesRepositoryProvider.overrideWith(
(ref) => PreferencesStateNotifier(prefs),
),
documentRepositoryProvider.overrideWith(
(ref) =>
DocumentStateNotifier()
..openPicked(pageCount: 3, bytes: pdfBytes),
),
pdfViewModelProvider.overrideWith(
(ref) => PdfViewModel(ref, useMockViewer: false),
),
],
child: MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
locale: Locale('en'),
home: PdfSignatureHomePage(
onPickPdf: () async {},
onClosePdf: () {},
currentFile: fs.XFile('test.pdf'),
),
),
),
);
await tester.pumpAndSettle();
await tester.pump(const Duration(milliseconds: 120));
final pdfViewer = find.byKey(const ValueKey('pdf_page_area'));
expect(pdfViewer, findsOneWidget);
final center = tester.getCenter(pdfViewer);
final gesture1 = await tester.createGesture();
final gesture2 = await tester.createGesture();
await gesture1.down(center - const Offset(10, 0));
await gesture2.down(center + const Offset(10, 0));
await gesture1.moveTo(center - const Offset(20, 0));
await gesture2.moveTo(center + const Offset(20, 0));
await gesture1.up();
await gesture2.up();
await tester.pumpAndSettle();
expect(pdfViewer, findsOneWidget);
});
testWidgets('PDF View: jump to page by clicking thumbnail', (tester) async {
final pdfBytes =
await File('integration_test/data/sample-local-pdf.pdf').readAsBytes();
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
await tester.pumpWidget(
ProviderScope(
overrides: [
preferencesRepositoryProvider.overrideWith(
(ref) => PreferencesStateNotifier(prefs),
),
documentRepositoryProvider.overrideWith(
(ref) =>
DocumentStateNotifier()
..openPicked(pageCount: 3, bytes: pdfBytes),
),
pdfViewModelProvider.overrideWith(
(ref) => PdfViewModel(ref, useMockViewer: false),
),
],
child: MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
locale: Locale('en'),
home: PdfSignatureHomePage(
onPickPdf: () async {},
onClosePdf: () {},
currentFile: fs.XFile('test.pdf'),
),
),
),
);
await tester.pumpAndSettle();
final ctx = tester.element(find.byType(PdfSignatureHomePage));
final container = ProviderScope.containerOf(ctx);
expect(container.read(pdfViewModelProvider).currentPage, 1);
final pagesSidebar = find.byType(PagesSidebar);
expect(pagesSidebar, findsOneWidget);
// Helper to read the background color of a thumbnail tile by page label
Color? tileBgForPage(int page) {
final pageLabel = find.descendant(
of: pagesSidebar,
matching: find.text('$page'),
);
if (pageLabel.evaluate().isEmpty) return null; // not visible yet
final decoratedAncestors = find.ancestor(
of: pageLabel,
matching: find.byType(DecoratedBox),
);
final decoratedBoxes =
decoratedAncestors
.evaluate()
.map((e) => e.widget)
.whereType<DecoratedBox>()
.toList();
for (final d in decoratedBoxes) {
final dec = d.decoration;
if (dec is BoxDecoration && dec.color != null) {
return dec.color;
}
}
return null;
}
final theme = Theme.of(tester.element(pagesSidebar));
// Initially, page 1 should be highlighted
expect(tileBgForPage(1), theme.colorScheme.primaryContainer);
// Scroll to make page 3 thumbnail visible
await tester.drag(pagesSidebar, const Offset(0, -300));
await tester.pumpAndSettle();
final page3Thumbnail = find.text('3');
expect(page3Thumbnail, findsOneWidget);
await tester.tap(page3Thumbnail);
await tester.pumpAndSettle();
expect(container.read(pdfViewModelProvider).currentPage, 3);
// After navigation completes, page 3 should be highlighted
expect(tileBgForPage(3), theme.colorScheme.primaryContainer);
});
testWidgets('PDF View: thumbnails scroll and select', (tester) async {
final pdfBytes =
await File('integration_test/data/sample-local-pdf.pdf').readAsBytes();
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
await tester.pumpWidget(
ProviderScope(
overrides: [
preferencesRepositoryProvider.overrideWith(
(ref) => PreferencesStateNotifier(prefs),
),
documentRepositoryProvider.overrideWith(
(ref) =>
DocumentStateNotifier()
..openPicked(pageCount: 3, bytes: pdfBytes),
),
pdfViewModelProvider.overrideWith(
(ref) => PdfViewModel(ref, useMockViewer: false),
),
],
child: MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
locale: Locale('en'),
home: PdfSignatureHomePage(
onPickPdf: () async {},
onClosePdf: () {},
currentFile: fs.XFile('test.pdf'),
),
),
),
);
await tester.pumpAndSettle();
final ctx = tester.element(find.byType(PdfSignatureHomePage));
final container = ProviderScope.containerOf(ctx);
expect(container.read(pdfViewModelProvider).currentPage, 1);
final pagesSidebar = find.byType(PagesSidebar);
expect(pagesSidebar, findsOneWidget);
await tester.drag(pagesSidebar, const Offset(0, -200));
await tester.pumpAndSettle();
// Page number '1' may appear in multiple text widgets (e.g., overlay/toolbar); restrict to sidebar.
final page1InSidebar = find.descendant(
of: pagesSidebar,
matching: find.text('1'),
);
expect(page1InSidebar, findsOneWidget);
expect(container.read(pdfViewModelProvider).currentPage, 1);
// Select page 2 thumbnail and verify page changes
final page2InSidebar = find.descendant(
of: pagesSidebar,
matching: find.text('2'),
);
await tester.tap(page2InSidebar);
await tester.pumpAndSettle();
expect(container.read(pdfViewModelProvider).currentPage, 2);
});
testWidgets('PDF View: scroll thumb to reveal and select last page', (
tester,
) async {
final pdfBytes =
await File('integration_test/data/sample-local-pdf.pdf').readAsBytes();
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
await tester.pumpWidget(
ProviderScope(
overrides: [
preferencesRepositoryProvider.overrideWith(
(ref) => PreferencesStateNotifier(prefs),
),
documentRepositoryProvider.overrideWith(
(ref) =>
DocumentStateNotifier()
..openPicked(pageCount: 3, bytes: pdfBytes),
),
pdfViewModelProvider.overrideWith(
(ref) => PdfViewModel(ref, useMockViewer: false),
),
],
child: MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
locale: const Locale('en'),
home: PdfSignatureHomePage(
onPickPdf: () async {},
onClosePdf: () {},
currentFile: fs.XFile('test.pdf'),
),
),
),
);
await tester.pumpAndSettle();
final ctx = tester.element(find.byType(PdfSignatureHomePage));
final container = ProviderScope.containerOf(ctx);
expect(container.read(pdfViewModelProvider).currentPage, 1);
final pagesSidebar = find.byType(PagesSidebar);
expect(pagesSidebar, findsOneWidget);
// Ensure page 3 not initially in view by trying to find it and allowing that it might be offstage.
// Perform a scroll/drag to bring page 3 into view.
await tester.drag(pagesSidebar, const Offset(0, -400));
await tester.pumpAndSettle();
final page3 = find.descendant(of: pagesSidebar, matching: find.text('3'));
expect(page3, findsOneWidget);
await tester.tap(page3);
await tester.pumpAndSettle();
expect(container.read(pdfViewModelProvider).currentPage, 3);
// Scroll back upward and verify selection persists.
await tester.drag(pagesSidebar, const Offset(0, 300));
await tester.pumpAndSettle();
expect(container.read(pdfViewModelProvider).currentPage, 3);
});
}

View File

@ -427,7 +427,7 @@
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO; ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++"; CLANG_CXX_LIBRARY = "libc++";
@ -484,7 +484,7 @@
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO; ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++"; CLANG_CXX_LIBRARY = "libc++";

View File

@ -1,122 +1 @@
{ {"images":[{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@3x.png","scale":"3x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@3x.png","scale":"3x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@3x.png","scale":"3x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@1x.png","scale":"1x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@3x.png","scale":"3x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@1x.png","scale":"1x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@1x.png","scale":"1x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@1x.png","scale":"1x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@2x.png","scale":"2x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@1x.png","scale":"1x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@2x.png","scale":"2x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@1x.png","scale":"1x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@2x.png","scale":"2x"},{"size":"83.5x83.5","idiom":"ipad","filename":"Icon-App-83.5x83.5@2x.png","scale":"2x"},{"size":"1024x1024","idiom":"ios-marketing","filename":"Icon-App-1024x1024@1x.png","scale":"1x"}],"info":{"version":1,"author":"xcode"}}
"images" : [
{
"size" : "20x20",
"idiom" : "iphone",
"filename" : "Icon-App-20x20@2x.png",
"scale" : "2x"
},
{
"size" : "20x20",
"idiom" : "iphone",
"filename" : "Icon-App-20x20@3x.png",
"scale" : "3x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@1x.png",
"scale" : "1x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@2x.png",
"scale" : "2x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@3x.png",
"scale" : "3x"
},
{
"size" : "40x40",
"idiom" : "iphone",
"filename" : "Icon-App-40x40@2x.png",
"scale" : "2x"
},
{
"size" : "40x40",
"idiom" : "iphone",
"filename" : "Icon-App-40x40@3x.png",
"scale" : "3x"
},
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "Icon-App-60x60@2x.png",
"scale" : "2x"
},
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "Icon-App-60x60@3x.png",
"scale" : "3x"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "Icon-App-20x20@1x.png",
"scale" : "1x"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "Icon-App-20x20@2x.png",
"scale" : "2x"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "Icon-App-29x29@1x.png",
"scale" : "1x"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "Icon-App-29x29@2x.png",
"scale" : "2x"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "Icon-App-40x40@1x.png",
"scale" : "1x"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "Icon-App-40x40@2x.png",
"scale" : "2x"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "Icon-App-76x76@1x.png",
"scale" : "1x"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "Icon-App-76x76@2x.png",
"scale" : "2x"
},
{
"size" : "83.5x83.5",
"idiom" : "ipad",
"filename" : "Icon-App-83.5x83.5@2x.png",
"scale" : "2x"
},
{
"size" : "1024x1024",
"idiom" : "ios-marketing",
"filename" : "Icon-App-1024x1024@1x.png",
"scale" : "1x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 295 B

After

Width:  |  Height:  |  Size: 1012 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 B

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 450 B

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 282 B

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 462 B

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 704 B

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 B

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 586 B

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 862 B

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 862 B

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 762 B

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

@ -2,11 +2,9 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_localized_locales/flutter_localized_locales.dart'; import 'package:flutter_localized_locales/flutter_localized_locales.dart';
import 'package:pdf_signature/l10n/app_localizations.dart'; import 'package:pdf_signature/l10n/app_localizations.dart';
import 'package:pdf_signature/ui/features/pdf/widgets/pdf_screen.dart'; import 'package:pdf_signature/routing/router.dart';
import 'package:pdf_signature/ui/features/pdf/view_model/pdf_controller.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'; import 'package:pdf_signature/ui/features/preferences/widgets/settings_screen.dart';
import 'data/repositories/preferences_repository.dart';
class MyApp extends StatelessWidget { class MyApp extends StatelessWidget {
const MyApp({super.key}); const MyApp({super.key});
@ -16,43 +14,35 @@ class MyApp extends StatelessWidget {
return ProviderScope( return ProviderScope(
child: Consumer( child: Consumer(
builder: (context, ref, _) { builder: (context, ref, _) {
// Ensure SharedPreferences loaded before building MaterialApp final prefs = ref.watch(preferencesRepositoryProvider);
final sp = ref.watch(sharedPreferencesProvider); final seed = themeSeedFromPrefs(prefs);
return sp.when( final appLocale =
loading: () => const SizedBox.shrink(), supportedLanguageTags().contains(prefs.language)
error: ? parseLanguageTag(prefs.language)
(e, st) => MaterialApp( : null;
onGenerateTitle: (ctx) => AppLocalizations.of(ctx).appTitle, final themeMode = () {
supportedLocales: AppLocalizations.supportedLocales, switch (prefs.theme) {
localizationsDelegates: case 'light':
AppLocalizations.localizationsDelegates, return ThemeMode.light;
home: Builder( case 'dark':
builder: return ThemeMode.dark;
(ctx) => Scaffold( case 'system':
body: Center( default:
child: Text( return ThemeMode.system;
AppLocalizations.of( }
ctx, }();
).errorWithMessage(e.toString()),
), return MaterialApp.router(
),
),
),
),
data: (_) {
final themeMode = ref.watch(themeModeProvider);
final appLocale = ref.watch(localeProvider);
return MaterialApp(
onGenerateTitle: (ctx) => AppLocalizations.of(ctx).appTitle, onGenerateTitle: (ctx) => AppLocalizations.of(ctx).appTitle,
theme: ThemeData( theme: ThemeData(
colorScheme: ColorScheme.fromSeed( colorScheme: ColorScheme.fromSeed(
seedColor: Colors.indigo, seedColor: seed,
brightness: Brightness.light, brightness: Brightness.light,
), ),
), ),
darkTheme: ThemeData( darkTheme: ThemeData(
colorScheme: ColorScheme.fromSeed( colorScheme: ColorScheme.fromSeed(
seedColor: Colors.indigo, seedColor: seed,
brightness: Brightness.dark, brightness: Brightness.dark,
), ),
), ),
@ -63,27 +53,30 @@ class MyApp extends StatelessWidget {
...AppLocalizations.localizationsDelegates, ...AppLocalizations.localizationsDelegates,
LocaleNamesLocalizationsDelegate(), LocaleNamesLocalizationsDelegate(),
], ],
home: Builder( routerConfig: ref.watch(routerProvider),
builder: builder: (context, child) {
(ctx) => Scaffold( final router = ref.watch(routerProvider);
return Scaffold(
appBar: AppBar( appBar: AppBar(
title: Text(AppLocalizations.of(ctx).appTitle), title: Text(AppLocalizations.of(context).appTitle),
actions: [ actions: [
OutlinedButton.icon( OutlinedButton.icon(
key: const Key('btn_appbar_settings'), key: const Key('btn_appbar_settings'),
icon: const Icon(Icons.settings), icon: const Icon(Icons.settings),
label: Text(AppLocalizations.of(ctx).settings), label: Text(AppLocalizations.of(context).settings),
onPressed: onPressed:
() => showDialog<bool>( () => showDialog<bool>(
context: ctx, context:
router
.routerDelegate
.navigatorKey
.currentContext!,
builder: (_) => const SettingsDialog(), builder: (_) => const SettingsDialog(),
), ),
), ),
], ],
), ),
body: const _RootHomeSwitcher(), body: child,
),
),
); );
}, },
); );
@ -92,16 +85,3 @@ 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

@ -1,147 +0,0 @@
import 'dart:typed_data';
import 'package:flutter/widgets.dart';
/// Represents a single signature placement on a page combining both the
/// geometric rectangle (UI coordinate space) and the identifier of the
/// image/signature asset assigned to that placement.
class SignaturePlacement {
final Rect rect;
/// Rotation in degrees to apply when rendering/exporting this placement.
final double rotationDeg;
/// Identifier of the image (e.g., filename / asset id) assigned to this placement.
/// Nullable to allow a placement reserved before an image is chosen.
final String? imageId;
const SignaturePlacement({
required this.rect,
this.imageId,
this.rotationDeg = 0.0,
});
SignaturePlacement copyWith({
Rect? rect,
String? imageId,
double? rotationDeg,
}) => SignaturePlacement(
rect: rect ?? this.rect,
imageId: imageId ?? this.imageId,
rotationDeg: rotationDeg ?? this.rotationDeg,
);
}
class PdfState {
final bool loaded;
final int pageCount;
final int currentPage;
final String? pickedPdfPath;
final Uint8List? pickedPdfBytes;
final int? signedPage;
// Multiple signature placements per page, each combines geometry and optional image id.
final Map<int, List<SignaturePlacement>> placementsByPage;
// UI state: selected placement index on the current page (if any)
final int? selectedPlacementIndex;
const PdfState({
required this.loaded,
required this.pageCount,
required this.currentPage,
this.pickedPdfPath,
this.pickedPdfBytes,
this.signedPage,
this.placementsByPage = const {},
this.selectedPlacementIndex,
});
factory PdfState.initial() => const PdfState(
loaded: false,
pageCount: 0,
currentPage: 1,
pickedPdfBytes: null,
signedPage: null,
placementsByPage: {},
selectedPlacementIndex: null,
);
PdfState copyWith({
bool? loaded,
int? pageCount,
int? currentPage,
String? pickedPdfPath,
Uint8List? pickedPdfBytes,
int? signedPage,
Map<int, List<SignaturePlacement>>? placementsByPage,
int? selectedPlacementIndex,
}) => PdfState(
loaded: loaded ?? this.loaded,
pageCount: pageCount ?? this.pageCount,
currentPage: currentPage ?? this.currentPage,
pickedPdfPath: pickedPdfPath ?? this.pickedPdfPath,
pickedPdfBytes: pickedPdfBytes ?? this.pickedPdfBytes,
signedPage: signedPage ?? this.signedPage,
placementsByPage: placementsByPage ?? this.placementsByPage,
selectedPlacementIndex:
selectedPlacementIndex ?? this.selectedPlacementIndex,
);
}
class SignatureState {
final Rect? rect;
final bool aspectLocked;
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;
const SignatureState({
required this.rect,
required this.aspectLocked,
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(
rect: null,
aspectLocked: false,
bgRemoval: false,
contrast: 1.0,
brightness: 0.0,
rotation: 0.0,
strokes: [],
imageBytes: null,
assetId: null,
editingEnabled: false,
);
SignatureState copyWith({
Rect? rect,
bool? aspectLocked,
bool? bgRemoval,
double? contrast,
double? brightness,
double? rotation,
List<List<Offset>>? strokes,
Uint8List? imageBytes,
String? assetId,
bool? editingEnabled,
}) => SignatureState(
rect: rect ?? this.rect,
aspectLocked: aspectLocked ?? this.aspectLocked,
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

@ -1,58 +1,37 @@
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:image/image.dart' as img;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:pdf_signature/data/services/export_service.dart';
import '../../../../data/model/model.dart'; import '../../domain/models/model.dart';
class PdfController extends StateNotifier<PdfState> { class DocumentStateNotifier extends StateNotifier<Document> {
PdfController() : super(PdfState.initial()); DocumentStateNotifier() : super(Document.initial());
static const int samplePageCount = 5;
final ExportService _service = ExportService();
@visibleForTesting @visibleForTesting
void openSample() { void openSample() {
state = state.copyWith( state = state.copyWith(
loaded: true, loaded: true,
pageCount: samplePageCount, pageCount: 5,
currentPage: 1, pickedPdfBytes: null,
pickedPdfPath: null, placementsByPage: <int, List<SignaturePlacement>>{},
signedPage: null,
placementsByPage: {},
selectedPlacementIndex: null,
); );
} }
void openPicked({ void openPicked({required int pageCount, Uint8List? bytes}) {
required String path,
int pageCount = samplePageCount,
Uint8List? bytes,
}) {
state = state.copyWith( state = state.copyWith(
loaded: true, loaded: true,
pageCount: pageCount, pageCount: pageCount,
currentPage: 1,
pickedPdfPath: path,
pickedPdfBytes: bytes, pickedPdfBytes: bytes,
signedPage: null, placementsByPage: <int, List<SignaturePlacement>>{},
placementsByPage: {},
selectedPlacementIndex: null,
); );
} }
void jumpTo(int page) { void close() {
if (!state.loaded) return; state = Document.initial();
final clamped = page.clamp(1, state.pageCount);
state = state.copyWith(currentPage: clamped, selectedPlacementIndex: null);
}
// Set or clear the page that will receive the signature overlay.
void setSignedPage(int? page) {
if (!state.loaded) return;
if (page == null) {
state = state.copyWith(signedPage: null, selectedPlacementIndex: null);
} else {
final clamped = page.clamp(1, state.pageCount);
state = state.copyWith(signedPage: clamped, selectedPlacementIndex: null);
}
} }
void setPageCount(int count) { void setPageCount(int count) {
@ -60,13 +39,18 @@ class PdfController extends StateNotifier<PdfState> {
state = state.copyWith(pageCount: count.clamp(1, 9999)); state = state.copyWith(pageCount: count.clamp(1, 9999));
} }
void jumpTo(int page) {
// currentPage is now in view model, so jumpTo does nothing here
}
// Multiple-signature helpers (rects are stored in normalized fractions 0..1 // Multiple-signature helpers (rects are stored in normalized fractions 0..1
// relative to the page size: left/top/width/height are all 0..1) // relative to the page size: left/top/width/height are all 0..1)
void addPlacement({ void addPlacement({
required int page, required int page,
required Rect rect, required Rect rect,
String? imageId = 'default.png', SignatureAsset? asset,
double rotationDeg = 0.0, double rotationDeg = 0.0,
GraphicAdjust? graphicAdjust,
}) { }) {
if (!state.loaded) return; if (!state.loaded) return;
final p = page.clamp(1, state.pageCount); final p = page.clamp(1, state.pageCount);
@ -75,14 +59,19 @@ class PdfController extends StateNotifier<PdfState> {
list.add( list.add(
SignaturePlacement( SignaturePlacement(
rect: rect, rect: rect,
imageId: imageId, asset: asset ?? SignatureAsset(sigImage: _singleTransparentPng),
rotationDeg: rotationDeg, rotationDeg: rotationDeg,
graphicAdjust: graphicAdjust ?? const GraphicAdjust(),
), ),
); );
map[p] = list; map[p] = list;
state = state.copyWith(placementsByPage: map, selectedPlacementIndex: null); state = state.copyWith(placementsByPage: map);
} }
// Tiny 1x1 transparent PNG to avoid decode crashes in tests when no real
// signature bytes were provided.
static final img.Image _singleTransparentPng = img.Image(width: 1, height: 1);
void updatePlacementRotation({ void updatePlacementRotation({
required int page, required int page,
required int index, required int index,
@ -111,10 +100,7 @@ class PdfController extends StateNotifier<PdfState> {
} else { } else {
map[p] = list; map[p] = list;
} }
state = state.copyWith( state = state.copyWith(placementsByPage: map);
placementsByPage: map,
selectedPlacementIndex: null,
);
} }
} }
@ -142,37 +128,32 @@ class PdfController extends StateNotifier<PdfState> {
); );
} }
void selectPlacement(int? index) { // Convenience to get asset for a placement
if (!state.loaded) return; SignatureAsset? assetOfPlacement({required int page, required int index}) {
// Only allow valid index on current page; otherwise clear
if (index == null) {
state = state.copyWith(selectedPlacementIndex: null);
return;
}
final list = state.placementsByPage[state.currentPage] ?? const [];
if (index >= 0 && index < list.length) {
state = state.copyWith(selectedPlacementIndex: index);
} else {
state = state.copyWith(selectedPlacementIndex: null);
}
}
void deleteSelectedPlacement() {
final idx = state.selectedPlacementIndex;
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.placementsByPage[page] ?? const []; final list = state.placementsByPage[page] ?? const [];
if (index < 0 || index >= list.length) return null; if (index < 0 || index >= list.length) return null;
return list[index].imageId; return list[index].asset;
}
Future<void> exportDocument({
required String outputPath,
required Size uiPageSize,
required Uint8List? signatureImageBytes,
}) async {
if (!state.loaded || state.pickedPdfBytes == null) return;
final bytes = await _service.exportSignedPdfFromBytes(
srcBytes: state.pickedPdfBytes!,
uiPageSize: uiPageSize,
signatureImageBytes: signatureImageBytes,
placementsByPage: state.placementsByPage,
);
if (bytes == null) return;
_service.saveBytesToFile(bytes: bytes, outputPath: outputPath);
// await
} }
} }
final pdfProvider = StateNotifierProvider<PdfController, PdfState>( final documentRepositoryProvider =
(ref) => PdfController(), StateNotifierProvider<DocumentStateNotifier, Document>(
); (ref) => DocumentStateNotifier(),
);

View File

@ -0,0 +1,299 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:pdf_signature/l10n/app_localizations.dart';
import 'package:flutter_localized_locales/flutter_localized_locales.dart';
import 'package:pdf_signature/domain/models/preferences.dart';
// Helpers to work with BCP-47 language tags
String toLanguageTag(Locale loc) {
final lang = loc.languageCode.toLowerCase();
final region = loc.countryCode;
if (region == null || region.isEmpty) return lang;
return '$lang-${region.toUpperCase()}';
}
Locale _parseLanguageTag(String tag) {
final cleaned = tag.replaceAll('_', '-');
final parts = cleaned.split('-');
if (parts.length >= 2 && parts[1].isNotEmpty) {
return Locale(parts[0].toLowerCase(), parts[1].toUpperCase());
}
return Locale(parts[0].toLowerCase());
}
Set<String> _supportedTags() {
return AppLocalizations.supportedLocales.map((l) => toLanguageTag(l)).toSet();
}
// Public helpers for other layers to consume without extra providers
Set<String> supportedLanguageTags() => _supportedTags();
Locale parseLanguageTag(String tag) => _parseLanguageTag(tag);
Color themeSeedFromPrefs(PreferencesState prefs) {
final c = PreferencesStateNotifier._tryParseColor(prefs.theme_color);
return c ?? Colors.blue;
}
Future<Map<String, String>> languageAutonyms() async {
final tags = _supportedTags().toList()..sort();
final delegate = LocaleNamesLocalizationsDelegate();
final Map<String, String> result = {};
for (final tag in tags) {
final locale = _parseLanguageTag(tag);
final names = await delegate.load(locale);
final name = names.nameOf(tag) ?? tag;
result[tag] = name;
}
return result;
}
// Keys
const _kTheme = 'theme'; // 'light'|'dark'|'system'
// Theme color persisted as hex ARGB string (e.g., '#FF2196F3').
// Backward compatible with historical names like 'blue', 'indigo', etc.
const _kThemeColor = 'theme_color';
const _kLanguage = 'language'; // BCP-47 tag like 'en', 'zh-TW', 'es'
const _kPageView = 'page_view'; // now only 'continuous'
const _kExportDpi = 'export_dpi'; // double, allowed: 96,144,200,300
String _normalizeLanguageTag(String tag) {
final tags = _supportedTags();
if (tag.isEmpty) return tags.contains('en') ? 'en' : tags.first;
// Replace underscore with hyphen and canonicalize case
final normalized = () {
final t = tag.replaceAll('_', '-');
final parts = t.split('-');
final lang = parts[0].toLowerCase();
if (parts.length >= 2 && parts[1].isNotEmpty) {
return '$lang-${parts[1].toUpperCase()}';
}
return lang;
}();
// Exact match
if (tags.contains(normalized)) return normalized;
// Try fallback to language-only if available
final langOnly = normalized.split('-')[0];
if (tags.contains(langOnly)) return langOnly;
// Try to pick first tag with same language
final candidate = tags.firstWhere(
(t) => t.split('-')[0] == langOnly,
orElse: () => '',
);
if (candidate.isNotEmpty) return candidate;
// Final fallback to English or first supported
return tags.contains('en') ? 'en' : tags.first;
}
class PreferencesStateNotifier extends StateNotifier<PreferencesState> {
late final SharedPreferences _prefs;
final Completer<void> _ready = Completer<void>();
static Color? _tryParseColor(String? s) {
if (s == null || s.isEmpty) return null;
final v = s.trim();
// 1) Direct hex formats: #AARRGGBB, #RRGGBB, AARRGGBB, RRGGBB
String hex = v.startsWith('#') ? v.substring(1) : v;
// Accept 0xAARRGGBB / 0xRRGGBB as well
if (hex.toLowerCase().startsWith('0x')) hex = hex.substring(2);
if (hex.length == 6) {
final intVal = int.tryParse('FF$hex', radix: 16);
if (intVal != null) return Color(intVal);
} else if (hex.length == 8) {
final intVal = int.tryParse(hex, radix: 16);
if (intVal != null) return Color(intVal);
}
// 2) Parse from Color(...) or MaterialColor(...) toString outputs
// e.g., 'Color(0xff2196f3)' or 'MaterialColor(primary value: Color(0xff2196f3))'
final lower = v.toLowerCase();
final idx = lower.indexOf('0x');
if (idx != -1) {
var sub = lower.substring(idx);
// Trim trailing non-hex chars
final hexChars = RegExp(r'^[0-9a-fx]+');
final m = hexChars.firstMatch(sub);
if (m != null) {
sub = m.group(0) ?? sub;
if (sub.startsWith('0x')) sub = sub.substring(2);
if (sub.length == 6) sub = 'FF$sub';
if (sub.length >= 8) {
final intVal = int.tryParse(sub.substring(0, 8), radix: 16);
if (intVal != null) return Color(intVal);
}
}
}
// 3) As a last resort, try to match any MaterialColor primary by toString equality
// (useful if some code persisted mat.toString()).
for (final mc in Colors.primaries) {
if (mc.toString() == v) {
return mc; // MaterialColor extends Color
}
}
return null;
}
static String _toHex(Color c) {
final a =
((c.a * 255.0).round() & 0xff)
.toRadixString(16)
.padLeft(2, '0')
.toUpperCase();
final r =
((c.r * 255.0).round() & 0xff)
.toRadixString(16)
.padLeft(2, '0')
.toUpperCase();
final g =
((c.g * 255.0).round() & 0xff)
.toRadixString(16)
.padLeft(2, '0')
.toUpperCase();
final b =
((c.b * 255.0).round() & 0xff)
.toRadixString(16)
.padLeft(2, '0')
.toUpperCase();
return '#$a$r$g$b';
}
PreferencesStateNotifier([SharedPreferences? prefs])
: super(
PreferencesState(
theme: 'system',
language: _normalizeLanguageTag(
WidgetsBinding.instance.platformDispatcher.locale.toLanguageTag(),
),
exportDpi: 144.0,
theme_color: '#FF2196F3', // blue
),
) {
_init(prefs);
}
Future<void> _init(SharedPreferences? injected) async {
_prefs = injected ?? await SharedPreferences.getInstance();
// Load persisted values (with sane defaults)
final loaded = PreferencesState(
theme: _prefs.getString(_kTheme) ?? 'system',
language: _normalizeLanguageTag(
_prefs.getString(_kLanguage) ??
WidgetsBinding.instance.platformDispatcher.locale.toLanguageTag(),
),
exportDpi: _readDpi(_prefs),
theme_color: _prefs.getString(_kThemeColor) ?? '#FF2196F3',
);
state = loaded;
_ensureValid();
if (!_ready.isCompleted) _ready.complete();
}
Future<void> _ensureReady() async {
if (!_ready.isCompleted) {
await _ready.future;
}
}
static double _readDpi(SharedPreferences prefs) {
final d = prefs.getDouble(_kExportDpi);
if (d == null) return 144.0;
const allowed = [96.0, 144.0, 200.0, 300.0];
return allowed.contains(d) ? d : 144.0;
}
void _ensureValid() {
final themeValid = {'light', 'dark', 'system'};
if (!themeValid.contains(state.theme)) {
state = state.copyWith(theme: 'system');
_prefs.setString(_kTheme, 'system');
}
final normalized = _normalizeLanguageTag(state.language);
if (normalized != state.language) {
state = state.copyWith(language: normalized);
_prefs.setString(_kLanguage, normalized);
}
// Ensure DPI is one of allowed values
const allowed = [96.0, 144.0, 200.0, 300.0];
if (!allowed.contains(state.exportDpi)) {
state = state.copyWith(exportDpi: 144.0);
_prefs.setDouble(_kExportDpi, 144.0);
}
// Ensure theme color is a valid hex or known name; normalize to hex
final parsed = _tryParseColor(state.theme_color);
if (parsed == null) {
final fallback = Colors.blue;
final hex = _toHex(fallback);
state = state.copyWith(theme_color: hex);
_prefs.setString(_kThemeColor, hex);
} else {
final hex = _toHex(parsed);
if (state.theme_color != hex) {
state = state.copyWith(theme_color: hex);
_prefs.setString(_kThemeColor, hex);
}
}
}
Future<void> setTheme(String theme) async {
final valid = {'light', 'dark', 'system'};
if (!valid.contains(theme)) return;
state = state.copyWith(theme: theme);
await _ensureReady();
await _prefs.setString(_kTheme, theme);
}
Future<void> setLanguage(String language) async {
final normalized = _normalizeLanguageTag(language);
state = state.copyWith(language: normalized);
await _ensureReady();
await _prefs.setString(_kLanguage, normalized);
}
Future<void> setThemeColor(String themeColor) async {
// Accept hex like '#FF2196F3', '#2196F3', or known names like 'blue'. Normalize to hex.
final c = _tryParseColor(themeColor) ?? Colors.blue;
final hex = _toHex(c);
state = state.copyWith(theme_color: hex);
await _ensureReady();
await _prefs.setString(_kThemeColor, hex);
}
Future<void> resetToDefaults() async {
final device =
WidgetsBinding.instance.platformDispatcher.locale.toLanguageTag();
final normalized = _normalizeLanguageTag(device);
state = PreferencesState(
theme: 'system',
language: normalized,
exportDpi: 144.0,
theme_color: '#FF2196F3',
);
await _ensureReady();
await _prefs.setString(_kTheme, 'system');
await _prefs.setString(_kLanguage, normalized);
await _prefs.setString(_kPageView, 'continuous');
await _prefs.setDouble(_kExportDpi, 144.0);
await _prefs.setString(_kThemeColor, '#FF2196F3');
}
Future<void> setExportDpi(double dpi) async {
const allowed = [96.0, 144.0, 200.0, 300.0];
if (!allowed.contains(dpi)) return;
state = state.copyWith(exportDpi: dpi);
await _ensureReady();
await _prefs.setDouble(_kExportDpi, dpi);
}
}
final preferencesRepositoryProvider =
StateNotifierProvider<PreferencesStateNotifier, PreferencesState>((ref) {
// Construct with lazy SharedPreferences initialization.
return PreferencesStateNotifier();
});
// pageViewModeProvider removed; the app always runs in continuous mode.

View File

@ -0,0 +1,22 @@
import 'package:image/image.dart' as img;
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:pdf_signature/domain/models/model.dart';
///
class SignatureAssetRepository extends StateNotifier<List<SignatureAsset>> {
SignatureAssetRepository() : super(const []);
/// Preferred API: add from an already decoded image to avoid re-decodes.
void addImage(img.Image image, {String? name}) {
state = List.of(state)..add(SignatureAsset(sigImage: image, name: name));
}
void remove(SignatureAsset asset) {
state = state.where((a) => a != asset).toList(growable: false);
}
}
final signatureAssetRepositoryProvider =
StateNotifierProvider<SignatureAssetRepository, List<SignatureAsset>>(
(ref) => SignatureAssetRepository(),
);

View File

@ -0,0 +1,212 @@
import 'package:image/image.dart' as img;
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../domain/models/model.dart';
import '../../data/services/signature_image_processing_service.dart';
class DisplaySignatureData {
final img.Image image; // image to render (image-first path)
final List<double>? colorMatrix; // optional GPU color matrix
const DisplaySignatureData({required this.image, this.colorMatrix});
}
/// CachedSignatureCard wraps SignatureCard data and stores a processed cache.
class CachedSignatureCard {
final SignatureAsset asset;
final double rotationDeg;
final GraphicAdjust graphicAdjust;
img.Image? _cachedProcessedImage;
CachedSignatureCard({
required this.asset,
required this.rotationDeg,
this.graphicAdjust = const GraphicAdjust(),
img.Image? initialProcessedImage,
}) {
if (initialProcessedImage != null) {
_cachedProcessedImage = initialProcessedImage;
}
}
// Intentionally no copyWith to avoid conflicting with Freezed interface
/// Invalidate the cached processed image, forcing recompute next time.
void invalidateCache() {
_cachedProcessedImage = null;
}
/// Sets/updates the processed image explicitly (used after adjustments update)
void setProcessedImage(img.Image image) {
_cachedProcessedImage = image;
}
factory CachedSignatureCard.initial() => CachedSignatureCard(
asset: SignatureCard.initial().asset,
rotationDeg: SignatureCard.initial().rotationDeg,
graphicAdjust: SignatureCard.initial().graphicAdjust,
);
factory CachedSignatureCard.fromPublic(SignatureCard card) =>
CachedSignatureCard(
asset: card.asset,
rotationDeg: card.rotationDeg,
graphicAdjust: card.graphicAdjust,
);
}
class SignatureCardStateNotifier extends StateNotifier<List<SignatureCard>> {
SignatureCardStateNotifier() : super(const []);
// Internal storage with cache
final List<CachedSignatureCard> _cards = <CachedSignatureCard>[];
// Stateless image processing service used by this repository
final SignatureImageProcessingService _processingService =
SignatureImageProcessingService();
void add(SignatureCard card) {
_cards.add(CachedSignatureCard.fromPublic(card));
_publish();
}
void addWithAsset(SignatureAsset asset, double rotationDeg) {
_cards.add(CachedSignatureCard(asset: asset, rotationDeg: rotationDeg));
_publish();
}
void update(
SignatureCard card,
double? rotationDeg,
GraphicAdjust? graphicAdjust,
) {
for (var i = 0; i < _cards.length; i++) {
final c = _cards[i];
final isSameCard =
c.asset == card.asset &&
c.rotationDeg == card.rotationDeg &&
c.graphicAdjust == card.graphicAdjust;
if (isSameCard) {
final newRotation = rotationDeg ?? c.rotationDeg;
final newAdjust = graphicAdjust ?? c.graphicAdjust;
// Compute processed image for updated adjust
final processedImage = _processingService.processImageToImage(
c.asset.sigImage,
newAdjust,
);
final next = CachedSignatureCard(
asset: c.asset,
rotationDeg: newRotation,
graphicAdjust: newAdjust,
);
next.setProcessedImage(processedImage);
_cards[i] = next;
_publish();
return;
}
}
}
void remove(SignatureCard card) {
_cards.removeWhere(
(c) =>
c.asset == card.asset &&
c.rotationDeg == card.rotationDeg &&
c.graphicAdjust == card.graphicAdjust,
);
_publish();
}
void clearAll() {
_cards.clear();
state = const <SignatureCard>[];
}
/// New: Returns processed decoded image for the given asset + adjustments.
img.Image getProcessedImage(SignatureAsset asset, GraphicAdjust adjust) {
// Try to find a matching card by asset
for (final c in _cards) {
if (c.asset == asset) {
if (c.graphicAdjust == adjust) {
// If cached bytes exist, decode once; otherwise compute from image
if (c._cachedProcessedImage != null) {
return c._cachedProcessedImage!;
}
return _processingService.processImageToImage(
c.asset.sigImage,
c.graphicAdjust,
);
}
// Previewing unsaved adjustments: compute from image without caching
return _processingService.processImageToImage(asset.sigImage, adjust);
}
}
// Asset not found among cards (e.g., preview in dialog): compute on-the-fly
return _processingService.processImageToImage(asset.sigImage, adjust);
}
/// Provide display data optimized: if bgRemoval false, returns original image + matrix;
/// if bgRemoval true, returns processed image with baked adjustments and null matrix.
DisplaySignatureData getDisplayData(
SignatureAsset asset,
GraphicAdjust adjust,
) {
if (!adjust.bgRemoval) {
// No CPU processing. Return original image + matrix for consumers.
final matrix = _processingService.buildColorMatrix(adjust);
return DisplaySignatureData(image: asset.sigImage, colorMatrix: matrix);
}
// bgRemoval path: provide processed image with baked adjustments.
final processed = getProcessedImage(asset, adjust);
return DisplaySignatureData(image: processed, colorMatrix: null);
}
/// New: Provide display image optimized for UI widgets that can accept img.Image.
/// If bgRemoval is false, returns original image and a GPU color matrix.
/// If bgRemoval is true, returns processed image with baked adjustments and null matrix.
(img.Image image, List<double>? colorMatrix) getDisplayImage(
SignatureAsset asset,
GraphicAdjust adjust,
) {
if (!adjust.bgRemoval) {
final matrix = _processingService.buildColorMatrix(adjust);
return (asset.sigImage, matrix);
}
final processed = getProcessedImage(asset, adjust);
return (processed, null);
}
/// Clears all cached processed images.
void clearProcessedCache() {
for (final c in _cards) {
c.invalidateCache();
}
}
/// Clears cached processed images for a specific asset only.
void clearCacheForAsset(SignatureAsset asset) {
for (final c in _cards) {
if (c.asset == asset) {
c.invalidateCache();
}
}
}
void _publish() {
state = List<SignatureCard>.unmodifiable(
_cards
.map(
(c) => SignatureCard(
asset: c.asset,
rotationDeg: c.rotationDeg,
graphicAdjust: c.graphicAdjust,
),
)
.toList(growable: false),
);
}
}
final signatureCardRepositoryProvider =
StateNotifierProvider<SignatureCardStateNotifier, List<SignatureCard>>(
(ref) => SignatureCardStateNotifier(),
);

View File

@ -1,60 +0,0 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:path_provider/path_provider.dart' as pp;
import 'package:file_selector/file_selector.dart' as fs;
import 'package:pdf_signature/data/services/export_service.dart';
import 'package:pdf_signature/data/services/preferences_providers.dart';
// Feature-scoped DI and configuration providers
// Toggle mock viewer (used by tests to show a gray placeholder instead of real PDF pages)
final useMockViewerProvider = Provider<bool>((_) => false);
// Export service injection for testability
final exportServiceProvider = Provider<ExportService>((_) => ExportService());
// Export DPI setting (points per inch mapping). Reads from SharedPreferences when available,
// otherwise falls back to 144.0 to keep tests deterministic without bootstrapping prefs.
final exportDpiProvider = Provider<double>((ref) {
final sp = ref.watch(sharedPreferencesProvider);
return sp.maybeWhen(
data: (prefs) {
const allowed = [96.0, 144.0, 200.0, 300.0];
final v = prefs.getDouble('export_dpi');
return (v != null && allowed.contains(v)) ? v : 144.0;
},
orElse: () => 144.0,
);
});
// Controls whether signature overlay is visible (used to hide on non-stamped pages during export)
final signatureVisibilityProvider = StateProvider<bool>((_) => true);
// Global exporting state to show loading UI and block interactions while saving/exporting
final exportingProvider = StateProvider<bool>((_) => false);
// Save path picker (injected for tests)
final savePathPickerProvider = Provider<Future<String?> Function()>((ref) {
return () async {
String? initialDir;
try {
final d = await pp.getDownloadsDirectory();
initialDir = d?.path;
} catch (_) {}
if (initialDir == null) {
try {
final d = await pp.getApplicationDocumentsDirectory();
initialDir = d.path;
} catch (_) {}
}
final location = await fs.getSaveLocation(
suggestedName: 'signed.pdf',
acceptedTypeGroups: [
const fs.XTypeGroup(label: 'PDF', extensions: ['pdf']),
],
initialDirectory: initialDir,
);
if (location == null) return null;
final path = location.path;
return path.toLowerCase().endsWith('.pdf') ? path : '$path.pdf';
};
});

View File

@ -6,7 +6,10 @@ import 'package:pdf/widgets.dart' as pw;
import 'package:pdf/pdf.dart' as pdf; import 'package:pdf/pdf.dart' as pdf;
import 'package:printing/printing.dart' as printing; import 'package:printing/printing.dart' as printing;
import 'package:image/image.dart' as img; import 'package:image/image.dart' as img;
import '../model/model.dart'; import '../../domain/models/model.dart';
// math moved to utils in rot
import '../../utils/rotation_utils.dart' as rot;
import '../../utils/background_removal.dart' as br;
// NOTE: // NOTE:
// - This exporter uses a raster snapshot of the UI (RepaintBoundary) and embeds it into a new PDF. // - This exporter uses a raster snapshot of the UI (RepaintBoundary) and embeds it into a new PDF.
@ -15,70 +18,106 @@ import '../model/model.dart';
// cannot import/modify existing PDF pages. If/when a suitable FOSS library exists, wire it here. // cannot import/modify existing PDF pages. If/when a suitable FOSS library exists, wire it here.
class ExportService { class ExportService {
/// Compose a new PDF by rasterizing the original PDF pages (via pdfrx engine)
/// and optionally stamping a signature image on the specified page.
///
/// Inputs:
/// - [inputPath]: Path to the original PDF to read
/// - [outputPath]: Path to write the composed PDF
/// - [signedPage]: 1-based page index to place the signature on (null = no overlay)
/// - [signatureRectUi]: Rect in the UI's logical page space (e.g. 400x560)
/// - [uiPageSize]: The logical page size used by the UI layout (SignatureController.pageSize)
/// - [signatureImageBytes]: PNG/JPEG bytes of the signature image to overlay
/// - [targetDpi]: Rasterization DPI for background pages
Future<bool> exportSignedPdfFromFile({
required String inputPath,
required String outputPath,
required int? signedPage,
required Rect? signatureRectUi,
required Size uiPageSize,
required Uint8List? signatureImageBytes,
Map<int, List<SignaturePlacement>>? placementsByPage,
Map<String, Uint8List>? libraryBytes,
double targetDpi = 144.0,
}) async {
// print(
// 'exportSignedPdfFromFile: enter signedPage=$signedPage outputPath=$outputPath',
// );
// Read source bytes and delegate to bytes-based exporter
Uint8List? srcBytes;
try {
srcBytes = await File(inputPath).readAsBytes();
} catch (_) {
srcBytes = null;
}
if (srcBytes == null) return false;
final bytes = await exportSignedPdfFromBytes(
srcBytes: srcBytes,
signedPage: signedPage,
signatureRectUi: signatureRectUi,
uiPageSize: uiPageSize,
signatureImageBytes: signatureImageBytes,
placementsByPage: placementsByPage,
libraryBytes: libraryBytes,
targetDpi: targetDpi,
);
if (bytes == null) return false;
try {
final file = File(outputPath);
await file.writeAsBytes(bytes, flush: true);
return true;
} catch (_) {
return false;
}
}
/// Compose a new PDF from source PDF bytes; returns the resulting PDF bytes. /// Compose a new PDF from source PDF bytes; returns the resulting PDF bytes.
Future<Uint8List?> exportSignedPdfFromBytes({ Future<Uint8List?> exportSignedPdfFromBytes({
required Uint8List srcBytes, required Uint8List srcBytes,
required int? signedPage,
required Rect? signatureRectUi,
required Size uiPageSize, required Size uiPageSize,
required Uint8List? signatureImageBytes, required Uint8List? signatureImageBytes,
Map<int, List<SignaturePlacement>>? placementsByPage, Map<int, List<SignaturePlacement>>? placementsByPage,
Map<String, Uint8List>? libraryBytes, Map<String, img.Image>? libraryImages,
double targetDpi = 144.0, double targetDpi = 144.0,
}) async { }) async {
// Per-call caches to avoid redundant decode/encode and image embedding work
final Map<String, img.Image> _baseImageCache = <String, img.Image>{};
final Map<String, img.Image> _processedImageCache = <String, img.Image>{};
final Map<String, Uint8List> _encodedPngCache = <String, Uint8List>{};
final Map<String, pw.MemoryImage> _memoryImageCache =
<String, pw.MemoryImage>{};
final Map<String, double> _aspectRatioCache = <String, double>{};
// Returns a stable-ish cache key for bytes within this process (not content-hash, but good enough per-call)
String _baseKeyForImage(img.Image im) =>
'im:${identityHashCode(im)}:${im.width}x${im.height}';
String _adjustKey(GraphicAdjust adj) =>
'c=${adj.contrast}|b=${adj.brightness}|bg=${adj.bgRemoval}';
// Removed: PNG signature helper is no longer needed; we always encode to PNG explicitly.
// Resolve base (unprocessed) image for a placement, considering library override.
img.Image _getBaseImage(SignaturePlacement placement) {
final libKey = placement.asset.name;
if (libKey != null && libraryImages != null) {
final cached = _baseImageCache[libKey];
if (cached != null) return cached;
final provided = libraryImages[libKey];
if (provided != null) {
_baseImageCache[libKey] = provided;
return provided;
}
}
return placement.asset.sigImage;
}
// Get processed image for a placement, with caching.
img.Image _getProcessedImage(SignaturePlacement placement) {
final base = _getBaseImage(placement);
final key =
'${_baseKeyForImage(base)}|${_adjustKey(placement.graphicAdjust)}';
final cached = _processedImageCache[key];
if (cached != null) return cached;
final adj = placement.graphicAdjust;
img.Image processed = base;
if (adj.contrast != 1.0 || adj.brightness != 1.0) {
processed = img.adjustColor(
processed,
contrast: adj.contrast,
brightness: adj.brightness,
);
}
if (adj.bgRemoval) {
processed = br.removeNearWhiteBackground(processed, threshold: 240);
}
_processedImageCache[key] = processed;
return processed;
}
// Get PNG bytes for the processed image, caching the encoding.
Uint8List _getProcessedPng(SignaturePlacement placement) {
final base = _getBaseImage(placement);
final key =
'${_baseKeyForImage(base)}|${_adjustKey(placement.graphicAdjust)}';
final cached = _encodedPngCache[key];
if (cached != null) return cached;
final processed = _getProcessedImage(placement);
final png = Uint8List.fromList(img.encodePng(processed, level: 6));
_encodedPngCache[key] = png;
return png;
}
// Wrap bytes in a pw.MemoryImage with caching.
pw.MemoryImage? _getMemoryImage(Uint8List bytes, String key) {
final cached = _memoryImageCache[key];
if (cached != null) return cached;
try {
final imgObj = pw.MemoryImage(bytes);
_memoryImageCache[key] = imgObj;
return imgObj;
} catch (_) {
return null;
}
}
// Compute and cache aspect ratio (width/height) for given image
double? _getAspectRatioFromImage(img.Image image) {
final key = _baseKeyForImage(image);
final c = _aspectRatioCache[key];
if (c != null) return c;
if (image.width <= 0 || image.height <= 0) return null;
final ar = image.width / image.height;
_aspectRatioCache[key] = ar;
return ar;
}
final out = pw.Document(version: pdf.PdfVersion.pdf_1_4, compress: false); final out = pw.Document(version: pdf.PdfVersion.pdf_1_4, compress: false);
int pageIndex = 0; int pageIndex = 0;
bool anyPage = false; bool anyPage = false;
@ -97,27 +136,12 @@ class ExportService {
final bgPng = await raster.toPng(); final bgPng = await raster.toPng();
final bgImg = pw.MemoryImage(bgPng); final bgImg = pw.MemoryImage(bgPng);
pw.MemoryImage? sigImgObj;
final hasMulti = final hasMulti =
(placementsByPage != null && placementsByPage.isNotEmpty); (placementsByPage != null && placementsByPage.isNotEmpty);
final pagePlacements = final pagePlacements =
hasMulti hasMulti
? (placementsByPage[pageIndex] ?? const <SignaturePlacement>[]) ? (placementsByPage[pageIndex] ?? const <SignaturePlacement>[])
: const <SignaturePlacement>[]; : const <SignaturePlacement>[];
final shouldStampSingle =
!hasMulti &&
signedPage != null &&
pageIndex == signedPage &&
signatureRectUi != null &&
signatureImageBytes != null &&
signatureImageBytes.isNotEmpty;
if (shouldStampSingle) {
try {
sigImgObj = pw.MemoryImage(signatureImageBytes);
} catch (_) {
sigImgObj = null;
}
}
out.addPage( out.addPage(
pw.Page( pw.Page(
@ -143,24 +167,26 @@ class ExportService {
for (var i = 0; i < pagePlacements.length; i++) { for (var i = 0; i < pagePlacements.length; i++) {
final placement = pagePlacements[i]; final placement = pagePlacements[i];
final r = placement.rect; final r = placement.rect;
final left = r.left / uiPageSize.width * widthPts; // rect is stored in normalized units (0..1) relative to page
final top = r.top / uiPageSize.height * heightPts; final left = r.left * widthPts;
final w = r.width / uiPageSize.width * widthPts; final top = r.top * heightPts;
final h = r.height / uiPageSize.height * heightPts; final w = r.width * widthPts;
Uint8List? bytes; final h = r.height * heightPts;
final id = placement.imageId;
if (id != null) { // Get processed image and embed as MemoryImage (cached)
bytes = libraryBytes?[id]; final processedPng = _getProcessedPng(placement);
} final baseImage = _getBaseImage(placement);
bytes ??= signatureImageBytes; // fallback final memKey =
if (bytes != null && bytes.isNotEmpty) { '${_baseKeyForImage(baseImage)}|${_adjustKey(placement.graphicAdjust)}';
pw.MemoryImage? imgObj; if (processedPng.isNotEmpty) {
try { final imgObj = _getMemoryImage(processedPng, memKey);
imgObj = pw.MemoryImage(bytes);
} catch (_) {
imgObj = null;
}
if (imgObj != null) { if (imgObj != null) {
// Align with RotatedSignatureImage: counterclockwise positive
final angle = rot.radians(placement.rotationDeg);
// Use AR from base image
final ar = _getAspectRatioFromImage(baseImage);
final scaleToFit = rot.scaleToFitForAngle(angle, ar: ar);
children.add( children.add(
pw.Positioned( pw.Positioned(
left: left, left: left,
@ -170,41 +196,21 @@ class ExportService {
height: h, height: h,
child: pw.FittedBox( child: pw.FittedBox(
fit: pw.BoxFit.contain, fit: pw.BoxFit.contain,
child: pw.Transform.scale(
scale: scaleToFit,
child: pw.Transform.rotate( child: pw.Transform.rotate(
angle: angle: angle,
placement.rotationDeg *
3.1415926535 /
180.0,
child: pw.Image(imgObj), child: pw.Image(imgObj),
), ),
), ),
), ),
), ),
);
}
}
}
} 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.SizedBox(
width: w,
height: h,
child: pw.FittedBox(
fit: pw.BoxFit.contain,
child: pw.Image(sigImgObj),
),
),
), ),
); );
} }
}
}
}
return pw.Stack(children: children); return pw.Stack(children: children);
}, },
), ),
@ -218,39 +224,14 @@ class ExportService {
// Fallback as A4 blank page with optional signature // Fallback as A4 blank page with optional signature
final widthPts = pdf.PdfPageFormat.a4.width; final widthPts = pdf.PdfPageFormat.a4.width;
final heightPts = pdf.PdfPageFormat.a4.height; final heightPts = pdf.PdfPageFormat.a4.height;
pw.MemoryImage? sigImgObj;
final hasMulti = final hasMulti =
(placementsByPage != null && placementsByPage.isNotEmpty); (placementsByPage != null && placementsByPage.isNotEmpty);
final pagePlacements = final pagePlacements =
hasMulti hasMulti
? (placementsByPage[1] ?? const <SignaturePlacement>[]) ? (placementsByPage[1] ?? const <SignaturePlacement>[])
: const <SignaturePlacement>[]; : const <SignaturePlacement>[];
final shouldStampSingle =
!hasMulti &&
signedPage != null &&
signedPage == 1 &&
signatureRectUi != null &&
signatureImageBytes != null &&
signatureImageBytes.isNotEmpty;
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));
final isPng =
signatureImageBytes.length > 8 &&
signatureImageBytes[0] == 0x89 &&
asStr.startsWith('\u0089PNG');
if (isPng) {
sigImgObj = pw.MemoryImage(signatureImageBytes);
} else {
final decoded = img.decodeImage(signatureImageBytes);
if (decoded != null) {
final png = img.encodePng(decoded, level: 6);
sigImgObj = pw.MemoryImage(Uint8List.fromList(png));
}
}
} catch (_) {}
}
out.addPage( out.addPage(
pw.Page( pw.Page(
pageTheme: pw.PageTheme( pageTheme: pw.PageTheme(
@ -265,43 +246,28 @@ class ExportService {
color: pdf.PdfColors.white, color: pdf.PdfColors.white,
), ),
]; ];
// Multi-placement stamping on fallback page
if (hasMulti && pagePlacements.isNotEmpty) { if (hasMulti && pagePlacements.isNotEmpty) {
for (var i = 0; i < pagePlacements.length; i++) { for (var i = 0; i < pagePlacements.length; i++) {
final placement = pagePlacements[i]; final placement = pagePlacements[i];
final r = placement.rect; final r = placement.rect;
final left = r.left / uiPageSize.width * widthPts; // rect is stored in normalized units (0..1) relative to page
final top = r.top / uiPageSize.height * heightPts; final left = r.left * widthPts;
final w = r.width / uiPageSize.width * widthPts; final top = r.top * heightPts;
final h = r.height / uiPageSize.height * heightPts; final w = r.width * widthPts;
Uint8List? bytes; final h = r.height * heightPts;
final id = placement.imageId;
if (id != null) { final processedPng = _getProcessedPng(placement);
bytes = libraryBytes?[id]; final baseImage = _getBaseImage(placement);
} final memKey =
bytes ??= signatureImageBytes; // fallback '${_baseKeyForImage(baseImage)}|${_adjustKey(placement.graphicAdjust)}';
if (bytes != null && bytes.isNotEmpty) { if (processedPng.isNotEmpty) {
pw.MemoryImage? imgObj; final imgObj = _getMemoryImage(processedPng, memKey);
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) { if (imgObj != null) {
final angle = rot.radians(placement.rotationDeg);
final ar = _getAspectRatioFromImage(baseImage);
final scaleToFit = rot.scaleToFitForAngle(angle, ar: ar);
children.add( children.add(
pw.Positioned( pw.Positioned(
left: left, left: left,
@ -311,39 +277,21 @@ class ExportService {
height: h, height: h,
child: pw.FittedBox( child: pw.FittedBox(
fit: pw.BoxFit.contain, fit: pw.BoxFit.contain,
child: pw.Transform.scale(
scale: scaleToFit,
child: pw.Transform.rotate( child: pw.Transform.rotate(
angle: angle: angle,
placement.rotationDeg * 3.1415926535 / 180.0,
child: pw.Image(imgObj), child: pw.Image(imgObj),
), ),
), ),
), ),
), ),
);
}
}
}
} 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.SizedBox(
width: w,
height: h,
child: pw.FittedBox(
fit: pw.BoxFit.contain,
child: pw.Image(sigImgObj),
),
),
), ),
); );
} }
}
}
}
return pw.Stack(children: children); return pw.Stack(children: children);
}, },
), ),
@ -370,4 +318,6 @@ class ExportService {
return false; return false;
} }
} }
// Background removal implemented in utils/background_removal.dart
} }

View File

@ -1,244 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:pdf_signature/l10n/app_localizations.dart';
import 'package:flutter_localized_locales/flutter_localized_locales.dart';
// Helpers to work with BCP-47 language tags
String toLanguageTag(Locale loc) {
final lang = loc.languageCode.toLowerCase();
final region = loc.countryCode;
if (region == null || region.isEmpty) return lang;
return '$lang-${region.toUpperCase()}';
}
Locale _parseLanguageTag(String tag) {
final cleaned = tag.replaceAll('_', '-');
final parts = cleaned.split('-');
if (parts.length >= 2 && parts[1].isNotEmpty) {
return Locale(parts[0].toLowerCase(), parts[1].toUpperCase());
}
return Locale(parts[0].toLowerCase());
}
Set<String> _supportedTags() {
return AppLocalizations.supportedLocales.map((l) => toLanguageTag(l)).toSet();
}
// 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'
const _kExportDpi = 'export_dpi'; // double, allowed: 96,144,200,300
String _normalizeLanguageTag(String tag) {
final tags = _supportedTags();
if (tag.isEmpty) return tags.contains('en') ? 'en' : tags.first;
// Replace underscore with hyphen and canonicalize case
final normalized = () {
final t = tag.replaceAll('_', '-');
final parts = t.split('-');
final lang = parts[0].toLowerCase();
if (parts.length >= 2 && parts[1].isNotEmpty) {
return '$lang-${parts[1].toUpperCase()}';
}
return lang;
}();
// Exact match
if (tags.contains(normalized)) return normalized;
// Try fallback to language-only if available
final langOnly = normalized.split('-')[0];
if (tags.contains(langOnly)) return langOnly;
// Try to pick first tag with same language
final candidate = tags.firstWhere(
(t) => t.split('-')[0] == langOnly,
orElse: () => '',
);
if (candidate.isNotEmpty) return candidate;
// Final fallback to English or first supported
return tags.contains('en') ? 'en' : tags.first;
}
class PreferencesState {
final String theme; // 'light' | 'dark' | 'system'
final String language; // 'en' | 'zh-TW' | 'es'
final String pageView; // only 'continuous'
final double exportDpi; // 96.0 | 144.0 | 200.0 | 300.0
const PreferencesState({
required this.theme,
required this.language,
required this.pageView,
required this.exportDpi,
});
PreferencesState copyWith({
String? theme,
String? language,
String? pageView,
double? exportDpi,
}) => PreferencesState(
theme: theme ?? this.theme,
language: language ?? this.language,
pageView: pageView ?? this.pageView,
exportDpi: exportDpi ?? this.exportDpi,
);
}
class PreferencesNotifier extends StateNotifier<PreferencesState> {
final SharedPreferences prefs;
PreferencesNotifier(this.prefs)
: super(
PreferencesState(
theme: prefs.getString(_kTheme) ?? 'system',
language: _normalizeLanguageTag(
prefs.getString(_kLanguage) ??
WidgetsBinding.instance.platformDispatcher.locale
.toLanguageTag(),
),
pageView: prefs.getString(_kPageView) ?? 'continuous',
exportDpi: _readDpi(prefs),
),
) {
// normalize language to supported/fallback
_ensureValid();
}
static double _readDpi(SharedPreferences prefs) {
final d = prefs.getDouble(_kExportDpi);
if (d == null) return 144.0;
const allowed = [96.0, 144.0, 200.0, 300.0];
return allowed.contains(d) ? d : 144.0;
}
void _ensureValid() {
final themeValid = {'light', 'dark', 'system'};
if (!themeValid.contains(state.theme)) {
state = state.copyWith(theme: 'system');
prefs.setString(_kTheme, 'system');
}
final normalized = _normalizeLanguageTag(state.language);
if (normalized != state.language) {
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');
}
// Ensure DPI is one of allowed values
const allowed = [96.0, 144.0, 200.0, 300.0];
if (!allowed.contains(state.exportDpi)) {
state = state.copyWith(exportDpi: 144.0);
prefs.setDouble(_kExportDpi, 144.0);
}
}
Future<void> setTheme(String theme) async {
final valid = {'light', 'dark', 'system'};
if (!valid.contains(theme)) return;
state = state.copyWith(theme: theme);
await prefs.setString(_kTheme, theme);
}
Future<void> setLanguage(String language) async {
final normalized = _normalizeLanguageTag(language);
state = state.copyWith(language: normalized);
await prefs.setString(_kLanguage, normalized);
}
Future<void> resetToDefaults() async {
final device =
WidgetsBinding.instance.platformDispatcher.locale.toLanguageTag();
final normalized = _normalizeLanguageTag(device);
state = PreferencesState(
theme: 'system',
language: normalized,
pageView: 'continuous',
exportDpi: 144.0,
);
await prefs.setString(_kTheme, 'system');
await prefs.setString(_kLanguage, normalized);
await prefs.setString(_kPageView, 'continuous');
await prefs.setDouble(_kExportDpi, 144.0);
}
Future<void> setPageView(String pageView) async {
final valid = {'continuous'};
if (!valid.contains(pageView)) return;
state = state.copyWith(pageView: pageView);
await prefs.setString(_kPageView, pageView);
}
Future<void> setExportDpi(double dpi) async {
const allowed = [96.0, 144.0, 200.0, 300.0];
if (!allowed.contains(dpi)) return;
state = state.copyWith(exportDpi: dpi);
await prefs.setDouble(_kExportDpi, dpi);
}
}
final sharedPreferencesProvider = FutureProvider<SharedPreferences>((
ref,
) async {
final p = await SharedPreferences.getInstance();
return p;
});
final preferencesProvider =
StateNotifierProvider<PreferencesNotifier, PreferencesState>((ref) {
// In tests, you can override sharedPreferencesProvider
final prefs = ref
.watch(sharedPreferencesProvider)
.maybeWhen(
data: (p) => p,
orElse: () => throw StateError('SharedPreferences not ready'),
);
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);
switch (prefs.theme) {
case 'light':
return ThemeMode.light;
case 'dark':
return ThemeMode.dark;
case 'system':
default:
return ThemeMode.system;
}
});
final localeProvider = Provider<Locale?>((ref) {
final prefs = ref.watch(preferencesProvider);
final supported = _supportedTags();
// Return explicit Locale for supported ones; if not supported, null to follow device
if (supported.contains(prefs.language)) {
return _parseLanguageTag(prefs.language);
}
return null;
});
/// Provides a map of BCP-47 tag -> autonym (self name), independent of UI locale.
final languageAutonymsProvider = FutureProvider<Map<String, String>>((
ref,
) async {
final tags = _supportedTags().toList()..sort();
final delegate = LocaleNamesLocalizationsDelegate();
final Map<String, String> result = {};
for (final tag in tags) {
final locale = _parseLanguageTag(tag);
final names = await delegate.load(locale);
final name = names.nameOf(tag) ?? tag;
result[tag] = name;
}
return result;
});

View File

@ -0,0 +1,48 @@
import 'package:image/image.dart' as img;
import 'package:colorfilter_generator/colorfilter_generator.dart';
import 'package:colorfilter_generator/addons.dart';
import '../../domain/models/model.dart' as domain;
import '../../utils/background_removal.dart' as br;
/// Service for processing signature images with graphic adjustments
class SignatureImageProcessingService {
/// Build a GPU color matrix (brightness/contrast) using colorfilter_generator.
/// Domain neutral value is 1.0; addon neutral is 0. Map by (value-1.0).
List<double>? buildColorMatrix(domain.GraphicAdjust adjust) {
final bAddon = adjust.brightness - 1.0;
final cAddon = adjust.contrast - 1.0;
if (bAddon == 0 && cAddon == 0) return null; // identity
final gen = ColorFilterGenerator(
name: 'signature_adjust',
filters: [
if (bAddon != 0) ColorFilterAddons.brightness(bAddon),
if (cAddon != 0) ColorFilterAddons.contrast(cAddon),
],
);
return gen.matrix;
}
/// Process an already decoded image and return a new decoded image.
img.Image processImageToImage(img.Image image, domain.GraphicAdjust adjust) {
img.Image processed = img.Image.from(image);
// Apply contrast and brightness first (domain neutral is 1.0)
if (adjust.contrast != 1.0 || adjust.brightness != 1.0) {
// performance actually bad due to dual forloops internally
processed = img.adjustColor(
processed,
contrast: adjust.contrast,
brightness: adjust.brightness,
);
}
// Apply background removal after color adjustments
if (adjust.bgRemoval) {
processed = br.removeNearWhiteBackground(processed, threshold: 240);
}
return processed;
}
// Background removal implemented in utils/background_removal.dart
}

View File

@ -0,0 +1,20 @@
import 'dart:typed_data';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'signature_placement.dart';
part 'document.freezed.dart';
/// PDF document to be signed
@freezed
abstract class Document with _$Document {
const factory Document({
@Default(false) bool loaded,
@Default(0) int pageCount,
Uint8List? pickedPdfBytes,
@Default(<int, List<SignaturePlacement>>{})
Map<int, List<SignaturePlacement>> placementsByPage,
}) = _Document;
factory Document.initial() => const Document();
}

View File

@ -0,0 +1,12 @@
import 'package:freezed_annotation/freezed_annotation.dart';
part 'graphic_adjust.freezed.dart';
@freezed
abstract class GraphicAdjust with _$GraphicAdjust {
const factory GraphicAdjust({
@Default(1.0) double contrast,
@Default(1.0) double brightness,
@Default(false) bool bgRemoval,
}) = _GraphicAdjust;
}

View File

@ -0,0 +1,6 @@
/// TODO: remove this file and export models directly from their files.
export 'signature_asset.dart';
export 'graphic_adjust.dart';
export 'signature_card.dart';
export 'signature_placement.dart';
export 'document.dart';

View File

@ -0,0 +1,18 @@
import 'package:freezed_annotation/freezed_annotation.dart';
part 'preferences.freezed.dart';
part 'preferences.g.dart';
/// Immutable preferences model with JSON support
@freezed
abstract class PreferencesState with _$PreferencesState {
const factory PreferencesState({
@Default('system') String theme, // 'light' | 'dark' | 'system'
@Default('#FF2196F3') String theme_color, // hex ARGB string
@Default('en') String language, // BCP-47 tag like 'en'|'zh-TW'
@Default(144.0) double exportDpi, // 96.0 | 144.0 | 200.0 | 300.0
}) = _PreferencesState;
factory PreferencesState.fromJson(Map<String, dynamic> json) =>
_$PreferencesStateFromJson(json);
}

View File

@ -0,0 +1,18 @@
import 'dart:typed_data';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:image/image.dart' as img;
part 'signature_asset.freezed.dart';
/// SignatureAsset store image file of a signature, stored in the device or cloud storage
@freezed
abstract class SignatureAsset with _$SignatureAsset {
const SignatureAsset._();
const factory SignatureAsset({required img.Image sigImage, String? name}) =
_SignatureAsset;
/// Encode this image to PNG bytes. Use a small compression level for speed by default.
Uint8List toPngBytes({int level = 3}) =>
Uint8List.fromList(img.encodePng(sigImage, level: level));
}

View File

@ -0,0 +1,24 @@
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:image/image.dart' as img;
import 'graphic_adjust.dart';
import 'signature_asset.dart';
part 'signature_card.freezed.dart';
/**
* signature card is template of signature placement
* Use the [SignatureCardRepository] to obtain a full [SignatureCard]
*/
@freezed
abstract class SignatureCard with _$SignatureCard {
const factory SignatureCard({
required SignatureAsset asset,
@Default(0.0) double rotationDeg,
@Default(GraphicAdjust()) GraphicAdjust graphicAdjust,
}) = _SignatureCard;
factory SignatureCard.initial() => SignatureCard(
asset: SignatureAsset(sigImage: img.Image(width: 1, height: 1)),
);
}

View File

@ -0,0 +1,20 @@
import 'dart:ui';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'graphic_adjust.dart';
import 'signature_asset.dart';
part 'signature_placement.freezed.dart';
/// Represents a single signature placement on a page combining both the
/// geometric rectangle (UI coordinate space) and the signature asset
/// assigned to that placement.
@freezed
abstract class SignaturePlacement with _$SignaturePlacement {
const factory SignaturePlacement({
required Rect rect,
required SignatureAsset asset,
@Default(0.0) double rotationDeg,
@Default(GraphicAdjust()) GraphicAdjust graphicAdjust,
}) = _SignaturePlacement;
}

View File

@ -1,4 +1,5 @@
{ {
"adjustGraphic": "Grafik anpassen",
"appTitle": "PDF-Signatur", "appTitle": "PDF-Signatur",
"backgroundRemoval": "Hintergrund entfernen", "backgroundRemoval": "Hintergrund entfernen",
"brightness": "Helligkeit", "brightness": "Helligkeit",
@ -12,6 +13,7 @@
"display": "Anzeige", "display": "Anzeige",
"downloadStarted": "Download gestartet", "downloadStarted": "Download gestartet",
"dpi": "DPI", "dpi": "DPI",
"dragOntoDocument": "Auf Dokument ziehen",
"drawSignature": "Signatur zeichnen", "drawSignature": "Signatur zeichnen",
"errorWithMessage": "Fehler: {message}", "errorWithMessage": "Fehler: {message}",
"exportingPleaseWait": "Exportiere… Bitte warten", "exportingPleaseWait": "Exportiere… Bitte warten",
@ -22,6 +24,7 @@
"image": "Bild", "image": "Bild",
"invalidOrUnsupportedFile": "Ungültige oder nicht unterstützte Datei", "invalidOrUnsupportedFile": "Ungültige oder nicht unterstützte Datei",
"language": "Sprache", "language": "Sprache",
"lock": "Sperren",
"loadSignatureFromFile": "Signatur aus Datei laden", "loadSignatureFromFile": "Signatur aus Datei laden",
"lockAspectRatio": "Seitenverhältnis sperren", "lockAspectRatio": "Seitenverhältnis sperren",
"longPressOrRightClickTheSignatureToConfirmOrDelete": "Drücken Sie lange oder klicken Sie mit der rechten Maustaste auf die Signatur, um sie zu bestätigen oder zu löschen.", "longPressOrRightClickTheSignatureToConfirmOrDelete": "Drücken Sie lange oder klicken Sie mit der rechten Maustaste auf die Signatur, um sie zu bestätigen oder zu löschen.",
@ -45,5 +48,11 @@
"themeDark": "Dunkel", "themeDark": "Dunkel",
"themeLight": "Hell", "themeLight": "Hell",
"themeSystem": "System", "themeSystem": "System",
"undo": "Rückgängig" "themeColor": "Themenfarbe",
"themeColorBlue": "Blau",
"themeColorGreen": "Grün",
"themeColorRed": "Rot",
"themeColorPurple": "Lila",
"undo": "Rückgängig",
"unlock": "Entsperren"
} }

View File

@ -1,5 +1,7 @@
{ {
"@@locale": "en", "@@locale": "en",
"adjustGraphic": "Adjust graphic",
"@adjustGraphic": {},
"appTitle": "PDF Signature", "appTitle": "PDF Signature",
"@appTitle": {}, "@appTitle": {},
"backgroundRemoval": "Background removal", "backgroundRemoval": "Background removal",
@ -26,6 +28,10 @@
"@downloadStarted": {}, "@downloadStarted": {},
"dpi": "DPI", "dpi": "DPI",
"@dpi": {}, "@dpi": {},
"dragOntoDocument": "Drag onto document",
"@dragOntoDocument": {
"description": "Tooltip message for dragging signature card onto PDF document"
},
"drawSignature": "Draw Signature", "drawSignature": "Draw Signature",
"@drawSignature": {}, "@drawSignature": {},
"errorWithMessage": "Error: {message}", "errorWithMessage": "Error: {message}",
@ -53,6 +59,8 @@
"@invalidOrUnsupportedFile": {}, "@invalidOrUnsupportedFile": {},
"language": "Language", "language": "Language",
"@language": {}, "@language": {},
"lock": "Lock",
"@lock": {},
"loadSignatureFromFile": "Load Signature from file", "loadSignatureFromFile": "Load Signature from file",
"@loadSignatureFromFile": {}, "@loadSignatureFromFile": {},
"lockAspectRatio": "Lock aspect ratio", "lockAspectRatio": "Lock aspect ratio",
@ -116,6 +124,18 @@
"@themeLight": {}, "@themeLight": {},
"themeSystem": "System", "themeSystem": "System",
"@themeSystem": {}, "@themeSystem": {},
"themeColor": "Theme color",
"@themeColor": {},
"themeColorBlue": "Blue",
"@themeColorBlue": {},
"themeColorGreen": "Green",
"@themeColorGreen": {},
"themeColorRed": "Red",
"@themeColorRed": {},
"themeColorPurple": "Purple",
"@themeColorPurple": {},
"undo": "Undo", "undo": "Undo",
"@undo": {} "@undo": {},
"unlock": "Unlock",
"@unlock": {}
} }

View File

@ -1,4 +1,5 @@
{ {
"adjustGraphic": "Ajustar gráfico",
"appTitle": "Firma PDF", "appTitle": "Firma PDF",
"backgroundRemoval": "Eliminar fondo", "backgroundRemoval": "Eliminar fondo",
"brightness": "Brillo", "brightness": "Brillo",
@ -12,6 +13,7 @@
"display": "Pantalla", "display": "Pantalla",
"downloadStarted": "Descarga iniciada", "downloadStarted": "Descarga iniciada",
"dpi": "DPI", "dpi": "DPI",
"dragOntoDocument": "Arrastra sobre el documento",
"drawSignature": "Dibujar firma", "drawSignature": "Dibujar firma",
"errorWithMessage": "Error: {message}", "errorWithMessage": "Error: {message}",
"exportingPleaseWait": "Exportando... Por favor, espere", "exportingPleaseWait": "Exportando... Por favor, espere",
@ -22,6 +24,7 @@
"image": "Imagen", "image": "Imagen",
"invalidOrUnsupportedFile": "Archivo inválido o no compatible", "invalidOrUnsupportedFile": "Archivo inválido o no compatible",
"language": "Idioma", "language": "Idioma",
"lock": "Bloquear",
"loadSignatureFromFile": "Cargar firma desde archivo", "loadSignatureFromFile": "Cargar firma desde archivo",
"lockAspectRatio": "Bloquear relación de aspecto", "lockAspectRatio": "Bloquear relación de aspecto",
"longPressOrRightClickTheSignatureToConfirmOrDelete": "Pulse prolongadamente o haga clic derecho en la firma para confirmar o eliminar.", "longPressOrRightClickTheSignatureToConfirmOrDelete": "Pulse prolongadamente o haga clic derecho en la firma para confirmar o eliminar.",
@ -45,5 +48,11 @@
"themeDark": "Oscuro", "themeDark": "Oscuro",
"themeLight": "Claro", "themeLight": "Claro",
"themeSystem": "Sistema", "themeSystem": "Sistema",
"undo": "Deshacer" "themeColor": "Color del tema",
"themeColorBlue": "Azul",
"themeColorGreen": "Verde",
"themeColorRed": "Rojo",
"themeColorPurple": "Púrpura",
"undo": "Deshacer",
"unlock": "Desbloquear"
} }

View File

@ -1,4 +1,5 @@
{ {
"adjustGraphic": "Ajuster le graphique",
"appTitle": "Signature PDF", "appTitle": "Signature PDF",
"backgroundRemoval": "Suppression de l'arrière-plan", "backgroundRemoval": "Suppression de l'arrière-plan",
"brightness": "Luminosité", "brightness": "Luminosité",
@ -12,6 +13,7 @@
"display": "Affichage", "display": "Affichage",
"downloadStarted": "Téléchargement commencé", "downloadStarted": "Téléchargement commencé",
"dpi": "DPI :", "dpi": "DPI :",
"dragOntoDocument": "Faites glisser sur le document",
"drawSignature": "Dessiner une signature", "drawSignature": "Dessiner une signature",
"errorWithMessage": "Erreur : {message}", "errorWithMessage": "Erreur : {message}",
"exportingPleaseWait": "Exportation… Veuillez patienter", "exportingPleaseWait": "Exportation… Veuillez patienter",
@ -22,6 +24,7 @@
"image": "Image", "image": "Image",
"invalidOrUnsupportedFile": "Fichier invalide ou non pris en charge", "invalidOrUnsupportedFile": "Fichier invalide ou non pris en charge",
"language": "Langue", "language": "Langue",
"lock": "Verrouiller",
"loadSignatureFromFile": "Charger une signature depuis un fichier", "loadSignatureFromFile": "Charger une signature depuis un fichier",
"lockAspectRatio": "Verrouiller le ratio largeur/hauteur", "lockAspectRatio": "Verrouiller le ratio largeur/hauteur",
"longPressOrRightClickTheSignatureToConfirmOrDelete": "Appuyez longuement ou cliquez droit sur la signature pour la confirmer ou la supprimer.", "longPressOrRightClickTheSignatureToConfirmOrDelete": "Appuyez longuement ou cliquez droit sur la signature pour la confirmer ou la supprimer.",
@ -45,5 +48,11 @@
"themeDark": "Sombre", "themeDark": "Sombre",
"themeLight": "Clair", "themeLight": "Clair",
"themeSystem": "Système", "themeSystem": "Système",
"undo": "Annuler" "themeColor": "Couleur du thème",
"themeColorBlue": "Bleu",
"themeColorGreen": "Vert",
"themeColorRed": "Rouge",
"themeColorPurple": "Violet",
"undo": "Annuler",
"unlock": "Déverrouiller"
} }

View File

@ -1,4 +1,5 @@
{ {
"adjustGraphic": "グラフィックを調整する",
"appTitle": "PDF署名", "appTitle": "PDF署名",
"backgroundRemoval": "背景除去", "backgroundRemoval": "背景除去",
"brightness": "明るさ", "brightness": "明るさ",
@ -12,6 +13,7 @@
"display": "表示", "display": "表示",
"downloadStarted": "ダウンロード開始", "downloadStarted": "ダウンロード開始",
"dpi": "DPI", "dpi": "DPI",
"dragOntoDocument": "ドキュメントにドラッグします",
"drawSignature": "署名をかく", "drawSignature": "署名をかく",
"errorWithMessage": "エラー:{message}", "errorWithMessage": "エラー:{message}",
"exportingPleaseWait": "エクスポート中…お待ちください", "exportingPleaseWait": "エクスポート中…お待ちください",
@ -22,6 +24,7 @@
"image": "画像", "image": "画像",
"invalidOrUnsupportedFile": "無効なファイルまたはサポートされていないファイル", "invalidOrUnsupportedFile": "無効なファイルまたはサポートされていないファイル",
"language": "言語", "language": "言語",
"lock": "ロック",
"loadSignatureFromFile": "ファイルから署名を読み込む", "loadSignatureFromFile": "ファイルから署名を読み込む",
"lockAspectRatio": "アスペクト比をロック", "lockAspectRatio": "アスペクト比をロック",
"longPressOrRightClickTheSignatureToConfirmOrDelete": "署名を長押しまたは右クリックして、確認または削除します。", "longPressOrRightClickTheSignatureToConfirmOrDelete": "署名を長押しまたは右クリックして、確認または削除します。",
@ -45,5 +48,11 @@
"themeDark": "ダーク", "themeDark": "ダーク",
"themeLight": "ライト", "themeLight": "ライト",
"themeSystem": "システム", "themeSystem": "システム",
"undo": "元に戻す" "themeColor": "テーマカラー",
"themeColorBlue": "青",
"themeColorGreen": "緑",
"themeColorRed": "赤",
"themeColorPurple": "紫",
"undo": "元に戻す",
"unlock": "ロック解除"
} }

View File

@ -1,4 +1,5 @@
{ {
"adjustGraphic": "그래픽 조정",
"appTitle": "PDF 서명", "appTitle": "PDF 서명",
"backgroundRemoval": "배경 제거", "backgroundRemoval": "배경 제거",
"brightness": "밝기", "brightness": "밝기",
@ -12,6 +13,7 @@
"display": "표시", "display": "표시",
"downloadStarted": "다운로드 시작됨", "downloadStarted": "다운로드 시작됨",
"dpi": "DPI", "dpi": "DPI",
"dragOntoDocument": "문서로 끌어다 놓습니다",
"drawSignature": "서명 그리기", "drawSignature": "서명 그리기",
"errorWithMessage": "오류: {message}", "errorWithMessage": "오류: {message}",
"exportingPleaseWait": "내보내는 중... 잠시 기다려주세요", "exportingPleaseWait": "내보내는 중... 잠시 기다려주세요",
@ -22,6 +24,7 @@
"image": "이미지", "image": "이미지",
"invalidOrUnsupportedFile": "잘못된 파일이거나 지원되지 않는 파일입니다.", "invalidOrUnsupportedFile": "잘못된 파일이거나 지원되지 않는 파일입니다.",
"language": "언어", "language": "언어",
"lock": "잠금",
"loadSignatureFromFile": "파일에서 서명 불러오기", "loadSignatureFromFile": "파일에서 서명 불러오기",
"lockAspectRatio": "종횡비 고정", "lockAspectRatio": "종횡비 고정",
"longPressOrRightClickTheSignatureToConfirmOrDelete": "서명을 길게 누르거나 마우스 오른쪽 버튼을 클릭하여 확인하거나 삭제합니다.", "longPressOrRightClickTheSignatureToConfirmOrDelete": "서명을 길게 누르거나 마우스 오른쪽 버튼을 클릭하여 확인하거나 삭제합니다.",
@ -45,5 +48,11 @@
"themeDark": "다크", "themeDark": "다크",
"themeLight": "라이트", "themeLight": "라이트",
"themeSystem": "시스템", "themeSystem": "시스템",
"undo": "실행 취소" "themeColor": "테마 색상",
"themeColorBlue": "파란색",
"themeColorGreen": "녹색",
"themeColorRed": "빨간색",
"themeColorPurple": "보라색",
"undo": "실행 취소",
"unlock": "잠금 해제"
} }

View File

@ -1,4 +1,5 @@
{ {
"adjustGraphic": "Регулювати графіку",
"appTitle": "Підпис PDF", "appTitle": "Підпис PDF",
"backgroundRemoval": "Видалення фону", "backgroundRemoval": "Видалення фону",
"brightness": "Яскравість", "brightness": "Яскравість",
@ -12,6 +13,7 @@
"display": "Відображення", "display": "Відображення",
"downloadStarted": "Завантаження розпочато", "downloadStarted": "Завантаження розпочато",
"dpi": "DPI", "dpi": "DPI",
"dragOntoDocument": "Перетягніть на документ",
"drawSignature": "Намалювати підпис", "drawSignature": "Намалювати підпис",
"errorWithMessage": "Помилка: {message}", "errorWithMessage": "Помилка: {message}",
"exportingPleaseWait": "Експортування... Зачекайте", "exportingPleaseWait": "Експортування... Зачекайте",
@ -22,6 +24,7 @@
"image": "Зображення", "image": "Зображення",
"invalidOrUnsupportedFile": "Недійсний або непідтримуваний файл", "invalidOrUnsupportedFile": "Недійсний або непідтримуваний файл",
"language": "Мова", "language": "Мова",
"lock": "Замкнути",
"loadSignatureFromFile": "Завантажити підпис з файлу", "loadSignatureFromFile": "Завантажити підпис з файлу",
"lockAspectRatio": "Зафіксувати співвідношення сторін", "lockAspectRatio": "Зафіксувати співвідношення сторін",
"longPressOrRightClickTheSignatureToConfirmOrDelete": "Довго натисніть або клацніть правою кнопкою миші на підпис, щоб підтвердити або видалити.", "longPressOrRightClickTheSignatureToConfirmOrDelete": "Довго натисніть або клацніть правою кнопкою миші на підпис, щоб підтвердити або видалити.",
@ -45,5 +48,11 @@
"themeDark": "Темна", "themeDark": "Темна",
"themeLight": "Світла", "themeLight": "Світла",
"themeSystem": "Системна", "themeSystem": "Системна",
"undo": "Відмінити" "themeColor": "Колір теми",
"themeColorBlue": "Синій",
"themeColorGreen": "Зелений",
"themeColorRed": "Червоний",
"themeColorPurple": "Фіолетовий",
"undo": "Відмінити",
"unlock": "Відмкнути"
} }

View File

@ -1,5 +1,6 @@
{ {
"@@locale": "zh", "@@locale": "zh",
"adjustGraphic": "調整圖形",
"appTitle": "PDF 簽名", "appTitle": "PDF 簽名",
"backgroundRemoval": "去除背景", "backgroundRemoval": "去除背景",
"brightness": "亮度", "brightness": "亮度",
@ -13,6 +14,7 @@
"display": "顯示", "display": "顯示",
"downloadStarted": "已開始下載", "downloadStarted": "已開始下載",
"dpi": "DPI", "dpi": "DPI",
"dragOntoDocument": "拖到文档上",
"drawSignature": "手寫簽名", "drawSignature": "手寫簽名",
"errorWithMessage": "錯誤:{message}", "errorWithMessage": "錯誤:{message}",
"exportingPleaseWait": "匯出中…請稍候", "exportingPleaseWait": "匯出中…請稍候",
@ -23,6 +25,7 @@
"image": "圖片", "image": "圖片",
"invalidOrUnsupportedFile": "無效或不支援的檔案", "invalidOrUnsupportedFile": "無效或不支援的檔案",
"language": "語言", "language": "語言",
"lock": "锁定",
"loadSignatureFromFile": "從檔案載入簽名", "loadSignatureFromFile": "從檔案載入簽名",
"lockAspectRatio": "鎖定長寬比", "lockAspectRatio": "鎖定長寬比",
"longPressOrRightClickTheSignatureToConfirmOrDelete": "長按或右鍵點擊簽名以確認或刪除。", "longPressOrRightClickTheSignatureToConfirmOrDelete": "長按或右鍵點擊簽名以確認或刪除。",
@ -46,5 +49,11 @@
"themeDark": "深色", "themeDark": "深色",
"themeLight": "淺色", "themeLight": "淺色",
"themeSystem": "系統", "themeSystem": "系統",
"undo": "復原" "themeColor": "主题颜色",
"themeColorBlue": "蓝色",
"themeColorGreen": "绿色",
"themeColorRed": "红色",
"themeColorPurple": "紫色",
"undo": "復原",
"unlock": "解锁"
} }

View File

@ -1,4 +1,5 @@
{ {
"adjustGraphic": "调整图形",
"appTitle": "PDF 签名", "appTitle": "PDF 签名",
"backgroundRemoval": "背景移除", "backgroundRemoval": "背景移除",
"brightness": "亮度", "brightness": "亮度",
@ -12,6 +13,7 @@
"display": "显示", "display": "显示",
"downloadStarted": "下载已开始", "downloadStarted": "下载已开始",
"dpi": "DPI", "dpi": "DPI",
"dragOntoDocument": "拖到文档上",
"drawSignature": "绘制签名", "drawSignature": "绘制签名",
"errorWithMessage": "错误:{message}", "errorWithMessage": "错误:{message}",
"exportingPleaseWait": "正在导出... 请稍候", "exportingPleaseWait": "正在导出... 请稍候",
@ -22,6 +24,7 @@
"image": "图片", "image": "图片",
"invalidOrUnsupportedFile": "无效或不支持的文件", "invalidOrUnsupportedFile": "无效或不支持的文件",
"language": "语言", "language": "语言",
"lock": "锁定",
"loadSignatureFromFile": "从文件加载签名", "loadSignatureFromFile": "从文件加载签名",
"lockAspectRatio": "锁定纵横比", "lockAspectRatio": "锁定纵横比",
"longPressOrRightClickTheSignatureToConfirmOrDelete": "长按或右键单击签名以确认或删除。", "longPressOrRightClickTheSignatureToConfirmOrDelete": "长按或右键单击签名以确认或删除。",
@ -45,5 +48,11 @@
"themeDark": "深色", "themeDark": "深色",
"themeLight": "浅色", "themeLight": "浅色",
"themeSystem": "系统", "themeSystem": "系统",
"undo": "撤销" "themeColor": "主题颜色",
"themeColorBlue": "蓝色",
"themeColorGreen": "绿色",
"themeColorRed": "红色",
"themeColorPurple": "紫色",
"undo": "撤销",
"unlock": "解锁"
} }

View File

@ -1,5 +1,6 @@
{ {
"@@locale": "zh_TW", "@@locale": "zh_TW",
"adjustGraphic": "調整圖形",
"appTitle": "PDF 簽名", "appTitle": "PDF 簽名",
"backgroundRemoval": "去除背景", "backgroundRemoval": "去除背景",
"brightness": "亮度", "brightness": "亮度",
@ -13,6 +14,7 @@
"display": "顯示", "display": "顯示",
"downloadStarted": "已開始下載", "downloadStarted": "已開始下載",
"dpi": "DPI", "dpi": "DPI",
"dragOntoDocument": "拖曳到文件",
"drawSignature": "手寫簽名", "drawSignature": "手寫簽名",
"errorWithMessage": "錯誤:{message}", "errorWithMessage": "錯誤:{message}",
"exportingPleaseWait": "匯出中…請稍候", "exportingPleaseWait": "匯出中…請稍候",
@ -23,6 +25,7 @@
"image": "圖片", "image": "圖片",
"invalidOrUnsupportedFile": "無效或不支援的檔案", "invalidOrUnsupportedFile": "無效或不支援的檔案",
"language": "語言", "language": "語言",
"lock": "鎖定",
"loadSignatureFromFile": "從檔案載入簽名", "loadSignatureFromFile": "從檔案載入簽名",
"lockAspectRatio": "鎖定長寬比", "lockAspectRatio": "鎖定長寬比",
"longPressOrRightClickTheSignatureToConfirmOrDelete": "長按或右鍵點擊簽名以確認或刪除。", "longPressOrRightClickTheSignatureToConfirmOrDelete": "長按或右鍵點擊簽名以確認或刪除。",
@ -46,5 +49,11 @@
"themeDark": "深色", "themeDark": "深色",
"themeLight": "淺色", "themeLight": "淺色",
"themeSystem": "系統", "themeSystem": "系統",
"undo": "復原" "themeColor": "主題顏色",
"themeColorBlue": "藍色",
"themeColorGreen": "綠色",
"themeColorRed": "紅色",
"themeColorPurple": "紫色",
"undo": "復原",
"unlock": "解鎖"
} }

View File

@ -1,5 +1,15 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:pdf_signature/app.dart'; import 'package:pdf_signature/app.dart';
export 'package:pdf_signature/app.dart'; export 'package:pdf_signature/app.dart';
void main() => runApp(const MyApp()); void main() {
// Ensure Flutter bindings are initialized before platform channel usage
WidgetsFlutterBinding.ensureInitialized();
// Disable right-click context menu on web using Flutter API
if (kIsWeb) {
BrowserContextMenu.disableContextMenu();
}
runApp(const MyApp());
}

59
lib/routing/router.dart Normal file
View File

@ -0,0 +1,59 @@
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:pdf_signature/ui/features/pdf/widgets/pdf_screen.dart';
import 'package:pdf_signature/ui/features/welcome/widgets/welcome_screen.dart';
import 'package:pdf_signature/data/repositories/document_repository.dart';
import 'package:pdf_signature/ui/features/pdf/view_model/pdf_view_model.dart';
// PdfManager removed: responsibilities moved into PdfSessionViewModel.
final routerProvider = Provider<GoRouter>((ref) {
// Determine initial location based on current document state.
// Access the state via the provider (not via the notifier's protected .state).
final docState = ref.read(documentRepositoryProvider);
final initialLocation = docState.loaded ? '/pdf' : '/';
// Session view model will be obtained inside each route builder; no shared
// late variable (avoids LateInitializationError on rebuilds).
final navigatorKey = GlobalKey<NavigatorState>();
late final GoRouter router; // declare before use in builders
router = GoRouter(
navigatorKey: navigatorKey,
routes: [
GoRoute(
path: '/',
builder: (context, state) {
final sessionVm = ref.read(pdfSessionViewModelProvider(router));
return WelcomeScreen(
onPickPdf: () => sessionVm.pickAndOpenPdf(),
onOpenPdf:
({String? path, Uint8List? bytes, String? fileName}) =>
sessionVm.openPdf(
path: path,
bytes: bytes,
fileName: fileName,
),
);
},
),
GoRoute(
path: '/pdf',
builder: (context, state) {
final sessionVm = ref.read(pdfSessionViewModelProvider(router));
return PdfSignatureHomePage(
onPickPdf: () => sessionVm.pickAndOpenPdf(),
onClosePdf: () => sessionVm.closePdf(),
currentFile: sessionVm.currentFile,
currentFileName: sessionVm.displayFileName,
);
},
),
],
initialLocation: initialLocation,
);
return router;
});

View File

@ -1,14 +0,0 @@
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

@ -0,0 +1,77 @@
import 'package:file_selector/file_selector.dart' as fs;
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:pdf_signature/data/services/export_service.dart';
/// ViewModel for export-related UI state and helpers.
class PdfExportViewModel extends ChangeNotifier {
final Ref ref;
bool _exporting = false;
// Dependencies (injectable via constructor for tests)
final ExportService _exporter;
// Zero-arg picker retained for backward compatibility with tests.
final Future<String?> Function() _savePathPicker;
// Preferred picker that accepts a suggested filename.
final Future<String?> Function(String suggestedName)
_savePathPickerWithSuggestedName;
PdfExportViewModel(
this.ref, {
ExportService? exporter,
Future<String?> Function()? savePathPicker,
Future<String?> Function(String suggestedName)?
savePathPickerWithSuggestedName,
}) : _exporter = exporter ?? ExportService(),
_savePathPicker = savePathPicker ?? _defaultSavePathPicker,
// Prefer provided suggested-name picker; otherwise, if only zero-arg
// picker is given (tests), wrap it; else use default that honors name.
_savePathPickerWithSuggestedName =
savePathPickerWithSuggestedName ??
(savePathPicker != null
? ((_) => savePathPicker())
: _defaultSavePathPickerWithSuggestedName);
bool get exporting => _exporting;
void setExporting(bool value) {
if (_exporting == value) return;
_exporting = value;
notifyListeners();
}
/// Get the export service (overridable in tests via constructor).
ExportService get exporter => _exporter;
/// Show save dialog and return the chosen path (null if canceled).
Future<String?> pickSavePath() async {
return _savePathPicker();
}
/// Show save dialog with a suggested name and return the chosen path.
Future<String?> pickSavePathWithSuggestedName(String suggestedName) async {
return _savePathPickerWithSuggestedName(suggestedName);
}
static Future<String?> _defaultSavePathPicker() async {
return _defaultSavePathPickerWithSuggestedName('signed.pdf');
}
static Future<String?> _defaultSavePathPickerWithSuggestedName(
String suggestedName,
) async {
final group = fs.XTypeGroup(label: 'PDF', extensions: ['pdf']);
final location = await fs.getSaveLocation(
acceptedTypeGroups: [group],
suggestedName: suggestedName,
confirmButtonText: 'Save',
);
return location?.path; // null if user cancels
}
}
final pdfExportViewModelProvider = ChangeNotifierProvider<PdfExportViewModel>((
ref,
) {
return PdfExportViewModel(ref);
});

View File

@ -0,0 +1,334 @@
import 'dart:typed_data';
import 'package:file_selector/file_selector.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:pdf_signature/data/repositories/document_repository.dart';
import 'package:pdf_signature/data/repositories/signature_card_repository.dart';
import 'package:pdf_signature/domain/models/model.dart';
import 'package:pdfrx/pdfrx.dart';
import 'package:file_selector/file_selector.dart' as fs;
import 'package:go_router/go_router.dart';
class PdfViewModel extends ChangeNotifier {
final Ref ref;
PdfViewerController _controller = PdfViewerController();
PdfViewerController get controller => _controller;
int _currentPage = 1;
late final bool _useMockViewer;
bool _isDisposed = false;
// Active rect for signature placement overlay
Rect? _activeRect;
Rect? get activeRect => _activeRect;
set activeRect(Rect? value) {
_activeRect = value;
if (!_isDisposed) {
notifyListeners();
}
}
// Locked placements: Set of (page, index) tuples
final Set<String> _lockedPlacements = {};
Set<String> get lockedPlacements => Set.unmodifiable(_lockedPlacements);
// const bool.fromEnvironment('FLUTTER_TEST', defaultValue: false);
PdfViewModel(this.ref, {bool? useMockViewer})
: _useMockViewer =
useMockViewer ??
const bool.fromEnvironment('FLUTTER_TEST', defaultValue: false);
bool get useMockViewer => _useMockViewer;
int get currentPage => _currentPage;
set currentPage(int value) {
_currentPage = value.clamp(1, document.pageCount);
if (!_isDisposed) {
notifyListeners();
}
}
// Do not watch the document repository here; watching would cause this
// ChangeNotifier to be disposed/recreated on every document change, which
// resets transient UI state like locked placements. Read instead.
Document get document => ref.read(documentRepositoryProvider);
void jumpToPage(int page) {
currentPage = page;
}
// Make this view model "int-like" for tests that compare it directly to an
// integer or use it as a Map key for page lookups.
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
if (other is int) {
return other == currentPage;
}
return false;
}
@override
int get hashCode => currentPage.hashCode;
// Allow repositories to request a UI refresh without mutating provider state
void notifyPlacementsChanged() {
if (!_isDisposed) {
notifyListeners();
}
}
// Document repository methods
// Lifecycle (open/close) removed: handled exclusively by PdfSessionViewModel.
void setPageCount(int count) {
ref.read(documentRepositoryProvider.notifier).setPageCount(count);
}
void addPlacement({
required int page,
required Rect rect,
SignatureAsset? asset,
double rotationDeg = 0.0,
GraphicAdjust? graphicAdjust,
}) {
ref
.read(documentRepositoryProvider.notifier)
.addPlacement(
page: page,
rect: rect,
asset: asset,
rotationDeg: rotationDeg,
graphicAdjust: graphicAdjust,
);
}
void updatePlacementRotation({
required int page,
required int index,
required double rotationDeg,
}) {
ref
.read(documentRepositoryProvider.notifier)
.updatePlacementRotation(
page: page,
index: index,
rotationDeg: rotationDeg,
);
}
void removePlacement({required int page, required int index}) {
ref
.read(documentRepositoryProvider.notifier)
.removePlacement(page: page, index: index);
// Also remove from locked placements if it was locked
_lockedPlacements.remove(_placementKey(page, index));
if (!_isDisposed) {
notifyListeners();
}
}
void updatePlacementRect({
required int page,
required int index,
required Rect rect,
}) {
ref
.read(documentRepositoryProvider.notifier)
.updatePlacementRect(page: page, index: index, rect: rect);
}
List<SignaturePlacement> placementsOn(int page) {
return ref.read(documentRepositoryProvider.notifier).placementsOn(page);
}
SignatureAsset? assetOfPlacement({required int page, required int index}) {
return ref
.read(documentRepositoryProvider.notifier)
.assetOfPlacement(page: page, index: index);
}
// Helper method to create a unique key for a placement
String _placementKey(int page, int index) => '${page}_${index}';
// Check if a placement is locked
bool isPlacementLocked({required int page, required int index}) {
return _lockedPlacements.contains(_placementKey(page, index));
}
// Lock a placement
void lockPlacement({required int page, required int index}) {
_lockedPlacements.add(_placementKey(page, index));
if (!_isDisposed) {
notifyListeners();
}
}
// Unlock a placement
void unlockPlacement({required int page, required int index}) {
_lockedPlacements.remove(_placementKey(page, index));
if (!_isDisposed) {
notifyListeners();
}
}
// Toggle lock state of a placement
void togglePlacementLock({required int page, required int index}) {
if (isPlacementLocked(page: page, index: index)) {
unlockPlacement(page: page, index: index);
} else {
lockPlacement(page: page, index: index);
}
}
Future<void> exportDocument({
required String outputPath,
required Size uiPageSize,
required Uint8List? signatureImageBytes,
}) async {
await ref
.read(documentRepositoryProvider.notifier)
.exportDocument(
outputPath: outputPath,
uiPageSize: uiPageSize,
signatureImageBytes: signatureImageBytes,
);
}
// Signature card repository methods
List<SignatureCard> get signatureCards =>
ref.read(signatureCardRepositoryProvider);
void addSignatureCard(SignatureCard card) {
ref.read(signatureCardRepositoryProvider.notifier).add(card);
}
void addSignatureCardWithAsset(SignatureAsset asset, double rotationDeg) {
ref
.read(signatureCardRepositoryProvider.notifier)
.addWithAsset(asset, rotationDeg);
}
void updateSignatureCard(
SignatureCard card,
double? rotationDeg,
GraphicAdjust? graphicAdjust,
) {
ref
.read(signatureCardRepositoryProvider.notifier)
.update(card, rotationDeg, graphicAdjust);
}
void removeSignatureCard(SignatureCard card) {
ref.read(signatureCardRepositoryProvider.notifier).remove(card);
}
void clearAllSignatureCards() {
ref.read(signatureCardRepositoryProvider.notifier).clearAll();
}
@override
void dispose() {
_isDisposed = true;
super.dispose();
}
}
final pdfViewModelProvider = ChangeNotifierProvider<PdfViewModel>((ref) {
return PdfViewModel(ref);
});
/// ViewModel managing PDF session lifecycle (file picking/open/close) and
/// navigation. Replaces the previous PdfManager helper.
class PdfSessionViewModel extends ChangeNotifier {
final Ref ref;
final GoRouter router;
fs.XFile _currentFile = fs.XFile('');
// Keep a human display name in addition to XFile, because on Linux via
// xdg-desktop-portal the path can look like /run/user/.../doc/<UUID>, and
// XFile.name derives from that basename, yielding a random UUID instead of
// the actual filename the user selected. We preserve the picker/drop name
// here to offer a sensible default like "signed_<original>.pdf".
String _displayFileName = '';
PdfSessionViewModel({required this.ref, required this.router});
fs.XFile get currentFile => _currentFile;
String get displayFileName => _displayFileName;
Future<void> pickAndOpenPdf() async {
final typeGroup = const fs.XTypeGroup(label: 'PDF', extensions: ['pdf']);
final XFile? file = await fs.openFile(acceptedTypeGroups: [typeGroup]);
if (file != null) {
Uint8List? bytes;
try {
bytes = await file.readAsBytes();
} catch (_) {
bytes = null;
}
await openPdf(path: file.path, bytes: bytes, fileName: file.name);
}
}
Future<void> openPdf({
String? path,
Uint8List? bytes,
String? fileName,
}) async {
int pageCount = 1; // default
if (bytes != null) {
try {
final doc = await PdfDocument.openData(bytes);
pageCount = doc.pages.length;
} catch (_) {
// ignore invalid bytes
}
}
if (path != null && path.isNotEmpty) {
_currentFile = fs.XFile(path);
} else if (bytes != null && (fileName != null && fileName.isNotEmpty)) {
// Keep in-memory XFile so .name is available for suggestion
try {
_currentFile = fs.XFile.fromData(
bytes,
name: fileName,
mimeType: 'application/pdf',
);
} catch (_) {
_currentFile = fs.XFile(fileName);
}
} else {
_currentFile = fs.XFile('');
}
// Update display name: prefer explicit fileName (from picker/drop),
// fall back to basename of path, otherwise empty.
if (fileName != null && fileName.isNotEmpty) {
_displayFileName = fileName;
} else if (path != null && path.isNotEmpty) {
_displayFileName = path.split('/').last.split('\\').last;
} else {
_displayFileName = '';
}
ref
.read(documentRepositoryProvider.notifier)
.openPicked(pageCount: pageCount, bytes: bytes);
ref.read(signatureCardRepositoryProvider.notifier).clearAll();
router.go('/pdf');
notifyListeners();
}
void closePdf() {
ref.read(documentRepositoryProvider.notifier).close();
ref.read(signatureCardRepositoryProvider.notifier).clearAll();
_currentFile = fs.XFile('');
_displayFileName = '';
router.go('/');
notifyListeners();
}
}
final pdfSessionViewModelProvider =
ChangeNotifierProvider.family<PdfSessionViewModel, GoRouter>((ref, router) {
return PdfSessionViewModel(ref: ref, router: router);
});

View File

@ -1,17 +1,30 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:pdf_signature/l10n/app_localizations.dart'; import 'package:pdf_signature/l10n/app_localizations.dart';
import '../../../../data/model/model.dart'; class AdjustmentsPanel extends StatelessWidget {
import '../../signature/view_model/signature_controller.dart'; const AdjustmentsPanel({
super.key,
required this.aspectLocked,
required this.bgRemoval,
required this.contrast,
required this.brightness,
required this.onAspectLockedChanged,
required this.onBgRemovalChanged,
required this.onContrastChanged,
required this.onBrightnessChanged,
});
class AdjustmentsPanel extends ConsumerWidget { final bool aspectLocked;
const AdjustmentsPanel({super.key, required this.sig}); final bool bgRemoval;
final double contrast;
final SignatureState sig; final double brightness;
final ValueChanged<bool> onAspectLockedChanged;
final ValueChanged<bool> onBgRemovalChanged;
final ValueChanged<double> onContrastChanged;
final ValueChanged<double> onBrightnessChanged;
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context) {
return Column( return Column(
key: const Key('adjustments_panel'), key: const Key('adjustments_panel'),
children: [ children: [
@ -20,21 +33,10 @@ class AdjustmentsPanel extends ConsumerWidget {
runSpacing: 8, runSpacing: 8,
crossAxisAlignment: WrapCrossAlignment.center, crossAxisAlignment: WrapCrossAlignment.center,
children: [ children: [
Checkbox(
key: const Key('chk_aspect_lock'),
value: sig.aspectLocked,
onChanged:
(v) => ref
.read(signatureProvider.notifier)
.toggleAspect(v ?? false),
),
Text(AppLocalizations.of(context).lockAspectRatio),
const SizedBox(width: 16),
Switch( Switch(
key: const Key('swt_bg_removal'), key: const Key('swt_bg_removal'),
value: sig.bgRemoval, value: bgRemoval,
onChanged: onChanged: (v) => onBgRemovalChanged(v),
(v) => ref.read(signatureProvider.notifier).setBgRemoval(v),
), ),
Text(AppLocalizations.of(context).backgroundRemoval), Text(AppLocalizations.of(context).backgroundRemoval),
], ],
@ -47,15 +49,14 @@ class AdjustmentsPanel extends ConsumerWidget {
Text(AppLocalizations.of(context).contrast), Text(AppLocalizations.of(context).contrast),
Align( Align(
alignment: Alignment.centerRight, alignment: Alignment.centerRight,
child: Text(sig.contrast.toStringAsFixed(2)), child: Text(contrast.toStringAsFixed(2)),
), ),
Slider( Slider(
key: const Key('sld_contrast'), key: const Key('sld_contrast'),
min: 0.0, min: 0.0,
max: 2.0, max: 2.0,
value: sig.contrast, value: contrast,
onChanged: onChanged: onContrastChanged,
(v) => ref.read(signatureProvider.notifier).setContrast(v),
), ),
], ],
), ),
@ -66,15 +67,14 @@ class AdjustmentsPanel extends ConsumerWidget {
Text(AppLocalizations.of(context).brightness), Text(AppLocalizations.of(context).brightness),
Align( Align(
alignment: Alignment.centerRight, alignment: Alignment.centerRight,
child: Text(sig.brightness.toStringAsFixed(2)), child: Text(brightness.toStringAsFixed(2)),
), ),
Slider( Slider(
key: const Key('sld_brightness'), key: const Key('sld_brightness'),
min: -1.0, min: 0.0,
max: 1.0, max: 2.0,
value: sig.brightness, value: brightness,
onChanged: onChanged: onBrightnessChanged,
(v) => ref.read(signatureProvider.notifier).setBrightness(v),
), ),
], ],
), ),

View File

@ -48,22 +48,25 @@ class _DrawCanvasState extends State<DrawCanvas> {
ElevatedButton( ElevatedButton(
key: const Key('btn_canvas_confirm'), key: const Key('btn_canvas_confirm'),
onPressed: () async { onPressed: () async {
// Export signature to PNG bytes // Export signature to PNG bytes first
final data = await _control.toImage( final byteData = await _control.toImage(
width: 512,
height: 256,
fit: true,
color: Colors.black, color: Colors.black,
background: Colors.transparent, background: Colors.transparent,
fit: true,
width: 1024,
height: 512,
); );
final bytes = data?.buffer.asUint8List(); final bytes = byteData?.buffer.asUint8List();
widget.debugBytesSink?.value = bytes; widget.debugBytesSink?.value = bytes;
// Handle callbacks and navigation
if (widget.onConfirm != null) { if (widget.onConfirm != null) {
widget.onConfirm!(bytes); widget.onConfirm!(bytes);
} else {
if (context.mounted) {
Navigator.of(context).pop(bytes);
} }
// Close the canvas
if (mounted && Navigator.canPop(context)) {
Navigator.of(context).pop(bytes);
} }
}, },
child: Text(l.confirm), child: Text(l.confirm),
@ -85,7 +88,10 @@ class _DrawCanvasState extends State<DrawCanvas> {
const SizedBox(height: 8), const SizedBox(height: 8),
SizedBox( SizedBox(
key: const Key('draw_canvas'), key: const Key('draw_canvas'),
height: math.max(MediaQuery.of(context).size.height * 0.6, 350), height: math.min(
math.max(MediaQuery.of(context).size.height * 0.6, 350),
MediaQuery.of(context).size.height * 0.8,
),
child: AspectRatio( child: AspectRatio(
aspectRatio: 10 / 3, aspectRatio: 10 / 3,
child: Container( child: Container(

View File

@ -1,106 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:pdf_signature/l10n/app_localizations.dart';
import '../../signature/view_model/signature_controller.dart';
import 'adjustments_panel.dart';
import '../../signature/widgets/rotated_signature_image.dart';
class ImageEditorDialog extends ConsumerWidget {
const ImageEditorDialog({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final l10n = Localizations.of<AppLocalizations>(context, AppLocalizations)!;
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 RotatedSignatureImage(
bytes: bytes,
rotationDeg: sig.rotation,
enableAngleAwareScale: true,
fit: BoxFit.contain,
wrapInRepaintBoundary: true,
);
},
),
),
),
),
const SizedBox(height: 12),
// Adjustments
AdjustmentsPanel(sig: sig),
const SizedBox(height: 8),
Row(
children: [
Text(l10n.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

@ -1,11 +1,129 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'pdf_pages_overview.dart'; import 'package:pdfrx/pdfrx.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../view_model/pdf_view_model.dart';
class ThumbnailsView extends ConsumerWidget {
const ThumbnailsView({
super.key,
required this.documentRef,
required this.controller,
required this.currentPage,
});
final PdfDocumentRefData documentRef;
final PdfViewerController controller;
final int currentPage;
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
// Access view model to detect mock viewer mode
final viewModel = ref.read(pdfViewModelProvider);
return Container(
color: theme.colorScheme.surface,
child: PdfDocumentViewBuilder(
documentRef: documentRef,
builder: (context, document) {
final pageCount = document?.pages.length ?? 0;
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 = currentPage == pageNumber;
return InkWell(
onTap: () {
// For real viewer: navigate first and wait for onPageChanged
// to update provider when the page is actually reached.
// For mock/unready: update provider immediately to drive scroll.
final isRealViewer = !viewModel.useMockViewer;
if (isRealViewer && controller.isReady) {
controller.goToPage(
pageNumber: pageNumber,
anchor: PdfPageAnchor.top,
);
// Do not set provider here; let onPageChanged handle it
} else {
// In tests or when controller isn't ready, drive state directly
try {
ref
.read(pdfViewModelProvider.notifier)
.jumpToPage(pageNumber);
} catch (_) {}
}
},
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: Column(
children: [
SizedBox(
height: 180,
child: ClipRRect(
borderRadius: BorderRadius.circular(4),
child: PdfPageView(
document: document,
pageNumber: pageNumber,
alignment: Alignment.center,
),
),
),
const SizedBox(height: 4),
Text('$pageNumber', style: theme.textTheme.bodySmall),
],
),
),
),
);
},
);
},
),
);
}
}
class PagesSidebar extends StatelessWidget { class PagesSidebar extends StatelessWidget {
const PagesSidebar({super.key}); const PagesSidebar({
super.key,
required this.documentRef,
required this.controller,
required this.currentPage,
});
final PdfDocumentRefData? documentRef;
final PdfViewerController controller;
final int currentPage;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Card(margin: EdgeInsets.zero, child: const PdfPagesOverview()); if (documentRef == null) {
return Card(margin: EdgeInsets.zero, child: const SizedBox.shrink());
}
return Card(
margin: EdgeInsets.zero,
child: ThumbnailsView(
documentRef: documentRef!,
controller: controller,
currentPage: currentPage,
),
);
} }
} }

View File

@ -3,11 +3,15 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:pdf_signature/l10n/app_localizations.dart'; import 'package:pdf_signature/l10n/app_localizations.dart';
import '../../../../data/services/export_providers.dart';
import 'pdf_page_overlays.dart'; import 'pdf_page_overlays.dart';
import 'package:pdf_signature/data/repositories/signature_asset_repository.dart';
// using only adjusted overlay, no direct model imports needed
import '../../signature/widgets/signature_drag_data.dart';
import '../view_model/pdf_view_model.dart';
/// Mocked continuous viewer for tests or platforms without real viewer. /// Mocked continuous viewer for tests or platforms without real viewer.
class PdfMockContinuousList extends ConsumerWidget { @visibleForTesting
class PdfMockContinuousList extends ConsumerStatefulWidget {
const PdfMockContinuousList({ const PdfMockContinuousList({
super.key, super.key,
required this.pageSize, required this.pageSize,
@ -37,14 +41,27 @@ class PdfMockContinuousList extends ConsumerWidget {
final ValueChanged<int?>? onSelectPlaced; final ValueChanged<int?>? onSelectPlaced;
@override @override
Widget build(BuildContext context, WidgetRef ref) { ConsumerState<PdfMockContinuousList> createState() =>
_PdfMockContinuousListState();
}
class _PdfMockContinuousListState extends ConsumerState<PdfMockContinuousList> {
Rect _activeRect = const Rect.fromLTWH(0.2, 0.2, 0.3, 0.15); // normalized
@override
Widget build(BuildContext context) {
final pageSize = widget.pageSize;
final count = widget.count;
final pageKeyBuilder = widget.pageKeyBuilder;
final pendingPage = widget.pendingPage;
final scrollToPage = widget.scrollToPage;
final clearPending = widget.clearPending;
final assets = ref.watch(signatureAssetRepositoryProvider);
if (pendingPage != null) { if (pendingPage != null) {
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
final p = pendingPage; final p = pendingPage;
if (p != null) {
clearPending?.call(); clearPending?.call();
scheduleMicrotask(() => scrollToPage(p)); scheduleMicrotask(() => scrollToPage(p));
}
}); });
} }
@ -63,8 +80,51 @@ class PdfMockContinuousList extends ConsumerWidget {
child: Stack( child: Stack(
key: ValueKey('page_stack_$pageNum'), key: ValueKey('page_stack_$pageNum'),
children: [ children: [
Container( DragTarget<SignatureDragData>(
color: Colors.grey.shade200, onAcceptWithDetails: (details) {
final dragData = details.data;
final offset = details.offset;
final renderBox =
context.findRenderObject() as RenderBox?;
if (renderBox != null) {
final localPosition = renderBox.globalToLocal(offset);
final normalizedX =
localPosition.dx / renderBox.size.width;
final normalizedY =
localPosition.dy / renderBox.size.height;
// Create a default rect for the signature (can be adjusted later)
final rect = Rect.fromLTWH(
(normalizedX - 0.1).clamp(
0.0,
0.8,
), // Center horizontally with some margin
(normalizedY - 0.05).clamp(
0.0,
0.9,
), // Center vertically with some margin
0.2, // Default width
0.1, // Default height
);
// Add placement to the document
ref
.read(pdfViewModelProvider.notifier)
.addPlacement(
page: pageNum,
rect: rect,
asset: dragData.card.asset,
rotationDeg: dragData.card.rotationDeg,
graphicAdjust: dragData.card.graphicAdjust,
);
}
},
builder: (context, candidateData, rejectedData) {
return Container(
color:
candidateData.isNotEmpty
? Colors.blue.withValues(alpha: 0.3)
: Colors.grey.shade200,
child: Center( child: Center(
child: Builder( child: Builder(
builder: (ctx) { builder: (ctx) {
@ -86,25 +146,80 @@ class PdfMockContinuousList extends ConsumerWidget {
}, },
), ),
), ),
);
},
), ),
Consumer( Stack(
builder: (context, ref, _) { children: [
final visible = ref.watch(signatureVisibilityProvider); PdfPageOverlays(
return visible
? PdfPageOverlays(
pageSize: pageSize, pageSize: pageSize,
pageNumber: pageNum, pageNumber: pageNum,
onDragSignature: onDragSignature, onDragSignature: widget.onDragSignature,
onResizeSignature: onResizeSignature, onResizeSignature: widget.onResizeSignature,
onConfirmSignature: onConfirmSignature, onConfirmSignature: widget.onConfirmSignature,
onClearActiveOverlay: onClearActiveOverlay, onClearActiveOverlay: widget.onClearActiveOverlay,
onSelectPlaced: onSelectPlaced, onSelectPlaced: widget.onSelectPlaced,
) ),
: const SizedBox.shrink(); // For tests expecting an active overlay, draw a mock
// overlay on page 1 when library has at least one asset
if (pageNum == 1 && assets.isNotEmpty)
LayoutBuilder(
builder: (context, constraints) {
final left =
_activeRect.left * constraints.maxWidth;
final top =
_activeRect.top * constraints.maxHeight;
final width =
_activeRect.width * constraints.maxWidth;
final height =
_activeRect.height * constraints.maxHeight;
// Publish rect for tests/other UI to observe
return Stack(
children: [
Positioned(
left: left,
top: top,
width: width,
height: height,
child: GestureDetector(
key: const Key('signature_overlay'),
// Removed onPanUpdate to allow scrolling
child: DecoratedBox(
decoration: BoxDecoration(
border: Border.all(
color: Colors.red,
width: 2,
),
),
child: const SizedBox.expand(),
),
),
),
// resize handle bottom-right
Positioned(
left: left + width - 14,
top: top + height - 14,
width: 14,
height: 14,
child: GestureDetector(
key: const Key('signature_handle'),
// Removed onPanUpdate to allow scrolling
child: DecoratedBox(
decoration: BoxDecoration(
color: Colors.white,
border: Border.all(color: Colors.red),
),
),
),
),
],
);
}, },
), ),
], ],
), ),
],
),
), ),
), ),
); );

View File

@ -1,42 +1,30 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:pdf_signature/l10n/app_localizations.dart'; import 'package:pdf_signature/l10n/app_localizations.dart';
import 'package:pdfrx/pdfrx.dart'; // Real viewer removed in migration; mock continuous list is used in tests.
import '../../../../data/services/export_providers.dart'; import 'pdf_viewer_widget.dart';
import '../../signature/view_model/signature_controller.dart'; import 'package:pdfrx/pdfrx.dart';
import '../view_model/pdf_controller.dart'; import '../view_model/pdf_view_model.dart';
import '../../signature/widgets/signature_drag_data.dart';
import 'pdf_mock_continuous_list.dart';
import 'pdf_page_overlays.dart';
class PdfPageArea extends ConsumerStatefulWidget { class PdfPageArea extends ConsumerStatefulWidget {
const PdfPageArea({ const PdfPageArea({
super.key, super.key,
required this.pageSize, required this.pageSize,
required this.onDragSignature, required this.controller,
required this.onResizeSignature,
required this.onConfirmSignature,
required this.onClearActiveOverlay,
required this.onSelectPlaced,
this.viewerController,
}); });
final Size pageSize; final Size pageSize;
final PdfViewerController? viewerController; final PdfViewerController controller;
final ValueChanged<Offset> onDragSignature;
final ValueChanged<Offset> onResizeSignature;
final VoidCallback onConfirmSignature;
final VoidCallback onClearActiveOverlay;
final ValueChanged<int?> onSelectPlaced;
@override @override
ConsumerState<PdfPageArea> createState() => _PdfPageAreaState(); ConsumerState<PdfPageArea> createState() => _PdfPageAreaState();
} }
class _PdfPageAreaState extends ConsumerState<PdfPageArea> { class _PdfPageAreaState extends ConsumerState<PdfPageArea> {
final Map<int, GlobalKey> _pageKeys = {}; final Map<int, GlobalKey> _pageKeys = {};
late final PdfViewerController _viewerController = // Real viewer controller removed; keep placeholder for API compatibility
widget.viewerController ?? PdfViewerController(); // ignore: unused_field
late final Object _viewerController = Object();
// Guards to avoid scroll feedback between provider and viewer // Guards to avoid scroll feedback between provider and viewer
int? _programmaticTargetPage; int? _programmaticTargetPage;
bool _suppressProviderListen = false; bool _suppressProviderListen = false;
@ -44,6 +32,7 @@ class _PdfPageAreaState extends ConsumerState<PdfPageArea> {
int? _pendingPage; // pending target for mock ensureVisible retry int? _pendingPage; // pending target for mock ensureVisible retry
int _scrollRetryCount = 0; int _scrollRetryCount = 0;
static const int _maxScrollRetries = 50; static const int _maxScrollRetries = 50;
int? _lastListenedPage;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@ -51,10 +40,7 @@ class _PdfPageAreaState extends ConsumerState<PdfPageArea> {
// is instructed to align to the provider's current page once ready. // is instructed to align to the provider's current page once ready.
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return; if (!mounted) return;
final pdf = ref.read(pdfProvider); // initial scroll not needed; controller handles positioning
if (pdf.pickedPdfPath != null && pdf.loaded) {
_scrollToPage(pdf.currentPage);
}
}); });
} }
@ -68,46 +54,8 @@ class _PdfPageAreaState extends ConsumerState<PdfPageArea> {
void _scrollToPage(int page) { void _scrollToPage(int page) {
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return; 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; _programmaticTargetPage = page;
// print("[DEBUG] viewerController Scrolling to page $page"); // Mock continuous: try ensureVisible on the page container
_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 // Mock continuous: try ensureVisible on the page container
final ctx = _pageKey(page).currentContext; final ctx = _pageKey(page).currentContext;
if (ctx != null) { if (ctx != null) {
@ -127,6 +75,8 @@ class _PdfPageAreaState extends ConsumerState<PdfPageArea> {
.clamp(position.minScrollExtent, position.maxScrollExtent) .clamp(position.minScrollExtent, position.maxScrollExtent)
.toDouble(); .toDouble();
position.jumpTo(newPixels); position.jumpTo(newPixels);
_visiblePage = page;
_programmaticTargetPage = null;
return; return;
} }
} catch (_) { } catch (_) {
@ -137,6 +87,8 @@ class _PdfPageAreaState extends ConsumerState<PdfPageArea> {
duration: Duration.zero, duration: Duration.zero,
curve: Curves.linear, curve: Curves.linear,
); );
_visiblePage = page;
_programmaticTargetPage = null;
return; return;
} }
return; return;
@ -156,24 +108,23 @@ class _PdfPageAreaState extends ConsumerState<PdfPageArea> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final pdf = ref.watch(pdfProvider); final pdfViewModel = ref.watch(pdfViewModelProvider);
final pdf = pdfViewModel.document;
const pageViewMode = 'continuous'; const pageViewMode = 'continuous';
// React to PdfViewModel currentPage changes. With ChangeNotifierProvider,
// React to provider currentPage changes (e.g., user tapped overview) // prev/next are the same instance, so compare to a local cache.
ref.listen(pdfProvider, (prev, next) { ref.listen(pdfViewModelProvider, (prev, next) {
if (_suppressProviderListen) return; if (_suppressProviderListen) return;
if ((prev?.currentPage != next.currentPage)) {
final target = next.currentPage; final target = next.currentPage;
// If we're already navigating to this target, ignore; otherwise allow new target. if (_lastListenedPage == target) return;
_lastListenedPage = target;
if (_programmaticTargetPage != null && if (_programmaticTargetPage != null &&
_programmaticTargetPage == target) { _programmaticTargetPage == target) {
return; return;
} }
// Only navigate if target differs from what viewer shows
if (_visiblePage != target) { if (_visiblePage != target) {
_scrollToPage(target); _scrollToPage(target);
} }
}
}); });
// No page view mode switching; always continuous. // No page view mode switching; always continuous.
@ -188,182 +139,17 @@ class _PdfPageAreaState extends ConsumerState<PdfPageArea> {
return Center(child: Text(text)); return Center(child: Text(text));
} }
final useMock = ref.watch(useMockViewerProvider);
final isContinuous = pageViewMode == 'continuous'; final isContinuous = pageViewMode == 'continuous';
// Mock continuous: ListView with prebuilt children, no controller // Use real PDF viewer
if (useMock && isContinuous) { if (isContinuous) {
final count = pdf.pageCount > 0 ? pdf.pageCount : 1; return PdfViewerWidget(
return PdfMockContinuousList(
pageSize: widget.pageSize, pageSize: widget.pageSize,
count: count,
pageKeyBuilder: _pageKey, pageKeyBuilder: _pageKey,
scrollToPage: _scrollToPage, scrollToPage: _scrollToPage,
pendingPage: _pendingPage, controller: widget.controller,
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 visible = ref.watch(signatureVisibilityProvider);
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,
),
),
);
},
),
];
},
// 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(); return const SizedBox.shrink();
} }
} }

View File

@ -1,10 +1,12 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:pdf_signature/ui/features/pdf/view_model/pdf_view_model.dart';
import 'package:pdf_signature/data/repositories/document_repository.dart';
import '../../signature/view_model/signature_controller.dart'; import '../../../../domain/models/model.dart';
import '../../../../data/model/model.dart';
import '../view_model/pdf_controller.dart';
import 'signature_overlay.dart'; import 'signature_overlay.dart';
import '../../signature/widgets/signature_drag_data.dart';
import '../../signature/view_model/dragging_signature_view_model.dart';
/// Builds all overlays for a given page: placed signatures and the active one. /// Builds all overlays for a given page: placed signatures and the active one.
class PdfPageOverlays extends ConsumerWidget { class PdfPageOverlays extends ConsumerWidget {
@ -29,46 +31,118 @@ class PdfPageOverlays extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final pdf = ref.watch(pdfProvider); final pdfViewModel = ref.watch(pdfViewModelProvider);
final sig = ref.watch(signatureProvider); // Subscribe to document changes to rebuild overlays
final pdf = ref.watch(documentRepositoryProvider);
final placed = final placed =
pdf.placementsByPage[pageNumber] ?? const <SignaturePlacement>[]; pdf.placementsByPage[pageNumber] ?? const <SignaturePlacement>[];
final activeRect = pdfViewModel.activeRect;
final widgets = <Widget>[]; final widgets = <Widget>[];
// Base DragTarget filling the whole page to accept drops from signature cards.
widgets.add(
// Use a Positioned.fill inside a LayoutBuilder to compute normalized coordinates.
Positioned.fill(
child: LayoutBuilder(
builder: (context, constraints) {
final isDragging = ref.watch(isDraggingSignatureViewModelProvider);
// Only activate DragTarget hit tests while dragging to preserve wheel scrolling.
final target = DragTarget<SignatureDragData>(
onAcceptWithDetails: (details) {
final box = context.findRenderObject() as RenderBox?;
if (box == null) return;
final local = box.globalToLocal(details.offset);
final w = constraints.maxWidth;
final h = constraints.maxHeight;
if (w <= 0 || h <= 0) return;
final nx = (local.dx / w).clamp(0.0, 1.0);
final ny = (local.dy / h).clamp(0.0, 1.0);
// Default size of the placed signature in normalized units
const defW = 0.2;
const defH = 0.1;
final left = (nx - defW / 2).clamp(0.0, 1.0 - defW);
final top = (ny - defH / 2).clamp(0.0, 1.0 - defH);
final rect = Rect.fromLTWH(left, top, defW, defH);
final d = details.data;
ref
.read(pdfViewModelProvider.notifier)
.addPlacement(
page: pageNumber,
rect: rect,
asset: d.card.asset,
rotationDeg: d.card.rotationDeg,
graphicAdjust: d.card.graphicAdjust,
);
},
builder: (context, candidateData, rejectedData) {
// Visual hint when hovering a draggable over the page.
return DecoratedBox(
decoration: BoxDecoration(
color:
candidateData.isNotEmpty
? Colors.blue.withValues(alpha: 0.12)
: Colors.transparent,
),
child: const SizedBox.expand(),
);
},
);
return IgnorePointer(ignoring: !isDragging, child: target);
},
),
),
);
for (int i = 0; i < placed.length; i++) { for (int i = 0; i < placed.length; i++) {
// Stored as UI-space rects (SignatureController.pageSize). // Stored as UI-space rects (SignatureCardStateNotifier.pageSize).
final uiRect = placed[i].rect; final p = placed[i];
final uiRect = p.rect;
widgets.add( widgets.add(
SignatureOverlay( SignatureOverlay(
pageSize: pageSize, pageSize: pageSize,
rect: uiRect, rect: uiRect,
sig: sig, placement: p,
pageNumber: pageNumber,
interactive: false,
placedIndex: i, placedIndex: i,
onSelectPlaced: onSelectPlaced, pageNumber: pageNumber,
), ),
); );
} }
final showActive = // TODO:Add active overlay if present and not using mock (mock has its own)
sig.rect != null &&
sig.editingEnabled &&
(pdf.signedPage == null || pdf.signedPage == pageNumber) &&
pdf.currentPage == pageNumber;
if (showActive) { final useMock = pdfViewModel.useMockViewer;
if (!useMock &&
activeRect != null &&
pageNumber == pdfViewModel.currentPage) {
widgets.add( widgets.add(
SignatureOverlay( LayoutBuilder(
pageSize: pageSize, builder: (context, constraints) {
rect: sig.rect!, final left = activeRect.left * constraints.maxWidth;
sig: sig, final top = activeRect.top * constraints.maxHeight;
pageNumber: pageNumber, final width = activeRect.width * constraints.maxWidth;
interactive: true, final height = activeRect.height * constraints.maxHeight;
onDragSignature: onDragSignature, return Stack(
onResizeSignature: onResizeSignature, children: [
onConfirmSignature: onConfirmSignature, Positioned(
onClearActiveOverlay: onClearActiveOverlay, left: left,
top: top,
width: width,
height: height,
child: GestureDetector(
key: const Key('signature_overlay'),
// Removed onPanUpdate to allow scrolling
child: DecoratedBox(
decoration: BoxDecoration(
border: Border.all(color: Colors.red, width: 2),
),
child: const SizedBox.expand(),
),
),
),
],
);
},
), ),
); );
} }

View File

@ -1,95 +0,0 @@
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/pdf_controller.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

@ -1,26 +1,39 @@
import 'dart:typed_data';
import 'package:file_selector/file_selector.dart' as fs; import 'package:file_selector/file_selector.dart' as fs;
import 'package:flutter/foundation.dart' show kIsWeb; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:pdf_signature/data/repositories/preferences_repository.dart';
import 'package:pdf_signature/l10n/app_localizations.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 'package:multi_split_view/multi_split_view.dart';
import '../../../../data/services/export_providers.dart'; import 'package:pdfrx/pdfrx.dart';
import 'package:image/image.dart' as img;
import '../../signature/view_model/signature_controller.dart';
import '../view_model/pdf_controller.dart';
import '../../signature/view_model/signature_library.dart';
import 'draw_canvas.dart'; import 'draw_canvas.dart';
import 'pdf_toolbar.dart'; import 'pdf_toolbar.dart';
import 'pdf_page_area.dart'; import 'pdf_page_area.dart';
import 'pages_sidebar.dart'; import 'pages_sidebar.dart';
import 'signatures_sidebar.dart'; import 'signatures_sidebar.dart';
import '../view_model/pdf_export_view_model.dart';
import 'package:pdf_signature/utils/download.dart';
import '../view_model/pdf_view_model.dart';
import 'package:image/image.dart' as img;
class PdfSignatureHomePage extends ConsumerStatefulWidget { class PdfSignatureHomePage extends ConsumerStatefulWidget {
const PdfSignatureHomePage({super.key}); final Future<void> Function() onPickPdf;
final VoidCallback onClosePdf;
final fs.XFile currentFile;
// Optional display name for the currently opened file. On Linux
// xdg-desktop-portal, XFile.name/path can be a UUID-like value. When
// available, this name preserves the user-selected filename so we can
// suggest a proper "signed_*.pdf" on save.
final String? currentFileName;
const PdfSignatureHomePage({
super.key,
required this.onPickPdf,
required this.onClosePdf,
required this.currentFile,
this.currentFileName,
});
@override @override
ConsumerState<PdfSignatureHomePage> createState() => ConsumerState<PdfSignatureHomePage> createState() =>
@ -28,8 +41,7 @@ class PdfSignatureHomePage extends ConsumerStatefulWidget {
} }
class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> { class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
static const Size _pageSize = SignatureController.pageSize; static const Size _pageSize = Size(676, 960 / 1.4142);
final PdfViewerController _viewerController = PdfViewerController();
bool _showPagesSidebar = true; bool _showPagesSidebar = true;
bool _showSignaturesSidebar = true; bool _showSignaturesSidebar = true;
int _zoomLevel = 100; // percentage for display only int _zoomLevel = 100; // percentage for display only
@ -44,33 +56,60 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
final double _pagesMax = 250; final double _pagesMax = 250;
final double _signaturesMin = 140; final double _signaturesMin = 140;
final double _signaturesMax = 250; final double _signaturesMax = 250;
late PdfViewModel _viewModel;
// Exposed for tests to trigger the invalid-file SnackBar without UI. // Exposed for tests to trigger the invalid-file SnackBar without UI.
@visibleForTesting @visibleForTesting
void debugShowInvalidSignatureSnackBar() { void debugShowInvalidSignatureSnackBar() {
ref.read(signatureProvider.notifier).setInvalidSelected(context); ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(AppLocalizations.of(context).invalidOrUnsupportedFile),
),
);
} }
Future<void> _pickPdf() async { Future<void> _pickPdf() async {
final typeGroup = const fs.XTypeGroup(label: 'PDF', extensions: ['pdf']); await widget.onPickPdf();
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();
} }
void _closePdf() {
widget.onClosePdf();
} }
void _jumpToPage(int page) { void _jumpToPage(int page) {
ref.read(pdfProvider.notifier).jumpTo(page); final controller = _viewModel.controller;
final current = _viewModel.currentPage;
final pdf = _viewModel.document;
int target;
if (page == -1) {
target = (current - 1).clamp(1, pdf.pageCount);
} else {
target = page.clamp(1, pdf.pageCount);
}
// Update reactive page providers so UI/tests reflect navigation even if controller is a stub
if (current != target) {
// Also notify view model (if used elsewhere) via its public API
try {
_viewModel.jumpToPage(target);
} catch (_) {
// ignore if provider not available
}
}
if (controller.isReady) controller.goToPage(pageNumber: target);
} }
Future<Uint8List?> _loadSignatureFromFile() async { img.Image? _toStdSignatureImage(img.Image? image) {
if (image == null) return null;
image.convert(numChannels: 4);
// Scale down if height > 256 to improve performance
if (image.height > 256) {
final newWidth = (image.width * 256) ~/ image.height;
image = img.copyResize(image, width: newWidth, height: 256);
}
return image;
}
Future<img.Image?> _loadSignatureFromFile() async {
final typeGroup = fs.XTypeGroup( final typeGroup = fs.XTypeGroup(
label: label:
Localizations.of<AppLocalizations>(context, AppLocalizations)?.image, Localizations.of<AppLocalizations>(context, AppLocalizations)?.image,
@ -79,55 +118,37 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
final file = await fs.openFile(acceptedTypeGroups: [typeGroup]); final file = await fs.openFile(acceptedTypeGroups: [typeGroup]);
if (file == null) return null; if (file == null) return null;
final bytes = await file.readAsBytes(); final bytes = await file.readAsBytes();
final sig = ref.read(signatureProvider.notifier); try {
sig.setImageBytes(bytes); var sigImage = img.decodeImage(bytes);
final p = ref.read(pdfProvider); return _toStdSignatureImage(sigImage);
if (p.loaded) { } catch (_) {
ref.read(pdfProvider.notifier).setSignedPage(p.currentPage); return null;
} }
return bytes;
} }
void _confirmSignature() { Future<img.Image?> _openDrawCanvas() async {
ref.read(signatureProvider.notifier).confirmCurrentSignature(ref);
}
void _onDragSignature(Offset delta) {
ref.read(signatureProvider.notifier).drag(delta);
}
void _onResizeSignature(Offset delta) {
ref.read(signatureProvider.notifier).resize(delta);
}
void _onSelectPlaced(int? index) {
ref.read(pdfProvider.notifier).selectPlacement(index);
}
Future<Uint8List?> _openDrawCanvas() async {
final result = await showModalBottomSheet<Uint8List>( final result = await showModalBottomSheet<Uint8List>(
context: context, context: context,
isScrollControlled: true, isScrollControlled: true,
enableDrag: false, enableDrag: false,
builder: (_) => const DrawCanvas(), builder: (_) => const DrawCanvas(),
); );
if (result != null && result.isNotEmpty) { if (result == null || result.isEmpty) return null;
ref.read(signatureProvider.notifier).setImageBytes(result); // In simplified UI, adding to library isn't implemented
final p = ref.read(pdfProvider); try {
if (p.loaded) { var sigImage = img.decodeImage(result);
ref.read(pdfProvider.notifier).setSignedPage(p.currentPage); return _toStdSignatureImage(sigImage);
} catch (_) {
return null;
} }
} }
return result;
}
Future<void> _saveSignedPdf() async { Future<void> _saveSignedPdf() async {
ref.read(exportingProvider.notifier).state = true; ref.read(pdfExportViewModelProvider.notifier).setExporting(true);
try { try {
final pdf = ref.read(pdfProvider); final pdf = _viewModel.document;
final sig = ref.read(signatureProvider);
final messenger = ScaffoldMessenger.of(context); final messenger = ScaffoldMessenger.of(context);
if (!pdf.loaded || sig.rect == null) { if (!pdf.loaded) {
messenger.showSnackBar( messenger.showSnackBar(
SnackBar( SnackBar(
content: Text(AppLocalizations.of(context).nothingToSaveYet), content: Text(AppLocalizations.of(context).nothingToSaveYet),
@ -135,118 +156,58 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
); );
return; return;
} }
final exporter = ref.read(exportServiceProvider); final exporter = ref.read(pdfExportViewModelProvider).exporter;
final targetDpi = ref.read(exportDpiProvider);
final useMock = ref.read(useMockViewerProvider); // get DPI from preferences
final targetDpi = ref.read(preferencesRepositoryProvider).exportDpi;
bool ok = false; bool ok = false;
String? savedPath; String? savedPath;
// Helper to apply rotation to bytes for export (single-signature path only)
Uint8List? _rotatedForExport(Uint8List? src, double deg) {
if (src == null || src.isEmpty) return src;
final r = deg % 360;
if (r == 0) return src;
try {
final decoded = img.decodeImage(src);
if (decoded == null) return src;
final out = img.copyRotate(
decoded,
angle: r,
interpolation: img.Interpolation.linear,
);
return Uint8List.fromList(img.encodePng(out, level: 6));
} catch (_) {
return src;
}
}
if (kIsWeb) { // Derive a suggested filename based on the opened file. Prefer the
Uint8List? src = pdf.pickedPdfBytes; // provided display name if available (see Linux portal note above).
if (src != null) { final display = widget.currentFileName;
final processed = ref.read(processedSignatureImageProvider); final originalName =
final rotated = _rotatedForExport( (display != null && display.trim().isNotEmpty)
processed ?? sig.imageBytes, ? display.trim()
sig.rotation, : widget.currentFile.name.isNotEmpty
); ? widget.currentFile.name
final bytes = await exporter.exportSignedPdfFromBytes( : widget.currentFile.path.isNotEmpty
srcBytes: src, ? widget.currentFile.path.split('/').last.split('\\').last
signedPage: pdf.signedPage, : 'document.pdf';
signatureRectUi: sig.rect, final suggested = _suggestSignedName(originalName);
uiPageSize: SignatureController.pageSize,
signatureImageBytes: rotated, if (!kIsWeb) {
placementsByPage: pdf.placementsByPage, final path = await ref
libraryBytes: { .read(pdfExportViewModelProvider)
for (final a in ref.read(signatureLibraryProvider)) a.id: a.bytes, .pickSavePathWithSuggestedName(suggested);
},
targetDpi: targetDpi,
);
if (bytes != null) {
try {
await printing.Printing.sharePdf(
bytes: bytes,
filename: 'signed.pdf',
);
ok = true;
} catch (_) {
ok = false;
}
}
}
} else {
final pick = ref.read(savePathPickerProvider);
final path = await pick();
if (path == null || path.trim().isEmpty) return; if (path == null || path.trim().isEmpty) return;
final fullPath = _ensurePdfExtension(path.trim()); final fullPath = _ensurePdfExtension(path.trim());
savedPath = fullPath; savedPath = fullPath;
if (pdf.pickedPdfBytes != null) { final src = pdf.pickedPdfBytes ?? Uint8List(0);
final processed = ref.read(processedSignatureImageProvider);
final rotated = _rotatedForExport(
processed ?? sig.imageBytes,
sig.rotation,
);
final out = await exporter.exportSignedPdfFromBytes( final out = await exporter.exportSignedPdfFromBytes(
srcBytes: pdf.pickedPdfBytes!, srcBytes: src,
signedPage: pdf.signedPage, uiPageSize: _pageSize,
signatureRectUi: sig.rect, signatureImageBytes: null,
uiPageSize: SignatureController.pageSize,
signatureImageBytes: rotated,
placementsByPage: pdf.placementsByPage, placementsByPage: pdf.placementsByPage,
libraryBytes: {
for (final a in ref.read(signatureLibraryProvider)) a.id: a.bytes,
},
targetDpi: targetDpi, targetDpi: targetDpi,
); );
if (useMock) { if (out != null) {
ok = out != null; ok = await exporter.saveBytesToFile(bytes: out, outputPath: fullPath);
} else if (out != null) {
ok = await exporter.saveBytesToFile(
bytes: out,
outputPath: fullPath,
);
} }
} else if (pdf.pickedPdfPath != null) {
if (useMock) {
ok = true;
} else { } else {
final processed = ref.read(processedSignatureImageProvider); // Web: export and trigger browser download
final rotated = _rotatedForExport( final src = pdf.pickedPdfBytes ?? Uint8List(0);
processed ?? sig.imageBytes, final out = await exporter.exportSignedPdfFromBytes(
sig.rotation, srcBytes: src,
); uiPageSize: _pageSize,
ok = await exporter.exportSignedPdfFromFile( signatureImageBytes: null,
inputPath: pdf.pickedPdfPath!,
outputPath: fullPath,
signedPage: pdf.signedPage,
signatureRectUi: sig.rect,
uiPageSize: SignatureController.pageSize,
signatureImageBytes: rotated,
placementsByPage: pdf.placementsByPage, placementsByPage: pdf.placementsByPage,
libraryBytes: {
for (final a in ref.read(signatureLibraryProvider))
a.id: a.bytes,
},
targetDpi: targetDpi, targetDpi: targetDpi,
); );
} if (out != null) {
// Use suggested filename for browser download
ok = await downloadBytes(out, filename: suggested);
savedPath = suggested;
} }
} }
if (!kIsWeb) { if (!kIsWeb) {
@ -266,22 +227,21 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
); );
} }
} else { } else {
if (ok) { // Web: show a toast-like confirmation
messenger.showSnackBar( messenger.showSnackBar(
SnackBar( SnackBar(
content: Text(AppLocalizations.of(context).downloadStarted), content: Text(
ok
? AppLocalizations.of(
context,
).savedWithPath(savedPath ?? 'signed.pdf')
: AppLocalizations.of(context).failedToSavePdf,
),
), ),
); );
} else {
messenger.showSnackBar(
SnackBar(
content: Text(AppLocalizations.of(context).failedToGeneratePdf),
),
);
}
} }
} finally { } finally {
ref.read(exportingProvider.notifier).state = false; ref.read(pdfExportViewModelProvider.notifier).setExporting(false);
} }
} }
@ -290,10 +250,46 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
return name; return name;
} }
String _suggestSignedName(String original) {
// Normalize to a base filename
final base = original.split('/').last.split('\\').last;
if (base.toLowerCase().endsWith('.pdf')) {
return 'signed_' + base;
}
return 'signed_' + base + '.pdf';
}
void _onControllerChanged() {
if (mounted) {
if (_viewModel.controller.isReady) {
final newZoomLevel = (_viewModel.controller.currentZoom * 100)
.round()
.clamp(10, 800);
if (newZoomLevel != _zoomLevel) {
setState(() {
_zoomLevel = newZoomLevel;
});
}
} else {
// Reset to default zoom level when controller is not ready
if (_zoomLevel != 100) {
setState(() {
_zoomLevel = 100;
});
}
}
}
}
@override @override
void initState() { void initState() {
super.initState(); super.initState();
// Build areas once with builders; keep these instances stable. // Build areas once with builders; keep these instances stable.
_viewModel = ref.read(pdfViewModelProvider.notifier);
// Add listener to update zoom level when controller zoom changes
_viewModel.controller.addListener(_onControllerChanged);
_areas = [ _areas = [
Area( Area(
size: _lastPagesWidth, size: _lastPagesWidth,
@ -302,7 +298,26 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
builder: builder:
(context, area) => Offstage( (context, area) => Offstage(
offstage: !_showPagesSidebar, offstage: !_showPagesSidebar,
child: const PagesSidebar(), child: Consumer(
builder: (context, ref, child) {
final pdfViewModel = ref.watch(pdfViewModelProvider);
final pdf = pdfViewModel.document;
final documentRef =
pdf.loaded && pdf.pickedPdfBytes != null
? PdfDocumentRefData(
pdf.pickedPdfBytes!,
sourceName: 'document.pdf',
)
: null;
return PagesSidebar(
documentRef: documentRef,
controller: _viewModel.controller,
currentPage: _viewModel.currentPage,
);
},
),
), ),
), ),
Area( Area(
@ -310,18 +325,9 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
builder: builder:
(context, area) => RepaintBoundary( (context, area) => RepaintBoundary(
child: PdfPageArea( child: PdfPageArea(
controller: _viewModel.controller,
key: const ValueKey('pdf_page_area'), key: const ValueKey('pdf_page_area'),
pageSize: _pageSize, pageSize: _pageSize,
viewerController: _viewerController,
onDragSignature: _onDragSignature,
onResizeSignature: _onResizeSignature,
onConfirmSignature: _confirmSignature,
onClearActiveOverlay:
() =>
ref
.read(signatureProvider.notifier)
.clearActiveOverlay(),
onSelectPlaced: _onSelectPlaced,
), ),
), ),
), ),
@ -347,6 +353,7 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
@override @override
void dispose() { void dispose() {
_viewModel.controller.removeListener(_onControllerChanged);
_splitController.dispose(); _splitController.dispose();
super.dispose(); super.dispose();
} }
@ -380,7 +387,11 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final isExporting = ref.watch(exportingProvider); return _buildScaffold(context);
}
Widget _buildScaffold(BuildContext context) {
final isExporting = ref.watch(pdfExportViewModelProvider).exporting;
final l = AppLocalizations.of(context); final l = AppLocalizations.of(context);
return Scaffold( return Scaffold(
body: Padding( body: Padding(
@ -393,25 +404,42 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
PdfToolbar( PdfToolbar(
disabled: isExporting, disabled: isExporting,
onPickPdf: _pickPdf, onPickPdf: _pickPdf,
onClosePdf: _closePdf,
onJumpToPage: _jumpToPage, onJumpToPage: _jumpToPage,
onZoomOut: () { onZoomOut: () {
if (_viewerController.isReady) { if (_viewModel.controller.isReady) {
_viewerController.zoomDown(); _viewModel.controller.zoomDown();
} // Update display zoom level after controller zoom
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
setState(() { setState(() {
_zoomLevel = (_zoomLevel - 10).clamp(10, 800); _zoomLevel = (_viewModel.controller.currentZoom *
100)
.round()
.clamp(10, 800);
}); });
}
});
}
}, },
onZoomIn: () { onZoomIn: () {
if (_viewerController.isReady) { if (_viewModel.controller.isReady) {
_viewerController.zoomUp(); _viewModel.controller.zoomUp();
} // Update display zoom level after controller zoom
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
setState(() { setState(() {
_zoomLevel = (_zoomLevel + 10).clamp(10, 800); _zoomLevel = (_viewModel.controller.currentZoom *
100)
.round()
.clamp(10, 800);
}); });
}
});
}
}, },
zoomLevel: _zoomLevel, zoomLevel: _zoomLevel,
fileName: ref.watch(pdfProvider).pickedPdfPath, filePath: widget.currentFile.path,
showPagesSidebar: _showPagesSidebar, showPagesSidebar: _showPagesSidebar,
showSignaturesSidebar: _showSignaturesSidebar, showSignaturesSidebar: _showSignaturesSidebar,
onTogglePagesSidebar: onTogglePagesSidebar:
@ -425,6 +453,24 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
_applySidebarVisibility(); _applySidebarVisibility();
}), }),
), ),
// Expose a compact signature drawer trigger area for tests when sidebar hidden
if (!_showSignaturesSidebar)
Align(
alignment: Alignment.centerLeft,
child: SizedBox(
height:
0, // zero-height container exposing buttons offstage
width: 0,
child: Offstage(
offstage: true,
child: SignaturesSidebar(
onLoadSignatureFromFile: _loadSignatureFromFile,
onOpenDrawCanvas: _openDrawCanvas,
onSave: _saveSignedPdf,
),
),
),
),
const SizedBox(height: 8), const SizedBox(height: 8),
Expanded( Expanded(
child: MultiSplitView( child: MultiSplitView(

View File

@ -3,18 +3,19 @@ import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:pdf_signature/l10n/app_localizations.dart'; import 'package:pdf_signature/l10n/app_localizations.dart';
import '../view_model/pdf_controller.dart'; import 'package:pdf_signature/ui/features/pdf/view_model/pdf_view_model.dart';
class PdfToolbar extends ConsumerStatefulWidget { class PdfToolbar extends ConsumerStatefulWidget {
const PdfToolbar({ const PdfToolbar({
super.key, super.key,
required this.disabled, required this.disabled,
required this.onPickPdf, required this.onPickPdf,
required this.onClosePdf,
required this.onJumpToPage, required this.onJumpToPage,
required this.onZoomOut, required this.onZoomOut,
required this.onZoomIn, required this.onZoomIn,
this.zoomLevel, this.zoomLevel,
this.fileName, this.filePath,
required this.showPagesSidebar, required this.showPagesSidebar,
required this.showSignaturesSidebar, required this.showSignaturesSidebar,
required this.onTogglePagesSidebar, required this.onTogglePagesSidebar,
@ -23,8 +24,9 @@ class PdfToolbar extends ConsumerStatefulWidget {
final bool disabled; final bool disabled;
final VoidCallback onPickPdf; final VoidCallback onPickPdf;
final VoidCallback onClosePdf;
final ValueChanged<int> onJumpToPage; final ValueChanged<int> onJumpToPage;
final String? fileName; final String? filePath;
final VoidCallback onZoomOut; final VoidCallback onZoomOut;
final VoidCallback onZoomIn; final VoidCallback onZoomIn;
// Current zoom level as a percentage (e.g., 100 for 100%) // Current zoom level as a percentage (e.g., 100 for 100%)
@ -55,9 +57,11 @@ class _PdfToolbarState extends ConsumerState<PdfToolbar> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final pdf = ref.watch(pdfProvider); final pdfViewModel = ref.watch(pdfViewModelProvider);
final pdf = pdfViewModel.document;
final currentPage = pdfViewModel.currentPage;
final l = AppLocalizations.of(context); final l = AppLocalizations.of(context);
final pageInfo = l.pageInfo(pdf.currentPage, pdf.pageCount); final pageInfo = l.pageInfo(currentPage, pdf.pageCount);
return LayoutBuilder( return LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
@ -81,9 +85,9 @@ class _PdfToolbarState extends ConsumerState<PdfToolbar> {
ConstrainedBox( ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 220), constraints: const BoxConstraints(maxWidth: 220),
child: Text( child: Text(
// if filename not null // if filePath not null
widget.fileName != null widget.filePath != null
? widget.fileName! ? widget.filePath!
: 'No file selected', : 'No file selected',
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
@ -92,6 +96,12 @@ class _PdfToolbarState extends ConsumerState<PdfToolbar> {
), ),
), ),
if (pdf.loaded) ...[ if (pdf.loaded) ...[
IconButton(
key: const Key('btn_close_pdf'),
onPressed: widget.disabled ? null : widget.onClosePdf,
icon: const Icon(Icons.close),
tooltip: l.close,
),
Wrap( Wrap(
spacing: 8, spacing: 8,
children: [ children: [
@ -103,8 +113,7 @@ class _PdfToolbarState extends ConsumerState<PdfToolbar> {
onPressed: onPressed:
widget.disabled widget.disabled
? null ? null
: () => : () => widget.onJumpToPage(-1),
widget.onJumpToPage(pdf.currentPage - 1),
icon: const Icon(Icons.chevron_left), icon: const Icon(Icons.chevron_left),
tooltip: l.prev, tooltip: l.prev,
), ),
@ -115,8 +124,7 @@ class _PdfToolbarState extends ConsumerState<PdfToolbar> {
onPressed: onPressed:
widget.disabled widget.disabled
? null ? null
: () => : () => widget.onJumpToPage(currentPage + 1),
widget.onJumpToPage(pdf.currentPage + 1),
icon: const Icon(Icons.chevron_right), icon: const Icon(Icons.chevron_right),
tooltip: l.next, tooltip: l.next,
), ),

View File

@ -0,0 +1,157 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:pdfrx/pdfrx.dart';
import 'package:pdf_signature/l10n/app_localizations.dart';
import 'pdf_page_overlays.dart';
import './pdf_mock_continuous_list.dart';
import '../view_model/pdf_view_model.dart';
class PdfViewerWidget extends ConsumerStatefulWidget {
const PdfViewerWidget({
super.key,
required this.pageSize,
this.pageKeyBuilder,
this.scrollToPage,
required this.controller,
});
final Size pageSize;
final GlobalKey Function(int page)? pageKeyBuilder;
final void Function(int page)? scrollToPage;
final PdfViewerController controller;
@override
ConsumerState<PdfViewerWidget> createState() => _PdfViewerWidgetState();
}
class _PdfViewerWidgetState extends ConsumerState<PdfViewerWidget> {
PdfDocumentRef? _documentRef;
// Public getter for testing the actual viewer page
int? get viewerCurrentPage => widget.controller.pageNumber;
@override
void initState() {
super.initState();
}
@override
void dispose() {
// PdfViewerController doesn't have dispose method
super.dispose();
}
@override
Widget build(BuildContext context) {
final pdfViewModel = ref.watch(pdfViewModelProvider);
final document = pdfViewModel.document;
final useMock = pdfViewModel.useMockViewer;
// trigger rebuild when active rect changes
// Update document ref when document changes
if (document.loaded && document.pickedPdfBytes != null) {
if (_documentRef == null) {
_documentRef = PdfDocumentRefData(
document.pickedPdfBytes!,
sourceName: 'document.pdf',
);
}
} else {
_documentRef = null;
}
if (_documentRef == null && !useMock) {
String text;
try {
text = AppLocalizations.of(context).noPdfLoaded;
} catch (_) {
text = 'No PDF loaded';
}
return Center(child: Text(text));
}
if (useMock) {
return PdfMockContinuousList(
pageSize: widget.pageSize,
count: document.pageCount,
pageKeyBuilder:
widget.pageKeyBuilder ??
(page) => GlobalKey(debugLabel: 'page_$page'),
scrollToPage: widget.scrollToPage ?? (page) {},
);
}
return PdfViewer(
_documentRef!,
key: const Key(
'pdf_continuous_mock_list',
), // Keep the same key for test compatibility
controller: widget.controller,
params: PdfViewerParams(
onViewerReady: (document, controller) {
// Update page count in repository
ref
.read(pdfViewModelProvider.notifier)
.setPageCount(document.pages.length);
},
onPageChanged: (page) {
if (page != null) {
// Also update the view model to keep them in sync
ref.read(pdfViewModelProvider.notifier).jumpToPage(page);
}
},
viewerOverlayBuilder: (context, size, handle) {
return [
// Vertical scroll thumb on the right
PdfViewerScrollThumb(
controller: widget.controller,
orientation: ScrollbarOrientation.right,
thumbSize: const Size(40, 25),
thumbBuilder:
(context, thumbSize, pageNumber, controller) => Container(
color: Colors.black.withValues(alpha: 0.7),
child: Center(
child: Text(
'Pg $pageNumber',
style: const TextStyle(
color: Colors.white,
fontSize: 12,
),
),
),
),
),
// Horizontal scroll thumb on the bottom
PdfViewerScrollThumb(
controller: widget.controller,
orientation: ScrollbarOrientation.bottom,
thumbSize: const Size(40, 25),
thumbBuilder:
(context, thumbSize, pageNumber, controller) => Container(
color: Colors.black.withValues(alpha: 0.7),
child: Center(
child: Text(
'Pg $pageNumber',
style: const TextStyle(
color: Colors.white,
fontSize: 12,
),
),
),
),
),
];
},
// Per-page overlays to enable page-specific drag targets and placed signatures
pageOverlaysBuilder: (context, pageRect, page) {
return [
PdfPageOverlays(
pageSize: Size(pageRect.width, pageRect.height),
pageNumber: page.pageNumber,
),
];
},
),
);
}
}

View File

@ -1,191 +0,0 @@
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/view_model/signature_controller.dart';
import '../../signature/view_model/signature_library.dart';
import 'image_editor_dialog.dart';
import '../../signature/widgets/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:
(sig.assetId == a.id)
? SignatureAsset(
id: a.id,
bytes: (processed ?? a.bytes),
name: a.name,
)
: a,
rotationDeg: (sig.assetId == a.id) ? sig.rotation : 0.0,
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: ''),
rotationDeg: sig.rotation,
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

@ -1,302 +1,163 @@
import 'dart:math' as math;
import 'dart:typed_data';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_box_transform/flutter_box_transform.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../domain/models/model.dart';
import '../../signature/widgets/rotated_signature_image.dart';
import '../../signature/view_model/signature_view_model.dart';
import '../view_model/pdf_view_model.dart';
import 'package:pdf_signature/l10n/app_localizations.dart'; import 'package:pdf_signature/l10n/app_localizations.dart';
import '../../../../data/model/model.dart'; /// Minimal overlay widget for rendering a placed signature.
import '../../signature/view_model/signature_controller.dart';
import '../view_model/pdf_controller.dart';
import '../../signature/view_model/signature_library.dart';
import 'image_editor_dialog.dart';
import '../../../common/menu_labels.dart';
import '../../signature/widgets/rotated_signature_image.dart';
/// Renders a single signature overlay (either interactive or placed) on a page.
class SignatureOverlay extends ConsumerWidget { class SignatureOverlay extends ConsumerWidget {
const SignatureOverlay({ const SignatureOverlay({
super.key, super.key,
required this.pageSize, required this.pageSize,
required this.rect, required this.rect,
required this.sig, required this.placement,
required this.placedIndex,
required this.pageNumber, required this.pageNumber,
this.interactive = true,
this.placedIndex,
this.onDragSignature,
this.onResizeSignature,
this.onConfirmSignature,
this.onClearActiveOverlay,
this.onSelectPlaced,
}); });
final Size pageSize; final Size pageSize; // not used directly, kept for API symmetry
final Rect rect; final Rect rect; // normalized 0..1 values (left, top, width, height)
final SignatureState sig; final SignaturePlacement placement;
final int placedIndex;
final int pageNumber; 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 @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final processedImage = ref
.watch(signatureViewModelProvider)
.getProcessedImage(placement.asset, placement.graphicAdjust);
return LayoutBuilder( return LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
final scaleX = constraints.maxWidth / pageSize.width; final pageW = constraints.maxWidth;
final scaleY = constraints.maxHeight / pageSize.height; final pageH = constraints.maxHeight;
final left = rect.left * scaleX; final rectPx = Rect.fromLTWH(
final top = rect.top * scaleY; rect.left * pageW,
final width = rect.width * scaleX; rect.top * pageH,
final height = rect.height * scaleY; rect.width * pageW,
rect.height * pageH,
);
Future<void> _showContextMenu(Offset position) async {
final pdfViewModel = ref.read(pdfViewModelProvider.notifier);
final isLocked = ref
.watch(pdfViewModelProvider)
.isPlacementLocked(page: pageNumber, index: placedIndex);
final selected = await showMenu<String>(
context: context,
position: RelativeRect.fromLTRB(
position.dx,
position.dy,
position.dx,
position.dy,
),
items: [
PopupMenuItem(
key: const Key('mi_placement_lock'),
value: isLocked ? 'unlock' : 'lock',
child: Text(
isLocked
? AppLocalizations.of(context).unlock
: AppLocalizations.of(context).lock,
),
),
PopupMenuItem(
key: const Key('mi_placement_delete'),
value: 'delete',
child: Text(AppLocalizations.of(context).delete),
),
],
);
if (selected == 'lock') {
pdfViewModel.lockPlacement(page: pageNumber, index: placedIndex);
} else if (selected == 'unlock') {
pdfViewModel.unlockPlacement(page: pageNumber, index: placedIndex);
} else if (selected == 'delete') {
pdfViewModel.removePlacement(page: pageNumber, index: placedIndex);
}
}
return Stack( return Stack(
children: [ children: [
Positioned( TransformableBox(
left: left, key: Key('placed_signature_$placedIndex'),
top: top, rect: rectPx,
width: width, flip: Flip.none,
height: height, // Keep the box within page bounds
child: _buildContent(context, ref, scaleX, scaleY), clampingRect: Rect.fromLTWH(0, 0, pageW, pageH),
), // Disable flips for signatures to avoid mirrored signatures
], allowFlippingWhileResizing: false,
); allowContentFlipping: false,
}, onChanged:
);
}
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(
alignment: Alignment.center,
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) {
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 ref
.read(pdfProvider.notifier) .watch(pdfViewModelProvider)
.removePlacement(page: pageNumber, index: placedIndex!); .isPlacementLocked(
} page: pageNumber,
break; index: placedIndex,
case 'adjust': )
showDialog( ? null
context: context, : (result, details) {
builder: (ctx) => const ImageEditorDialog(), final r = result.rect;
// Persist as normalized rect (0..1)
final newRect = Rect.fromLTWH(
(r.left / pageW).clamp(0.0, 1.0),
(r.top / pageH).clamp(0.0, 1.0),
(r.width / pageW).clamp(0.0, 1.0),
(r.height / pageH).clamp(0.0, 1.0),
); );
break; ref
default: .read(pdfViewModelProvider.notifier)
break; .updatePlacementRect(
} page: pageNumber,
}); index: placedIndex,
} rect: newRect,
} );
},
class _SignatureImage extends ConsumerWidget { // Keep default handles; you can customize later if needed
const _SignatureImage({ contentBuilder: (context, boxRect, flip) {
required this.interactive, final isLocked = ref
required this.placedIndex, .watch(pdfViewModelProvider)
required this.pageNumber, .isPlacementLocked(page: pageNumber, index: placedIndex);
required this.sig, return DecoratedBox(
}); decoration: BoxDecoration(
border: Border.all(
final bool interactive; color: isLocked ? Colors.green : Colors.red,
final int? placedIndex; width: 2,
final int pageNumber; ),
final SignatureState sig; ),
child: SizedBox(
@override width: boxRect.width,
Widget build(BuildContext context, WidgetRef ref) { height: boxRect.height,
Uint8List? bytes; child: FittedBox(
if (interactive) {
final processed = ref.watch(processedSignatureImageProvider);
bytes = processed ?? sig.imageBytes;
} else if (placedIndex != null) {
final placementList = ref.read(pdfProvider).placementsByPage[pageNumber];
final placement =
(placementList != null && placedIndex! < placementList.length)
? placementList[placedIndex!]
: null;
final imgId = placement?.imageId;
if (imgId != null) {
final lib = ref.watch(signatureLibraryProvider);
for (final a in lib) {
if (a.id == imgId) {
bytes = a.bytes;
break;
}
}
}
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));
}
// Use live rotation for interactive overlay; stored rotation for placed
double rotationDeg = 0.0;
if (interactive) {
rotationDeg = sig.rotation;
} else if (placedIndex != null) {
final placementList = ref.read(pdfProvider).placementsByPage[pageNumber];
if (placementList != null && placedIndex! < placementList.length) {
rotationDeg = placementList[placedIndex!].rotationDeg;
}
}
return RotatedSignatureImage(
bytes: bytes,
rotationDeg: rotationDeg,
enableAngleAwareScale: interactive,
fit: BoxFit.contain, fit: BoxFit.contain,
wrapInRepaintBoundary: true, child: RotatedSignatureImage(
image: processedImage,
rotationDeg: placement.rotationDeg,
),
),
),
);
},
),
// Invisible overlay for right-click context menu
Positioned(
left: rectPx.left,
top: rectPx.top,
width: rectPx.width,
height: rectPx.height,
child: GestureDetector(
behavior: HitTestBehavior.translucent,
onSecondaryTapDown:
(details) => _showContextMenu(details.globalPosition),
onLongPressStart:
(details) => _showContextMenu(details.globalPosition),
),
),
],
);
},
); );
} }
} }

View File

@ -1,10 +1,11 @@
import 'dart:typed_data'; // no bytes here; use decoded images
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:pdf_signature/l10n/app_localizations.dart'; import 'package:pdf_signature/l10n/app_localizations.dart';
import 'package:image/image.dart' as img;
import '../../../../data/services/export_providers.dart'; import '../../signature/widgets/signature_drawer.dart';
import 'signature_drawer.dart'; import '../view_model/pdf_export_view_model.dart';
class SignaturesSidebar extends ConsumerWidget { class SignaturesSidebar extends ConsumerWidget {
const SignaturesSidebar({ const SignaturesSidebar({
@ -14,14 +15,14 @@ class SignaturesSidebar extends ConsumerWidget {
required this.onSave, required this.onSave,
}); });
final Future<Uint8List?> Function() onLoadSignatureFromFile; final Future<img.Image?> Function() onLoadSignatureFromFile;
final Future<Uint8List?> Function() onOpenDrawCanvas; final Future<img.Image?> Function() onOpenDrawCanvas;
final VoidCallback onSave; final VoidCallback onSave;
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final l = AppLocalizations.of(context); final l = AppLocalizations.of(context);
final isExporting = ref.watch(exportingProvider); final isExporting = ref.watch(pdfExportViewModelProvider).exporting;
return AbsorbPointer( return AbsorbPointer(
absorbing: isExporting, absorbing: isExporting,
child: Card( child: Card(

View File

@ -0,0 +1,13 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
class PreferencesViewModel {
final Ref ref;
PreferencesViewModel(this.ref);
// Add methods as needed
}
final preferencesViewModelProvider = Provider<PreferencesViewModel>((ref) {
return PreferencesViewModel(ref);
});

View File

@ -1,7 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:pdf_signature/l10n/app_localizations.dart'; import 'package:pdf_signature/l10n/app_localizations.dart';
import '../../../../data/services/preferences_providers.dart'; import '../../../../data/repositories/preferences_repository.dart';
class SettingsDialog extends ConsumerStatefulWidget { class SettingsDialog extends ConsumerStatefulWidget {
const SettingsDialog({super.key}); const SettingsDialog({super.key});
@ -19,7 +19,7 @@ class _SettingsDialogState extends ConsumerState<SettingsDialog> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
final prefs = ref.read(preferencesProvider); final prefs = ref.read(preferencesRepositoryProvider);
_theme = prefs.theme; _theme = prefs.theme;
_language = prefs.language; _language = prefs.language;
_exportDpi = prefs.exportDpi; _exportDpi = prefs.exportDpi;
@ -62,40 +62,18 @@ class _SettingsDialogState extends ConsumerState<SettingsDialog> {
SizedBox(width: 140, child: Text('${l.language}:')), SizedBox(width: 140, child: Text('${l.language}:')),
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded( Expanded(
child: ref child: FutureBuilder<Map<String, String>>(
.watch(languageAutonymsProvider) future: languageAutonyms(),
.when( builder: (context, snapshot) {
loading: if (snapshot.connectionState ==
() => const SizedBox( ConnectionState.waiting) {
return const SizedBox(
height: 48, height: 48,
child: Center( child: Center(child: CircularProgressIndicator()),
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 names = snapshot.data;
final items = final tags =
AppLocalizations.supportedLocales AppLocalizations.supportedLocales
.map((loc) => toLanguageTag(loc)) .map((loc) => toLanguageTag(loc))
.toList() .toList()
@ -105,15 +83,21 @@ class _SettingsDialogState extends ConsumerState<SettingsDialog> {
isExpanded: true, isExpanded: true,
value: _language, value: _language,
items: items:
items tags
.map( .map(
(tag) => DropdownMenuItem<String>( (tag) => DropdownMenuItem<String>(
value: tag, value: tag,
child: Text(names[tag] ?? tag), child: Text(names?[tag] ?? tag),
), ),
) )
.toList(), .toList(),
onChanged: (v) => setState(() => _language = v), onChanged: (v) async {
if (v == null) return;
setState(() => _language = v);
await ref
.read(preferencesRepositoryProvider.notifier)
.setLanguage(v);
},
); );
}, },
), ),
@ -138,7 +122,13 @@ class _SettingsDialogState extends ConsumerState<SettingsDialog> {
), ),
) )
.toList(), .toList(),
onChanged: (v) => setState(() => _exportDpi = v), onChanged: (v) async {
if (v == null) return;
setState(() => _exportDpi = v);
await ref
.read(preferencesRepositoryProvider.notifier)
.setExportDpi(v);
},
), ),
), ),
], ],
@ -169,33 +159,40 @@ class _SettingsDialogState extends ConsumerState<SettingsDialog> {
child: Text(l.themeSystem), child: Text(l.themeSystem),
), ),
], ],
onChanged: (v) => setState(() => _theme = v), onChanged: (v) async {
if (v == null) return;
setState(() => _theme = v);
await ref
.read(preferencesRepositoryProvider.notifier)
.setTheme(v);
},
), ),
), ),
], ],
), ),
const SizedBox(height: 8),
Row(
children: [
SizedBox(width: 140, child: Text('${l.themeColor}:')),
const SizedBox(width: 8),
_ThemeColorCircle(
onPick: (value) async {
if (value == null) return;
await ref
.read(preferencesRepositoryProvider.notifier)
.setThemeColor(value);
},
),
],
),
const SizedBox(height: 24), const SizedBox(height: 24),
Row( Align(
mainAxisAlignment: MainAxisAlignment.end, alignment: Alignment.centerRight,
children: [ child: FilledButton.tonal(
OutlinedButton( onPressed: () => Navigator.of(context).pop(true),
onPressed: () => Navigator.of(context).pop(false), child: Text(l.close),
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!);
if (_exportDpi != null) await n.setExportDpi(_exportDpi!);
// pageView not configurable anymore
if (mounted) Navigator.of(context).pop(true);
},
child: Text(l.save),
),
],
), ),
], ],
), ),
@ -204,3 +201,113 @@ class _SettingsDialogState extends ConsumerState<SettingsDialog> {
); );
} }
} }
class _ColorDot extends StatelessWidget {
final Color color;
final double size;
const _ColorDot({required this.color, this.size = 14});
@override
Widget build(BuildContext context) => Container(
width: size,
height: size,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
border: Border.all(color: Theme.of(context).dividerColor),
),
);
}
class _ThemeColorCircle extends ConsumerWidget {
final ValueChanged<String?> onPick;
const _ThemeColorCircle({required this.onPick});
@override
Widget build(BuildContext context, WidgetRef ref) {
final seed = themeSeedFromPrefs(ref.watch(preferencesRepositoryProvider));
return InkWell(
key: const Key('btn_theme_color_picker'),
onTap: () async {
final picked = await showDialog<String>(
context: context,
builder: (ctx) => _ThemeColorPickerDialog(currentColor: seed),
);
onPick(picked);
},
customBorder: const CircleBorder(),
child: Padding(
padding: const EdgeInsets.all(6.0),
child: _ColorDot(color: seed, size: 22),
),
);
}
}
class _ThemeColorPickerDialog extends StatelessWidget {
final Color currentColor;
const _ThemeColorPickerDialog({required this.currentColor});
@override
Widget build(BuildContext context) {
final l = AppLocalizations.of(context);
return AlertDialog(
title: Text(l.themeColor),
content: SizedBox(
width: 320,
child: Wrap(
spacing: 12,
runSpacing: 12,
children:
Colors.primaries.map((mat) {
final Color c = mat; // MaterialColor is a Color
final selected = c == currentColor;
// Store as ARGB hex string, e.g., #FF2196F3
String hex(Color color) {
final a =
((color.a * 255.0).round() & 0xff)
.toRadixString(16)
.padLeft(2, '0')
.toUpperCase();
final r =
((color.r * 255.0).round() & 0xff)
.toRadixString(16)
.padLeft(2, '0')
.toUpperCase();
final g =
((color.g * 255.0).round() & 0xff)
.toRadixString(16)
.padLeft(2, '0')
.toUpperCase();
final b =
((color.b * 255.0).round() & 0xff)
.toRadixString(16)
.padLeft(2, '0')
.toUpperCase();
return '#$a$r$g$b';
}
return InkWell(
key: Key('pick_${hex(c)}'),
onTap: () => Navigator.of(context).pop(hex(c)),
customBorder: const CircleBorder(),
child: Stack(
alignment: Alignment.center,
children: [
_ColorDot(color: c, size: 32),
if (selected)
const Icon(Icons.check, color: Colors.white, size: 20),
],
),
);
}).toList(),
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(null),
child: Text(l.cancel),
),
],
);
}
}

View File

@ -0,0 +1,6 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
/// Global flag indicating whether a signature card is currently being dragged.
final isDraggingSignatureViewModelProvider = StateProvider<bool>(
(ref) => false,
);

View File

@ -1,349 +0,0 @@
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:image/image.dart' as img;
import 'package:pdf_signature/l10n/app_localizations.dart';
import '../../../../data/model/model.dart';
import '../../pdf/view_model/pdf_controller.dart';
import 'signature_library.dart';
class SignatureController extends StateNotifier<SignatureState> {
SignatureController() : super(SignatureState.initial());
static const Size pageSize = Size(400, 560);
void resetForNewPage() {
state = SignatureState.initial();
}
@visibleForTesting
void placeDefaultRect() {
final w = 120.0, h = 60.0;
final rand = Random();
// Generate a center within 10%..90% of each axis to reduce off-screen risk
final cx = pageSize.width * (0.1 + rand.nextDouble() * 0.8);
final cy = pageSize.height * (0.1 + rand.nextDouble() * 0.8);
Rect r = Rect.fromCenter(center: Offset(cx, cy), width: w, height: h);
r = _clampRectToPage(r);
state = state.copyWith(rect: r, editingEnabled: true);
}
void loadSample() {
final w = 120.0, h = 60.0;
state = state.copyWith(
rect: Rect.fromCenter(
center: Offset(pageSize.width / 2, pageSize.height * 0.75),
width: w,
height: h,
),
editingEnabled: true,
);
}
void setInvalidSelected(BuildContext context) {
// Fallback message without localization to keep core logic testable
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
Localizations.of<AppLocalizations>(
context,
AppLocalizations,
)!.invalidOrUnsupportedFile,
),
),
);
}
void drag(Offset delta) {
if (state.rect == null || !state.editingEnabled) return;
final moved = state.rect!.shift(delta);
state = state.copyWith(rect: _clampRectToPage(moved));
}
void resize(Offset delta) {
if (state.rect == null || !state.editingEnabled) return;
final r = state.rect!;
double newW = r.width + delta.dx;
double newH = r.height + delta.dy;
if (state.aspectLocked) {
final aspect = r.width / r.height;
// Keep ratio based on the dominant proportional delta
final dxRel = (delta.dx / r.width).abs();
final dyRel = (delta.dy / r.height).abs();
if (dxRel >= dyRel) {
newW = newW.clamp(20.0, double.infinity);
newH = newW / aspect;
} else {
newH = newH.clamp(20.0, double.infinity);
newW = newH * aspect;
}
// Scale down to fit within page bounds while preserving ratio
final scaleW = pageSize.width / newW;
final scaleH = pageSize.height / newH;
final scale = math.min(1.0, math.min(scaleW, scaleH));
newW *= scale;
newH *= scale;
// Ensure minimum size of 20x20, scaling up proportionally if needed
final minScale = math.max(1.0, math.max(20.0 / newW, 20.0 / newH));
newW *= minScale;
newH *= minScale;
Rect resized = Rect.fromLTWH(r.left, r.top, newW, newH);
resized = _clampRectPositionToPage(resized);
state = state.copyWith(rect: resized);
return;
}
// Unlocked aspect: clamp each dimension independently
newW = newW.clamp(20.0, pageSize.width);
newH = newH.clamp(20.0, pageSize.height);
Rect resized = Rect.fromLTWH(r.left, r.top, newW, newH);
resized = _clampRectToPage(resized);
state = state.copyWith(rect: resized);
}
Rect _clampRectToPage(Rect r) {
// Ensure size never exceeds page bounds first, to avoid invalid clamp ranges
final double w = r.width.clamp(20.0, pageSize.width);
final double h = r.height.clamp(20.0, pageSize.height);
final double left = r.left.clamp(0.0, pageSize.width - w);
final double top = r.top.clamp(0.0, pageSize.height - h);
return Rect.fromLTWH(left, top, w, h);
}
Rect _clampRectPositionToPage(Rect r) {
final double left = r.left.clamp(0.0, pageSize.width - r.width);
final double top = r.top.clamp(0.0, pageSize.height - r.height);
return Rect.fromLTWH(left, top, r.width, r.height);
}
void toggleAspect(bool v) => state = state.copyWith(aspectLocked: v);
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);
void ensureRectForStrokes() {
state = state.copyWith(
rect:
state.rect ??
Rect.fromCenter(
center: Offset(pageSize.width / 2, pageSize.height * 0.75),
width: 140,
height: 70,
),
editingEnabled: true,
);
}
void setImageBytes(Uint8List bytes) {
state = state.copyWith(imageBytes: bytes, assetId: null);
if (state.rect == null) {
placeDefaultRect();
}
// Mark as draft/editable when user just loaded image
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.
// Stores the placement rect in UI-space (SignatureController.pageSize units).
// Returns the Rect placed, or null if no rect to confirm.
Rect? confirmCurrentSignature(WidgetRef ref) {
final r = state.rect;
if (r == null) return null;
// Place onto the current page
final pdf = ref.read(pdfProvider);
if (!pdf.loaded) return null;
// Bind the processed image at placement time (so placed preview matches adjustments).
// If processed bytes exist, always create a new asset for this placement.
// Prefer reusing an existing library asset id when the active overlay is
// based on a library item. If there is no library asset, do NOT create
// a new library card here keep the placement's image id empty so the
// UI and exporter will fall back to using the processed/current bytes.
String id = state.assetId ?? '';
// Store as UI-space rect (consistent with export and rendering paths)
ref
.read(pdfProvider.notifier)
.addPlacement(
page: pdf.currentPage,
rect: r,
imageId: id,
rotationDeg: state.rotation,
);
// 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;
}
// Test/helper variant: confirm using a ProviderContainer instead of WidgetRef.
// Useful in widget tests where obtaining a WidgetRef is not straightforward.
@visibleForTesting
Rect? confirmCurrentSignatureWithContainer(ProviderContainer container) {
final r = state.rect;
if (r == null) return null;
final pdf = container.read(pdfProvider);
if (!pdf.loaded) return null;
// Reuse existing library id if present; otherwise leave empty so the
// placement will reference the current bytes via fallback paths.
String id = state.assetId ?? '';
container
.read(pdfProvider.notifier)
.addPlacement(
page: pdf.currentPage,
rect: r,
imageId: id,
rotationDeg: state.rotation,
);
final idx =
(container
.read(pdfProvider)
.placementsByPage[pdf.currentPage]
?.length ??
1) -
1;
if (idx >= 0) {
container.read(pdfProvider.notifier).selectPlacement(idx);
}
state = state.copyWith(editingEnabled: false);
return r;
}
// Remove the active overlay (draft or confirmed preview) but keep image settings intact
void clearActiveOverlay() {
state = state.copyWith(rect: null, editingEnabled: false);
}
}
final signatureProvider =
StateNotifierProvider<SignatureController, SignatureState>(
(ref) => SignatureController(),
);
/// Derived provider that returns processed signature image bytes according to
/// current adjustment settings (contrast/brightness) and background removal.
/// Returns null if no image is loaded. The output is a PNG to preserve alpha.
final processedSignatureImageProvider = Provider<Uint8List?>((ref) {
// Watch only the fields that affect pixel processing to avoid recompute on rotation.
final String? assetId = ref.watch(signatureProvider.select((s) => s.assetId));
final Uint8List? directBytes = ref.watch(
signatureProvider.select((s) => s.imageBytes),
);
final double contrast = ref.watch(
signatureProvider.select((s) => s.contrast),
);
final double brightness = ref.watch(
signatureProvider.select((s) => s.brightness),
);
final bool bgRemoval = ref.watch(
signatureProvider.select((s) => s.bgRemoval),
);
// If active overlay is based on a library asset, pull its bytes
Uint8List? bytes;
if (assetId != null) {
final lib = ref.watch(signatureLibraryProvider);
for (final a in lib) {
if (a.id == assetId) {
bytes = a.bytes;
break;
}
}
} else {
bytes = directBytes;
}
if (bytes == null || bytes.isEmpty) return null;
// Decode (supports PNG/JPEG, etc.)
final decoded = img.decodeImage(bytes);
if (decoded == null) return bytes;
// Work on a copy and ensure an alpha channel is present (RGBA)
var out = decoded.clone();
if (out.hasPalette || !out.hasAlpha) {
// Force truecolor RGBA image so per-pixel alpha writes take effect
out = out.convert(numChannels: 4);
}
// Parameters
// Rotation is not applied here (UI uses Transform; export applies once).
const int thrLow = 220; // begin soft transparency from this avg luminance
const int thrHigh = 245; // fully transparent from this avg luminance
// Helper to clamp int
int clamp255(num v) => v.clamp(0, 255).toInt();
// Iterate pixels
for (int y = 0; y < out.height; y++) {
for (int x = 0; x < out.width; x++) {
final p = out.getPixel(x, y);
int a = clamp255(p.aNormalized * 255.0);
int r = clamp255(p.rNormalized * 255.0);
int g = clamp255(p.gNormalized * 255.0);
int b = clamp255(p.bNormalized * 255.0);
// Apply contrast/brightness in sRGB space
// new = (old-128)*contrast + 128 + brightness*255
final double brOffset = brightness * 255.0;
r = clamp255((r - 128) * contrast + 128 + brOffset);
g = clamp255((g - 128) * contrast + 128 + brOffset);
b = clamp255((b - 128) * contrast + 128 + brOffset);
// Near-white background removal (compute average luminance)
final int avg = ((r + g + b) / 3).round();
int remAlpha = 255; // 255 = fully opaque, 0 = transparent
if (bgRemoval) {
if (avg >= thrHigh) {
remAlpha = 0;
} else if (avg >= thrLow) {
// Soft fade between thrLow..thrHigh
final double t = (avg - thrLow) / (thrHigh - thrLow);
remAlpha = clamp255(255 * (1.0 - t));
} else {
remAlpha = 255;
}
}
// Combine with existing alpha (preserve existing transparency)
final newA = math.min(a, remAlpha);
out.setPixelRgba(x, y, r, g, b, newA);
}
}
// NOTE: Do not rotate here to keep UI responsive while dragging the slider.
// Rotation is applied in the UI using Transform.rotate for preview and
// performed once on confirm/export to avoid per-frame recomputation.
// Encode as PNG to preserve transparency
final png = img.encodePng(out, level: 6);
return Uint8List.fromList(png);
});

View File

@ -1,40 +0,0 @@
import 'dart:typed_data';
import 'package:flutter_riverpod/flutter_riverpod.dart';
/// 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(),
);

View File

@ -0,0 +1,45 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:pdf_signature/domain/models/model.dart' as domain;
import 'package:pdf_signature/data/repositories/signature_card_repository.dart'
as repo;
import 'package:image/image.dart' as img;
class SignatureViewModel {
final Ref ref;
SignatureViewModel(this.ref);
repo.DisplaySignatureData getDisplaySignatureData(
domain.SignatureAsset asset,
domain.GraphicAdjust adjust,
) {
final notifier = ref.read(repo.signatureCardRepositoryProvider.notifier);
return notifier.getDisplayData(asset, adjust);
}
// New image-based accessors
img.Image getProcessedImage(
domain.SignatureAsset asset,
domain.GraphicAdjust adjust,
) {
final notifier = ref.read(repo.signatureCardRepositoryProvider.notifier);
return notifier.getProcessedImage(asset, adjust);
}
(img.Image image, List<double>? colorMatrix) getDisplayImage(
domain.SignatureAsset asset,
domain.GraphicAdjust adjust,
) {
final notifier = ref.read(repo.signatureCardRepositoryProvider.notifier);
return notifier.getDisplayImage(asset, adjust);
}
void clearCache() {
final notifier = ref.read(repo.signatureCardRepositoryProvider.notifier);
notifier.clearProcessedCache();
}
}
final signatureViewModelProvider = Provider<SignatureViewModel>((ref) {
return SignatureViewModel(ref);
});

View File

@ -0,0 +1,292 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:image/image.dart' as img;
import 'package:colorfilter_generator/colorfilter_generator.dart';
import 'package:colorfilter_generator/addons.dart';
import 'package:pdf_signature/l10n/app_localizations.dart';
import '../../pdf/widgets/adjustments_panel.dart';
import '../../../../domain/models/model.dart' as domain;
import 'rotated_signature_image.dart';
import '../../../../utils/background_removal.dart' as br;
class ImageEditorResult {
final double rotation;
final domain.GraphicAdjust graphicAdjust;
const ImageEditorResult({
required this.rotation,
required this.graphicAdjust,
});
}
class ImageEditorDialog extends StatefulWidget {
const ImageEditorDialog({
super.key,
required this.asset,
required this.initialRotation,
required this.initialGraphicAdjust,
});
final domain.SignatureAsset asset;
final double initialRotation;
final domain.GraphicAdjust initialGraphicAdjust;
@override
State<ImageEditorDialog> createState() => _ImageEditorDialogState();
}
class _ImageEditorDialogState extends State<ImageEditorDialog> {
// UI state
late bool _aspectLocked;
late bool _bgRemoval;
late double _contrast;
late double _brightness;
late final ValueNotifier<double> _rotation;
// Cached image data
late img.Image _originalImage; // Original asset image
img.Image?
_processedBgRemovedImage; // Cached brightness/contrast adjusted then bg-removed image
// Debounce for background removal (in case we later tie it to brightness/contrast)
Timer? _bgRemovalDebounce;
@override
void initState() {
super.initState();
_aspectLocked = false; // Not persisted in GraphicAdjust
_bgRemoval = widget.initialGraphicAdjust.bgRemoval;
_contrast = widget.initialGraphicAdjust.contrast;
_brightness = widget.initialGraphicAdjust.brightness;
_rotation = ValueNotifier<double>(widget.initialRotation);
_originalImage = widget.asset.sigImage;
// If background removal initially enabled, precompute immediately
if (_bgRemoval) {
_scheduleBgRemovalReprocess(immediate: true);
}
}
// No _displayBytes cache: the preview now uses img.Image directly.
void _onBgRemovalChanged(bool value) {
setState(() {
_bgRemoval = value;
if (value) {
_scheduleBgRemovalReprocess(immediate: true);
}
});
}
void _scheduleBgRemovalReprocess({bool immediate = false}) {
if (!_bgRemoval) return; // Only when enabled
_bgRemovalDebounce?.cancel();
if (immediate) {
_recomputeBgRemoval();
} else {
_bgRemovalDebounce = Timer(
const Duration(milliseconds: 120),
_recomputeBgRemoval,
);
}
}
void _recomputeBgRemoval() {
final base = _originalImage;
// Apply brightness & contrast first (domain uses 1.0 neutral)
img.Image working = img.Image.from(base);
final needAdjust = _brightness != 1.0 || _contrast != 1.0;
if (needAdjust) {
working = img.adjustColor(
working,
brightness: _brightness,
contrast: _contrast,
);
}
// Then remove background on adjusted pixels
working = br.removeNearWhiteBackground(working, threshold: 240);
if (!mounted) return;
setState(() {
_processedBgRemovedImage = working;
});
}
ColorFilter _currentColorFilter() {
// The original domain model uses 1.0 as neutral for brightness/contrast.
// colorfilter_generator expects values between -1..1 for adjustments when using addons.
// We'll map: domain brightness (default 1.0) -> addon brightness(value-1)
// Same for contrast.
final bAddon = _brightness - 1.0; // so 1.0 => 0
final cAddon = _contrast - 1.0; // so 1.0 => 0
final generator = ColorFilterGenerator(
name: 'dynamic_adjust',
filters: [
if (bAddon != 0) ColorFilterAddons.brightness(bAddon),
if (cAddon != 0) ColorFilterAddons.contrast(cAddon),
],
);
// If neutral, return identity filter to avoid unnecessary matrix mul
if (bAddon == 0 && cAddon == 0) {
// Identity matrix
return const ColorFilter.matrix(<double>[
1,
0,
0,
0,
0,
0,
1,
0,
0,
0,
0,
0,
1,
0,
0,
0,
0,
0,
1,
0,
]);
}
return ColorFilter.matrix(generator.matrix);
}
@override
void dispose() {
_rotation.dispose();
_bgRemovalDebounce?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
final l10n = Localizations.of<AppLocalizations>(context, AppLocalizations)!;
final l = AppLocalizations.of(context);
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: if bg removal active we already applied adjustments in CPU pipeline,
// otherwise apply brightness/contrast via GPU ColorFilter.
SizedBox(
height: 160,
child: DecoratedBox(
decoration: BoxDecoration(
border: Border.all(color: Theme.of(context).dividerColor),
borderRadius: BorderRadius.circular(8),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: ValueListenableBuilder<double>(
valueListenable: _rotation,
builder: (context, rot, child) {
final image = RotatedSignatureImage(
image:
_bgRemoval
? (_processedBgRemovedImage ??
_originalImage)
: _originalImage,
rotationDeg: rot,
);
if (_bgRemoval) return image;
return ColorFiltered(
colorFilter: _currentColorFilter(),
child: image,
);
},
),
),
),
),
const SizedBox(height: 12),
// Adjustments
AdjustmentsPanel(
aspectLocked: _aspectLocked,
bgRemoval: _bgRemoval,
contrast: _contrast,
brightness: _brightness,
onAspectLockedChanged:
(v) => setState(() => _aspectLocked = v),
onBgRemovalChanged: (v) => _onBgRemovalChanged(v),
onContrastChanged:
(v) => setState(() {
_contrast = v;
if (_bgRemoval) _scheduleBgRemovalReprocess();
}),
onBrightnessChanged:
(v) => setState(() {
_brightness = v;
if (_bgRemoval) _scheduleBgRemovalReprocess();
}),
),
const SizedBox(height: 8),
Row(
children: [
Text(l10n.rotate),
Expanded(
child: ValueListenableBuilder<double>(
valueListenable: _rotation,
builder: (context, rot, _) {
return Slider(
key: const Key('sld_rotation'),
min: -180,
max: 180,
divisions: 72,
value: rot,
onChanged: (v) => _rotation.value = v,
);
},
),
),
ValueListenableBuilder<double>(
valueListenable: _rotation,
builder:
(context, rot, _) =>
Text('${rot.toStringAsFixed(0)}°'),
),
],
),
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
key: const Key('btn_image_editor_close'),
onPressed:
() => Navigator.of(context).pop(
ImageEditorResult(
rotation: _rotation.value,
graphicAdjust: domain.GraphicAdjust(
contrast: _contrast,
brightness: _brightness,
bgRemoval: _bgRemoval,
),
),
),
child: Text(
MaterialLocalizations.of(context).closeButtonLabel,
),
),
],
),
],
),
),
),
),
);
}
}

View File

@ -1,132 +1,164 @@
import 'dart:math' as math; import 'dart:async';
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:image/image.dart' as img;
import '../../../../utils/rotation_utils.dart' as rot;
/// A lightweight widget to render signature bytes with rotation and an /// A lightweight widget to render signature bytes with rotation and an
/// angle-aware scale-to-fit so the rotated image stays within its bounds. /// angle-aware scale-to-fit so the rotated image stays within its bounds.
/// Don't use `decodeImage`, large images can be crazily slow, especially on web.
class RotatedSignatureImage extends StatefulWidget { class RotatedSignatureImage extends StatefulWidget {
const RotatedSignatureImage({ const RotatedSignatureImage({
super.key, super.key,
required this.bytes, required this.image,
this.rotationDeg = 0.0, this.rotationDeg = 0.0, // counterclockwise as positive
this.enableAngleAwareScale = true,
this.fit = BoxFit.contain,
this.gaplessPlayback = true,
this.filterQuality = FilterQuality.low, this.filterQuality = FilterQuality.low,
this.wrapInRepaintBoundary = true,
this.alignment = Alignment.center,
this.semanticLabel, this.semanticLabel,
this.intrinsicAspectRatio, this.cacheWidth,
this.cacheHeight,
}); });
final Uint8List bytes; /// Decoded CPU image (from `package:image`).
final img.Image image;
/// Rotation in degrees. Positive values rotate counterclockwise in math sense.
/// Screen-space is handled via [rot.ccwRadians].
final double rotationDeg; final double rotationDeg;
final bool enableAngleAwareScale;
final BoxFit fit;
final bool gaplessPlayback;
final FilterQuality filterQuality; final FilterQuality filterQuality;
final bool wrapInRepaintBoundary;
final AlignmentGeometry alignment;
final String? semanticLabel; final String? semanticLabel;
// Optional: intrinsic aspect ratio (width / height). If provided, we compute
// an angle-aware scale for non-square images to ensure the rotated rectangle /// Optional target size hints to reduce decode cost.
// (W,H) fits back into its (W,H) bounds. If null, we attempt to derive it /// If only one is provided, the other is computed to preserve aspect.
// from the image stream; only fall back to the square heuristic if unknown. final int? cacheWidth;
final double? intrinsicAspectRatio; final int? cacheHeight;
@override @override
State<RotatedSignatureImage> createState() => _RotatedSignatureImageState(); State<RotatedSignatureImage> createState() => _RotatedSignatureImageState();
} }
class _RotatedSignatureImageState extends State<RotatedSignatureImage> { class _RotatedSignatureImageState extends State<RotatedSignatureImage> {
ImageStream? _stream; Uint8List? _encodedBytes; // PNG-encoded bytes for Image.memory
ImageStreamListener? _listener; img.Image? _lastSrc; // To detect changes cheaply
double? _derivedAspectRatio; // width / height int? _lastW;
int? _lastH;
MemoryImage get _provider => MemoryImage(widget.bytes);
@override @override
void didChangeDependencies() { void initState() {
super.didChangeDependencies(); super.initState();
_resolveImage(); _prepare();
} }
@override @override
void didUpdateWidget(covariant RotatedSignatureImage oldWidget) { void didUpdateWidget(covariant RotatedSignatureImage oldWidget) {
super.didUpdateWidget(oldWidget); super.didUpdateWidget(oldWidget);
if (!identical(oldWidget.bytes, widget.bytes)) { final srcChanged =
_derivedAspectRatio = null; !identical(widget.image, _lastSrc) ||
_resolveImage(); widget.image.width != (oldWidget.image.width) ||
widget.image.height != (oldWidget.image.height);
final sizeHintChanged =
widget.cacheWidth != oldWidget.cacheWidth ||
widget.cacheHeight != oldWidget.cacheHeight;
if (srcChanged || sizeHintChanged) {
_prepare();
} }
} }
void _resolveImage() {
_unlisten();
// Only derive AR if not provided
if (widget.intrinsicAspectRatio != null) return;
final stream = _provider.resolve(createLocalImageConfiguration(context));
_stream = stream;
_listener = ImageStreamListener((ImageInfo info, bool sync) {
final w = info.image.width;
final h = info.image.height;
if (w > 0 && h > 0) {
final ar = w / h;
if (mounted && _derivedAspectRatio != ar) {
setState(() => _derivedAspectRatio = ar);
}
}
});
stream.addListener(_listener!);
}
void _unlisten() {
if (_stream != null && _listener != null) {
_stream!.removeListener(_listener!);
}
_stream = null;
_listener = null;
}
@override @override
void dispose() { void dispose() {
_unlisten();
super.dispose(); super.dispose();
} }
Future<void> _prepare() async {
final src = widget.image;
_lastSrc = src;
// Compute target decode size preserving aspect if hints provided.
int targetW = src.width;
int targetH = src.height;
if (widget.cacheWidth != null || widget.cacheHeight != null) {
if (widget.cacheWidth != null && widget.cacheHeight != null) {
targetW = widget.cacheWidth!.clamp(1, src.width);
targetH = widget.cacheHeight!.clamp(1, src.height);
} else if (widget.cacheWidth != null) {
targetW = widget.cacheWidth!.clamp(1, src.width);
targetH = (targetW * src.height / src.width).round().clamp(
1,
src.height,
);
} else if (widget.cacheHeight != null) {
targetH = widget.cacheHeight!.clamp(1, src.height);
targetW = (targetH * src.width / src.height).round().clamp(
1,
src.width,
);
}
}
img.Image working = src;
if (working.width != targetW || working.height != targetH) {
// High-quality resize; image package chooses a reasonable default.
working = img.copyResize(working, width: targetW, height: targetH);
}
// Ensure RGBA (4 channels) so alpha is preserved when encoding.
working = working.convert(numChannels: 4);
_lastW = working.width;
_lastH = working.height;
// Encode to PNG with low compression level for faster encode.
// This avoids manual decode in the widget; Flutter will decode the PNG.
final pngEncoder = img.PngEncoder(level: 1);
final bytes = Uint8List.fromList(pngEncoder.encode(working));
if (!mounted) return;
setState(() => _encodedBytes = bytes);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final angle = widget.rotationDeg * math.pi / 180.0; // Compute angle-aware scale so rotated image stays within bounds.
Widget img = Image.memory( final double angleRad = rot.ccwRadians(widget.rotationDeg);
widget.bytes, final double ar =
fit: widget.fit, widget.image.width == 0
gaplessPlayback: widget.gaplessPlayback, ? 1.0
: widget.image.width / widget.image.height;
final double k = rot.scaleToFitForAngle(angleRad, ar: ar);
Widget core =
_encodedBytes == null
? const SizedBox.shrink()
: Image.memory(
_encodedBytes!,
fit: BoxFit.contain,
filterQuality: widget.filterQuality, filterQuality: widget.filterQuality,
alignment: widget.alignment, gaplessPlayback: true,
semanticLabel: widget.semanticLabel,
); );
if (widget.semanticLabel != null) {
if (angle != 0.0) { core = Semantics(label: widget.semanticLabel, child: core);
if (widget.enableAngleAwareScale) {
final double c = math.cos(angle).abs();
final double s = math.sin(angle).abs();
final ar = widget.intrinsicAspectRatio ?? _derivedAspectRatio;
double scaleToFit;
if (ar != null && ar > 0) {
scaleToFit = math.min(ar / (ar * c + s), 1.0 / (ar * s + c));
} else {
// Fallback: square approximation
scaleToFit = 1.0 / (c + s).clamp(1.0, double.infinity);
}
img = Transform.scale(
scale: scaleToFit,
child: Transform.rotate(angle: angle, child: img),
);
} else {
img = Transform.rotate(angle: angle, child: img);
}
} }
if (!widget.wrapInRepaintBoundary) return img; // Order: scale first, then rotate. Scale ensures rotated bounds fit.
return RepaintBoundary(child: img); Widget transformed = Transform.scale(
scale: k,
alignment: Alignment.center,
child: Transform.rotate(
angle: angleRad,
alignment: Alignment.center,
child: core,
),
);
// Allow parent to size; we simply contain within available space.
return FittedBox(
fit: BoxFit.contain,
alignment: Alignment.center,
child: SizedBox(
width: _lastW?.toDouble() ?? widget.image.width.toDouble(),
height: _lastH?.toDouble() ?? widget.image.height.toDouble(),
child: transformed,
),
);
} }
} }

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