Compare commits
No commits in common. "f133ecb17cbb20153391dbab53b152e4a01ea405" and "4f149656bd015c125db08515359d157246500569" have entirely different histories.
f133ecb17c
...
4f149656bd
|
|
@ -135,7 +135,3 @@ AppDir/bundle/
|
||||||
appimage-build/
|
appimage-build/
|
||||||
/*.AppImage
|
/*.AppImage
|
||||||
.vscode/settings.json
|
.vscode/settings.json
|
||||||
|
|
||||||
*.patch
|
|
||||||
*.freezed.dart
|
|
||||||
*.g.dart
|
|
||||||
|
|
|
||||||
|
|
@ -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 `ViewModel`, `View` of MVVM (UI widget) (files in `lib/ui/features/*/widgets/*`)
|
* If want to modify code (implement or test) of `View` of MVVM (UI widget) (files at `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`, repositories, services...
|
* If want to modify code (implement or test) of non-View e.g. `Model`, `View Model`, services...
|
||||||
* read `test/features/*.feature`, [`NFRs.md`](docs/NFRs.md)
|
* read `test/features/*.feature`, [`NFRs.md`](docs/NFRs.md)
|
||||||
|
|
|
||||||
|
|
@ -1,52 +1,27 @@
|
||||||
<?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>
|
|
||||||
|
|
||||||
<!-- Background tile -->
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
<rect x="4" y="4" width="56" height="56" rx="12" fill="#2563EB" />
|
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||||
|
<svg version="1.1" id="_x32_" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||||
<!-- Paper with folded corner -->
|
viewBox="0 0 512 512" xml:space="preserve">
|
||||||
<g>
|
<style type="text/css">
|
||||||
<path
|
.st0{fill:#000000;}
|
||||||
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"
|
</style>
|
||||||
fill="#FFFFFF"
|
<g>
|
||||||
/>
|
<path class="st0" d="M56.007,114.35c-5.535-5.539-14.51-5.539-20.045,0L4.148,146.159c-5.531,5.539-5.531,14.506,0,20.046
|
||||||
<path
|
l20.622,20.621l51.859-51.855L56.007,114.35z"/>
|
||||||
d="M38 16v8c0 3.3 2.7 6 6 6h8l-14-14z"
|
<polygon class="st0" points="286.422,396.623 268.742,327.077 216.884,378.94 "/>
|
||||||
fill="#F3F4F6"
|
<path class="st0" d="M258.136,316.475L86.058,144.397L34.2,196.26l172.073,172.077L258.136,316.475z M87.468,166.56
|
||||||
/>
|
l149.919,149.922l-11.784,11.78L75.684,178.348L87.468,166.56z"/>
|
||||||
</g>
|
<rect x="195.662" y="132.491" class="st0" width="29.356" height="28.017"/>
|
||||||
|
<rect x="195.662" y="200.693" class="st0" width="29.356" height="28.009"/>
|
||||||
<!-- Signature stroke -->
|
<rect x="256.69" y="132.491" class="st0" width="173.056" height="28.017"/>
|
||||||
<path
|
<rect x="256.69" y="200.693" class="st0" width="173.056" height="28.009"/>
|
||||||
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"
|
<rect x="288.598" y="268.894" class="st0" width="141.148" height="28.01"/>
|
||||||
fill="none"
|
<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
|
||||||
stroke="#1F2937"
|
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-width="2.5"
|
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-linecap="round"
|
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-linejoin="round"
|
v-12.61V93.238C512,47.917,475.138,11.059,429.817,11.059z"/>
|
||||||
/>
|
</g>
|
||||||
|
</svg>
|
||||||
<!-- 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>
|
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.7 KiB |
|
|
@ -1,9 +0,0 @@
|
||||||
|
|
||||||
## 1.1.0
|
|
||||||
|
|
||||||
* refactor to clear domain models
|
|
||||||
|
|
||||||
## 1.0.0
|
|
||||||
|
|
||||||
* basic implementation
|
|
||||||
* support localization
|
|
||||||
|
|
@ -20,7 +20,6 @@ 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
|
||||||
|
|
|
||||||
|
|
@ -10,9 +10,8 @@ checkout [`docs/FRs.md`](docs/FRs.md)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# flutter clean
|
# flutter clean
|
||||||
flutter pub get
|
|
||||||
# arb_translate
|
# arb_translate
|
||||||
# flutter gen-l10n
|
flutter pub get
|
||||||
# > 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
|
||||||
|
|
@ -23,7 +22,6 @@ 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
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
<application
|
<application
|
||||||
android:label="pdf_signature"
|
android:label="pdf_signature"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
android:icon="@mipmap/launcher_icon">
|
android:icon="@mipmap/ic_launcher">
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 1.4 KiB |
|
|
@ -1,52 +0,0 @@
|
||||||
<?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>
|
|
||||||
|
Before Width: | Height: | Size: 1.4 KiB |
14
build.yaml
|
|
@ -1,18 +1,8 @@
|
||||||
targets:
|
targets:
|
||||||
$default:
|
$default:
|
||||||
sources:
|
sources:
|
||||||
- integration_test/** # By default, build runner will not generate code in the integration folder
|
- integration_test/**
|
||||||
- test/** # so we override paths for code generation here
|
- test/**
|
||||||
- lib/**
|
- lib/**
|
||||||
- $package$
|
- $package$
|
||||||
builders:
|
builders:
|
||||||
bdd_widget_test|featureBuilder:
|
|
||||||
generate_for:
|
|
||||||
- test/**
|
|
||||||
- integration_test/**
|
|
||||||
freezed:
|
|
||||||
generate_for:
|
|
||||||
- lib/**
|
|
||||||
json_serializable:
|
|
||||||
generate_for:
|
|
||||||
- lib/**
|
|
||||||
|
|
|
||||||
14
docs/FRs.md
|
|
@ -2,27 +2,25 @@
|
||||||
|
|
||||||
## 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](../test/features/load_signature.feature)
|
* name: [load signature picture](../test/features/load_signature_picture.feature)
|
||||||
* role: user
|
* role: user
|
||||||
* functionality: load a signature asset file and create a signature card
|
* functionality: load a signature picture file
|
||||||
* 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 scale, rotation and position of the signature placement on the PDF page
|
* functionality: adjust the size and position of the signature picture
|
||||||
* 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... to enhance the appearance of the signature asset within the signature card
|
* functionality: background removal, contrast adjustment...
|
||||||
* 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 asset using mouse or touch input
|
* functionality: draw a signature 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
|
||||||
|
|
@ -30,7 +28,7 @@ The following user stories may not use formal terminology as [meta-arch.md](./me
|
||||||
* 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 `language`, `theme`, `theme-color`.
|
* functionality: configure app preferences such as `theme`, `language`.
|
||||||
* 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
|
||||||
|
|
|
||||||
|
|
@ -3,4 +3,3 @@
|
||||||
* 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`.
|
|
||||||
|
|
|
||||||
|
|
@ -1,84 +1,16 @@
|
||||||
# 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).
|
The repo structure follows official [Package structure](https://docs.flutter.dev/app-architecture/case-study#package-structure) with slight modifications.
|
||||||
|
|
||||||
```
|
|
||||||
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)
|
||||||
|
|
@ -90,7 +22,3 @@ Some rule of thumb:
|
||||||
* [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.
|
|
||||||
|
|
|
||||||
|
|
@ -396,13 +396,151 @@
|
||||||
"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": 509.59787769019385,
|
"y": 505.261726567147,
|
||||||
"width": 123.56657324612611,
|
"width": 146.7306357461261,
|
||||||
"height": 36.296948006649586,
|
"height": 40.63309912969646,
|
||||||
"angle": 0,
|
"angle": 0,
|
||||||
"strokeColor": "#1f2937",
|
"strokeColor": "#1f2937",
|
||||||
"backgroundColor": "#ffffff",
|
"backgroundColor": "#ffffff",
|
||||||
|
|
@ -418,11 +556,11 @@
|
||||||
"index": "aD",
|
"index": "aD",
|
||||||
"roundness": null,
|
"roundness": null,
|
||||||
"seed": 1608525080,
|
"seed": 1608525080,
|
||||||
"version": 114,
|
"version": 101,
|
||||||
"versionNonce": 1580272529,
|
"versionNonce": 679299830,
|
||||||
"isDeleted": false,
|
"isDeleted": false,
|
||||||
"boundElements": [],
|
"boundElements": [],
|
||||||
"updated": 1758364887319,
|
"updated": 1756647235527,
|
||||||
"link": null,
|
"link": null,
|
||||||
"locked": false
|
"locked": false
|
||||||
},
|
},
|
||||||
|
|
@ -431,7 +569,7 @@
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"x": 601.8763252741879,
|
"x": 601.8763252741879,
|
||||||
"y": 514.291304151524,
|
"y": 514.291304151524,
|
||||||
"width": 45.983367919921875,
|
"width": 39.54961113185798,
|
||||||
"height": 24.379859477817877,
|
"height": 24.379859477817877,
|
||||||
"angle": 0,
|
"angle": 0,
|
||||||
"strokeColor": "#1f2937",
|
"strokeColor": "#1f2937",
|
||||||
|
|
@ -448,20 +586,20 @@
|
||||||
"index": "aE",
|
"index": "aE",
|
||||||
"roundness": null,
|
"roundness": null,
|
||||||
"seed": 533447192,
|
"seed": 533447192,
|
||||||
"version": 111,
|
"version": 103,
|
||||||
"versionNonce": 935775633,
|
"versionNonce": 554272618,
|
||||||
"isDeleted": false,
|
"isDeleted": false,
|
||||||
"boundElements": [],
|
"boundElements": [],
|
||||||
"updated": 1758364882876,
|
"updated": 1756647235527,
|
||||||
"link": null,
|
"link": null,
|
||||||
"locked": false,
|
"locked": false,
|
||||||
"text": "Close",
|
"text": "Save",
|
||||||
"fontSize": 18.059155168753982,
|
"fontSize": 18.059155168753982,
|
||||||
"fontFamily": 6,
|
"fontFamily": 6,
|
||||||
"textAlign": "left",
|
"textAlign": "left",
|
||||||
"verticalAlign": "top",
|
"verticalAlign": "top",
|
||||||
"containerId": null,
|
"containerId": null,
|
||||||
"originalText": "Close",
|
"originalText": "Save",
|
||||||
"autoResize": true,
|
"autoResize": true,
|
||||||
"lineHeight": 1.35
|
"lineHeight": 1.35
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,6 @@ 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:
|
||||||
|
|
@ -30,10 +29,9 @@ 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 right of top bar.
|
- Opened via "Configure" button in the top bar.
|
||||||
- Model with simple sections (e.g., General, Display).
|
- Modal with simple sections (e.g., General, Display).
|
||||||
- When select option, option will take effect immediately.
|
- Primary action to save, secondary to cancel.
|
||||||
- A button to close the dialog and return to the previous screen.
|
|
||||||
|
|
||||||
Illustration:
|
Illustration:
|
||||||
|
|
||||||
|
|
@ -58,12 +56,11 @@ 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):
|
||||||
|
|
|
||||||
|
|
@ -3,22 +3,14 @@ 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 'dart:io';
|
import 'package:image/image.dart' as img;
|
||||||
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/data/repositories/signature_asset_repository.dart';
|
import 'package:pdf_signature/ui/features/signature/view_model/signature_library.dart';
|
||||||
import 'package:pdf_signature/data/repositories/signature_card_repository.dart';
|
import 'package:pdf_signature/ui/features/signature/view_model/signature_controller.dart';
|
||||||
import 'package:pdf_signature/data/repositories/document_repository.dart';
|
import 'package:pdf_signature/ui/features/pdf/view_model/pdf_controller.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 {
|
||||||
|
|
@ -30,30 +22,6 @@ 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();
|
||||||
|
|
||||||
|
|
@ -61,42 +29,26 @@ 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: [
|
||||||
preferencesRepositoryProvider.overrideWith(
|
pdfProvider.overrideWith(
|
||||||
(ref) => PreferencesStateNotifier(prefs),
|
(ref) => PdfController()..openPicked(path: 'test.pdf'),
|
||||||
),
|
),
|
||||||
documentRepositoryProvider.overrideWith(
|
signatureProvider.overrideWith(
|
||||||
(ref) => DocumentStateNotifier()..openPicked(pageCount: 3),
|
(ref) => SignatureController()..placeDefaultRect(),
|
||||||
),
|
),
|
||||||
pdfViewModelProvider.overrideWith(
|
useMockViewerProvider.overrideWith((ref) => true),
|
||||||
(ref) => PdfViewModel(ref, useMockViewer: false),
|
exportServiceProvider.overrideWith((_) => fake),
|
||||||
),
|
savePathPickerProvider.overrideWith(
|
||||||
pdfExportViewModelProvider.overrideWith(
|
(_) => () async => 'C:/tmp/output.pdf',
|
||||||
(ref) => PdfExportViewModel(
|
|
||||||
ref,
|
|
||||||
exporter: fake,
|
|
||||||
savePathPicker: () async {
|
|
||||||
final dir = Directory.systemTemp.createTempSync('pdfsig_');
|
|
||||||
return '${dir.path}/output.pdf';
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
child: MaterialApp(
|
child: const 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'),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
@ -129,49 +81,26 @@ 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: [
|
||||||
preferencesRepositoryProvider.overrideWith(
|
pdfProvider.overrideWith(
|
||||||
(ref) => PreferencesStateNotifier(prefs),
|
(ref) => PdfController()..openPicked(path: 'test.pdf'),
|
||||||
),
|
),
|
||||||
documentRepositoryProvider.overrideWith(
|
signatureLibraryProvider.overrideWith((ref) {
|
||||||
(ref) =>
|
final c = SignatureLibraryController();
|
||||||
DocumentStateNotifier()
|
c.add(sigBytes, name: 'image');
|
||||||
..openPicked(pageCount: 3, bytes: pdfBytes),
|
|
||||||
),
|
|
||||||
signatureAssetRepositoryProvider.overrideWith((ref) {
|
|
||||||
final c = SignatureAssetRepository();
|
|
||||||
c.addImage(img.decodeImage(sigBytes)!, name: 'image');
|
|
||||||
return c;
|
return c;
|
||||||
}),
|
}),
|
||||||
signatureCardRepositoryProvider.overrideWith((ref) {
|
// Keep mock viewer for determinism on CI/desktop devices
|
||||||
final cardRepo = SignatureCardStateNotifier();
|
useMockViewerProvider.overrideWithValue(true),
|
||||||
final asset = SignatureAsset(
|
|
||||||
sigImage: img.decodeImage(sigBytes)!,
|
|
||||||
name: 'image',
|
|
||||||
);
|
|
||||||
cardRepo.addWithAsset(asset, 0.0);
|
|
||||||
return cardRepo;
|
|
||||||
}),
|
|
||||||
pdfViewModelProvider.overrideWith(
|
|
||||||
(ref) => PdfViewModel(ref, useMockViewer: false),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
child: MaterialApp(
|
child: const 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'),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
@ -190,18 +119,15 @@ 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 r = container.read(pdfViewModelProvider).activeRect!;
|
final sigState = container.read(signatureProvider);
|
||||||
final lib = container.read(signatureAssetRepositoryProvider);
|
final r = sigState.rect!;
|
||||||
final asset = lib.isNotEmpty ? lib.first : null;
|
final lib = container.read(signatureLibraryProvider);
|
||||||
final currentPage = container.read(pdfViewModelProvider).currentPage;
|
final imageId = lib.isNotEmpty ? lib.first.id : 'default.png';
|
||||||
|
final pdf = container.read(pdfProvider);
|
||||||
container
|
container
|
||||||
.read(documentRepositoryProvider.notifier)
|
.read(pdfProvider.notifier)
|
||||||
.addPlacement(page: currentPage, rect: r, asset: asset);
|
.addPlacement(page: pdf.currentPage, rect: r, imageId: imageId);
|
||||||
// Clear active overlay by hiding signatures temporarily
|
container.read(signatureProvider.notifier).clearActiveOverlay();
|
||||||
// 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'));
|
||||||
|
|
@ -217,265 +143,4 @@ 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);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,342 +0,0 @@
|
||||||
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);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -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 = AppIcon;
|
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||||
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 = AppIcon;
|
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||||
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++";
|
||||||
|
|
|
||||||
|
|
@ -1 +1,122 @@
|
||||||
{"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 8.1 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 1012 B After Width: | Height: | Size: 295 B |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 406 B |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 450 B |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 282 B |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 462 B |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 704 B |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 406 B |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 586 B |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 862 B |
|
Before Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 862 B |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 762 B |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 1.4 KiB |
146
lib/app.dart
|
|
@ -2,9 +2,11 @@ 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/routing/router.dart';
|
import 'package:pdf_signature/ui/features/pdf/widgets/pdf_screen.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});
|
||||||
|
|
@ -14,69 +16,74 @@ class MyApp extends StatelessWidget {
|
||||||
return ProviderScope(
|
return ProviderScope(
|
||||||
child: Consumer(
|
child: Consumer(
|
||||||
builder: (context, ref, _) {
|
builder: (context, ref, _) {
|
||||||
final prefs = ref.watch(preferencesRepositoryProvider);
|
// Ensure SharedPreferences loaded before building MaterialApp
|
||||||
final seed = themeSeedFromPrefs(prefs);
|
final sp = ref.watch(sharedPreferencesProvider);
|
||||||
final appLocale =
|
return sp.when(
|
||||||
supportedLanguageTags().contains(prefs.language)
|
loading: () => const SizedBox.shrink(),
|
||||||
? parseLanguageTag(prefs.language)
|
error:
|
||||||
: null;
|
(e, st) => MaterialApp(
|
||||||
final themeMode = () {
|
onGenerateTitle: (ctx) => AppLocalizations.of(ctx).appTitle,
|
||||||
switch (prefs.theme) {
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
case 'light':
|
localizationsDelegates:
|
||||||
return ThemeMode.light;
|
AppLocalizations.localizationsDelegates,
|
||||||
case 'dark':
|
home: Builder(
|
||||||
return ThemeMode.dark;
|
builder:
|
||||||
case 'system':
|
(ctx) => Scaffold(
|
||||||
default:
|
body: Center(
|
||||||
return ThemeMode.system;
|
child: Text(
|
||||||
}
|
AppLocalizations.of(
|
||||||
}();
|
ctx,
|
||||||
|
).errorWithMessage(e.toString()),
|
||||||
return MaterialApp.router(
|
),
|
||||||
onGenerateTitle: (ctx) => AppLocalizations.of(ctx).appTitle,
|
|
||||||
theme: ThemeData(
|
|
||||||
colorScheme: ColorScheme.fromSeed(
|
|
||||||
seedColor: seed,
|
|
||||||
brightness: Brightness.light,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
darkTheme: ThemeData(
|
|
||||||
colorScheme: ColorScheme.fromSeed(
|
|
||||||
seedColor: seed,
|
|
||||||
brightness: Brightness.dark,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
themeMode: themeMode,
|
|
||||||
locale: appLocale,
|
|
||||||
supportedLocales: AppLocalizations.supportedLocales,
|
|
||||||
localizationsDelegates: [
|
|
||||||
...AppLocalizations.localizationsDelegates,
|
|
||||||
LocaleNamesLocalizationsDelegate(),
|
|
||||||
],
|
|
||||||
routerConfig: ref.watch(routerProvider),
|
|
||||||
builder: (context, child) {
|
|
||||||
final router = ref.watch(routerProvider);
|
|
||||||
return Scaffold(
|
|
||||||
appBar: AppBar(
|
|
||||||
title: Text(AppLocalizations.of(context).appTitle),
|
|
||||||
actions: [
|
|
||||||
OutlinedButton.icon(
|
|
||||||
key: const Key('btn_appbar_settings'),
|
|
||||||
icon: const Icon(Icons.settings),
|
|
||||||
label: Text(AppLocalizations.of(context).settings),
|
|
||||||
onPressed:
|
|
||||||
() => showDialog<bool>(
|
|
||||||
context:
|
|
||||||
router
|
|
||||||
.routerDelegate
|
|
||||||
.navigatorKey
|
|
||||||
.currentContext!,
|
|
||||||
builder: (_) => const SettingsDialog(),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
|
),
|
||||||
|
data: (_) {
|
||||||
|
final themeMode = ref.watch(themeModeProvider);
|
||||||
|
final appLocale = ref.watch(localeProvider);
|
||||||
|
return MaterialApp(
|
||||||
|
onGenerateTitle: (ctx) => AppLocalizations.of(ctx).appTitle,
|
||||||
|
theme: ThemeData(
|
||||||
|
colorScheme: ColorScheme.fromSeed(
|
||||||
|
seedColor: Colors.indigo,
|
||||||
|
brightness: Brightness.light,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
darkTheme: ThemeData(
|
||||||
|
colorScheme: ColorScheme.fromSeed(
|
||||||
|
seedColor: Colors.indigo,
|
||||||
|
brightness: Brightness.dark,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
themeMode: themeMode,
|
||||||
|
locale: appLocale,
|
||||||
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
|
localizationsDelegates: [
|
||||||
|
...AppLocalizations.localizationsDelegates,
|
||||||
|
LocaleNamesLocalizationsDelegate(),
|
||||||
|
],
|
||||||
|
home: Builder(
|
||||||
|
builder:
|
||||||
|
(ctx) => Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: Text(AppLocalizations.of(ctx).appTitle),
|
||||||
|
actions: [
|
||||||
|
OutlinedButton.icon(
|
||||||
|
key: const Key('btn_appbar_settings'),
|
||||||
|
icon: const Icon(Icons.settings),
|
||||||
|
label: Text(AppLocalizations.of(ctx).settings),
|
||||||
|
onPressed:
|
||||||
|
() => showDialog<bool>(
|
||||||
|
context: ctx,
|
||||||
|
builder: (_) => const SettingsDialog(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: const _RootHomeSwitcher(),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
body: child,
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
@ -85,3 +92,16 @@ class MyApp extends StatelessWidget {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _RootHomeSwitcher extends ConsumerWidget {
|
||||||
|
const _RootHomeSwitcher();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final pdf = ref.watch(pdfProvider);
|
||||||
|
if (!pdf.loaded) {
|
||||||
|
return const WelcomeScreen();
|
||||||
|
}
|
||||||
|
return const PdfSignatureHomePage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,147 @@
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,299 +0,0 @@
|
||||||
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.
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
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(),
|
|
||||||
);
|
|
||||||
|
|
@ -1,212 +0,0 @@
|
||||||
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(),
|
|
||||||
);
|
|
||||||
|
|
@ -0,0 +1,60 @@
|
||||||
|
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';
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
@ -6,10 +6,7 @@ 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 '../../domain/models/model.dart';
|
import '../model/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.
|
||||||
|
|
@ -18,106 +15,70 @@ import '../../utils/background_removal.dart' as br;
|
||||||
// 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 from source PDF bytes; returns the resulting PDF bytes.
|
/// Compose a new PDF by rasterizing the original PDF pages (via pdfrx engine)
|
||||||
Future<Uint8List?> exportSignedPdfFromBytes({
|
/// and optionally stamping a signature image on the specified page.
|
||||||
required Uint8List srcBytes,
|
///
|
||||||
|
/// 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 Size uiPageSize,
|
||||||
required Uint8List? signatureImageBytes,
|
required Uint8List? signatureImageBytes,
|
||||||
Map<int, List<SignaturePlacement>>? placementsByPage,
|
Map<int, List<SignaturePlacement>>? placementsByPage,
|
||||||
Map<String, img.Image>? libraryImages,
|
Map<String, Uint8List>? libraryBytes,
|
||||||
double targetDpi = 144.0,
|
double targetDpi = 144.0,
|
||||||
}) async {
|
}) async {
|
||||||
// Per-call caches to avoid redundant decode/encode and image embedding work
|
// print(
|
||||||
final Map<String, img.Image> _baseImageCache = <String, img.Image>{};
|
// 'exportSignedPdfFromFile: enter signedPage=$signedPage outputPath=$outputPath',
|
||||||
final Map<String, img.Image> _processedImageCache = <String, img.Image>{};
|
// );
|
||||||
final Map<String, Uint8List> _encodedPngCache = <String, Uint8List>{};
|
// Read source bytes and delegate to bytes-based exporter
|
||||||
final Map<String, pw.MemoryImage> _memoryImageCache =
|
Uint8List? srcBytes;
|
||||||
<String, pw.MemoryImage>{};
|
try {
|
||||||
final Map<String, double> _aspectRatioCache = <String, double>{};
|
srcBytes = await File(inputPath).readAsBytes();
|
||||||
|
} catch (_) {
|
||||||
// Returns a stable-ish cache key for bytes within this process (not content-hash, but good enough per-call)
|
srcBytes = null;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
if (srcBytes == null) return false;
|
||||||
// Get processed image for a placement, with caching.
|
final bytes = await exportSignedPdfFromBytes(
|
||||||
img.Image _getProcessedImage(SignaturePlacement placement) {
|
srcBytes: srcBytes,
|
||||||
final base = _getBaseImage(placement);
|
signedPage: signedPage,
|
||||||
final key =
|
signatureRectUi: signatureRectUi,
|
||||||
'${_baseKeyForImage(base)}|${_adjustKey(placement.graphicAdjust)}';
|
uiPageSize: uiPageSize,
|
||||||
final cached = _processedImageCache[key];
|
signatureImageBytes: signatureImageBytes,
|
||||||
if (cached != null) return cached;
|
placementsByPage: placementsByPage,
|
||||||
final adj = placement.graphicAdjust;
|
libraryBytes: libraryBytes,
|
||||||
img.Image processed = base;
|
targetDpi: targetDpi,
|
||||||
if (adj.contrast != 1.0 || adj.brightness != 1.0) {
|
);
|
||||||
processed = img.adjustColor(
|
if (bytes == null) return false;
|
||||||
processed,
|
try {
|
||||||
contrast: adj.contrast,
|
final file = File(outputPath);
|
||||||
brightness: adj.brightness,
|
await file.writeAsBytes(bytes, flush: true);
|
||||||
);
|
return true;
|
||||||
}
|
} catch (_) {
|
||||||
if (adj.bgRemoval) {
|
return false;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compose a new PDF from source PDF bytes; returns the resulting PDF bytes.
|
||||||
|
Future<Uint8List?> exportSignedPdfFromBytes({
|
||||||
|
required Uint8List srcBytes,
|
||||||
|
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 {
|
||||||
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;
|
||||||
|
|
@ -136,12 +97,27 @@ 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(
|
||||||
|
|
@ -167,26 +143,24 @@ 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;
|
||||||
// rect is stored in normalized units (0..1) relative to page
|
final left = r.left / uiPageSize.width * widthPts;
|
||||||
final left = r.left * widthPts;
|
final top = r.top / uiPageSize.height * heightPts;
|
||||||
final top = r.top * heightPts;
|
final w = r.width / uiPageSize.width * widthPts;
|
||||||
final w = r.width * widthPts;
|
final h = r.height / uiPageSize.height * heightPts;
|
||||||
final h = r.height * heightPts;
|
Uint8List? bytes;
|
||||||
|
final id = placement.imageId;
|
||||||
// Get processed image and embed as MemoryImage (cached)
|
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 {
|
||||||
|
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,
|
||||||
|
|
@ -196,12 +170,12 @@ class ExportService {
|
||||||
height: h,
|
height: h,
|
||||||
child: pw.FittedBox(
|
child: pw.FittedBox(
|
||||||
fit: pw.BoxFit.contain,
|
fit: pw.BoxFit.contain,
|
||||||
child: pw.Transform.scale(
|
child: pw.Transform.rotate(
|
||||||
scale: scaleToFit,
|
angle:
|
||||||
child: pw.Transform.rotate(
|
placement.rotationDeg *
|
||||||
angle: angle,
|
3.1415926535 /
|
||||||
child: pw.Image(imgObj),
|
180.0,
|
||||||
),
|
child: pw.Image(imgObj),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -210,6 +184,26 @@ class ExportService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} 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);
|
||||||
},
|
},
|
||||||
|
|
@ -224,14 +218,39 @@ 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(
|
||||||
|
|
@ -246,28 +265,43 @@ 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;
|
||||||
// rect is stored in normalized units (0..1) relative to page
|
final left = r.left / uiPageSize.width * widthPts;
|
||||||
final left = r.left * widthPts;
|
final top = r.top / uiPageSize.height * heightPts;
|
||||||
final top = r.top * heightPts;
|
final w = r.width / uiPageSize.width * widthPts;
|
||||||
final w = r.width * widthPts;
|
final h = r.height / uiPageSize.height * heightPts;
|
||||||
final h = r.height * heightPts;
|
Uint8List? bytes;
|
||||||
|
final id = placement.imageId;
|
||||||
final processedPng = _getProcessedPng(placement);
|
if (id != null) {
|
||||||
final baseImage = _getBaseImage(placement);
|
bytes = libraryBytes?[id];
|
||||||
final memKey =
|
}
|
||||||
'${_baseKeyForImage(baseImage)}|${_adjustKey(placement.graphicAdjust)}';
|
bytes ??= signatureImageBytes; // fallback
|
||||||
if (processedPng.isNotEmpty) {
|
if (bytes != null && bytes.isNotEmpty) {
|
||||||
final imgObj = _getMemoryImage(processedPng, memKey);
|
pw.MemoryImage? imgObj;
|
||||||
|
try {
|
||||||
|
// Ensure PNG for transparency if not already
|
||||||
|
final asStr = String.fromCharCodes(bytes.take(8));
|
||||||
|
final isPng =
|
||||||
|
bytes.length > 8 &&
|
||||||
|
bytes[0] == 0x89 &&
|
||||||
|
asStr.startsWith('\u0089PNG');
|
||||||
|
if (isPng) {
|
||||||
|
imgObj = pw.MemoryImage(bytes);
|
||||||
|
} else {
|
||||||
|
final decoded = img.decodeImage(bytes);
|
||||||
|
if (decoded != null) {
|
||||||
|
final png = img.encodePng(decoded, level: 6);
|
||||||
|
imgObj = pw.MemoryImage(Uint8List.fromList(png));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
imgObj = null;
|
||||||
|
}
|
||||||
if (imgObj != null) {
|
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,
|
||||||
|
|
@ -277,12 +311,10 @@ class ExportService {
|
||||||
height: h,
|
height: h,
|
||||||
child: pw.FittedBox(
|
child: pw.FittedBox(
|
||||||
fit: pw.BoxFit.contain,
|
fit: pw.BoxFit.contain,
|
||||||
child: pw.Transform.scale(
|
child: pw.Transform.rotate(
|
||||||
scale: scaleToFit,
|
angle:
|
||||||
child: pw.Transform.rotate(
|
placement.rotationDeg * 3.1415926535 / 180.0,
|
||||||
angle: angle,
|
child: pw.Image(imgObj),
|
||||||
child: pw.Image(imgObj),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -291,6 +323,26 @@ class ExportService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} 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);
|
||||||
},
|
},
|
||||||
|
|
@ -318,6 +370,4 @@ class ExportService {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Background removal implemented in utils/background_removal.dart
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,244 @@
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
|
@ -1,48 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
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();
|
|
||||||
}
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
/// 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';
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
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));
|
|
||||||
}
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
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)),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
{
|
{
|
||||||
"adjustGraphic": "Grafik anpassen",
|
|
||||||
"appTitle": "PDF-Signatur",
|
"appTitle": "PDF-Signatur",
|
||||||
"backgroundRemoval": "Hintergrund entfernen",
|
"backgroundRemoval": "Hintergrund entfernen",
|
||||||
"brightness": "Helligkeit",
|
"brightness": "Helligkeit",
|
||||||
|
|
@ -13,7 +12,6 @@
|
||||||
"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",
|
||||||
|
|
@ -24,7 +22,6 @@
|
||||||
"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.",
|
||||||
|
|
@ -48,11 +45,5 @@
|
||||||
"themeDark": "Dunkel",
|
"themeDark": "Dunkel",
|
||||||
"themeLight": "Hell",
|
"themeLight": "Hell",
|
||||||
"themeSystem": "System",
|
"themeSystem": "System",
|
||||||
"themeColor": "Themenfarbe",
|
"undo": "Rückgängig"
|
||||||
"themeColorBlue": "Blau",
|
|
||||||
"themeColorGreen": "Grün",
|
|
||||||
"themeColorRed": "Rot",
|
|
||||||
"themeColorPurple": "Lila",
|
|
||||||
"undo": "Rückgängig",
|
|
||||||
"unlock": "Entsperren"
|
|
||||||
}
|
}
|
||||||
|
|
@ -1,7 +1,5 @@
|
||||||
{
|
{
|
||||||
"@@locale": "en",
|
"@@locale": "en",
|
||||||
"adjustGraphic": "Adjust graphic",
|
|
||||||
"@adjustGraphic": {},
|
|
||||||
"appTitle": "PDF Signature",
|
"appTitle": "PDF Signature",
|
||||||
"@appTitle": {},
|
"@appTitle": {},
|
||||||
"backgroundRemoval": "Background removal",
|
"backgroundRemoval": "Background removal",
|
||||||
|
|
@ -28,10 +26,6 @@
|
||||||
"@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}",
|
||||||
|
|
@ -59,8 +53,6 @@
|
||||||
"@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",
|
||||||
|
|
@ -124,18 +116,6 @@
|
||||||
"@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": {}
|
|
||||||
}
|
}
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
{
|
{
|
||||||
"adjustGraphic": "Ajustar gráfico",
|
|
||||||
"appTitle": "Firma PDF",
|
"appTitle": "Firma PDF",
|
||||||
"backgroundRemoval": "Eliminar fondo",
|
"backgroundRemoval": "Eliminar fondo",
|
||||||
"brightness": "Brillo",
|
"brightness": "Brillo",
|
||||||
|
|
@ -13,7 +12,6 @@
|
||||||
"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",
|
||||||
|
|
@ -24,7 +22,6 @@
|
||||||
"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.",
|
||||||
|
|
@ -48,11 +45,5 @@
|
||||||
"themeDark": "Oscuro",
|
"themeDark": "Oscuro",
|
||||||
"themeLight": "Claro",
|
"themeLight": "Claro",
|
||||||
"themeSystem": "Sistema",
|
"themeSystem": "Sistema",
|
||||||
"themeColor": "Color del tema",
|
"undo": "Deshacer"
|
||||||
"themeColorBlue": "Azul",
|
|
||||||
"themeColorGreen": "Verde",
|
|
||||||
"themeColorRed": "Rojo",
|
|
||||||
"themeColorPurple": "Púrpura",
|
|
||||||
"undo": "Deshacer",
|
|
||||||
"unlock": "Desbloquear"
|
|
||||||
}
|
}
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
{
|
{
|
||||||
"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é",
|
||||||
|
|
@ -13,7 +12,6 @@
|
||||||
"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",
|
||||||
|
|
@ -24,7 +22,6 @@
|
||||||
"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.",
|
||||||
|
|
@ -48,11 +45,5 @@
|
||||||
"themeDark": "Sombre",
|
"themeDark": "Sombre",
|
||||||
"themeLight": "Clair",
|
"themeLight": "Clair",
|
||||||
"themeSystem": "Système",
|
"themeSystem": "Système",
|
||||||
"themeColor": "Couleur du thème",
|
"undo": "Annuler"
|
||||||
"themeColorBlue": "Bleu",
|
|
||||||
"themeColorGreen": "Vert",
|
|
||||||
"themeColorRed": "Rouge",
|
|
||||||
"themeColorPurple": "Violet",
|
|
||||||
"undo": "Annuler",
|
|
||||||
"unlock": "Déverrouiller"
|
|
||||||
}
|
}
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
{
|
{
|
||||||
"adjustGraphic": "グラフィックを調整する",
|
|
||||||
"appTitle": "PDF署名",
|
"appTitle": "PDF署名",
|
||||||
"backgroundRemoval": "背景除去",
|
"backgroundRemoval": "背景除去",
|
||||||
"brightness": "明るさ",
|
"brightness": "明るさ",
|
||||||
|
|
@ -13,7 +12,6 @@
|
||||||
"display": "表示",
|
"display": "表示",
|
||||||
"downloadStarted": "ダウンロード開始",
|
"downloadStarted": "ダウンロード開始",
|
||||||
"dpi": "DPI",
|
"dpi": "DPI",
|
||||||
"dragOntoDocument": "ドキュメントにドラッグします",
|
|
||||||
"drawSignature": "署名をかく",
|
"drawSignature": "署名をかく",
|
||||||
"errorWithMessage": "エラー:{message}",
|
"errorWithMessage": "エラー:{message}",
|
||||||
"exportingPleaseWait": "エクスポート中…お待ちください",
|
"exportingPleaseWait": "エクスポート中…お待ちください",
|
||||||
|
|
@ -24,7 +22,6 @@
|
||||||
"image": "画像",
|
"image": "画像",
|
||||||
"invalidOrUnsupportedFile": "無効なファイルまたはサポートされていないファイル",
|
"invalidOrUnsupportedFile": "無効なファイルまたはサポートされていないファイル",
|
||||||
"language": "言語",
|
"language": "言語",
|
||||||
"lock": "ロック",
|
|
||||||
"loadSignatureFromFile": "ファイルから署名を読み込む",
|
"loadSignatureFromFile": "ファイルから署名を読み込む",
|
||||||
"lockAspectRatio": "アスペクト比をロック",
|
"lockAspectRatio": "アスペクト比をロック",
|
||||||
"longPressOrRightClickTheSignatureToConfirmOrDelete": "署名を長押しまたは右クリックして、確認または削除します。",
|
"longPressOrRightClickTheSignatureToConfirmOrDelete": "署名を長押しまたは右クリックして、確認または削除します。",
|
||||||
|
|
@ -48,11 +45,5 @@
|
||||||
"themeDark": "ダーク",
|
"themeDark": "ダーク",
|
||||||
"themeLight": "ライト",
|
"themeLight": "ライト",
|
||||||
"themeSystem": "システム",
|
"themeSystem": "システム",
|
||||||
"themeColor": "テーマカラー",
|
"undo": "元に戻す"
|
||||||
"themeColorBlue": "青",
|
|
||||||
"themeColorGreen": "緑",
|
|
||||||
"themeColorRed": "赤",
|
|
||||||
"themeColorPurple": "紫",
|
|
||||||
"undo": "元に戻す",
|
|
||||||
"unlock": "ロック解除"
|
|
||||||
}
|
}
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
{
|
{
|
||||||
"adjustGraphic": "그래픽 조정",
|
|
||||||
"appTitle": "PDF 서명",
|
"appTitle": "PDF 서명",
|
||||||
"backgroundRemoval": "배경 제거",
|
"backgroundRemoval": "배경 제거",
|
||||||
"brightness": "밝기",
|
"brightness": "밝기",
|
||||||
|
|
@ -13,7 +12,6 @@
|
||||||
"display": "표시",
|
"display": "표시",
|
||||||
"downloadStarted": "다운로드 시작됨",
|
"downloadStarted": "다운로드 시작됨",
|
||||||
"dpi": "DPI",
|
"dpi": "DPI",
|
||||||
"dragOntoDocument": "문서로 끌어다 놓습니다",
|
|
||||||
"drawSignature": "서명 그리기",
|
"drawSignature": "서명 그리기",
|
||||||
"errorWithMessage": "오류: {message}",
|
"errorWithMessage": "오류: {message}",
|
||||||
"exportingPleaseWait": "내보내는 중... 잠시 기다려주세요",
|
"exportingPleaseWait": "내보내는 중... 잠시 기다려주세요",
|
||||||
|
|
@ -24,7 +22,6 @@
|
||||||
"image": "이미지",
|
"image": "이미지",
|
||||||
"invalidOrUnsupportedFile": "잘못된 파일이거나 지원되지 않는 파일입니다.",
|
"invalidOrUnsupportedFile": "잘못된 파일이거나 지원되지 않는 파일입니다.",
|
||||||
"language": "언어",
|
"language": "언어",
|
||||||
"lock": "잠금",
|
|
||||||
"loadSignatureFromFile": "파일에서 서명 불러오기",
|
"loadSignatureFromFile": "파일에서 서명 불러오기",
|
||||||
"lockAspectRatio": "종횡비 고정",
|
"lockAspectRatio": "종횡비 고정",
|
||||||
"longPressOrRightClickTheSignatureToConfirmOrDelete": "서명을 길게 누르거나 마우스 오른쪽 버튼을 클릭하여 확인하거나 삭제합니다.",
|
"longPressOrRightClickTheSignatureToConfirmOrDelete": "서명을 길게 누르거나 마우스 오른쪽 버튼을 클릭하여 확인하거나 삭제합니다.",
|
||||||
|
|
@ -48,11 +45,5 @@
|
||||||
"themeDark": "다크",
|
"themeDark": "다크",
|
||||||
"themeLight": "라이트",
|
"themeLight": "라이트",
|
||||||
"themeSystem": "시스템",
|
"themeSystem": "시스템",
|
||||||
"themeColor": "테마 색상",
|
"undo": "실행 취소"
|
||||||
"themeColorBlue": "파란색",
|
|
||||||
"themeColorGreen": "녹색",
|
|
||||||
"themeColorRed": "빨간색",
|
|
||||||
"themeColorPurple": "보라색",
|
|
||||||
"undo": "실행 취소",
|
|
||||||
"unlock": "잠금 해제"
|
|
||||||
}
|
}
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
{
|
{
|
||||||
"adjustGraphic": "Регулювати графіку",
|
|
||||||
"appTitle": "Підпис PDF",
|
"appTitle": "Підпис PDF",
|
||||||
"backgroundRemoval": "Видалення фону",
|
"backgroundRemoval": "Видалення фону",
|
||||||
"brightness": "Яскравість",
|
"brightness": "Яскравість",
|
||||||
|
|
@ -13,7 +12,6 @@
|
||||||
"display": "Відображення",
|
"display": "Відображення",
|
||||||
"downloadStarted": "Завантаження розпочато",
|
"downloadStarted": "Завантаження розпочато",
|
||||||
"dpi": "DPI",
|
"dpi": "DPI",
|
||||||
"dragOntoDocument": "Перетягніть на документ",
|
|
||||||
"drawSignature": "Намалювати підпис",
|
"drawSignature": "Намалювати підпис",
|
||||||
"errorWithMessage": "Помилка: {message}",
|
"errorWithMessage": "Помилка: {message}",
|
||||||
"exportingPleaseWait": "Експортування... Зачекайте",
|
"exportingPleaseWait": "Експортування... Зачекайте",
|
||||||
|
|
@ -24,7 +22,6 @@
|
||||||
"image": "Зображення",
|
"image": "Зображення",
|
||||||
"invalidOrUnsupportedFile": "Недійсний або непідтримуваний файл",
|
"invalidOrUnsupportedFile": "Недійсний або непідтримуваний файл",
|
||||||
"language": "Мова",
|
"language": "Мова",
|
||||||
"lock": "Замкнути",
|
|
||||||
"loadSignatureFromFile": "Завантажити підпис з файлу",
|
"loadSignatureFromFile": "Завантажити підпис з файлу",
|
||||||
"lockAspectRatio": "Зафіксувати співвідношення сторін",
|
"lockAspectRatio": "Зафіксувати співвідношення сторін",
|
||||||
"longPressOrRightClickTheSignatureToConfirmOrDelete": "Довго натисніть або клацніть правою кнопкою миші на підпис, щоб підтвердити або видалити.",
|
"longPressOrRightClickTheSignatureToConfirmOrDelete": "Довго натисніть або клацніть правою кнопкою миші на підпис, щоб підтвердити або видалити.",
|
||||||
|
|
@ -48,11 +45,5 @@
|
||||||
"themeDark": "Темна",
|
"themeDark": "Темна",
|
||||||
"themeLight": "Світла",
|
"themeLight": "Світла",
|
||||||
"themeSystem": "Системна",
|
"themeSystem": "Системна",
|
||||||
"themeColor": "Колір теми",
|
"undo": "Відмінити"
|
||||||
"themeColorBlue": "Синій",
|
|
||||||
"themeColorGreen": "Зелений",
|
|
||||||
"themeColorRed": "Червоний",
|
|
||||||
"themeColorPurple": "Фіолетовий",
|
|
||||||
"undo": "Відмінити",
|
|
||||||
"unlock": "Відмкнути"
|
|
||||||
}
|
}
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
{
|
{
|
||||||
"@@locale": "zh",
|
"@@locale": "zh",
|
||||||
"adjustGraphic": "調整圖形",
|
|
||||||
"appTitle": "PDF 簽名",
|
"appTitle": "PDF 簽名",
|
||||||
"backgroundRemoval": "去除背景",
|
"backgroundRemoval": "去除背景",
|
||||||
"brightness": "亮度",
|
"brightness": "亮度",
|
||||||
|
|
@ -14,7 +13,6 @@
|
||||||
"display": "顯示",
|
"display": "顯示",
|
||||||
"downloadStarted": "已開始下載",
|
"downloadStarted": "已開始下載",
|
||||||
"dpi": "DPI",
|
"dpi": "DPI",
|
||||||
"dragOntoDocument": "拖到文档上",
|
|
||||||
"drawSignature": "手寫簽名",
|
"drawSignature": "手寫簽名",
|
||||||
"errorWithMessage": "錯誤:{message}",
|
"errorWithMessage": "錯誤:{message}",
|
||||||
"exportingPleaseWait": "匯出中…請稍候",
|
"exportingPleaseWait": "匯出中…請稍候",
|
||||||
|
|
@ -25,7 +23,6 @@
|
||||||
"image": "圖片",
|
"image": "圖片",
|
||||||
"invalidOrUnsupportedFile": "無效或不支援的檔案",
|
"invalidOrUnsupportedFile": "無效或不支援的檔案",
|
||||||
"language": "語言",
|
"language": "語言",
|
||||||
"lock": "锁定",
|
|
||||||
"loadSignatureFromFile": "從檔案載入簽名",
|
"loadSignatureFromFile": "從檔案載入簽名",
|
||||||
"lockAspectRatio": "鎖定長寬比",
|
"lockAspectRatio": "鎖定長寬比",
|
||||||
"longPressOrRightClickTheSignatureToConfirmOrDelete": "長按或右鍵點擊簽名以確認或刪除。",
|
"longPressOrRightClickTheSignatureToConfirmOrDelete": "長按或右鍵點擊簽名以確認或刪除。",
|
||||||
|
|
@ -49,11 +46,5 @@
|
||||||
"themeDark": "深色",
|
"themeDark": "深色",
|
||||||
"themeLight": "淺色",
|
"themeLight": "淺色",
|
||||||
"themeSystem": "系統",
|
"themeSystem": "系統",
|
||||||
"themeColor": "主题颜色",
|
"undo": "復原"
|
||||||
"themeColorBlue": "蓝色",
|
|
||||||
"themeColorGreen": "绿色",
|
|
||||||
"themeColorRed": "红色",
|
|
||||||
"themeColorPurple": "紫色",
|
|
||||||
"undo": "復原",
|
|
||||||
"unlock": "解锁"
|
|
||||||
}
|
}
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
{
|
{
|
||||||
"adjustGraphic": "调整图形",
|
|
||||||
"appTitle": "PDF 签名",
|
"appTitle": "PDF 签名",
|
||||||
"backgroundRemoval": "背景移除",
|
"backgroundRemoval": "背景移除",
|
||||||
"brightness": "亮度",
|
"brightness": "亮度",
|
||||||
|
|
@ -13,7 +12,6 @@
|
||||||
"display": "显示",
|
"display": "显示",
|
||||||
"downloadStarted": "下载已开始",
|
"downloadStarted": "下载已开始",
|
||||||
"dpi": "DPI",
|
"dpi": "DPI",
|
||||||
"dragOntoDocument": "拖到文档上",
|
|
||||||
"drawSignature": "绘制签名",
|
"drawSignature": "绘制签名",
|
||||||
"errorWithMessage": "错误:{message}",
|
"errorWithMessage": "错误:{message}",
|
||||||
"exportingPleaseWait": "正在导出... 请稍候",
|
"exportingPleaseWait": "正在导出... 请稍候",
|
||||||
|
|
@ -24,7 +22,6 @@
|
||||||
"image": "图片",
|
"image": "图片",
|
||||||
"invalidOrUnsupportedFile": "无效或不支持的文件",
|
"invalidOrUnsupportedFile": "无效或不支持的文件",
|
||||||
"language": "语言",
|
"language": "语言",
|
||||||
"lock": "锁定",
|
|
||||||
"loadSignatureFromFile": "从文件加载签名",
|
"loadSignatureFromFile": "从文件加载签名",
|
||||||
"lockAspectRatio": "锁定纵横比",
|
"lockAspectRatio": "锁定纵横比",
|
||||||
"longPressOrRightClickTheSignatureToConfirmOrDelete": "长按或右键单击签名以确认或删除。",
|
"longPressOrRightClickTheSignatureToConfirmOrDelete": "长按或右键单击签名以确认或删除。",
|
||||||
|
|
@ -48,11 +45,5 @@
|
||||||
"themeDark": "深色",
|
"themeDark": "深色",
|
||||||
"themeLight": "浅色",
|
"themeLight": "浅色",
|
||||||
"themeSystem": "系统",
|
"themeSystem": "系统",
|
||||||
"themeColor": "主题颜色",
|
"undo": "撤销"
|
||||||
"themeColorBlue": "蓝色",
|
|
||||||
"themeColorGreen": "绿色",
|
|
||||||
"themeColorRed": "红色",
|
|
||||||
"themeColorPurple": "紫色",
|
|
||||||
"undo": "撤销",
|
|
||||||
"unlock": "解锁"
|
|
||||||
}
|
}
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
{
|
{
|
||||||
"@@locale": "zh_TW",
|
"@@locale": "zh_TW",
|
||||||
"adjustGraphic": "調整圖形",
|
|
||||||
"appTitle": "PDF 簽名",
|
"appTitle": "PDF 簽名",
|
||||||
"backgroundRemoval": "去除背景",
|
"backgroundRemoval": "去除背景",
|
||||||
"brightness": "亮度",
|
"brightness": "亮度",
|
||||||
|
|
@ -14,7 +13,6 @@
|
||||||
"display": "顯示",
|
"display": "顯示",
|
||||||
"downloadStarted": "已開始下載",
|
"downloadStarted": "已開始下載",
|
||||||
"dpi": "DPI",
|
"dpi": "DPI",
|
||||||
"dragOntoDocument": "拖曳到文件",
|
|
||||||
"drawSignature": "手寫簽名",
|
"drawSignature": "手寫簽名",
|
||||||
"errorWithMessage": "錯誤:{message}",
|
"errorWithMessage": "錯誤:{message}",
|
||||||
"exportingPleaseWait": "匯出中…請稍候",
|
"exportingPleaseWait": "匯出中…請稍候",
|
||||||
|
|
@ -25,7 +23,6 @@
|
||||||
"image": "圖片",
|
"image": "圖片",
|
||||||
"invalidOrUnsupportedFile": "無效或不支援的檔案",
|
"invalidOrUnsupportedFile": "無效或不支援的檔案",
|
||||||
"language": "語言",
|
"language": "語言",
|
||||||
"lock": "鎖定",
|
|
||||||
"loadSignatureFromFile": "從檔案載入簽名",
|
"loadSignatureFromFile": "從檔案載入簽名",
|
||||||
"lockAspectRatio": "鎖定長寬比",
|
"lockAspectRatio": "鎖定長寬比",
|
||||||
"longPressOrRightClickTheSignatureToConfirmOrDelete": "長按或右鍵點擊簽名以確認或刪除。",
|
"longPressOrRightClickTheSignatureToConfirmOrDelete": "長按或右鍵點擊簽名以確認或刪除。",
|
||||||
|
|
@ -49,11 +46,5 @@
|
||||||
"themeDark": "深色",
|
"themeDark": "深色",
|
||||||
"themeLight": "淺色",
|
"themeLight": "淺色",
|
||||||
"themeSystem": "系統",
|
"themeSystem": "系統",
|
||||||
"themeColor": "主題顏色",
|
"undo": "復原"
|
||||||
"themeColorBlue": "藍色",
|
|
||||||
"themeColorGreen": "綠色",
|
|
||||||
"themeColorRed": "紅色",
|
|
||||||
"themeColorPurple": "紫色",
|
|
||||||
"undo": "復原",
|
|
||||||
"unlock": "解鎖"
|
|
||||||
}
|
}
|
||||||
|
|
@ -1,15 +1,5 @@
|
||||||
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() {
|
void main() => runApp(const MyApp());
|
||||||
// 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());
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,59 +0,0 @@
|
||||||
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;
|
|
||||||
});
|
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
import 'package:pdf_signature/l10n/app_localizations.dart';
|
||||||
|
|
||||||
|
/// Centralized accessors for context menu labels to avoid duplication.
|
||||||
|
class MenuLabels {
|
||||||
|
static String confirm(BuildContext context) =>
|
||||||
|
AppLocalizations.of(context).confirm;
|
||||||
|
|
||||||
|
static String delete(BuildContext context) =>
|
||||||
|
AppLocalizations.of(context).delete;
|
||||||
|
|
||||||
|
// Not yet localized in l10n; keep here for single source of truth.
|
||||||
|
static String adjustGraphic(BuildContext context) => 'Adjust graphic';
|
||||||
|
}
|
||||||
|
|
@ -1,37 +1,58 @@
|
||||||
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 '../../domain/models/model.dart';
|
import '../../../../data/model/model.dart';
|
||||||
|
|
||||||
class DocumentStateNotifier extends StateNotifier<Document> {
|
class PdfController extends StateNotifier<PdfState> {
|
||||||
DocumentStateNotifier() : super(Document.initial());
|
PdfController() : super(PdfState.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: 5,
|
pageCount: samplePageCount,
|
||||||
pickedPdfBytes: null,
|
currentPage: 1,
|
||||||
placementsByPage: <int, List<SignaturePlacement>>{},
|
pickedPdfPath: null,
|
||||||
|
signedPage: null,
|
||||||
|
placementsByPage: {},
|
||||||
|
selectedPlacementIndex: null,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void openPicked({required int pageCount, Uint8List? bytes}) {
|
void openPicked({
|
||||||
|
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,
|
||||||
placementsByPage: <int, List<SignaturePlacement>>{},
|
signedPage: null,
|
||||||
|
placementsByPage: {},
|
||||||
|
selectedPlacementIndex: null,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void close() {
|
void jumpTo(int page) {
|
||||||
state = Document.initial();
|
if (!state.loaded) return;
|
||||||
|
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) {
|
||||||
|
|
@ -39,18 +60,13 @@ class DocumentStateNotifier extends StateNotifier<Document> {
|
||||||
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,
|
||||||
SignatureAsset? asset,
|
String? imageId = 'default.png',
|
||||||
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);
|
||||||
|
|
@ -59,19 +75,14 @@ class DocumentStateNotifier extends StateNotifier<Document> {
|
||||||
list.add(
|
list.add(
|
||||||
SignaturePlacement(
|
SignaturePlacement(
|
||||||
rect: rect,
|
rect: rect,
|
||||||
asset: asset ?? SignatureAsset(sigImage: _singleTransparentPng),
|
imageId: imageId,
|
||||||
rotationDeg: rotationDeg,
|
rotationDeg: rotationDeg,
|
||||||
graphicAdjust: graphicAdjust ?? const GraphicAdjust(),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
map[p] = list;
|
map[p] = list;
|
||||||
state = state.copyWith(placementsByPage: map);
|
state = state.copyWith(placementsByPage: map, selectedPlacementIndex: null);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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,
|
||||||
|
|
@ -100,7 +111,10 @@ class DocumentStateNotifier extends StateNotifier<Document> {
|
||||||
} else {
|
} else {
|
||||||
map[p] = list;
|
map[p] = list;
|
||||||
}
|
}
|
||||||
state = state.copyWith(placementsByPage: map);
|
state = state.copyWith(
|
||||||
|
placementsByPage: map,
|
||||||
|
selectedPlacementIndex: null,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -128,32 +142,37 @@ class DocumentStateNotifier extends StateNotifier<Document> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convenience to get asset for a placement
|
void selectPlacement(int? index) {
|
||||||
SignatureAsset? assetOfPlacement({required int page, required int index}) {
|
if (!state.loaded) return;
|
||||||
final list = state.placementsByPage[page] ?? const [];
|
// Only allow valid index on current page; otherwise clear
|
||||||
if (index < 0 || index >= list.length) return null;
|
if (index == null) {
|
||||||
return list[index].asset;
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> exportDocument({
|
void deleteSelectedPlacement() {
|
||||||
required String outputPath,
|
final idx = state.selectedPlacementIndex;
|
||||||
required Size uiPageSize,
|
if (idx == null) return;
|
||||||
required Uint8List? signatureImageBytes,
|
removePlacement(page: state.currentPage, index: idx);
|
||||||
}) async {
|
}
|
||||||
if (!state.loaded || state.pickedPdfBytes == null) return;
|
|
||||||
final bytes = await _service.exportSignedPdfFromBytes(
|
// NOTE: Programmatic reassignment of images has been removed.
|
||||||
srcBytes: state.pickedPdfBytes!,
|
|
||||||
uiPageSize: uiPageSize,
|
// Convenience to get image name for a placement
|
||||||
signatureImageBytes: signatureImageBytes,
|
String? imageOfPlacement({required int page, required int index}) {
|
||||||
placementsByPage: state.placementsByPage,
|
final list = state.placementsByPage[page] ?? const [];
|
||||||
);
|
if (index < 0 || index >= list.length) return null;
|
||||||
if (bytes == null) return;
|
return list[index].imageId;
|
||||||
_service.saveBytesToFile(bytes: bytes, outputPath: outputPath);
|
|
||||||
// await
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final documentRepositoryProvider =
|
final pdfProvider = StateNotifierProvider<PdfController, PdfState>(
|
||||||
StateNotifierProvider<DocumentStateNotifier, Document>(
|
(ref) => PdfController(),
|
||||||
(ref) => DocumentStateNotifier(),
|
);
|
||||||
);
|
|
||||||
|
|
@ -1,77 +0,0 @@
|
||||||
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);
|
|
||||||
});
|
|
||||||
|
|
@ -1,334 +0,0 @@
|
||||||
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);
|
|
||||||
});
|
|
||||||
|
|
@ -1,30 +1,17 @@
|
||||||
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';
|
||||||
|
|
||||||
class AdjustmentsPanel extends StatelessWidget {
|
import '../../../../data/model/model.dart';
|
||||||
const AdjustmentsPanel({
|
import '../../signature/view_model/signature_controller.dart';
|
||||||
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,
|
|
||||||
});
|
|
||||||
|
|
||||||
final bool aspectLocked;
|
class AdjustmentsPanel extends ConsumerWidget {
|
||||||
final bool bgRemoval;
|
const AdjustmentsPanel({super.key, required this.sig});
|
||||||
final double contrast;
|
|
||||||
final double brightness;
|
final SignatureState sig;
|
||||||
final ValueChanged<bool> onAspectLockedChanged;
|
|
||||||
final ValueChanged<bool> onBgRemovalChanged;
|
|
||||||
final ValueChanged<double> onContrastChanged;
|
|
||||||
final ValueChanged<double> onBrightnessChanged;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
return Column(
|
return Column(
|
||||||
key: const Key('adjustments_panel'),
|
key: const Key('adjustments_panel'),
|
||||||
children: [
|
children: [
|
||||||
|
|
@ -33,10 +20,21 @@ class AdjustmentsPanel extends StatelessWidget {
|
||||||
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: bgRemoval,
|
value: sig.bgRemoval,
|
||||||
onChanged: (v) => onBgRemovalChanged(v),
|
onChanged:
|
||||||
|
(v) => ref.read(signatureProvider.notifier).setBgRemoval(v),
|
||||||
),
|
),
|
||||||
Text(AppLocalizations.of(context).backgroundRemoval),
|
Text(AppLocalizations.of(context).backgroundRemoval),
|
||||||
],
|
],
|
||||||
|
|
@ -49,14 +47,15 @@ class AdjustmentsPanel extends StatelessWidget {
|
||||||
Text(AppLocalizations.of(context).contrast),
|
Text(AppLocalizations.of(context).contrast),
|
||||||
Align(
|
Align(
|
||||||
alignment: Alignment.centerRight,
|
alignment: Alignment.centerRight,
|
||||||
child: Text(contrast.toStringAsFixed(2)),
|
child: Text(sig.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: contrast,
|
value: sig.contrast,
|
||||||
onChanged: onContrastChanged,
|
onChanged:
|
||||||
|
(v) => ref.read(signatureProvider.notifier).setContrast(v),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
@ -67,14 +66,15 @@ class AdjustmentsPanel extends StatelessWidget {
|
||||||
Text(AppLocalizations.of(context).brightness),
|
Text(AppLocalizations.of(context).brightness),
|
||||||
Align(
|
Align(
|
||||||
alignment: Alignment.centerRight,
|
alignment: Alignment.centerRight,
|
||||||
child: Text(brightness.toStringAsFixed(2)),
|
child: Text(sig.brightness.toStringAsFixed(2)),
|
||||||
),
|
),
|
||||||
Slider(
|
Slider(
|
||||||
key: const Key('sld_brightness'),
|
key: const Key('sld_brightness'),
|
||||||
min: 0.0,
|
min: -1.0,
|
||||||
max: 2.0,
|
max: 1.0,
|
||||||
value: brightness,
|
value: sig.brightness,
|
||||||
onChanged: onBrightnessChanged,
|
onChanged:
|
||||||
|
(v) => ref.read(signatureProvider.notifier).setBrightness(v),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -48,25 +48,22 @@ 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 first
|
// Export signature to PNG bytes
|
||||||
final byteData = await _control.toImage(
|
final data = 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 = byteData?.buffer.asUint8List();
|
final bytes = data?.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) {
|
||||||
// Close the canvas
|
Navigator.of(context).pop(bytes);
|
||||||
if (mounted && Navigator.canPop(context)) {
|
}
|
||||||
Navigator.of(context).pop(bytes);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: Text(l.confirm),
|
child: Text(l.confirm),
|
||||||
|
|
@ -88,10 +85,7 @@ 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.min(
|
height: math.max(MediaQuery.of(context).size.height * 0.6, 350),
|
||||||
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(
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,106 @@
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,129 +1,11 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:pdfrx/pdfrx.dart';
|
import 'pdf_pages_overview.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({
|
const PagesSidebar({super.key});
|
||||||
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) {
|
||||||
if (documentRef == null) {
|
return Card(margin: EdgeInsets.zero, child: const PdfPagesOverview());
|
||||||
return Card(margin: EdgeInsets.zero, child: const SizedBox.shrink());
|
|
||||||
}
|
|
||||||
|
|
||||||
return Card(
|
|
||||||
margin: EdgeInsets.zero,
|
|
||||||
child: ThumbnailsView(
|
|
||||||
documentRef: documentRef!,
|
|
||||||
controller: controller,
|
|
||||||
currentPage: currentPage,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,15 +3,11 @@ 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.
|
||||||
@visibleForTesting
|
class PdfMockContinuousList extends ConsumerWidget {
|
||||||
class PdfMockContinuousList extends ConsumerStatefulWidget {
|
|
||||||
const PdfMockContinuousList({
|
const PdfMockContinuousList({
|
||||||
super.key,
|
super.key,
|
||||||
required this.pageSize,
|
required this.pageSize,
|
||||||
|
|
@ -41,27 +37,14 @@ class PdfMockContinuousList extends ConsumerStatefulWidget {
|
||||||
final ValueChanged<int?>? onSelectPlaced;
|
final ValueChanged<int?>? onSelectPlaced;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
ConsumerState<PdfMockContinuousList> createState() =>
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
_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;
|
||||||
clearPending?.call();
|
if (p != null) {
|
||||||
scheduleMicrotask(() => scrollToPage(p));
|
clearPending?.call();
|
||||||
|
scheduleMicrotask(() => scrollToPage(p));
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -80,143 +63,45 @@ class _PdfMockContinuousListState extends ConsumerState<PdfMockContinuousList> {
|
||||||
child: Stack(
|
child: Stack(
|
||||||
key: ValueKey('page_stack_$pageNum'),
|
key: ValueKey('page_stack_$pageNum'),
|
||||||
children: [
|
children: [
|
||||||
DragTarget<SignatureDragData>(
|
Container(
|
||||||
onAcceptWithDetails: (details) {
|
color: Colors.grey.shade200,
|
||||||
final dragData = details.data;
|
child: Center(
|
||||||
final offset = details.offset;
|
child: Builder(
|
||||||
final renderBox =
|
builder: (ctx) {
|
||||||
context.findRenderObject() as RenderBox?;
|
String label;
|
||||||
if (renderBox != null) {
|
try {
|
||||||
final localPosition = renderBox.globalToLocal(offset);
|
label = AppLocalizations.of(
|
||||||
final normalizedX =
|
ctx,
|
||||||
localPosition.dx / renderBox.size.width;
|
).pageInfo(pageNum, count);
|
||||||
final normalizedY =
|
} catch (_) {
|
||||||
localPosition.dy / renderBox.size.height;
|
label = 'Page $pageNum of $count';
|
||||||
|
}
|
||||||
// Create a default rect for the signature (can be adjusted later)
|
return Text(
|
||||||
final rect = Rect.fromLTWH(
|
label,
|
||||||
(normalizedX - 0.1).clamp(
|
style: const TextStyle(
|
||||||
0.0,
|
fontSize: 24,
|
||||||
0.8,
|
color: Colors.black54,
|
||||||
), // 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: Builder(
|
|
||||||
builder: (ctx) {
|
|
||||||
String label;
|
|
||||||
try {
|
|
||||||
label = AppLocalizations.of(
|
|
||||||
ctx,
|
|
||||||
).pageInfo(pageNum, count);
|
|
||||||
} catch (_) {
|
|
||||||
label = 'Page $pageNum of $count';
|
|
||||||
}
|
|
||||||
return Text(
|
|
||||||
label,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 24,
|
|
||||||
color: Colors.black54,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
Stack(
|
|
||||||
children: [
|
|
||||||
PdfPageOverlays(
|
|
||||||
pageSize: pageSize,
|
|
||||||
pageNumber: pageNum,
|
|
||||||
onDragSignature: widget.onDragSignature,
|
|
||||||
onResizeSignature: widget.onResizeSignature,
|
|
||||||
onConfirmSignature: widget.onConfirmSignature,
|
|
||||||
onClearActiveOverlay: widget.onClearActiveOverlay,
|
|
||||||
onSelectPlaced: widget.onSelectPlaced,
|
|
||||||
),
|
),
|
||||||
// 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)
|
Consumer(
|
||||||
LayoutBuilder(
|
builder: (context, ref, _) {
|
||||||
builder: (context, constraints) {
|
final visible = ref.watch(signatureVisibilityProvider);
|
||||||
final left =
|
return visible
|
||||||
_activeRect.left * constraints.maxWidth;
|
? PdfPageOverlays(
|
||||||
final top =
|
pageSize: pageSize,
|
||||||
_activeRect.top * constraints.maxHeight;
|
pageNumber: pageNum,
|
||||||
final width =
|
onDragSignature: onDragSignature,
|
||||||
_activeRect.width * constraints.maxWidth;
|
onResizeSignature: onResizeSignature,
|
||||||
final height =
|
onConfirmSignature: onConfirmSignature,
|
||||||
_activeRect.height * constraints.maxHeight;
|
onClearActiveOverlay: onClearActiveOverlay,
|
||||||
// Publish rect for tests/other UI to observe
|
onSelectPlaced: onSelectPlaced,
|
||||||
return Stack(
|
)
|
||||||
children: [
|
: const SizedBox.shrink();
|
||||||
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),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -1,30 +1,42 @@
|
||||||
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';
|
||||||
// Real viewer removed in migration; mock continuous list is used in tests.
|
|
||||||
|
|
||||||
import 'pdf_viewer_widget.dart';
|
|
||||||
import 'package:pdfrx/pdfrx.dart';
|
import 'package:pdfrx/pdfrx.dart';
|
||||||
import '../view_model/pdf_view_model.dart';
|
|
||||||
|
import '../../../../data/services/export_providers.dart';
|
||||||
|
import '../../signature/view_model/signature_controller.dart';
|
||||||
|
import '../view_model/pdf_controller.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.controller,
|
required this.onDragSignature,
|
||||||
|
required this.onResizeSignature,
|
||||||
|
required this.onConfirmSignature,
|
||||||
|
required this.onClearActiveOverlay,
|
||||||
|
required this.onSelectPlaced,
|
||||||
|
this.viewerController,
|
||||||
});
|
});
|
||||||
|
|
||||||
final Size pageSize;
|
final Size pageSize;
|
||||||
final PdfViewerController controller;
|
final PdfViewerController? viewerController;
|
||||||
|
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 = {};
|
||||||
// Real viewer controller removed; keep placeholder for API compatibility
|
late final PdfViewerController _viewerController =
|
||||||
// ignore: unused_field
|
widget.viewerController ?? PdfViewerController();
|
||||||
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;
|
||||||
|
|
@ -32,7 +44,6 @@ 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();
|
||||||
|
|
@ -40,7 +51,10 @@ 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;
|
||||||
// initial scroll not needed; controller handles positioning
|
final pdf = ref.read(pdfProvider);
|
||||||
|
if (pdf.pickedPdfPath != null && pdf.loaded) {
|
||||||
|
_scrollToPage(pdf.currentPage);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -54,8 +68,46 @@ 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;
|
||||||
_programmaticTargetPage = page;
|
final pdf = ref.read(pdfProvider);
|
||||||
// Mock continuous: try ensureVisible on the page container
|
const isContinuous = true;
|
||||||
|
|
||||||
|
// Real continuous: drive via PdfViewerController
|
||||||
|
if (pdf.pickedPdfPath != null && isContinuous) {
|
||||||
|
if (_viewerController.isReady) {
|
||||||
|
_programmaticTargetPage = page;
|
||||||
|
// print("[DEBUG] viewerController Scrolling to page $page");
|
||||||
|
_viewerController.goToPage(
|
||||||
|
pageNumber: page,
|
||||||
|
anchor: PdfPageAnchor.top,
|
||||||
|
);
|
||||||
|
// Fallback: if no onPageChanged arrives (e.g., same page), don't block future jumps
|
||||||
|
// Use post-frame callbacks to avoid scheduling timers in tests.
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (!mounted) return;
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (!mounted) return;
|
||||||
|
if (_programmaticTargetPage == page) {
|
||||||
|
_programmaticTargetPage = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
_pendingPage = null;
|
||||||
|
_scrollRetryCount = 0;
|
||||||
|
} else {
|
||||||
|
_pendingPage = page;
|
||||||
|
if (_scrollRetryCount < _maxScrollRetries) {
|
||||||
|
_scrollRetryCount += 1;
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (!mounted) return;
|
||||||
|
final p = _pendingPage;
|
||||||
|
if (p == null) return;
|
||||||
|
_scrollToPage(p);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// print("[DEBUG] Mock Scrolling to page $page");
|
||||||
// Mock continuous: try ensureVisible on the page container
|
// Mock continuous: try ensureVisible on the page container
|
||||||
final ctx = _pageKey(page).currentContext;
|
final ctx = _pageKey(page).currentContext;
|
||||||
if (ctx != null) {
|
if (ctx != null) {
|
||||||
|
|
@ -75,8 +127,6 @@ 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 (_) {
|
||||||
|
|
@ -87,8 +137,6 @@ 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;
|
||||||
|
|
@ -108,22 +156,23 @@ class _PdfPageAreaState extends ConsumerState<PdfPageArea> {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final pdfViewModel = ref.watch(pdfViewModelProvider);
|
final pdf = ref.watch(pdfProvider);
|
||||||
final pdf = pdfViewModel.document;
|
|
||||||
const pageViewMode = 'continuous';
|
const pageViewMode = 'continuous';
|
||||||
// React to PdfViewModel currentPage changes. With ChangeNotifierProvider,
|
|
||||||
// prev/next are the same instance, so compare to a local cache.
|
// React to provider currentPage changes (e.g., user tapped overview)
|
||||||
ref.listen(pdfViewModelProvider, (prev, next) {
|
ref.listen(pdfProvider, (prev, next) {
|
||||||
if (_suppressProviderListen) return;
|
if (_suppressProviderListen) return;
|
||||||
final target = next.currentPage;
|
if ((prev?.currentPage != next.currentPage)) {
|
||||||
if (_lastListenedPage == target) return;
|
final target = next.currentPage;
|
||||||
_lastListenedPage = target;
|
// If we're already navigating to this target, ignore; otherwise allow new target.
|
||||||
if (_programmaticTargetPage != null &&
|
if (_programmaticTargetPage != null &&
|
||||||
_programmaticTargetPage == target) {
|
_programmaticTargetPage == target) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (_visiblePage != target) {
|
// Only navigate if target differs from what viewer shows
|
||||||
_scrollToPage(target);
|
if (_visiblePage != target) {
|
||||||
|
_scrollToPage(target);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
// No page view mode switching; always continuous.
|
// No page view mode switching; always continuous.
|
||||||
|
|
@ -139,17 +188,182 @@ 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';
|
||||||
|
|
||||||
// Use real PDF viewer
|
// Mock continuous: ListView with prebuilt children, no controller
|
||||||
if (isContinuous) {
|
if (useMock && isContinuous) {
|
||||||
return PdfViewerWidget(
|
final count = pdf.pageCount > 0 ? pdf.pageCount : 1;
|
||||||
|
return PdfMockContinuousList(
|
||||||
pageSize: widget.pageSize,
|
pageSize: widget.pageSize,
|
||||||
|
count: count,
|
||||||
pageKeyBuilder: _pageKey,
|
pageKeyBuilder: _pageKey,
|
||||||
scrollToPage: _scrollToPage,
|
scrollToPage: _scrollToPage,
|
||||||
controller: widget.controller,
|
pendingPage: _pendingPage,
|
||||||
|
clearPending: () {
|
||||||
|
_pendingPage = null;
|
||||||
|
_scrollRetryCount = 0;
|
||||||
|
},
|
||||||
|
onDragSignature: (delta) => widget.onDragSignature(delta),
|
||||||
|
onResizeSignature: (delta) => widget.onResizeSignature(delta),
|
||||||
|
onConfirmSignature: widget.onConfirmSignature,
|
||||||
|
onClearActiveOverlay: widget.onClearActiveOverlay,
|
||||||
|
onSelectPlaced: widget.onSelectPlaced,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Real continuous mode (pdfrx): copy example patterns
|
||||||
|
// https://github.com/espresso3389/pdfrx/blob/2cc32c1e2aa2a054602d20a5e7cf60bcc2d6a889/packages/pdfrx/example/viewer/lib/main.dart
|
||||||
|
if (pdf.pickedPdfPath != null && isContinuous) {
|
||||||
|
final viewer = PdfViewer.file(
|
||||||
|
pdf.pickedPdfPath!,
|
||||||
|
controller: _viewerController,
|
||||||
|
params: PdfViewerParams(
|
||||||
|
pageAnchor: PdfPageAnchor.top,
|
||||||
|
keyHandlerParams: PdfViewerKeyHandlerParams(autofocus: true),
|
||||||
|
maxScale: 8,
|
||||||
|
scrollByMouseWheel: 0.6,
|
||||||
|
// Render signature overlays on each page via pdfrx pageOverlaysBuilder
|
||||||
|
pageOverlaysBuilder: (context, pageRect, page) {
|
||||||
|
return [
|
||||||
|
Consumer(
|
||||||
|
builder: (context, ref, _) {
|
||||||
|
final 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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,10 @@
|
||||||
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 '../../../../domain/models/model.dart';
|
import '../../signature/view_model/signature_controller.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 {
|
||||||
|
|
@ -31,118 +29,46 @@ class PdfPageOverlays extends ConsumerWidget {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final pdfViewModel = ref.watch(pdfViewModelProvider);
|
final pdf = ref.watch(pdfProvider);
|
||||||
// Subscribe to document changes to rebuild overlays
|
final sig = ref.watch(signatureProvider);
|
||||||
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 (SignatureCardStateNotifier.pageSize).
|
// Stored as UI-space rects (SignatureController.pageSize).
|
||||||
final p = placed[i];
|
final uiRect = placed[i].rect;
|
||||||
final uiRect = p.rect;
|
|
||||||
widgets.add(
|
widgets.add(
|
||||||
SignatureOverlay(
|
SignatureOverlay(
|
||||||
pageSize: pageSize,
|
pageSize: pageSize,
|
||||||
rect: uiRect,
|
rect: uiRect,
|
||||||
placement: p,
|
sig: sig,
|
||||||
placedIndex: i,
|
|
||||||
pageNumber: pageNumber,
|
pageNumber: pageNumber,
|
||||||
|
interactive: false,
|
||||||
|
placedIndex: i,
|
||||||
|
onSelectPlaced: onSelectPlaced,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO:Add active overlay if present and not using mock (mock has its own)
|
final showActive =
|
||||||
|
sig.rect != null &&
|
||||||
|
sig.editingEnabled &&
|
||||||
|
(pdf.signedPage == null || pdf.signedPage == pageNumber) &&
|
||||||
|
pdf.currentPage == pageNumber;
|
||||||
|
|
||||||
final useMock = pdfViewModel.useMockViewer;
|
if (showActive) {
|
||||||
if (!useMock &&
|
|
||||||
activeRect != null &&
|
|
||||||
pageNumber == pdfViewModel.currentPage) {
|
|
||||||
widgets.add(
|
widgets.add(
|
||||||
LayoutBuilder(
|
SignatureOverlay(
|
||||||
builder: (context, constraints) {
|
pageSize: pageSize,
|
||||||
final left = activeRect.left * constraints.maxWidth;
|
rect: sig.rect!,
|
||||||
final top = activeRect.top * constraints.maxHeight;
|
sig: sig,
|
||||||
final width = activeRect.width * constraints.maxWidth;
|
pageNumber: pageNumber,
|
||||||
final height = activeRect.height * constraints.maxHeight;
|
interactive: true,
|
||||||
return Stack(
|
onDragSignature: onDragSignature,
|
||||||
children: [
|
onResizeSignature: onResizeSignature,
|
||||||
Positioned(
|
onConfirmSignature: onConfirmSignature,
|
||||||
left: left,
|
onClearActiveOverlay: onClearActiveOverlay,
|
||||||
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(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,95 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:pdfrx/pdfrx.dart';
|
||||||
|
|
||||||
|
import '../../../../data/services/export_providers.dart';
|
||||||
|
import '../view_model/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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,39 +1,26 @@
|
||||||
|
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';
|
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||||
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 'package:pdfrx/pdfrx.dart';
|
import '../../../../data/services/export_providers.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 {
|
||||||
final Future<void> Function() onPickPdf;
|
const PdfSignatureHomePage({super.key});
|
||||||
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() =>
|
||||||
|
|
@ -41,7 +28,8 @@ class PdfSignatureHomePage extends ConsumerStatefulWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
|
class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
|
||||||
static const Size _pageSize = Size(676, 960 / 1.4142);
|
static const Size _pageSize = SignatureController.pageSize;
|
||||||
|
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
|
||||||
|
|
@ -56,60 +44,33 @@ 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() {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ref.read(signatureProvider.notifier).setInvalidSelected(context);
|
||||||
SnackBar(
|
|
||||||
content: Text(AppLocalizations.of(context).invalidOrUnsupportedFile),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _pickPdf() async {
|
Future<void> _pickPdf() async {
|
||||||
await widget.onPickPdf();
|
final typeGroup = const fs.XTypeGroup(label: 'PDF', extensions: ['pdf']);
|
||||||
}
|
final file = await fs.openFile(acceptedTypeGroups: [typeGroup]);
|
||||||
|
if (file != null) {
|
||||||
void _closePdf() {
|
Uint8List? bytes;
|
||||||
widget.onClosePdf();
|
try {
|
||||||
|
bytes = await file.readAsBytes();
|
||||||
|
} catch (_) {
|
||||||
|
bytes = null;
|
||||||
|
}
|
||||||
|
ref.read(pdfProvider.notifier).openPicked(path: file.path, bytes: bytes);
|
||||||
|
ref.read(signatureProvider.notifier).resetForNewPage();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _jumpToPage(int page) {
|
void _jumpToPage(int page) {
|
||||||
final controller = _viewModel.controller;
|
ref.read(pdfProvider.notifier).jumpTo(page);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
img.Image? _toStdSignatureImage(img.Image? image) {
|
Future<Uint8List?> _loadSignatureFromFile() async {
|
||||||
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,
|
||||||
|
|
@ -118,37 +79,55 @@ 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();
|
||||||
try {
|
final sig = ref.read(signatureProvider.notifier);
|
||||||
var sigImage = img.decodeImage(bytes);
|
sig.setImageBytes(bytes);
|
||||||
return _toStdSignatureImage(sigImage);
|
final p = ref.read(pdfProvider);
|
||||||
} catch (_) {
|
if (p.loaded) {
|
||||||
return null;
|
ref.read(pdfProvider.notifier).setSignedPage(p.currentPage);
|
||||||
}
|
}
|
||||||
|
return bytes;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<img.Image?> _openDrawCanvas() async {
|
void _confirmSignature() {
|
||||||
|
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.isEmpty) return null;
|
if (result != null && result.isNotEmpty) {
|
||||||
// In simplified UI, adding to library isn't implemented
|
ref.read(signatureProvider.notifier).setImageBytes(result);
|
||||||
try {
|
final p = ref.read(pdfProvider);
|
||||||
var sigImage = img.decodeImage(result);
|
if (p.loaded) {
|
||||||
return _toStdSignatureImage(sigImage);
|
ref.read(pdfProvider.notifier).setSignedPage(p.currentPage);
|
||||||
} catch (_) {
|
}
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _saveSignedPdf() async {
|
Future<void> _saveSignedPdf() async {
|
||||||
ref.read(pdfExportViewModelProvider.notifier).setExporting(true);
|
ref.read(exportingProvider.notifier).state = true;
|
||||||
try {
|
try {
|
||||||
final pdf = _viewModel.document;
|
final pdf = ref.read(pdfProvider);
|
||||||
|
final sig = ref.read(signatureProvider);
|
||||||
final messenger = ScaffoldMessenger.of(context);
|
final messenger = ScaffoldMessenger.of(context);
|
||||||
if (!pdf.loaded) {
|
if (!pdf.loaded || sig.rect == null) {
|
||||||
messenger.showSnackBar(
|
messenger.showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text(AppLocalizations.of(context).nothingToSaveYet),
|
content: Text(AppLocalizations.of(context).nothingToSaveYet),
|
||||||
|
|
@ -156,58 +135,118 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final exporter = ref.read(pdfExportViewModelProvider).exporter;
|
final exporter = ref.read(exportServiceProvider);
|
||||||
|
final targetDpi = ref.read(exportDpiProvider);
|
||||||
// get DPI from preferences
|
final useMock = ref.read(useMockViewerProvider);
|
||||||
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Derive a suggested filename based on the opened file. Prefer the
|
if (kIsWeb) {
|
||||||
// provided display name if available (see Linux portal note above).
|
Uint8List? src = pdf.pickedPdfBytes;
|
||||||
final display = widget.currentFileName;
|
if (src != null) {
|
||||||
final originalName =
|
final processed = ref.read(processedSignatureImageProvider);
|
||||||
(display != null && display.trim().isNotEmpty)
|
final rotated = _rotatedForExport(
|
||||||
? display.trim()
|
processed ?? sig.imageBytes,
|
||||||
: widget.currentFile.name.isNotEmpty
|
sig.rotation,
|
||||||
? widget.currentFile.name
|
);
|
||||||
: widget.currentFile.path.isNotEmpty
|
final bytes = await exporter.exportSignedPdfFromBytes(
|
||||||
? widget.currentFile.path.split('/').last.split('\\').last
|
srcBytes: src,
|
||||||
: 'document.pdf';
|
signedPage: pdf.signedPage,
|
||||||
final suggested = _suggestSignedName(originalName);
|
signatureRectUi: sig.rect,
|
||||||
|
uiPageSize: SignatureController.pageSize,
|
||||||
if (!kIsWeb) {
|
signatureImageBytes: rotated,
|
||||||
final path = await ref
|
placementsByPage: pdf.placementsByPage,
|
||||||
.read(pdfExportViewModelProvider)
|
libraryBytes: {
|
||||||
.pickSavePathWithSuggestedName(suggested);
|
for (final a in ref.read(signatureLibraryProvider)) a.id: a.bytes,
|
||||||
|
},
|
||||||
|
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;
|
||||||
final src = pdf.pickedPdfBytes ?? Uint8List(0);
|
if (pdf.pickedPdfBytes != null) {
|
||||||
final out = await exporter.exportSignedPdfFromBytes(
|
final processed = ref.read(processedSignatureImageProvider);
|
||||||
srcBytes: src,
|
final rotated = _rotatedForExport(
|
||||||
uiPageSize: _pageSize,
|
processed ?? sig.imageBytes,
|
||||||
signatureImageBytes: null,
|
sig.rotation,
|
||||||
placementsByPage: pdf.placementsByPage,
|
);
|
||||||
targetDpi: targetDpi,
|
final out = await exporter.exportSignedPdfFromBytes(
|
||||||
);
|
srcBytes: pdf.pickedPdfBytes!,
|
||||||
if (out != null) {
|
signedPage: pdf.signedPage,
|
||||||
ok = await exporter.saveBytesToFile(bytes: out, outputPath: fullPath);
|
signatureRectUi: sig.rect,
|
||||||
}
|
uiPageSize: SignatureController.pageSize,
|
||||||
} else {
|
signatureImageBytes: rotated,
|
||||||
// Web: export and trigger browser download
|
placementsByPage: pdf.placementsByPage,
|
||||||
final src = pdf.pickedPdfBytes ?? Uint8List(0);
|
libraryBytes: {
|
||||||
final out = await exporter.exportSignedPdfFromBytes(
|
for (final a in ref.read(signatureLibraryProvider)) a.id: a.bytes,
|
||||||
srcBytes: src,
|
},
|
||||||
uiPageSize: _pageSize,
|
targetDpi: targetDpi,
|
||||||
signatureImageBytes: null,
|
);
|
||||||
placementsByPage: pdf.placementsByPage,
|
if (useMock) {
|
||||||
targetDpi: targetDpi,
|
ok = out != null;
|
||||||
);
|
} else if (out != null) {
|
||||||
if (out != null) {
|
ok = await exporter.saveBytesToFile(
|
||||||
// Use suggested filename for browser download
|
bytes: out,
|
||||||
ok = await downloadBytes(out, filename: suggested);
|
outputPath: fullPath,
|
||||||
savedPath = suggested;
|
);
|
||||||
|
}
|
||||||
|
} else if (pdf.pickedPdfPath != null) {
|
||||||
|
if (useMock) {
|
||||||
|
ok = true;
|
||||||
|
} else {
|
||||||
|
final processed = ref.read(processedSignatureImageProvider);
|
||||||
|
final rotated = _rotatedForExport(
|
||||||
|
processed ?? sig.imageBytes,
|
||||||
|
sig.rotation,
|
||||||
|
);
|
||||||
|
ok = await exporter.exportSignedPdfFromFile(
|
||||||
|
inputPath: pdf.pickedPdfPath!,
|
||||||
|
outputPath: fullPath,
|
||||||
|
signedPage: pdf.signedPage,
|
||||||
|
signatureRectUi: sig.rect,
|
||||||
|
uiPageSize: SignatureController.pageSize,
|
||||||
|
signatureImageBytes: rotated,
|
||||||
|
placementsByPage: pdf.placementsByPage,
|
||||||
|
libraryBytes: {
|
||||||
|
for (final a in ref.read(signatureLibraryProvider))
|
||||||
|
a.id: a.bytes,
|
||||||
|
},
|
||||||
|
targetDpi: targetDpi,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!kIsWeb) {
|
if (!kIsWeb) {
|
||||||
|
|
@ -227,21 +266,22 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Web: show a toast-like confirmation
|
if (ok) {
|
||||||
messenger.showSnackBar(
|
messenger.showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text(
|
content: Text(AppLocalizations.of(context).downloadStarted),
|
||||||
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(pdfExportViewModelProvider.notifier).setExporting(false);
|
ref.read(exportingProvider.notifier).state = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -250,46 +290,10 @@ 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,
|
||||||
|
|
@ -298,26 +302,7 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
|
||||||
builder:
|
builder:
|
||||||
(context, area) => Offstage(
|
(context, area) => Offstage(
|
||||||
offstage: !_showPagesSidebar,
|
offstage: !_showPagesSidebar,
|
||||||
child: Consumer(
|
child: const PagesSidebar(),
|
||||||
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(
|
||||||
|
|
@ -325,9 +310,18 @@ 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,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -353,7 +347,6 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_viewModel.controller.removeListener(_onControllerChanged);
|
|
||||||
_splitController.dispose();
|
_splitController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
@ -387,11 +380,7 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return _buildScaffold(context);
|
final isExporting = ref.watch(exportingProvider);
|
||||||
}
|
|
||||||
|
|
||||||
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(
|
||||||
|
|
@ -404,42 +393,25 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
|
||||||
PdfToolbar(
|
PdfToolbar(
|
||||||
disabled: isExporting,
|
disabled: isExporting,
|
||||||
onPickPdf: _pickPdf,
|
onPickPdf: _pickPdf,
|
||||||
onClosePdf: _closePdf,
|
|
||||||
onJumpToPage: _jumpToPage,
|
onJumpToPage: _jumpToPage,
|
||||||
onZoomOut: () {
|
onZoomOut: () {
|
||||||
if (_viewModel.controller.isReady) {
|
if (_viewerController.isReady) {
|
||||||
_viewModel.controller.zoomDown();
|
_viewerController.zoomDown();
|
||||||
// Update display zoom level after controller zoom
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
||||||
if (mounted) {
|
|
||||||
setState(() {
|
|
||||||
_zoomLevel = (_viewModel.controller.currentZoom *
|
|
||||||
100)
|
|
||||||
.round()
|
|
||||||
.clamp(10, 800);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
setState(() {
|
||||||
|
_zoomLevel = (_zoomLevel - 10).clamp(10, 800);
|
||||||
|
});
|
||||||
},
|
},
|
||||||
onZoomIn: () {
|
onZoomIn: () {
|
||||||
if (_viewModel.controller.isReady) {
|
if (_viewerController.isReady) {
|
||||||
_viewModel.controller.zoomUp();
|
_viewerController.zoomUp();
|
||||||
// Update display zoom level after controller zoom
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
||||||
if (mounted) {
|
|
||||||
setState(() {
|
|
||||||
_zoomLevel = (_viewModel.controller.currentZoom *
|
|
||||||
100)
|
|
||||||
.round()
|
|
||||||
.clamp(10, 800);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
setState(() {
|
||||||
|
_zoomLevel = (_zoomLevel + 10).clamp(10, 800);
|
||||||
|
});
|
||||||
},
|
},
|
||||||
zoomLevel: _zoomLevel,
|
zoomLevel: _zoomLevel,
|
||||||
filePath: widget.currentFile.path,
|
fileName: ref.watch(pdfProvider).pickedPdfPath,
|
||||||
showPagesSidebar: _showPagesSidebar,
|
showPagesSidebar: _showPagesSidebar,
|
||||||
showSignaturesSidebar: _showSignaturesSidebar,
|
showSignaturesSidebar: _showSignaturesSidebar,
|
||||||
onTogglePagesSidebar:
|
onTogglePagesSidebar:
|
||||||
|
|
@ -453,24 +425,6 @@ 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(
|
||||||
|
|
|
||||||
|
|
@ -3,19 +3,18 @@ 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 'package:pdf_signature/ui/features/pdf/view_model/pdf_view_model.dart';
|
import '../view_model/pdf_controller.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.filePath,
|
this.fileName,
|
||||||
required this.showPagesSidebar,
|
required this.showPagesSidebar,
|
||||||
required this.showSignaturesSidebar,
|
required this.showSignaturesSidebar,
|
||||||
required this.onTogglePagesSidebar,
|
required this.onTogglePagesSidebar,
|
||||||
|
|
@ -24,9 +23,8 @@ 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? filePath;
|
final String? fileName;
|
||||||
final VoidCallback onZoomOut;
|
final VoidCallback onZoomOut;
|
||||||
final VoidCallback onZoomIn;
|
final VoidCallback onZoomIn;
|
||||||
// Current zoom level as a percentage (e.g., 100 for 100%)
|
// Current zoom level as a percentage (e.g., 100 for 100%)
|
||||||
|
|
@ -57,11 +55,9 @@ class _PdfToolbarState extends ConsumerState<PdfToolbar> {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final pdfViewModel = ref.watch(pdfViewModelProvider);
|
final pdf = ref.watch(pdfProvider);
|
||||||
final pdf = pdfViewModel.document;
|
|
||||||
final currentPage = pdfViewModel.currentPage;
|
|
||||||
final l = AppLocalizations.of(context);
|
final l = AppLocalizations.of(context);
|
||||||
final pageInfo = l.pageInfo(currentPage, pdf.pageCount);
|
final pageInfo = l.pageInfo(pdf.currentPage, pdf.pageCount);
|
||||||
|
|
||||||
return LayoutBuilder(
|
return LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
|
|
@ -85,9 +81,9 @@ class _PdfToolbarState extends ConsumerState<PdfToolbar> {
|
||||||
ConstrainedBox(
|
ConstrainedBox(
|
||||||
constraints: const BoxConstraints(maxWidth: 220),
|
constraints: const BoxConstraints(maxWidth: 220),
|
||||||
child: Text(
|
child: Text(
|
||||||
// if filePath not null
|
// if filename not null
|
||||||
widget.filePath != null
|
widget.fileName != null
|
||||||
? widget.filePath!
|
? widget.fileName!
|
||||||
: 'No file selected',
|
: 'No file selected',
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
|
|
@ -96,12 +92,6 @@ 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: [
|
||||||
|
|
@ -113,7 +103,8 @@ 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,
|
||||||
),
|
),
|
||||||
|
|
@ -124,7 +115,8 @@ 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,
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -1,157 +0,0 @@
|
||||||
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,
|
|
||||||
),
|
|
||||||
];
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -0,0 +1,191 @@
|
||||||
|
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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,163 +1,302 @@
|
||||||
|
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';
|
||||||
|
|
||||||
/// Minimal overlay widget for rendering a placed signature.
|
import '../../../../data/model/model.dart';
|
||||||
|
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.placement,
|
required this.sig,
|
||||||
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; // not used directly, kept for API symmetry
|
final Size pageSize;
|
||||||
final Rect rect; // normalized 0..1 values (left, top, width, height)
|
final Rect rect;
|
||||||
final SignaturePlacement placement;
|
final SignatureState sig;
|
||||||
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 pageW = constraints.maxWidth;
|
final scaleX = constraints.maxWidth / pageSize.width;
|
||||||
final pageH = constraints.maxHeight;
|
final scaleY = constraints.maxHeight / pageSize.height;
|
||||||
final rectPx = Rect.fromLTWH(
|
final left = rect.left * scaleX;
|
||||||
rect.left * pageW,
|
final top = rect.top * scaleY;
|
||||||
rect.top * pageH,
|
final width = rect.width * scaleX;
|
||||||
rect.width * pageW,
|
final height = rect.height * scaleY;
|
||||||
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: [
|
||||||
TransformableBox(
|
|
||||||
key: Key('placed_signature_$placedIndex'),
|
|
||||||
rect: rectPx,
|
|
||||||
flip: Flip.none,
|
|
||||||
// Keep the box within page bounds
|
|
||||||
clampingRect: Rect.fromLTWH(0, 0, pageW, pageH),
|
|
||||||
// Disable flips for signatures to avoid mirrored signatures
|
|
||||||
allowFlippingWhileResizing: false,
|
|
||||||
allowContentFlipping: false,
|
|
||||||
onChanged:
|
|
||||||
ref
|
|
||||||
.watch(pdfViewModelProvider)
|
|
||||||
.isPlacementLocked(
|
|
||||||
page: pageNumber,
|
|
||||||
index: placedIndex,
|
|
||||||
)
|
|
||||||
? null
|
|
||||||
: (result, details) {
|
|
||||||
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),
|
|
||||||
);
|
|
||||||
ref
|
|
||||||
.read(pdfViewModelProvider.notifier)
|
|
||||||
.updatePlacementRect(
|
|
||||||
page: pageNumber,
|
|
||||||
index: placedIndex,
|
|
||||||
rect: newRect,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
// Keep default handles; you can customize later if needed
|
|
||||||
contentBuilder: (context, boxRect, flip) {
|
|
||||||
final isLocked = ref
|
|
||||||
.watch(pdfViewModelProvider)
|
|
||||||
.isPlacementLocked(page: pageNumber, index: placedIndex);
|
|
||||||
return DecoratedBox(
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
border: Border.all(
|
|
||||||
color: isLocked ? Colors.green : Colors.red,
|
|
||||||
width: 2,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: SizedBox(
|
|
||||||
width: boxRect.width,
|
|
||||||
height: boxRect.height,
|
|
||||||
child: FittedBox(
|
|
||||||
fit: BoxFit.contain,
|
|
||||||
child: RotatedSignatureImage(
|
|
||||||
image: processedImage,
|
|
||||||
rotationDeg: placement.rotationDeg,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
// Invisible overlay for right-click context menu
|
|
||||||
Positioned(
|
Positioned(
|
||||||
left: rectPx.left,
|
left: left,
|
||||||
top: rectPx.top,
|
top: top,
|
||||||
width: rectPx.width,
|
width: width,
|
||||||
height: rectPx.height,
|
height: height,
|
||||||
child: GestureDetector(
|
child: _buildContent(context, ref, scaleX, scaleY),
|
||||||
behavior: HitTestBehavior.translucent,
|
|
||||||
onSecondaryTapDown:
|
|
||||||
(details) => _showContextMenu(details.globalPosition),
|
|
||||||
onLongPressStart:
|
|
||||||
(details) => _showContextMenu(details.globalPosition),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
.read(pdfProvider.notifier)
|
||||||
|
.removePlacement(page: pageNumber, index: placedIndex!);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'adjust':
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => const ImageEditorDialog(),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SignatureImage extends ConsumerWidget {
|
||||||
|
const _SignatureImage({
|
||||||
|
required this.interactive,
|
||||||
|
required this.placedIndex,
|
||||||
|
required this.pageNumber,
|
||||||
|
required this.sig,
|
||||||
|
});
|
||||||
|
|
||||||
|
final bool interactive;
|
||||||
|
final int? placedIndex;
|
||||||
|
final int pageNumber;
|
||||||
|
final SignatureState sig;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
Uint8List? bytes;
|
||||||
|
if (interactive) {
|
||||||
|
final processed = ref.watch(processedSignatureImageProvider);
|
||||||
|
bytes = processed ?? sig.imageBytes;
|
||||||
|
} else if (placedIndex != null) {
|
||||||
|
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,
|
||||||
|
wrapInRepaintBoundary: true,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,10 @@
|
||||||
// no bytes here; use decoded images
|
import 'dart:typed_data';
|
||||||
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 '../../signature/widgets/signature_drawer.dart';
|
import '../../../../data/services/export_providers.dart';
|
||||||
import '../view_model/pdf_export_view_model.dart';
|
import 'signature_drawer.dart';
|
||||||
|
|
||||||
class SignaturesSidebar extends ConsumerWidget {
|
class SignaturesSidebar extends ConsumerWidget {
|
||||||
const SignaturesSidebar({
|
const SignaturesSidebar({
|
||||||
|
|
@ -15,14 +14,14 @@ class SignaturesSidebar extends ConsumerWidget {
|
||||||
required this.onSave,
|
required this.onSave,
|
||||||
});
|
});
|
||||||
|
|
||||||
final Future<img.Image?> Function() onLoadSignatureFromFile;
|
final Future<Uint8List?> Function() onLoadSignatureFromFile;
|
||||||
final Future<img.Image?> Function() onOpenDrawCanvas;
|
final Future<Uint8List?> 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(pdfExportViewModelProvider).exporting;
|
final isExporting = ref.watch(exportingProvider);
|
||||||
return AbsorbPointer(
|
return AbsorbPointer(
|
||||||
absorbing: isExporting,
|
absorbing: isExporting,
|
||||||
child: Card(
|
child: Card(
|
||||||
|
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
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);
|
|
||||||
});
|
|
||||||
|
|
@ -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/repositories/preferences_repository.dart';
|
import '../../../../data/services/preferences_providers.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(preferencesRepositoryProvider);
|
final prefs = ref.read(preferencesProvider);
|
||||||
_theme = prefs.theme;
|
_theme = prefs.theme;
|
||||||
_language = prefs.language;
|
_language = prefs.language;
|
||||||
_exportDpi = prefs.exportDpi;
|
_exportDpi = prefs.exportDpi;
|
||||||
|
|
@ -62,45 +62,61 @@ 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: FutureBuilder<Map<String, String>>(
|
child: ref
|
||||||
future: languageAutonyms(),
|
.watch(languageAutonymsProvider)
|
||||||
builder: (context, snapshot) {
|
.when(
|
||||||
if (snapshot.connectionState ==
|
loading:
|
||||||
ConnectionState.waiting) {
|
() => const SizedBox(
|
||||||
return const SizedBox(
|
height: 48,
|
||||||
height: 48,
|
child: Center(
|
||||||
child: Center(child: CircularProgressIndicator()),
|
child: CircularProgressIndicator(),
|
||||||
);
|
),
|
||||||
}
|
),
|
||||||
final names = snapshot.data;
|
error: (_, _) {
|
||||||
final tags =
|
final items =
|
||||||
AppLocalizations.supportedLocales
|
AppLocalizations.supportedLocales
|
||||||
.map((loc) => toLanguageTag(loc))
|
.map((loc) => toLanguageTag(loc))
|
||||||
.toList()
|
.toList()
|
||||||
..sort();
|
..sort();
|
||||||
return DropdownButton<String>(
|
return DropdownButton<String>(
|
||||||
key: const Key('ddl_language'),
|
key: const Key('ddl_language'),
|
||||||
isExpanded: true,
|
isExpanded: true,
|
||||||
value: _language,
|
value: _language,
|
||||||
items:
|
items:
|
||||||
tags
|
items
|
||||||
.map(
|
.map(
|
||||||
(tag) => DropdownMenuItem<String>(
|
(tag) => DropdownMenuItem(
|
||||||
value: tag,
|
value: tag,
|
||||||
child: Text(names?[tag] ?? tag),
|
child: Text(tag),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.toList(),
|
.toList(),
|
||||||
onChanged: (v) async {
|
onChanged: (v) => setState(() => _language = v),
|
||||||
if (v == null) return;
|
);
|
||||||
setState(() => _language = v);
|
|
||||||
await ref
|
|
||||||
.read(preferencesRepositoryProvider.notifier)
|
|
||||||
.setLanguage(v);
|
|
||||||
},
|
},
|
||||||
);
|
data: (names) {
|
||||||
},
|
final items =
|
||||||
),
|
AppLocalizations.supportedLocales
|
||||||
|
.map((loc) => toLanguageTag(loc))
|
||||||
|
.toList()
|
||||||
|
..sort();
|
||||||
|
return DropdownButton<String>(
|
||||||
|
key: const Key('ddl_language'),
|
||||||
|
isExpanded: true,
|
||||||
|
value: _language,
|
||||||
|
items:
|
||||||
|
items
|
||||||
|
.map(
|
||||||
|
(tag) => DropdownMenuItem<String>(
|
||||||
|
value: tag,
|
||||||
|
child: Text(names[tag] ?? tag),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
|
onChanged: (v) => setState(() => _language = v),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
@ -122,13 +138,7 @@ class _SettingsDialogState extends ConsumerState<SettingsDialog> {
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.toList(),
|
.toList(),
|
||||||
onChanged: (v) async {
|
onChanged: (v) => setState(() => _exportDpi = v),
|
||||||
if (v == null) return;
|
|
||||||
setState(() => _exportDpi = v);
|
|
||||||
await ref
|
|
||||||
.read(preferencesRepositoryProvider.notifier)
|
|
||||||
.setExportDpi(v);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
@ -159,40 +169,33 @@ class _SettingsDialogState extends ConsumerState<SettingsDialog> {
|
||||||
child: Text(l.themeSystem),
|
child: Text(l.themeSystem),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
onChanged: (v) async {
|
onChanged: (v) => setState(() => _theme = v),
|
||||||
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),
|
||||||
Align(
|
Row(
|
||||||
alignment: Alignment.centerRight,
|
mainAxisAlignment: MainAxisAlignment.end,
|
||||||
child: FilledButton.tonal(
|
children: [
|
||||||
onPressed: () => Navigator.of(context).pop(true),
|
OutlinedButton(
|
||||||
child: Text(l.close),
|
onPressed: () => Navigator.of(context).pop(false),
|
||||||
),
|
child: Text(l.cancel),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () async {
|
||||||
|
final n = ref.read(preferencesProvider.notifier);
|
||||||
|
if (_theme != null) await n.setTheme(_theme!);
|
||||||
|
if (_language != null) await n.setLanguage(_language!);
|
||||||
|
if (_exportDpi != null) await n.setExportDpi(_exportDpi!);
|
||||||
|
// pageView not configurable anymore
|
||||||
|
if (mounted) Navigator.of(context).pop(true);
|
||||||
|
},
|
||||||
|
child: Text(l.save),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
@ -201,113 +204,3 @@ 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),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
||||||
|
|
||||||
/// Global flag indicating whether a signature card is currently being dragged.
|
|
||||||
final isDraggingSignatureViewModelProvider = StateProvider<bool>(
|
|
||||||
(ref) => false,
|
|
||||||
);
|
|
||||||
|
|
@ -0,0 +1,349 @@
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
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(),
|
||||||
|
);
|
||||||
|
|
@ -1,45 +0,0 @@
|
||||||
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);
|
|
||||||
});
|
|
||||||
|
|
@ -1,292 +0,0 @@
|
||||||
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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,164 +1,132 @@
|
||||||
import 'dart:async';
|
import 'dart:math' as math;
|
||||||
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.image,
|
required this.bytes,
|
||||||
this.rotationDeg = 0.0, // counterclockwise as positive
|
this.rotationDeg = 0.0,
|
||||||
|
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.cacheWidth,
|
this.intrinsicAspectRatio,
|
||||||
this.cacheHeight,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Decoded CPU image (from `package:image`).
|
final Uint8List bytes;
|
||||||
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
|
||||||
/// Optional target size hints to reduce decode cost.
|
// an angle-aware scale for non-square images to ensure the rotated rectangle
|
||||||
/// If only one is provided, the other is computed to preserve aspect.
|
// (W,H) fits back into its (W,H) bounds. If null, we attempt to derive it
|
||||||
final int? cacheWidth;
|
// from the image stream; only fall back to the square heuristic if unknown.
|
||||||
final int? cacheHeight;
|
final double? intrinsicAspectRatio;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<RotatedSignatureImage> createState() => _RotatedSignatureImageState();
|
State<RotatedSignatureImage> createState() => _RotatedSignatureImageState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _RotatedSignatureImageState extends State<RotatedSignatureImage> {
|
class _RotatedSignatureImageState extends State<RotatedSignatureImage> {
|
||||||
Uint8List? _encodedBytes; // PNG-encoded bytes for Image.memory
|
ImageStream? _stream;
|
||||||
img.Image? _lastSrc; // To detect changes cheaply
|
ImageStreamListener? _listener;
|
||||||
int? _lastW;
|
double? _derivedAspectRatio; // width / height
|
||||||
int? _lastH;
|
|
||||||
|
MemoryImage get _provider => MemoryImage(widget.bytes);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void didChangeDependencies() {
|
||||||
super.initState();
|
super.didChangeDependencies();
|
||||||
_prepare();
|
_resolveImage();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void didUpdateWidget(covariant RotatedSignatureImage oldWidget) {
|
void didUpdateWidget(covariant RotatedSignatureImage oldWidget) {
|
||||||
super.didUpdateWidget(oldWidget);
|
super.didUpdateWidget(oldWidget);
|
||||||
final srcChanged =
|
if (!identical(oldWidget.bytes, widget.bytes)) {
|
||||||
!identical(widget.image, _lastSrc) ||
|
_derivedAspectRatio = null;
|
||||||
widget.image.width != (oldWidget.image.width) ||
|
_resolveImage();
|
||||||
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) {
|
||||||
// Compute angle-aware scale so rotated image stays within bounds.
|
final angle = widget.rotationDeg * math.pi / 180.0;
|
||||||
final double angleRad = rot.ccwRadians(widget.rotationDeg);
|
Widget img = Image.memory(
|
||||||
final double ar =
|
widget.bytes,
|
||||||
widget.image.width == 0
|
fit: widget.fit,
|
||||||
? 1.0
|
gaplessPlayback: widget.gaplessPlayback,
|
||||||
: widget.image.width / widget.image.height;
|
filterQuality: widget.filterQuality,
|
||||||
final double k = rot.scaleToFitForAngle(angleRad, ar: ar);
|
alignment: widget.alignment,
|
||||||
|
semanticLabel: widget.semanticLabel,
|
||||||
|
);
|
||||||
|
|
||||||
Widget core =
|
if (angle != 0.0) {
|
||||||
_encodedBytes == null
|
if (widget.enableAngleAwareScale) {
|
||||||
? const SizedBox.shrink()
|
final double c = math.cos(angle).abs();
|
||||||
: Image.memory(
|
final double s = math.sin(angle).abs();
|
||||||
_encodedBytes!,
|
final ar = widget.intrinsicAspectRatio ?? _derivedAspectRatio;
|
||||||
fit: BoxFit.contain,
|
double scaleToFit;
|
||||||
filterQuality: widget.filterQuality,
|
if (ar != null && ar > 0) {
|
||||||
gaplessPlayback: true,
|
scaleToFit = math.min(ar / (ar * c + s), 1.0 / (ar * s + c));
|
||||||
);
|
} else {
|
||||||
if (widget.semanticLabel != null) {
|
// Fallback: square approximation
|
||||||
core = Semantics(label: widget.semanticLabel, child: core);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Order: scale first, then rotate. Scale ensures rotated bounds fit.
|
if (!widget.wrapInRepaintBoundary) return img;
|
||||||
Widget transformed = Transform.scale(
|
return RepaintBoundary(child: img);
|
||||||
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,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||