Compare commits
22 Commits
d9969e5ea5
...
0f7d840e48
| Author | SHA1 | Date |
|---|---|---|
|
|
0f7d840e48 | |
|
|
41eea5f00c | |
|
|
5ad4d6136f | |
|
|
69d5a9a248 | |
|
|
2043bfc14c | |
|
|
feaf7aee9f | |
|
|
6652de28bf | |
|
|
994c1b2569 | |
|
|
26a0c93390 | |
|
|
80cf115ab3 | |
|
|
8f3039f99e | |
|
|
461c8f6ae5 | |
|
|
5549f08b4c | |
|
|
7336ca4d57 | |
|
|
c82bb7fa2a | |
|
|
00e2e1deb4 | |
|
|
c46aca1331 | |
|
|
545d3ad688 | |
|
|
4d2cd09adf | |
|
|
189bc7e6e6 | |
|
|
f0a8e25890 | |
|
|
b0a3ff1f57 |
|
|
@ -1,27 +1,52 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||||
|
viewBox="0 0 64 64"
|
||||||
|
width="64"
|
||||||
|
height="64"
|
||||||
|
role="img"
|
||||||
|
aria-labelledby="title desc"
|
||||||
|
>
|
||||||
|
<title id="title">PDF Signature</title>
|
||||||
|
<desc id="desc">An app icon showing a PDF page with a folded corner and a handwritten signature.</desc>
|
||||||
|
|
||||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
<!-- Background tile -->
|
||||||
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
<rect x="4" y="4" width="56" height="56" rx="12" fill="#2563EB" />
|
||||||
<svg version="1.1" id="_x32_" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
|
|
||||||
viewBox="0 0 512 512" xml:space="preserve">
|
<!-- Paper with folded corner -->
|
||||||
<style type="text/css">
|
<g>
|
||||||
.st0{fill:#000000;}
|
<path
|
||||||
</style>
|
d="M20 16h18l10 10v22c0 2.2-1.8 4-4 4H20c-2.2 0-4-1.8-4-4V20c0-2.2 1.8-4 4-4z"
|
||||||
<g>
|
fill="#FFFFFF"
|
||||||
<path class="st0" d="M56.007,114.35c-5.535-5.539-14.51-5.539-20.045,0L4.148,146.159c-5.531,5.539-5.531,14.506,0,20.046
|
/>
|
||||||
l20.622,20.621l51.859-51.855L56.007,114.35z"/>
|
<path
|
||||||
<polygon class="st0" points="286.422,396.623 268.742,327.077 216.884,378.94 "/>
|
d="M38 16v8c0 3.3 2.7 6 6 6h8l-14-14z"
|
||||||
<path class="st0" d="M258.136,316.475L86.058,144.397L34.2,196.26l172.073,172.077L258.136,316.475z M87.468,166.56
|
fill="#F3F4F6"
|
||||||
l149.919,149.922l-11.784,11.78L75.684,178.348L87.468,166.56z"/>
|
/>
|
||||||
<rect x="195.662" y="132.491" class="st0" width="29.356" height="28.017"/>
|
</g>
|
||||||
<rect x="195.662" y="200.693" class="st0" width="29.356" height="28.009"/>
|
|
||||||
<rect x="256.69" y="132.491" class="st0" width="173.056" height="28.017"/>
|
<!-- Signature stroke -->
|
||||||
<rect x="256.69" y="200.693" class="st0" width="173.056" height="28.009"/>
|
<path
|
||||||
<rect x="288.598" y="268.894" class="st0" width="141.148" height="28.01"/>
|
d="M18 42c3-2 6-2.2 8.5 0 2.5 2.2 4.8 1.8 8.2-1.2 3.4-3 6.9-5.3 9.4-2.8 1.2 1.2 0.5 3.2-1.2 3.6-3.5 0.9 3.3-6.8 6.4-4.6 2 1.4-1.5 6.7-4.8 7.8-4.6 1.6-10.9-0.6-13.8-0.6-4.4 0-7.5 2.4-12 2.8"
|
||||||
<path class="st0" d="M429.817,11.059H195.582c-45.32,0-82.182,36.858-82.182,82.179v32.726l30.427,30.435V93.238
|
fill="none"
|
||||||
c0-28.586,23.178-51.752,51.755-51.752h234.235c28.594,0,51.756,23.166,51.756,51.752v254.042h-80.097
|
stroke="#1F2937"
|
||||||
c-23.822,0-43.124,19.318-43.124,43.132v80.101h-162.77c-28.578,0-51.755-23.166-51.755-51.752v-37.072l-6.234-1.587l6.234-6.235
|
stroke-width="2.5"
|
||||||
v-22.202L113.4,321.239v97.522c0,45.313,36.862,82.179,82.182,82.179h162.77h12.598l8.917-8.913l123.224-123.224l8.909-8.912
|
stroke-linecap="round"
|
||||||
v-12.61V93.238C512,47.917,475.138,11.059,429.817,11.059z"/>
|
stroke-linejoin="round"
|
||||||
</g>
|
/>
|
||||||
|
|
||||||
|
<!-- Subtle page shadow for depth (kept minimal for clarity) -->
|
||||||
|
<path
|
||||||
|
d="M20 16h18l10 10v1H44c-4.4 0-8-3.6-8-8v-3H20c-1.1 0-2 .9-2 2v0c0-1.1.9-2 2-2z"
|
||||||
|
fill="#000"
|
||||||
|
opacity=".05"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Optional PDF label dots (very subtle) -->
|
||||||
|
<g fill="#E53935" opacity=".9">
|
||||||
|
<circle cx="24" cy="28" r="1" />
|
||||||
|
<circle cx="28" cy="28" r="1" />
|
||||||
|
<circle cx="32" cy="28" r="1" />
|
||||||
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.4 KiB |
|
|
@ -2,7 +2,7 @@
|
||||||
<application
|
<application
|
||||||
android:label="pdf_signature"
|
android:label="pdf_signature"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
android:icon="@mipmap/ic_launcher">
|
android:icon="@mipmap/launcher_icon">
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
|
|
|
||||||
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
|
@ -0,0 +1,52 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||||
|
viewBox="0 0 64 64"
|
||||||
|
width="64"
|
||||||
|
height="64"
|
||||||
|
role="img"
|
||||||
|
aria-labelledby="title desc"
|
||||||
|
>
|
||||||
|
<title id="title">PDF Signature</title>
|
||||||
|
<desc id="desc">An app icon showing a PDF page with a folded corner and a handwritten signature.</desc>
|
||||||
|
|
||||||
|
<!-- Background tile -->
|
||||||
|
<rect x="4" y="4" width="56" height="56" rx="12" fill="#2563EB" />
|
||||||
|
|
||||||
|
<!-- Paper with folded corner -->
|
||||||
|
<g>
|
||||||
|
<path
|
||||||
|
d="M20 16h18l10 10v22c0 2.2-1.8 4-4 4H20c-2.2 0-4-1.8-4-4V20c0-2.2 1.8-4 4-4z"
|
||||||
|
fill="#FFFFFF"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M38 16v8c0 3.3 2.7 6 6 6h8l-14-14z"
|
||||||
|
fill="#F3F4F6"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Signature stroke -->
|
||||||
|
<path
|
||||||
|
d="M18 42c3-2 6-2.2 8.5 0 2.5 2.2 4.8 1.8 8.2-1.2 3.4-3 6.9-5.3 9.4-2.8 1.2 1.2 0.5 3.2-1.2 3.6-3.5 0.9 3.3-6.8 6.4-4.6 2 1.4-1.5 6.7-4.8 7.8-4.6 1.6-10.9-0.6-13.8-0.6-4.4 0-7.5 2.4-12 2.8"
|
||||||
|
fill="none"
|
||||||
|
stroke="#1F2937"
|
||||||
|
stroke-width="2.5"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Subtle page shadow for depth (kept minimal for clarity) -->
|
||||||
|
<path
|
||||||
|
d="M20 16h18l10 10v1H44c-4.4 0-8-3.6-8-8v-3H20c-1.1 0-2 .9-2 2v0c0-1.1.9-2 2-2z"
|
||||||
|
fill="#000"
|
||||||
|
opacity=".05"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Optional PDF label dots (very subtle) -->
|
||||||
|
<g fill="#E53935" opacity=".9">
|
||||||
|
<circle cx="24" cy="28" r="1" />
|
||||||
|
<circle cx="28" cy="28" r="1" />
|
||||||
|
<circle cx="32" cy="28" r="1" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.4 KiB |
14
build.yaml
|
|
@ -1,8 +1,18 @@
|
||||||
targets:
|
targets:
|
||||||
$default:
|
$default:
|
||||||
sources:
|
sources:
|
||||||
- integration_test/**
|
- integration_test/** # By default, build runner will not generate code in the integration folder
|
||||||
- test/**
|
- test/** # so we override paths for code generation here
|
||||||
- lib/**
|
- lib/**
|
||||||
- $package$
|
- $package$
|
||||||
builders:
|
builders:
|
||||||
|
bdd_widget_test|featureBuilder:
|
||||||
|
generate_for:
|
||||||
|
- test/**
|
||||||
|
- integration_test/**
|
||||||
|
freezed:
|
||||||
|
generate_for:
|
||||||
|
- lib/**
|
||||||
|
json_serializable:
|
||||||
|
generate_for:
|
||||||
|
- lib/**
|
||||||
|
|
|
||||||
|
|
@ -3,3 +3,4 @@
|
||||||
* support multiple platforms (windows, linux, android, web)
|
* support multiple platforms (windows, linux, android, web)
|
||||||
* only FOSS libs can use
|
* only FOSS libs can use
|
||||||
* should not exceed 350 lines of code per file
|
* should not exceed 350 lines of code per file
|
||||||
|
* Direct Passing is better than Singleton(e.g.Provider) especially for `view`, `viewModel`.
|
||||||
|
|
|
||||||
|
|
@ -396,75 +396,6 @@
|
||||||
"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",
|
"id": "Q0v5ejctIV2msui0iDFEg",
|
||||||
"type": "rectangle",
|
"type": "rectangle",
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ Refs:
|
||||||
## Welcome / First screen
|
## Welcome / First screen
|
||||||
|
|
||||||
Purpose: let the user open a PDF quickly via drag & drop or file picker.
|
Purpose: let the user open a PDF quickly via drag & drop or file picker.
|
||||||
|
|
||||||
Route: root
|
Route: root
|
||||||
|
|
||||||
Design notes:
|
Design notes:
|
||||||
|
|
@ -29,8 +30,8 @@ Purpose: provide basic configuration before/after opening a PDF.
|
||||||
Route: root --> settings
|
Route: root --> settings
|
||||||
|
|
||||||
Design notes:
|
Design notes:
|
||||||
- Opened via "Configure" button in the top bar.
|
- Opened via "Configure" button in the right of top bar.
|
||||||
- Modal with simple sections (e.g., General, Display).
|
- Model with simple sections (e.g., General, Display).
|
||||||
- Primary action to save, secondary to cancel.
|
- Primary action to save, secondary to cancel.
|
||||||
|
|
||||||
Illustration:
|
Illustration:
|
||||||
|
|
@ -61,6 +62,7 @@ Design notes:
|
||||||
- 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):
|
||||||
|
|
|
||||||
|
|
@ -4,13 +4,21 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:integration_test/integration_test.dart';
|
import 'package:integration_test/integration_test.dart';
|
||||||
import 'package:image/image.dart' as img;
|
import 'package:image/image.dart' as img;
|
||||||
|
import 'dart:io';
|
||||||
|
import 'package:file_selector/file_selector.dart' as fs;
|
||||||
|
|
||||||
import 'package:pdf_signature/data/services/export_service.dart';
|
import 'package:pdf_signature/data/services/export_service.dart';
|
||||||
|
|
||||||
import 'package:pdf_signature/data/repositories/signature_asset_repository.dart';
|
import 'package:pdf_signature/data/repositories/signature_asset_repository.dart';
|
||||||
import 'package:pdf_signature/data/repositories/signature_card_repository.dart';
|
import 'package:pdf_signature/data/repositories/signature_card_repository.dart';
|
||||||
import 'package:pdf_signature/data/repositories/document_repository.dart';
|
import 'package:pdf_signature/data/repositories/document_repository.dart';
|
||||||
|
import 'package:pdf_signature/domain/models/model.dart';
|
||||||
|
import 'package:pdf_signature/ui/features/pdf/view_model/pdf_view_model.dart';
|
||||||
|
import 'package:pdf_signature/ui/features/pdf/widgets/ui_services.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: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 {
|
||||||
|
|
@ -29,26 +37,36 @@ 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(
|
||||||
|
(ref) => PreferencesStateNotifier(prefs),
|
||||||
|
),
|
||||||
documentRepositoryProvider.overrideWith(
|
documentRepositoryProvider.overrideWith(
|
||||||
(ref) => DocumentStateNotifier()..openPicked(path: 'test.pdf'),
|
(ref) => DocumentStateNotifier()..openPicked(pageCount: 3),
|
||||||
),
|
),
|
||||||
signatureProvider.overrideWith(
|
pdfViewModelProvider.overrideWith(
|
||||||
(ref) => SignatureCardStateNotifier()..placeDefaultRect(),
|
(ref) => PdfViewModel(ref, useMockViewer: false),
|
||||||
),
|
),
|
||||||
useMockViewerProvider.overrideWith((ref) => true),
|
|
||||||
exportServiceProvider.overrideWith((_) => fake),
|
exportServiceProvider.overrideWith((_) => fake),
|
||||||
savePathPickerProvider.overrideWith(
|
savePathPickerProvider.overrideWith(
|
||||||
(_) => () async => 'C:/tmp/output.pdf',
|
(_) => () async => 'C:/tmp/output.pdf',
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
child: const MaterialApp(
|
child: MaterialApp(
|
||||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||||
supportedLocales: AppLocalizations.supportedLocales,
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
locale: Locale('en'),
|
locale: Locale('en'),
|
||||||
home: PdfSignatureHomePage(),
|
home: PdfSignatureHomePage(
|
||||||
|
onPickPdf: () async {},
|
||||||
|
onClosePdf: () {},
|
||||||
|
currentFile: fs.XFile('test.pdf'),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
@ -81,26 +99,46 @@ 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(
|
||||||
|
(ref) => PreferencesStateNotifier(prefs),
|
||||||
|
),
|
||||||
documentRepositoryProvider.overrideWith(
|
documentRepositoryProvider.overrideWith(
|
||||||
(ref) => DocumentStateNotifier()..openPicked(path: 'test.pdf'),
|
(ref) =>
|
||||||
|
DocumentStateNotifier()
|
||||||
|
..openPicked(pageCount: 3, bytes: pdfBytes),
|
||||||
),
|
),
|
||||||
signatureAssetRepositoryProvider.overrideWith((ref) {
|
signatureAssetRepositoryProvider.overrideWith((ref) {
|
||||||
final c = SignatureAssetRepository();
|
final c = SignatureAssetRepository();
|
||||||
c.add(sigBytes, name: 'image');
|
c.add(sigBytes, name: 'image');
|
||||||
return c;
|
return c;
|
||||||
}),
|
}),
|
||||||
// Keep mock viewer for determinism on CI/desktop devices
|
signatureCardRepositoryProvider.overrideWith((ref) {
|
||||||
useMockViewerProvider.overrideWithValue(true),
|
final cardRepo = SignatureCardStateNotifier();
|
||||||
|
final asset = SignatureAsset(bytes: sigBytes, name: 'image');
|
||||||
|
cardRepo.addWithAsset(asset, 0.0);
|
||||||
|
return cardRepo;
|
||||||
|
}),
|
||||||
|
pdfViewModelProvider.overrideWith(
|
||||||
|
(ref) => PdfViewModel(ref, useMockViewer: false),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
child: const MaterialApp(
|
child: MaterialApp(
|
||||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||||
supportedLocales: AppLocalizations.supportedLocales,
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
locale: Locale('en'),
|
locale: Locale('en'),
|
||||||
home: PdfSignatureHomePage(),
|
home: PdfSignatureHomePage(
|
||||||
|
onPickPdf: () async {},
|
||||||
|
onClosePdf: () {},
|
||||||
|
currentFile: fs.XFile('test.pdf'),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
@ -119,15 +157,18 @@ void main() {
|
||||||
// Programmatically simulate confirm: add placement with current rect and bound image, then clear active overlay.
|
// Programmatically simulate confirm: add placement with current rect and bound image, then clear active overlay.
|
||||||
final ctx = tester.element(find.byType(PdfSignatureHomePage));
|
final ctx = tester.element(find.byType(PdfSignatureHomePage));
|
||||||
final container = ProviderScope.containerOf(ctx);
|
final container = ProviderScope.containerOf(ctx);
|
||||||
final sigState = container.read(signatureProvider);
|
final r = container.read(pdfViewModelProvider).activeRect!;
|
||||||
final r = sigState.rect!;
|
|
||||||
final lib = container.read(signatureAssetRepositoryProvider);
|
final lib = container.read(signatureAssetRepositoryProvider);
|
||||||
final asset = lib.isNotEmpty ? lib.first : null;
|
final asset = lib.isNotEmpty ? lib.first : null;
|
||||||
final pdf = container.read(documentRepositoryProvider);
|
final currentPage = container.read(pdfViewModelProvider).currentPage;
|
||||||
container
|
container
|
||||||
.read(documentRepositoryProvider.notifier)
|
.read(documentRepositoryProvider.notifier)
|
||||||
.addPlacement(page: pdf.currentPage, rect: r, asset: asset);
|
.addPlacement(page: currentPage, rect: r, asset: asset);
|
||||||
container.read(signatureProvider.notifier).clearActiveOverlay();
|
// Clear active overlay by hiding signatures temporarily
|
||||||
|
// Note: signatureVisibilityProvider was removed in migration
|
||||||
|
// container.read(signatureVisibilityProvider.notifier).state = false;
|
||||||
|
await tester.pump();
|
||||||
|
// container.read(signatureVisibilityProvider.notifier).state = true;
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
final placed = find.byKey(const Key('placed_signature_0'));
|
final placed = find.byKey(const Key('placed_signature_0'));
|
||||||
|
|
@ -143,4 +184,202 @@ 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), 1);
|
||||||
|
container.read(pdfViewModelProvider.notifier).jumpToPage(2);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(container.read(pdfViewModelProvider), 2);
|
||||||
|
container.read(pdfViewModelProvider.notifier).jumpToPage(3);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(container.read(pdfViewModelProvider), 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), 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), 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), 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), 1);
|
||||||
|
await tester.tap(find.text('2'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(container.read(pdfViewModelProvider), 2);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,343 @@
|
||||||
|
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:flutter/services.dart';
|
||||||
|
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 = YES;
|
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
|
||||||
CLANG_ANALYZER_NONNULL = YES;
|
CLANG_ANALYZER_NONNULL = YES;
|
||||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
||||||
CLANG_CXX_LIBRARY = "libc++";
|
CLANG_CXX_LIBRARY = "libc++";
|
||||||
|
|
@ -484,7 +484,7 @@
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
|
||||||
CLANG_ANALYZER_NONNULL = YES;
|
CLANG_ANALYZER_NONNULL = YES;
|
||||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
||||||
CLANG_CXX_LIBRARY = "libc++";
|
CLANG_CXX_LIBRARY = "libc++";
|
||||||
|
|
|
||||||
|
|
@ -1,122 +1 @@
|
||||||
{
|
{"images":[{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@3x.png","scale":"3x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@3x.png","scale":"3x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@3x.png","scale":"3x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@1x.png","scale":"1x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@3x.png","scale":"3x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@1x.png","scale":"1x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@1x.png","scale":"1x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@1x.png","scale":"1x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@2x.png","scale":"2x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@1x.png","scale":"1x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@2x.png","scale":"2x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@1x.png","scale":"1x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@2x.png","scale":"2x"},{"size":"83.5x83.5","idiom":"ipad","filename":"Icon-App-83.5x83.5@2x.png","scale":"2x"},{"size":"1024x1024","idiom":"ios-marketing","filename":"Icon-App-1024x1024@1x.png","scale":"1x"}],"info":{"version":1,"author":"xcode"}}
|
||||||
"images" : [
|
|
||||||
{
|
|
||||||
"size" : "20x20",
|
|
||||||
"idiom" : "iphone",
|
|
||||||
"filename" : "Icon-App-20x20@2x.png",
|
|
||||||
"scale" : "2x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "20x20",
|
|
||||||
"idiom" : "iphone",
|
|
||||||
"filename" : "Icon-App-20x20@3x.png",
|
|
||||||
"scale" : "3x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "29x29",
|
|
||||||
"idiom" : "iphone",
|
|
||||||
"filename" : "Icon-App-29x29@1x.png",
|
|
||||||
"scale" : "1x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "29x29",
|
|
||||||
"idiom" : "iphone",
|
|
||||||
"filename" : "Icon-App-29x29@2x.png",
|
|
||||||
"scale" : "2x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "29x29",
|
|
||||||
"idiom" : "iphone",
|
|
||||||
"filename" : "Icon-App-29x29@3x.png",
|
|
||||||
"scale" : "3x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "40x40",
|
|
||||||
"idiom" : "iphone",
|
|
||||||
"filename" : "Icon-App-40x40@2x.png",
|
|
||||||
"scale" : "2x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "40x40",
|
|
||||||
"idiom" : "iphone",
|
|
||||||
"filename" : "Icon-App-40x40@3x.png",
|
|
||||||
"scale" : "3x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "60x60",
|
|
||||||
"idiom" : "iphone",
|
|
||||||
"filename" : "Icon-App-60x60@2x.png",
|
|
||||||
"scale" : "2x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "60x60",
|
|
||||||
"idiom" : "iphone",
|
|
||||||
"filename" : "Icon-App-60x60@3x.png",
|
|
||||||
"scale" : "3x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "20x20",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"filename" : "Icon-App-20x20@1x.png",
|
|
||||||
"scale" : "1x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "20x20",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"filename" : "Icon-App-20x20@2x.png",
|
|
||||||
"scale" : "2x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "29x29",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"filename" : "Icon-App-29x29@1x.png",
|
|
||||||
"scale" : "1x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "29x29",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"filename" : "Icon-App-29x29@2x.png",
|
|
||||||
"scale" : "2x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "40x40",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"filename" : "Icon-App-40x40@1x.png",
|
|
||||||
"scale" : "1x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "40x40",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"filename" : "Icon-App-40x40@2x.png",
|
|
||||||
"scale" : "2x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "76x76",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"filename" : "Icon-App-76x76@1x.png",
|
|
||||||
"scale" : "1x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "76x76",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"filename" : "Icon-App-76x76@2x.png",
|
|
||||||
"scale" : "2x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "83.5x83.5",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"filename" : "Icon-App-83.5x83.5@2x.png",
|
|
||||||
"scale" : "2x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "1024x1024",
|
|
||||||
"idiom" : "ios-marketing",
|
|
||||||
"filename" : "Icon-App-1024x1024@1x.png",
|
|
||||||
"scale" : "1x"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"info" : {
|
|
||||||
"version" : 1,
|
|
||||||
"author" : "xcode"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 8.1 KiB |
|
Before Width: | Height: | Size: 295 B After Width: | Height: | Size: 1012 B |
|
Before Width: | Height: | Size: 406 B After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 450 B After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 282 B After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 462 B After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 704 B After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 406 B After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 586 B After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 862 B After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 862 B After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 762 B After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 2.2 KiB |
66
lib/app.dart
|
|
@ -2,11 +2,9 @@ import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:flutter_localized_locales/flutter_localized_locales.dart';
|
import 'package:flutter_localized_locales/flutter_localized_locales.dart';
|
||||||
import 'package:pdf_signature/l10n/app_localizations.dart';
|
import 'package:pdf_signature/l10n/app_localizations.dart';
|
||||||
import 'package:pdf_signature/ui/features/pdf/widgets/pdf_screen.dart';
|
import 'package:pdf_signature/routing/router.dart';
|
||||||
import 'package:pdf_signature/data/repositories/document_repository.dart';
|
|
||||||
import 'package:pdf_signature/ui/features/welcome/widgets/welcome_screen.dart';
|
|
||||||
import 'data/repositories/preferences_repository.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});
|
||||||
|
|
@ -42,7 +40,7 @@ class MyApp extends StatelessWidget {
|
||||||
data: (_) {
|
data: (_) {
|
||||||
final themeMode = ref.watch(themeModeProvider);
|
final themeMode = ref.watch(themeModeProvider);
|
||||||
final appLocale = ref.watch(localeProvider);
|
final appLocale = ref.watch(localeProvider);
|
||||||
return MaterialApp(
|
return MaterialApp.router(
|
||||||
onGenerateTitle: (ctx) => AppLocalizations.of(ctx).appTitle,
|
onGenerateTitle: (ctx) => AppLocalizations.of(ctx).appTitle,
|
||||||
theme: ThemeData(
|
theme: ThemeData(
|
||||||
colorScheme: ColorScheme.fromSeed(
|
colorScheme: ColorScheme.fromSeed(
|
||||||
|
|
@ -63,27 +61,32 @@ class MyApp extends StatelessWidget {
|
||||||
...AppLocalizations.localizationsDelegates,
|
...AppLocalizations.localizationsDelegates,
|
||||||
LocaleNamesLocalizationsDelegate(),
|
LocaleNamesLocalizationsDelegate(),
|
||||||
],
|
],
|
||||||
home: Builder(
|
routerConfig: ref.watch(routerProvider),
|
||||||
builder:
|
builder: (context, child) {
|
||||||
(ctx) => Scaffold(
|
final router = ref.watch(routerProvider);
|
||||||
appBar: AppBar(
|
return Scaffold(
|
||||||
title: Text(AppLocalizations.of(ctx).appTitle),
|
appBar: AppBar(
|
||||||
actions: [
|
title: Text(AppLocalizations.of(context).appTitle),
|
||||||
OutlinedButton.icon(
|
actions: [
|
||||||
key: const Key('btn_appbar_settings'),
|
OutlinedButton.icon(
|
||||||
icon: const Icon(Icons.settings),
|
key: const Key('btn_appbar_settings'),
|
||||||
label: Text(AppLocalizations.of(ctx).settings),
|
icon: const Icon(Icons.settings),
|
||||||
onPressed:
|
label: Text(AppLocalizations.of(context).settings),
|
||||||
() => showDialog<bool>(
|
onPressed:
|
||||||
context: ctx,
|
() => showDialog<bool>(
|
||||||
builder: (_) => const SettingsDialog(),
|
context:
|
||||||
),
|
router
|
||||||
),
|
.routerDelegate
|
||||||
],
|
.navigatorKey
|
||||||
|
.currentContext!,
|
||||||
|
builder: (_) => const SettingsDialog(),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
body: const _RootHomeSwitcher(),
|
],
|
||||||
),
|
),
|
||||||
),
|
body: child,
|
||||||
|
);
|
||||||
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
@ -92,16 +95,3 @@ class MyApp extends StatelessWidget {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _RootHomeSwitcher extends ConsumerWidget {
|
|
||||||
const _RootHomeSwitcher();
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
|
||||||
final pdf = ref.watch(documentRepositoryProvider);
|
|
||||||
if (!pdf.loaded) {
|
|
||||||
return const WelcomeScreen();
|
|
||||||
}
|
|
||||||
return const PdfSignatureHomePage();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -15,29 +15,22 @@ class DocumentStateNotifier extends StateNotifier<Document> {
|
||||||
state = state.copyWith(
|
state = state.copyWith(
|
||||||
loaded: true,
|
loaded: true,
|
||||||
pageCount: 5,
|
pageCount: 5,
|
||||||
currentPage: 1,
|
pickedPdfBytes: null,
|
||||||
placementsByPage: {},
|
placementsByPage: <int, List<SignaturePlacement>>{},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void openPicked({
|
void openPicked({required int pageCount, Uint8List? bytes}) {
|
||||||
required String path,
|
|
||||||
required int pageCount,
|
|
||||||
Uint8List? bytes,
|
|
||||||
}) {
|
|
||||||
state = state.copyWith(
|
state = state.copyWith(
|
||||||
loaded: true,
|
loaded: true,
|
||||||
pageCount: pageCount,
|
pageCount: pageCount,
|
||||||
currentPage: 1,
|
|
||||||
pickedPdfBytes: bytes,
|
pickedPdfBytes: bytes,
|
||||||
placementsByPage: {},
|
placementsByPage: <int, List<SignaturePlacement>>{},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void jumpTo(int page) {
|
void close() {
|
||||||
if (!state.loaded) return;
|
state = Document.initial();
|
||||||
final clamped = page.clamp(1, state.pageCount);
|
|
||||||
state = state.copyWith(currentPage: clamped);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void setPageCount(int count) {
|
void setPageCount(int count) {
|
||||||
|
|
@ -45,6 +38,10 @@ 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({
|
||||||
|
|
@ -52,6 +49,7 @@ class DocumentStateNotifier extends StateNotifier<Document> {
|
||||||
required Rect rect,
|
required Rect rect,
|
||||||
SignatureAsset? asset,
|
SignatureAsset? asset,
|
||||||
double rotationDeg = 0.0,
|
double rotationDeg = 0.0,
|
||||||
|
GraphicAdjust? graphicAdjust,
|
||||||
}) {
|
}) {
|
||||||
if (!state.loaded) return;
|
if (!state.loaded) return;
|
||||||
final p = page.clamp(1, state.pageCount);
|
final p = page.clamp(1, state.pageCount);
|
||||||
|
|
@ -60,14 +58,87 @@ class DocumentStateNotifier extends StateNotifier<Document> {
|
||||||
list.add(
|
list.add(
|
||||||
SignaturePlacement(
|
SignaturePlacement(
|
||||||
rect: rect,
|
rect: rect,
|
||||||
asset: asset ?? SignatureAsset(bytes: Uint8List(0)),
|
asset: asset ?? SignatureAsset(bytes: _singleTransparentPng),
|
||||||
rotationDeg: rotationDeg,
|
rotationDeg: rotationDeg,
|
||||||
|
graphicAdjust: graphicAdjust ?? const GraphicAdjust(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
map[p] = list;
|
map[p] = list;
|
||||||
state = state.copyWith(placementsByPage: map);
|
state = state.copyWith(placementsByPage: map);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tiny 1x1 transparent PNG to avoid decode crashes in tests when no real
|
||||||
|
// signature bytes were provided.
|
||||||
|
static final Uint8List _singleTransparentPng = Uint8List.fromList([
|
||||||
|
0x89,
|
||||||
|
0x50,
|
||||||
|
0x4E,
|
||||||
|
0x47,
|
||||||
|
0x0D,
|
||||||
|
0x0A,
|
||||||
|
0x1A,
|
||||||
|
0x0A,
|
||||||
|
0x00,
|
||||||
|
0x00,
|
||||||
|
0x00,
|
||||||
|
0x0D,
|
||||||
|
0x49,
|
||||||
|
0x48,
|
||||||
|
0x44,
|
||||||
|
0x52,
|
||||||
|
0x00,
|
||||||
|
0x00,
|
||||||
|
0x00,
|
||||||
|
0x01,
|
||||||
|
0x00,
|
||||||
|
0x00,
|
||||||
|
0x00,
|
||||||
|
0x01,
|
||||||
|
0x08,
|
||||||
|
0x06,
|
||||||
|
0x00,
|
||||||
|
0x00,
|
||||||
|
0x00,
|
||||||
|
0x1F,
|
||||||
|
0x15,
|
||||||
|
0xC4,
|
||||||
|
0x89,
|
||||||
|
0x00,
|
||||||
|
0x00,
|
||||||
|
0x00,
|
||||||
|
0x0A,
|
||||||
|
0x49,
|
||||||
|
0x44,
|
||||||
|
0x41,
|
||||||
|
0x54,
|
||||||
|
0x78,
|
||||||
|
0x9C,
|
||||||
|
0x63,
|
||||||
|
0x60,
|
||||||
|
0x00,
|
||||||
|
0x00,
|
||||||
|
0x00,
|
||||||
|
0x02,
|
||||||
|
0x00,
|
||||||
|
0x01,
|
||||||
|
0xE5,
|
||||||
|
0x27,
|
||||||
|
0xD4,
|
||||||
|
0xA6,
|
||||||
|
0x00,
|
||||||
|
0x00,
|
||||||
|
0x00,
|
||||||
|
0x00,
|
||||||
|
0x49,
|
||||||
|
0x45,
|
||||||
|
0x4E,
|
||||||
|
0x44,
|
||||||
|
0xAE,
|
||||||
|
0x42,
|
||||||
|
0x60,
|
||||||
|
0x82,
|
||||||
|
]);
|
||||||
|
|
||||||
void updatePlacementRotation({
|
void updatePlacementRotation({
|
||||||
required int page,
|
required int page,
|
||||||
required int index,
|
required int index,
|
||||||
|
|
|
||||||
|
|
@ -1,43 +1,183 @@
|
||||||
|
import 'dart:typed_data';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import '../../domain/models/model.dart';
|
import '../../domain/models/model.dart';
|
||||||
|
import '../../data/services/signature_image_processing_service.dart';
|
||||||
|
|
||||||
class SignatureCardStateNotifier extends StateNotifier<List<SignatureCard>> {
|
class DisplaySignatureData {
|
||||||
SignatureCardStateNotifier() : super(const []);
|
final Uint8List bytes; // bytes to render
|
||||||
|
final List<double>? colorMatrix; // optional GPU color matrix
|
||||||
|
const DisplaySignatureData({required this.bytes, this.colorMatrix});
|
||||||
|
}
|
||||||
|
|
||||||
add({required SignatureAsset asset, double rotationDeg = 0.0}) {
|
/// CachedSignatureCard extends SignatureCard with an internal processed cache
|
||||||
state = List.of(state)
|
class CachedSignatureCard extends SignatureCard {
|
||||||
..add(SignatureCard(asset: asset, rotationDeg: rotationDeg));
|
Uint8List? _cachedProcessed;
|
||||||
|
|
||||||
|
CachedSignatureCard({
|
||||||
|
required super.asset,
|
||||||
|
required super.rotationDeg,
|
||||||
|
super.graphicAdjust,
|
||||||
|
Uint8List? initialProcessed,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Returns cached processed bytes for the current [graphicAdjust], computing
|
||||||
|
/// via [service] if not cached yet.
|
||||||
|
Uint8List getOrComputeProcessed(SignatureImageProcessingService service) {
|
||||||
|
final existing = _cachedProcessed;
|
||||||
|
if (existing != null) return existing;
|
||||||
|
final computed = service.processImage(asset.bytes, graphicAdjust);
|
||||||
|
_cachedProcessed = computed;
|
||||||
|
return computed;
|
||||||
}
|
}
|
||||||
|
|
||||||
void update({
|
/// Invalidate the cached processed bytes, forcing recompute next time.
|
||||||
required SignatureCard card,
|
void invalidateCache() {
|
||||||
|
_cachedProcessed = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets/updates the processed bytes explicitly (used after adjustments update)
|
||||||
|
void setProcessed(Uint8List bytes) {
|
||||||
|
_cachedProcessed = bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
factory CachedSignatureCard.initial() => CachedSignatureCard(
|
||||||
|
asset: SignatureCard.initial().asset,
|
||||||
|
rotationDeg: SignatureCard.initial().rotationDeg,
|
||||||
|
graphicAdjust: SignatureCard.initial().graphicAdjust,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class SignatureCardStateNotifier
|
||||||
|
extends StateNotifier<List<CachedSignatureCard>> {
|
||||||
|
SignatureCardStateNotifier() : super(const []) {
|
||||||
|
state = const <CachedSignatureCard>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stateless image processing service used by this repository
|
||||||
|
final SignatureImageProcessingService _processingService =
|
||||||
|
SignatureImageProcessingService();
|
||||||
|
|
||||||
|
void add(SignatureCard card) {
|
||||||
|
final wrapped =
|
||||||
|
card is CachedSignatureCard
|
||||||
|
? card
|
||||||
|
: CachedSignatureCard(
|
||||||
|
asset: card.asset,
|
||||||
|
rotationDeg: card.rotationDeg,
|
||||||
|
graphicAdjust: card.graphicAdjust,
|
||||||
|
);
|
||||||
|
final next = List<CachedSignatureCard>.of(state)..add(wrapped);
|
||||||
|
state = List<CachedSignatureCard>.unmodifiable(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
void addWithAsset(SignatureAsset asset, double rotationDeg) {
|
||||||
|
final next = List<CachedSignatureCard>.of(state)
|
||||||
|
..add(CachedSignatureCard(asset: asset, rotationDeg: rotationDeg));
|
||||||
|
state = List<CachedSignatureCard>.unmodifiable(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
void update(
|
||||||
|
SignatureCard card,
|
||||||
double? rotationDeg,
|
double? rotationDeg,
|
||||||
GraphicAdjust? graphicAdjust,
|
GraphicAdjust? graphicAdjust,
|
||||||
}) {
|
) {
|
||||||
final list = List<SignatureCard>.of(state);
|
final list = List<CachedSignatureCard>.of(state);
|
||||||
for (var i = 0; i < list.length; i++) {
|
for (var i = 0; i < list.length; i++) {
|
||||||
final c = list[i];
|
final c = list[i];
|
||||||
if (c == card) {
|
if (c == card) {
|
||||||
list[i] = c.copyWith(
|
final updated = c.copyWith(
|
||||||
rotationDeg: rotationDeg ?? c.rotationDeg,
|
rotationDeg: rotationDeg ?? c.rotationDeg,
|
||||||
graphicAdjust: graphicAdjust ?? c.graphicAdjust,
|
graphicAdjust: graphicAdjust ?? c.graphicAdjust,
|
||||||
);
|
);
|
||||||
state = list;
|
// Compute and set the single processed bytes for the updated adjust
|
||||||
|
final processed = _processingService.processImage(
|
||||||
|
updated.asset.bytes,
|
||||||
|
updated.graphicAdjust,
|
||||||
|
);
|
||||||
|
final next = CachedSignatureCard(
|
||||||
|
asset: updated.asset,
|
||||||
|
rotationDeg: updated.rotationDeg,
|
||||||
|
graphicAdjust: updated.graphicAdjust,
|
||||||
|
);
|
||||||
|
next.setProcessed(processed);
|
||||||
|
list[i] = next;
|
||||||
|
state = List<CachedSignatureCard>.unmodifiable(list);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void remove(SignatureCard card) {
|
void remove(SignatureCard card) {
|
||||||
state = state.where((c) => c != card).toList(growable: false);
|
state = List<CachedSignatureCard>.unmodifiable(
|
||||||
|
state.where((c) => c != card).toList(growable: false),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void clearAll() {
|
void clearAll() {
|
||||||
state = const [];
|
state = const <CachedSignatureCard>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns processed image bytes for the given asset + adjustments.
|
||||||
|
/// Uses an internal cache to avoid re-processing.
|
||||||
|
Uint8List getProcessedBytes(SignatureAsset asset, GraphicAdjust adjust) {
|
||||||
|
// Try to find a matching card by asset
|
||||||
|
for (final c in state) {
|
||||||
|
if (c.asset == asset) {
|
||||||
|
// If requested adjust equals the card's current adjust, use per-card cache
|
||||||
|
if (c.graphicAdjust == adjust) {
|
||||||
|
return c.getOrComputeProcessed(_processingService);
|
||||||
|
}
|
||||||
|
// Previewing unsaved adjustments: compute without caching
|
||||||
|
return _processingService.processImage(asset.bytes, adjust);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Asset not found among cards (e.g., preview in dialog): compute on-the-fly
|
||||||
|
return _processingService.processImage(asset.bytes, adjust);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Provide display data optimized: if bgRemoval false, returns original bytes + matrix;
|
||||||
|
/// if bgRemoval true, returns processed bytes with baked adjustments and null matrix.
|
||||||
|
DisplaySignatureData getDisplayData(
|
||||||
|
SignatureAsset asset,
|
||||||
|
GraphicAdjust adjust,
|
||||||
|
) {
|
||||||
|
if (!adjust.bgRemoval) {
|
||||||
|
// Find card for potential original bytes (identical object) - no CPU processing.
|
||||||
|
for (final c in state) {
|
||||||
|
if (c.asset == asset) {
|
||||||
|
final matrix = _processingService.buildColorMatrix(adjust);
|
||||||
|
return DisplaySignatureData(
|
||||||
|
bytes: c.asset.bytes,
|
||||||
|
colorMatrix: matrix,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
final matrix = _processingService.buildColorMatrix(adjust);
|
||||||
|
return DisplaySignatureData(bytes: asset.bytes, colorMatrix: matrix);
|
||||||
|
}
|
||||||
|
// bgRemoval path: need CPU processed bytes (includes brightness/contrast first)
|
||||||
|
final processed = getProcessedBytes(asset, adjust);
|
||||||
|
return DisplaySignatureData(bytes: processed, colorMatrix: null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clears all cached processed images.
|
||||||
|
void clearProcessedCache() {
|
||||||
|
for (final c in state) {
|
||||||
|
c.invalidateCache();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clears cached processed images for a specific asset only.
|
||||||
|
void clearCacheForAsset(SignatureAsset asset) {
|
||||||
|
for (final c in state) {
|
||||||
|
if (c.asset == asset) {
|
||||||
|
c.invalidateCache();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final signatureCardProvider =
|
final signatureCardRepositoryProvider = StateNotifierProvider<
|
||||||
StateNotifierProvider<SignatureCardStateNotifier, List<SignatureCard>>(
|
SignatureCardStateNotifier,
|
||||||
(ref) => SignatureCardStateNotifier(),
|
List<CachedSignatureCard>
|
||||||
);
|
>((ref) => SignatureCardStateNotifier());
|
||||||
|
|
|
||||||
|
|
@ -66,7 +66,7 @@ class ExportService {
|
||||||
required Uint8List? signatureImageBytes,
|
required Uint8List? signatureImageBytes,
|
||||||
Map<int, List<SignaturePlacement>>? placementsByPage,
|
Map<int, List<SignaturePlacement>>? placementsByPage,
|
||||||
Map<String, Uint8List>? libraryBytes,
|
Map<String, Uint8List>? libraryBytes,
|
||||||
double targetDpi = 144.0
|
double targetDpi = 144.0,
|
||||||
}) async {
|
}) 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;
|
||||||
|
|
@ -86,7 +86,6 @@ class ExportService {
|
||||||
final bgPng = await raster.toPng();
|
final bgPng = await raster.toPng();
|
||||||
final bgImg = pw.MemoryImage(bgPng);
|
final bgImg = pw.MemoryImage(bgPng);
|
||||||
|
|
||||||
|
|
||||||
final hasMulti =
|
final hasMulti =
|
||||||
(placementsByPage != null && placementsByPage.isNotEmpty);
|
(placementsByPage != null && placementsByPage.isNotEmpty);
|
||||||
final pagePlacements =
|
final pagePlacements =
|
||||||
|
|
@ -118,14 +117,50 @@ class ExportService {
|
||||||
for (var i = 0; i < pagePlacements.length; i++) {
|
for (var i = 0; i < pagePlacements.length; i++) {
|
||||||
final placement = pagePlacements[i];
|
final placement = pagePlacements[i];
|
||||||
final r = placement.rect;
|
final r = placement.rect;
|
||||||
final left = r.left / uiPageSize.width * widthPts;
|
// rect is stored in normalized units (0..1) relative to page
|
||||||
final top = r.top / uiPageSize.height * heightPts;
|
final left = r.left * widthPts;
|
||||||
final w = r.width / uiPageSize.width * widthPts;
|
final top = r.top * heightPts;
|
||||||
final h = r.height / uiPageSize.height * heightPts;
|
final w = r.width * widthPts;
|
||||||
Uint8List? bytes;
|
final h = r.height * heightPts;
|
||||||
|
|
||||||
bytes ??= signatureImageBytes; // fallback
|
// Process the signature asset with its graphic adjustments
|
||||||
if (bytes != null && bytes.isNotEmpty) {
|
Uint8List bytes = placement.asset.bytes;
|
||||||
|
if (bytes.isNotEmpty) {
|
||||||
|
try {
|
||||||
|
// Decode the image
|
||||||
|
final decoded = img.decodeImage(bytes);
|
||||||
|
if (decoded != null) {
|
||||||
|
img.Image processed = decoded;
|
||||||
|
|
||||||
|
// Apply contrast and brightness first
|
||||||
|
if (placement.graphicAdjust.contrast != 1.0 ||
|
||||||
|
placement.graphicAdjust.brightness != 0.0) {
|
||||||
|
processed = img.adjustColor(
|
||||||
|
processed,
|
||||||
|
contrast: placement.graphicAdjust.contrast,
|
||||||
|
brightness: placement.graphicAdjust.brightness,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply background removal after color adjustments
|
||||||
|
if (placement.graphicAdjust.bgRemoval) {
|
||||||
|
processed = _removeBackground(processed);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encode back to PNG to preserve transparency
|
||||||
|
bytes = Uint8List.fromList(img.encodePng(processed));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// If processing fails, use original bytes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use fallback if no bytes available
|
||||||
|
if (bytes.isEmpty && signatureImageBytes != null) {
|
||||||
|
bytes = signatureImageBytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bytes.isNotEmpty) {
|
||||||
pw.MemoryImage? imgObj;
|
pw.MemoryImage? imgObj;
|
||||||
try {
|
try {
|
||||||
imgObj = pw.MemoryImage(bytes);
|
imgObj = pw.MemoryImage(bytes);
|
||||||
|
|
@ -197,14 +232,50 @@ class ExportService {
|
||||||
for (var i = 0; i < pagePlacements.length; i++) {
|
for (var i = 0; i < pagePlacements.length; i++) {
|
||||||
final placement = pagePlacements[i];
|
final placement = pagePlacements[i];
|
||||||
final r = placement.rect;
|
final r = placement.rect;
|
||||||
final left = r.left / uiPageSize.width * widthPts;
|
// rect is stored in normalized units (0..1) relative to page
|
||||||
final top = r.top / uiPageSize.height * heightPts;
|
final left = r.left * widthPts;
|
||||||
final w = r.width / uiPageSize.width * widthPts;
|
final top = r.top * heightPts;
|
||||||
final h = r.height / uiPageSize.height * heightPts;
|
final w = r.width * widthPts;
|
||||||
Uint8List? bytes;
|
final h = r.height * heightPts;
|
||||||
|
|
||||||
bytes ??= signatureImageBytes; // fallback
|
// Process the signature asset with its graphic adjustments
|
||||||
if (bytes != null && bytes.isNotEmpty) {
|
Uint8List bytes = placement.asset.bytes;
|
||||||
|
if (bytes.isNotEmpty) {
|
||||||
|
try {
|
||||||
|
// Decode the image
|
||||||
|
final decoded = img.decodeImage(bytes);
|
||||||
|
if (decoded != null) {
|
||||||
|
img.Image processed = decoded;
|
||||||
|
|
||||||
|
// Apply contrast and brightness first
|
||||||
|
if (placement.graphicAdjust.contrast != 1.0 ||
|
||||||
|
placement.graphicAdjust.brightness != 0.0) {
|
||||||
|
processed = img.adjustColor(
|
||||||
|
processed,
|
||||||
|
contrast: placement.graphicAdjust.contrast,
|
||||||
|
brightness: placement.graphicAdjust.brightness,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply background removal after color adjustments
|
||||||
|
if (placement.graphicAdjust.bgRemoval) {
|
||||||
|
processed = _removeBackground(processed);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encode back to PNG to preserve transparency
|
||||||
|
bytes = Uint8List.fromList(img.encodePng(processed));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// If processing fails, use original bytes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use fallback if no bytes available
|
||||||
|
if (bytes.isEmpty && signatureImageBytes != null) {
|
||||||
|
bytes = signatureImageBytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bytes.isNotEmpty) {
|
||||||
pw.MemoryImage? imgObj;
|
pw.MemoryImage? imgObj;
|
||||||
try {
|
try {
|
||||||
// Ensure PNG for transparency if not already
|
// Ensure PNG for transparency if not already
|
||||||
|
|
@ -274,4 +345,31 @@ class ExportService {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Remove near-white background by making pixels with high brightness transparent
|
||||||
|
img.Image _removeBackground(img.Image image) {
|
||||||
|
final result =
|
||||||
|
image.hasAlpha ? img.Image.from(image) : image.convert(numChannels: 4);
|
||||||
|
|
||||||
|
const int threshold = 245; // Near-white threshold (0-255)
|
||||||
|
|
||||||
|
for (int y = 0; y < result.height; y++) {
|
||||||
|
for (int x = 0; x < result.width; x++) {
|
||||||
|
final pixel = result.getPixel(x, y);
|
||||||
|
|
||||||
|
// Get RGB values
|
||||||
|
final r = pixel.r;
|
||||||
|
final g = pixel.g;
|
||||||
|
final b = pixel.b;
|
||||||
|
|
||||||
|
// Check if pixel is near-white (all channels above threshold)
|
||||||
|
if (r >= threshold && g >= threshold && b >= threshold) {
|
||||||
|
// Make transparent
|
||||||
|
result.setPixelRgba(x, y, r, g, b, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,159 @@
|
||||||
|
import 'dart:typed_data';
|
||||||
|
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;
|
||||||
|
|
||||||
|
/// 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// For display: if bgRemoval not requested, return original bytes + matrix.
|
||||||
|
/// If bgRemoval requested, perform full CPU pipeline (brightness/contrast then bg removal)
|
||||||
|
/// and return processed bytes with null matrix (already baked in).
|
||||||
|
Uint8List processForDisplay(Uint8List bytes, domain.GraphicAdjust adjust) {
|
||||||
|
if (!adjust.bgRemoval) {
|
||||||
|
// No CPU processing unless any color adjust combined with bg removal.
|
||||||
|
if (adjust.contrast == 1.0 && adjust.brightness == 1.0) {
|
||||||
|
return bytes; // identity
|
||||||
|
}
|
||||||
|
// We let GPU handle; return original bytes.
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
return processImage(bytes, adjust);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decode image bytes once and reuse the decoded image for preview processing.
|
||||||
|
img.Image? decode(Uint8List bytes) {
|
||||||
|
try {
|
||||||
|
return img.decodeImage(bytes);
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Process image bytes with the given graphic adjustments
|
||||||
|
Uint8List processImage(Uint8List bytes, domain.GraphicAdjust adjust) {
|
||||||
|
if (adjust.contrast == 1.0 &&
|
||||||
|
adjust.brightness == 0.0 &&
|
||||||
|
!adjust.bgRemoval) {
|
||||||
|
return bytes; // No processing needed
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
final decoded = img.decodeImage(bytes);
|
||||||
|
if (decoded != null) {
|
||||||
|
img.Image processed = decoded;
|
||||||
|
|
||||||
|
// Apply contrast and brightness first
|
||||||
|
if (adjust.contrast != 1.0 || adjust.brightness != 0.0) {
|
||||||
|
processed = img.adjustColor(
|
||||||
|
processed,
|
||||||
|
contrast: adjust.contrast,
|
||||||
|
brightness: adjust.brightness,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply background removal after color adjustments
|
||||||
|
if (adjust.bgRemoval) {
|
||||||
|
processed = _removeBackground(processed);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encode back to PNG to preserve transparency
|
||||||
|
return Uint8List.fromList(img.encodePng(processed));
|
||||||
|
} else {
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// If processing fails, return original bytes
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fast preview processing:
|
||||||
|
/// - Reuses a decoded image
|
||||||
|
/// - Downscales to a small size for UI preview
|
||||||
|
/// - Uses low-compression PNG to reduce CPU cost
|
||||||
|
Uint8List processPreviewFromDecoded(
|
||||||
|
img.Image decoded,
|
||||||
|
domain.GraphicAdjust adjust, {
|
||||||
|
int maxDimension = 256,
|
||||||
|
}) {
|
||||||
|
try {
|
||||||
|
// Create a small working copy for quick adjustments
|
||||||
|
final int w = decoded.width;
|
||||||
|
final int h = decoded.height;
|
||||||
|
final double scale = (w > h ? maxDimension / w : maxDimension / h).clamp(
|
||||||
|
0.0,
|
||||||
|
1.0,
|
||||||
|
);
|
||||||
|
img.Image work =
|
||||||
|
(scale < 1.0)
|
||||||
|
? img.copyResize(decoded, width: (w * scale).round())
|
||||||
|
: img.Image.from(decoded);
|
||||||
|
|
||||||
|
// Apply contrast and brightness
|
||||||
|
if (adjust.contrast != 1.0 || adjust.brightness != 0.0) {
|
||||||
|
work = img.adjustColor(
|
||||||
|
work,
|
||||||
|
contrast: adjust.contrast,
|
||||||
|
brightness: adjust.brightness,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Background removal on downscaled image for speed
|
||||||
|
if (adjust.bgRemoval) {
|
||||||
|
work = _removeBackground(work);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encode with low compression (level 0) for speed
|
||||||
|
return Uint8List.fromList(img.encodePng(work, level: 0));
|
||||||
|
} catch (_) {
|
||||||
|
// Fall back to original size path if something goes wrong
|
||||||
|
return processImage(
|
||||||
|
Uint8List.fromList(img.encodePng(decoded, level: 0)),
|
||||||
|
adjust,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove near-white background using simple threshold approach for maximum speed
|
||||||
|
img.Image _removeBackground(img.Image image) {
|
||||||
|
final result =
|
||||||
|
image.hasAlpha ? img.Image.from(image) : image.convert(numChannels: 4);
|
||||||
|
|
||||||
|
// Simple and fast: single pass through all pixels
|
||||||
|
for (int y = 0; y < result.height; y++) {
|
||||||
|
for (int x = 0; x < result.width; x++) {
|
||||||
|
final pixel = result.getPixel(x, y);
|
||||||
|
final r = pixel.r;
|
||||||
|
final g = pixel.g;
|
||||||
|
final b = pixel.b;
|
||||||
|
|
||||||
|
// Simple threshold: if pixel is close to white, make it transparent
|
||||||
|
const int threshold = 240; // Very close to white
|
||||||
|
if (r >= threshold && g >= threshold && b >= threshold) {
|
||||||
|
result.setPixel(
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
img.ColorRgba8(r.toInt(), g.toInt(), b.toInt(), 0),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,36 +3,34 @@ import 'signature_placement.dart';
|
||||||
|
|
||||||
/// PDF document to be signed
|
/// PDF document to be signed
|
||||||
class Document {
|
class Document {
|
||||||
final bool loaded;
|
bool loaded;
|
||||||
final int pageCount;
|
int pageCount;
|
||||||
final int currentPage;
|
Uint8List? pickedPdfBytes;
|
||||||
final Uint8List? pickedPdfBytes;
|
|
||||||
// Multiple signature placements per page, each combines geometry and asset.
|
// Multiple signature placements per page, each combines geometry and asset.
|
||||||
final Map<int, List<SignaturePlacement>> placementsByPage;
|
Map<int, List<SignaturePlacement>> placementsByPage;
|
||||||
const Document({
|
|
||||||
|
Document({
|
||||||
required this.loaded,
|
required this.loaded,
|
||||||
required this.pageCount,
|
required this.pageCount,
|
||||||
required this.currentPage,
|
|
||||||
this.pickedPdfBytes,
|
this.pickedPdfBytes,
|
||||||
this.placementsByPage = const {},
|
Map<int, List<SignaturePlacement>>? placementsByPage,
|
||||||
});
|
}) : placementsByPage = placementsByPage ?? <int, List<SignaturePlacement>>{};
|
||||||
factory Document.initial() => const Document(
|
|
||||||
|
factory Document.initial() => Document(
|
||||||
loaded: false,
|
loaded: false,
|
||||||
pageCount: 0,
|
pageCount: 0,
|
||||||
currentPage: 1,
|
|
||||||
pickedPdfBytes: null,
|
pickedPdfBytes: null,
|
||||||
placementsByPage: {},
|
placementsByPage: <int, List<SignaturePlacement>>{},
|
||||||
);
|
);
|
||||||
|
|
||||||
Document copyWith({
|
Document copyWith({
|
||||||
bool? loaded,
|
bool? loaded,
|
||||||
int? pageCount,
|
int? pageCount,
|
||||||
int? currentPage,
|
|
||||||
Uint8List? pickedPdfBytes,
|
Uint8List? pickedPdfBytes,
|
||||||
Map<int, List<SignaturePlacement>>? placementsByPage,
|
Map<int, List<SignaturePlacement>>? placementsByPage,
|
||||||
}) => Document(
|
}) => Document(
|
||||||
loaded: loaded ?? this.loaded,
|
loaded: loaded ?? this.loaded,
|
||||||
pageCount: pageCount ?? this.pageCount,
|
pageCount: pageCount ?? this.pageCount,
|
||||||
currentPage: currentPage ?? this.currentPage,
|
|
||||||
pickedPdfBytes: pickedPdfBytes ?? this.pickedPdfBytes,
|
pickedPdfBytes: pickedPdfBytes ?? this.pickedPdfBytes,
|
||||||
placementsByPage: placementsByPage ?? this.placementsByPage,
|
placementsByPage: placementsByPage ?? this.placementsByPage,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ class GraphicAdjust {
|
||||||
|
|
||||||
const GraphicAdjust({
|
const GraphicAdjust({
|
||||||
this.contrast = 1.0,
|
this.contrast = 1.0,
|
||||||
this.brightness = 0.0,
|
this.brightness = 1.0,
|
||||||
this.bgRemoval = false,
|
this.bgRemoval = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -18,4 +18,17 @@ class GraphicAdjust {
|
||||||
brightness: brightness ?? this.brightness,
|
brightness: brightness ?? this.brightness,
|
||||||
bgRemoval: bgRemoval ?? this.bgRemoval,
|
bgRemoval: bgRemoval ?? this.bgRemoval,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) =>
|
||||||
|
identical(this, other) ||
|
||||||
|
other is GraphicAdjust &&
|
||||||
|
runtimeType == other.runtimeType &&
|
||||||
|
contrast == other.contrast &&
|
||||||
|
brightness == other.brightness &&
|
||||||
|
bgRemoval == other.bgRemoval;
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode =>
|
||||||
|
contrast.hashCode ^ brightness.hashCode ^ bgRemoval.hashCode;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,4 +6,22 @@ class SignatureAsset {
|
||||||
// List<List<Offset>>? strokes;
|
// List<List<Offset>>? strokes;
|
||||||
final String? name; // optional display name (e.g., filename)
|
final String? name; // optional display name (e.g., filename)
|
||||||
const SignatureAsset({required this.bytes, this.name});
|
const SignatureAsset({required this.bytes, this.name});
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) =>
|
||||||
|
identical(this, other) ||
|
||||||
|
other is SignatureAsset &&
|
||||||
|
name == other.name &&
|
||||||
|
_bytesEqual(bytes, other.bytes);
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => name.hashCode ^ bytes.length.hashCode;
|
||||||
|
|
||||||
|
static bool _bytesEqual(Uint8List a, Uint8List b) {
|
||||||
|
if (a.length != b.length) return false;
|
||||||
|
for (int i = 0; i < a.length; i++) {
|
||||||
|
if (a[i] != b[i]) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@
|
||||||
"image": "Bild",
|
"image": "Bild",
|
||||||
"invalidOrUnsupportedFile": "Ungültige oder nicht unterstützte Datei",
|
"invalidOrUnsupportedFile": "Ungültige oder nicht unterstützte Datei",
|
||||||
"language": "Sprache",
|
"language": "Sprache",
|
||||||
|
"lock": "Sperren",
|
||||||
"loadSignatureFromFile": "Signatur aus Datei laden",
|
"loadSignatureFromFile": "Signatur aus Datei laden",
|
||||||
"lockAspectRatio": "Seitenverhältnis sperren",
|
"lockAspectRatio": "Seitenverhältnis sperren",
|
||||||
"longPressOrRightClickTheSignatureToConfirmOrDelete": "Drücken Sie lange oder klicken Sie mit der rechten Maustaste auf die Signatur, um sie zu bestätigen oder zu löschen.",
|
"longPressOrRightClickTheSignatureToConfirmOrDelete": "Drücken Sie lange oder klicken Sie mit der rechten Maustaste auf die Signatur, um sie zu bestätigen oder zu löschen.",
|
||||||
|
|
@ -46,5 +47,6 @@
|
||||||
"themeDark": "Dunkel",
|
"themeDark": "Dunkel",
|
||||||
"themeLight": "Hell",
|
"themeLight": "Hell",
|
||||||
"themeSystem": "System",
|
"themeSystem": "System",
|
||||||
"undo": "Rückgängig"
|
"undo": "Rückgängig",
|
||||||
|
"unlock": "Entsperren"
|
||||||
}
|
}
|
||||||
|
|
@ -55,6 +55,8 @@
|
||||||
"@invalidOrUnsupportedFile": {},
|
"@invalidOrUnsupportedFile": {},
|
||||||
"language": "Language",
|
"language": "Language",
|
||||||
"@language": {},
|
"@language": {},
|
||||||
|
"lock": "Lock",
|
||||||
|
"@lock": {},
|
||||||
"loadSignatureFromFile": "Load Signature from file",
|
"loadSignatureFromFile": "Load Signature from file",
|
||||||
"@loadSignatureFromFile": {},
|
"@loadSignatureFromFile": {},
|
||||||
"lockAspectRatio": "Lock aspect ratio",
|
"lockAspectRatio": "Lock aspect ratio",
|
||||||
|
|
@ -119,5 +121,7 @@
|
||||||
"themeSystem": "System",
|
"themeSystem": "System",
|
||||||
"@themeSystem": {},
|
"@themeSystem": {},
|
||||||
"undo": "Undo",
|
"undo": "Undo",
|
||||||
"@undo": {}
|
"@undo": {},
|
||||||
|
"unlock": "Unlock",
|
||||||
|
"@unlock": {}
|
||||||
}
|
}
|
||||||
|
|
@ -23,6 +23,7 @@
|
||||||
"image": "Imagen",
|
"image": "Imagen",
|
||||||
"invalidOrUnsupportedFile": "Archivo inválido o no compatible",
|
"invalidOrUnsupportedFile": "Archivo inválido o no compatible",
|
||||||
"language": "Idioma",
|
"language": "Idioma",
|
||||||
|
"lock": "Bloquear",
|
||||||
"loadSignatureFromFile": "Cargar firma desde archivo",
|
"loadSignatureFromFile": "Cargar firma desde archivo",
|
||||||
"lockAspectRatio": "Bloquear relación de aspecto",
|
"lockAspectRatio": "Bloquear relación de aspecto",
|
||||||
"longPressOrRightClickTheSignatureToConfirmOrDelete": "Pulse prolongadamente o haga clic derecho en la firma para confirmar o eliminar.",
|
"longPressOrRightClickTheSignatureToConfirmOrDelete": "Pulse prolongadamente o haga clic derecho en la firma para confirmar o eliminar.",
|
||||||
|
|
@ -46,5 +47,6 @@
|
||||||
"themeDark": "Oscuro",
|
"themeDark": "Oscuro",
|
||||||
"themeLight": "Claro",
|
"themeLight": "Claro",
|
||||||
"themeSystem": "Sistema",
|
"themeSystem": "Sistema",
|
||||||
"undo": "Deshacer"
|
"undo": "Deshacer",
|
||||||
|
"unlock": "Desbloquear"
|
||||||
}
|
}
|
||||||
|
|
@ -23,6 +23,7 @@
|
||||||
"image": "Image",
|
"image": "Image",
|
||||||
"invalidOrUnsupportedFile": "Fichier invalide ou non pris en charge",
|
"invalidOrUnsupportedFile": "Fichier invalide ou non pris en charge",
|
||||||
"language": "Langue",
|
"language": "Langue",
|
||||||
|
"lock": "Verrouiller",
|
||||||
"loadSignatureFromFile": "Charger une signature depuis un fichier",
|
"loadSignatureFromFile": "Charger une signature depuis un fichier",
|
||||||
"lockAspectRatio": "Verrouiller le ratio largeur/hauteur",
|
"lockAspectRatio": "Verrouiller le ratio largeur/hauteur",
|
||||||
"longPressOrRightClickTheSignatureToConfirmOrDelete": "Appuyez longuement ou cliquez droit sur la signature pour la confirmer ou la supprimer.",
|
"longPressOrRightClickTheSignatureToConfirmOrDelete": "Appuyez longuement ou cliquez droit sur la signature pour la confirmer ou la supprimer.",
|
||||||
|
|
@ -46,5 +47,6 @@
|
||||||
"themeDark": "Sombre",
|
"themeDark": "Sombre",
|
||||||
"themeLight": "Clair",
|
"themeLight": "Clair",
|
||||||
"themeSystem": "Système",
|
"themeSystem": "Système",
|
||||||
"undo": "Annuler"
|
"undo": "Annuler",
|
||||||
|
"unlock": "Déverrouiller"
|
||||||
}
|
}
|
||||||
|
|
@ -23,6 +23,7 @@
|
||||||
"image": "画像",
|
"image": "画像",
|
||||||
"invalidOrUnsupportedFile": "無効なファイルまたはサポートされていないファイル",
|
"invalidOrUnsupportedFile": "無効なファイルまたはサポートされていないファイル",
|
||||||
"language": "言語",
|
"language": "言語",
|
||||||
|
"lock": "ロック",
|
||||||
"loadSignatureFromFile": "ファイルから署名を読み込む",
|
"loadSignatureFromFile": "ファイルから署名を読み込む",
|
||||||
"lockAspectRatio": "アスペクト比をロック",
|
"lockAspectRatio": "アスペクト比をロック",
|
||||||
"longPressOrRightClickTheSignatureToConfirmOrDelete": "署名を長押しまたは右クリックして、確認または削除します。",
|
"longPressOrRightClickTheSignatureToConfirmOrDelete": "署名を長押しまたは右クリックして、確認または削除します。",
|
||||||
|
|
@ -46,5 +47,6 @@
|
||||||
"themeDark": "ダーク",
|
"themeDark": "ダーク",
|
||||||
"themeLight": "ライト",
|
"themeLight": "ライト",
|
||||||
"themeSystem": "システム",
|
"themeSystem": "システム",
|
||||||
"undo": "元に戻す"
|
"undo": "元に戻す",
|
||||||
|
"unlock": "ロック解除"
|
||||||
}
|
}
|
||||||
|
|
@ -23,6 +23,7 @@
|
||||||
"image": "이미지",
|
"image": "이미지",
|
||||||
"invalidOrUnsupportedFile": "잘못된 파일이거나 지원되지 않는 파일입니다.",
|
"invalidOrUnsupportedFile": "잘못된 파일이거나 지원되지 않는 파일입니다.",
|
||||||
"language": "언어",
|
"language": "언어",
|
||||||
|
"lock": "잠금",
|
||||||
"loadSignatureFromFile": "파일에서 서명 불러오기",
|
"loadSignatureFromFile": "파일에서 서명 불러오기",
|
||||||
"lockAspectRatio": "종횡비 고정",
|
"lockAspectRatio": "종횡비 고정",
|
||||||
"longPressOrRightClickTheSignatureToConfirmOrDelete": "서명을 길게 누르거나 마우스 오른쪽 버튼을 클릭하여 확인하거나 삭제합니다.",
|
"longPressOrRightClickTheSignatureToConfirmOrDelete": "서명을 길게 누르거나 마우스 오른쪽 버튼을 클릭하여 확인하거나 삭제합니다.",
|
||||||
|
|
@ -46,5 +47,6 @@
|
||||||
"themeDark": "다크",
|
"themeDark": "다크",
|
||||||
"themeLight": "라이트",
|
"themeLight": "라이트",
|
||||||
"themeSystem": "시스템",
|
"themeSystem": "시스템",
|
||||||
"undo": "실행 취소"
|
"undo": "실행 취소",
|
||||||
|
"unlock": "잠금 해제"
|
||||||
}
|
}
|
||||||
|
|
@ -23,6 +23,7 @@
|
||||||
"image": "Зображення",
|
"image": "Зображення",
|
||||||
"invalidOrUnsupportedFile": "Недійсний або непідтримуваний файл",
|
"invalidOrUnsupportedFile": "Недійсний або непідтримуваний файл",
|
||||||
"language": "Мова",
|
"language": "Мова",
|
||||||
|
"lock": "Замкнути",
|
||||||
"loadSignatureFromFile": "Завантажити підпис з файлу",
|
"loadSignatureFromFile": "Завантажити підпис з файлу",
|
||||||
"lockAspectRatio": "Зафіксувати співвідношення сторін",
|
"lockAspectRatio": "Зафіксувати співвідношення сторін",
|
||||||
"longPressOrRightClickTheSignatureToConfirmOrDelete": "Довго натисніть або клацніть правою кнопкою миші на підпис, щоб підтвердити або видалити.",
|
"longPressOrRightClickTheSignatureToConfirmOrDelete": "Довго натисніть або клацніть правою кнопкою миші на підпис, щоб підтвердити або видалити.",
|
||||||
|
|
@ -46,5 +47,6 @@
|
||||||
"themeDark": "Темна",
|
"themeDark": "Темна",
|
||||||
"themeLight": "Світла",
|
"themeLight": "Світла",
|
||||||
"themeSystem": "Системна",
|
"themeSystem": "Системна",
|
||||||
"undo": "Відмінити"
|
"undo": "Відмінити",
|
||||||
|
"unlock": "Відмкнути"
|
||||||
}
|
}
|
||||||
|
|
@ -24,6 +24,7 @@
|
||||||
"image": "圖片",
|
"image": "圖片",
|
||||||
"invalidOrUnsupportedFile": "無效或不支援的檔案",
|
"invalidOrUnsupportedFile": "無效或不支援的檔案",
|
||||||
"language": "語言",
|
"language": "語言",
|
||||||
|
"lock": "锁定",
|
||||||
"loadSignatureFromFile": "從檔案載入簽名",
|
"loadSignatureFromFile": "從檔案載入簽名",
|
||||||
"lockAspectRatio": "鎖定長寬比",
|
"lockAspectRatio": "鎖定長寬比",
|
||||||
"longPressOrRightClickTheSignatureToConfirmOrDelete": "長按或右鍵點擊簽名以確認或刪除。",
|
"longPressOrRightClickTheSignatureToConfirmOrDelete": "長按或右鍵點擊簽名以確認或刪除。",
|
||||||
|
|
@ -47,5 +48,6 @@
|
||||||
"themeDark": "深色",
|
"themeDark": "深色",
|
||||||
"themeLight": "淺色",
|
"themeLight": "淺色",
|
||||||
"themeSystem": "系統",
|
"themeSystem": "系統",
|
||||||
"undo": "復原"
|
"undo": "復原",
|
||||||
|
"unlock": "解锁"
|
||||||
}
|
}
|
||||||
|
|
@ -23,6 +23,7 @@
|
||||||
"image": "图片",
|
"image": "图片",
|
||||||
"invalidOrUnsupportedFile": "无效或不支持的文件",
|
"invalidOrUnsupportedFile": "无效或不支持的文件",
|
||||||
"language": "语言",
|
"language": "语言",
|
||||||
|
"lock": "锁定",
|
||||||
"loadSignatureFromFile": "从文件加载签名",
|
"loadSignatureFromFile": "从文件加载签名",
|
||||||
"lockAspectRatio": "锁定纵横比",
|
"lockAspectRatio": "锁定纵横比",
|
||||||
"longPressOrRightClickTheSignatureToConfirmOrDelete": "长按或右键单击签名以确认或删除。",
|
"longPressOrRightClickTheSignatureToConfirmOrDelete": "长按或右键单击签名以确认或删除。",
|
||||||
|
|
@ -46,5 +47,6 @@
|
||||||
"themeDark": "深色",
|
"themeDark": "深色",
|
||||||
"themeLight": "浅色",
|
"themeLight": "浅色",
|
||||||
"themeSystem": "系统",
|
"themeSystem": "系统",
|
||||||
"undo": "撤销"
|
"undo": "撤销",
|
||||||
|
"unlock": "解锁"
|
||||||
}
|
}
|
||||||
|
|
@ -24,6 +24,7 @@
|
||||||
"image": "圖片",
|
"image": "圖片",
|
||||||
"invalidOrUnsupportedFile": "無效或不支援的檔案",
|
"invalidOrUnsupportedFile": "無效或不支援的檔案",
|
||||||
"language": "語言",
|
"language": "語言",
|
||||||
|
"lock": "鎖定",
|
||||||
"loadSignatureFromFile": "從檔案載入簽名",
|
"loadSignatureFromFile": "從檔案載入簽名",
|
||||||
"lockAspectRatio": "鎖定長寬比",
|
"lockAspectRatio": "鎖定長寬比",
|
||||||
"longPressOrRightClickTheSignatureToConfirmOrDelete": "長按或右鍵點擊簽名以確認或刪除。",
|
"longPressOrRightClickTheSignatureToConfirmOrDelete": "長按或右鍵點擊簽名以確認或刪除。",
|
||||||
|
|
@ -47,5 +48,6 @@
|
||||||
"themeDark": "深色",
|
"themeDark": "深色",
|
||||||
"themeLight": "淺色",
|
"themeLight": "淺色",
|
||||||
"themeSystem": "系統",
|
"themeSystem": "系統",
|
||||||
"undo": "復原"
|
"undo": "復原",
|
||||||
|
"unlock": "解鎖"
|
||||||
}
|
}
|
||||||
|
|
@ -0,0 +1,54 @@
|
||||||
|
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),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/pdf',
|
||||||
|
builder: (context, state) {
|
||||||
|
final sessionVm = ref.read(pdfSessionViewModelProvider(router));
|
||||||
|
return PdfSignatureHomePage(
|
||||||
|
onPickPdf: () => sessionVm.pickAndOpenPdf(),
|
||||||
|
onClosePdf: () => sessionVm.closePdf(),
|
||||||
|
currentFile: sessionVm.currentFile,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
initialLocation: initialLocation,
|
||||||
|
);
|
||||||
|
|
||||||
|
return router;
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,298 @@
|
||||||
|
import 'dart:typed_data';
|
||||||
|
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 ??
|
||||||
|
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('');
|
||||||
|
|
||||||
|
PdfSessionViewModel({required this.ref, required this.router});
|
||||||
|
|
||||||
|
fs.XFile get currentFile => _currentFile;
|
||||||
|
|
||||||
|
Future<void> pickAndOpenPdf() async {
|
||||||
|
final typeGroup = const fs.XTypeGroup(label: 'PDF', extensions: ['pdf']);
|
||||||
|
final file = await fs.openFile(acceptedTypeGroups: [typeGroup]);
|
||||||
|
if (file != null) {
|
||||||
|
Uint8List? bytes;
|
||||||
|
try {
|
||||||
|
bytes = await file.readAsBytes();
|
||||||
|
} catch (_) {
|
||||||
|
bytes = null;
|
||||||
|
}
|
||||||
|
await openPdf(path: file.path, bytes: bytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> openPdf({String? path, Uint8List? bytes}) 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) {
|
||||||
|
_currentFile = fs.XFile(path);
|
||||||
|
}
|
||||||
|
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('');
|
||||||
|
router.go('/');
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final pdfSessionViewModelProvider =
|
||||||
|
ChangeNotifierProvider.family<PdfSessionViewModel, GoRouter>((ref, router) {
|
||||||
|
return PdfSessionViewModel(ref: ref, router: router);
|
||||||
|
});
|
||||||
|
|
@ -1,17 +1,30 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
||||||
import 'package:pdf_signature/l10n/app_localizations.dart';
|
import 'package:pdf_signature/l10n/app_localizations.dart';
|
||||||
|
|
||||||
import '../../../../domain/models/model.dart';
|
class AdjustmentsPanel extends StatelessWidget {
|
||||||
import 'package:pdf_signature/data/repositories/signature_card_repository.dart';
|
const AdjustmentsPanel({
|
||||||
|
super.key,
|
||||||
|
required this.aspectLocked,
|
||||||
|
required this.bgRemoval,
|
||||||
|
required this.contrast,
|
||||||
|
required this.brightness,
|
||||||
|
required this.onAspectLockedChanged,
|
||||||
|
required this.onBgRemovalChanged,
|
||||||
|
required this.onContrastChanged,
|
||||||
|
required this.onBrightnessChanged,
|
||||||
|
});
|
||||||
|
|
||||||
class AdjustmentsPanel extends ConsumerWidget {
|
final bool aspectLocked;
|
||||||
const AdjustmentsPanel({super.key, required this.sig});
|
final bool bgRemoval;
|
||||||
|
final double contrast;
|
||||||
final SignatureCard sig;
|
final double brightness;
|
||||||
|
final ValueChanged<bool> onAspectLockedChanged;
|
||||||
|
final ValueChanged<bool> onBgRemovalChanged;
|
||||||
|
final ValueChanged<double> onContrastChanged;
|
||||||
|
final ValueChanged<double> onBrightnessChanged;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context) {
|
||||||
return Column(
|
return Column(
|
||||||
key: const Key('adjustments_panel'),
|
key: const Key('adjustments_panel'),
|
||||||
children: [
|
children: [
|
||||||
|
|
@ -20,22 +33,10 @@ class AdjustmentsPanel extends ConsumerWidget {
|
||||||
runSpacing: 8,
|
runSpacing: 8,
|
||||||
crossAxisAlignment: WrapCrossAlignment.center,
|
crossAxisAlignment: WrapCrossAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Checkbox(
|
|
||||||
key: const Key('chk_aspect_lock'),
|
|
||||||
value: ref.watch(aspectLockedProvider),
|
|
||||||
onChanged:
|
|
||||||
(v) => ref
|
|
||||||
.read(signatureCardProvider.notifier)
|
|
||||||
.toggleAspect(v ?? false),
|
|
||||||
),
|
|
||||||
Text(AppLocalizations.of(context).lockAspectRatio),
|
|
||||||
const SizedBox(width: 16),
|
|
||||||
Switch(
|
Switch(
|
||||||
key: const Key('swt_bg_removal'),
|
key: const Key('swt_bg_removal'),
|
||||||
value: sig.graphicAdjust.bgRemoval,
|
value: bgRemoval,
|
||||||
onChanged:
|
onChanged: (v) => onBgRemovalChanged(v),
|
||||||
(v) =>
|
|
||||||
ref.read(signatureCardProvider.notifier).setBgRemoval(v),
|
|
||||||
),
|
),
|
||||||
Text(AppLocalizations.of(context).backgroundRemoval),
|
Text(AppLocalizations.of(context).backgroundRemoval),
|
||||||
],
|
],
|
||||||
|
|
@ -48,16 +49,14 @@ class AdjustmentsPanel extends ConsumerWidget {
|
||||||
Text(AppLocalizations.of(context).contrast),
|
Text(AppLocalizations.of(context).contrast),
|
||||||
Align(
|
Align(
|
||||||
alignment: Alignment.centerRight,
|
alignment: Alignment.centerRight,
|
||||||
child: Text(sig.graphicAdjust.contrast.toStringAsFixed(2)),
|
child: Text(contrast.toStringAsFixed(2)),
|
||||||
),
|
),
|
||||||
Slider(
|
Slider(
|
||||||
key: const Key('sld_contrast'),
|
key: const Key('sld_contrast'),
|
||||||
min: 0.0,
|
min: 0.0,
|
||||||
max: 2.0,
|
max: 2.0,
|
||||||
value: sig.graphicAdjust.contrast,
|
value: contrast,
|
||||||
onChanged:
|
onChanged: onContrastChanged,
|
||||||
(v) =>
|
|
||||||
ref.read(signatureCardProvider.notifier).setContrast(v),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
@ -68,16 +67,14 @@ class AdjustmentsPanel extends ConsumerWidget {
|
||||||
Text(AppLocalizations.of(context).brightness),
|
Text(AppLocalizations.of(context).brightness),
|
||||||
Align(
|
Align(
|
||||||
alignment: Alignment.centerRight,
|
alignment: Alignment.centerRight,
|
||||||
child: Text(sig.graphicAdjust.brightness.toStringAsFixed(2)),
|
child: Text(brightness.toStringAsFixed(2)),
|
||||||
),
|
),
|
||||||
Slider(
|
Slider(
|
||||||
key: const Key('sld_brightness'),
|
key: const Key('sld_brightness'),
|
||||||
min: -1.0,
|
min: 0.0,
|
||||||
max: 1.0,
|
max: 2.0,
|
||||||
value: sig.graphicAdjust.brightness,
|
value: brightness,
|
||||||
onChanged:
|
onChanged: onBrightnessChanged,
|
||||||
(v) =>
|
|
||||||
ref.read(signatureCardProvider.notifier).setBrightness(v),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ class DrawCanvas extends StatefulWidget {
|
||||||
this.control,
|
this.control,
|
||||||
this.onConfirm,
|
this.onConfirm,
|
||||||
this.debugBytesSink,
|
this.debugBytesSink,
|
||||||
|
this.closeOnConfirmImmediately = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
final hand.HandSignatureControl? control;
|
final hand.HandSignatureControl? control;
|
||||||
|
|
@ -17,6 +18,9 @@ class DrawCanvas extends StatefulWidget {
|
||||||
// For tests: allows observing exported bytes without relying on Navigator
|
// For tests: allows observing exported bytes without relying on Navigator
|
||||||
@visibleForTesting
|
@visibleForTesting
|
||||||
final ValueNotifier<Uint8List?>? debugBytesSink;
|
final ValueNotifier<Uint8List?>? debugBytesSink;
|
||||||
|
// When true (used by bottom sheet), the sheet will be closed immediately
|
||||||
|
// on confirm without waiting for export to finish.
|
||||||
|
final bool closeOnConfirmImmediately;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<DrawCanvas> createState() => _DrawCanvasState();
|
State<DrawCanvas> createState() => _DrawCanvasState();
|
||||||
|
|
@ -48,22 +52,25 @@ class _DrawCanvasState extends State<DrawCanvas> {
|
||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
key: const Key('btn_canvas_confirm'),
|
key: const Key('btn_canvas_confirm'),
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
// Export signature to PNG bytes
|
// Export signature to PNG bytes first
|
||||||
final data = await _control.toImage(
|
final byteData = await _control.toImage(
|
||||||
color: Colors.black,
|
|
||||||
background: Colors.transparent,
|
|
||||||
fit: true,
|
|
||||||
width: 1024,
|
width: 1024,
|
||||||
height: 512,
|
height: 512,
|
||||||
|
fit: true,
|
||||||
|
color: Colors.black,
|
||||||
|
background: Colors.transparent,
|
||||||
);
|
);
|
||||||
final bytes = data?.buffer.asUint8List();
|
final bytes = byteData?.buffer.asUint8List();
|
||||||
widget.debugBytesSink?.value = bytes;
|
widget.debugBytesSink?.value = bytes;
|
||||||
|
|
||||||
|
// Handle callbacks and navigation
|
||||||
if (widget.onConfirm != null) {
|
if (widget.onConfirm != null) {
|
||||||
widget.onConfirm!(bytes);
|
widget.onConfirm!(bytes);
|
||||||
} else {
|
}
|
||||||
if (context.mounted) {
|
|
||||||
Navigator.of(context).pop(bytes);
|
// Close the canvas
|
||||||
}
|
if (mounted && Navigator.canPop(context)) {
|
||||||
|
Navigator.of(context).pop(bytes);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: Text(l.confirm),
|
child: Text(l.confirm),
|
||||||
|
|
@ -85,7 +92,10 @@ class _DrawCanvasState extends State<DrawCanvas> {
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
key: const Key('draw_canvas'),
|
key: const Key('draw_canvas'),
|
||||||
height: math.max(MediaQuery.of(context).size.height * 0.6, 350),
|
height: math.min(
|
||||||
|
math.max(MediaQuery.of(context).size.height * 0.6, 350),
|
||||||
|
MediaQuery.of(context).size.height * 0.8,
|
||||||
|
),
|
||||||
child: AspectRatio(
|
child: AspectRatio(
|
||||||
aspectRatio: 10 / 3,
|
aspectRatio: 10 / 3,
|
||||||
child: Container(
|
child: Container(
|
||||||
|
|
|
||||||
|
|
@ -1,103 +0,0 @@
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
||||||
import 'package:pdf_signature/l10n/app_localizations.dart';
|
|
||||||
|
|
||||||
import 'package:pdf_signature/data/repositories/signature_card_repository.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,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
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,11 +1,129 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'pdf_pages_overview.dart';
|
import 'package:pdfrx/pdfrx.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import '../view_model/pdf_view_model.dart';
|
||||||
|
|
||||||
|
class ThumbnailsView extends ConsumerWidget {
|
||||||
|
const ThumbnailsView({
|
||||||
|
super.key,
|
||||||
|
required this.documentRef,
|
||||||
|
required this.controller,
|
||||||
|
required this.currentPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
final PdfDocumentRefData documentRef;
|
||||||
|
final PdfViewerController controller;
|
||||||
|
final int currentPage;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
// Access view model to detect mock viewer mode
|
||||||
|
final viewModel = ref.read(pdfViewModelProvider);
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
color: theme.colorScheme.surface,
|
||||||
|
child: PdfDocumentViewBuilder(
|
||||||
|
documentRef: documentRef,
|
||||||
|
builder: (context, document) {
|
||||||
|
final pageCount = document?.pages.length ?? 0;
|
||||||
|
return ListView.separated(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 8),
|
||||||
|
itemCount: pageCount,
|
||||||
|
separatorBuilder: (_, _) => const SizedBox(height: 8),
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final pageNumber = index + 1;
|
||||||
|
final isSelected = currentPage == pageNumber;
|
||||||
|
return InkWell(
|
||||||
|
onTap: () {
|
||||||
|
// For real viewer: navigate first and wait for onPageChanged
|
||||||
|
// to update provider when the page is actually reached.
|
||||||
|
// For mock/unready: update provider immediately to drive scroll.
|
||||||
|
final isRealViewer = !viewModel.useMockViewer;
|
||||||
|
if (isRealViewer && controller.isReady) {
|
||||||
|
controller.goToPage(
|
||||||
|
pageNumber: pageNumber,
|
||||||
|
anchor: PdfPageAnchor.top,
|
||||||
|
);
|
||||||
|
// Do not set provider here; let onPageChanged handle it
|
||||||
|
} else {
|
||||||
|
// In tests or when controller isn't ready, drive state directly
|
||||||
|
try {
|
||||||
|
ref
|
||||||
|
.read(pdfViewModelProvider.notifier)
|
||||||
|
.jumpToPage(pageNumber);
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color:
|
||||||
|
isSelected
|
||||||
|
? theme.colorScheme.primaryContainer
|
||||||
|
: theme.cardColor,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(
|
||||||
|
color:
|
||||||
|
isSelected
|
||||||
|
? theme.colorScheme.primary
|
||||||
|
: theme.dividerColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(6),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
height: 180,
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
child: PdfPageView(
|
||||||
|
document: document,
|
||||||
|
pageNumber: pageNumber,
|
||||||
|
alignment: Alignment.center,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text('$pageNumber', style: theme.textTheme.bodySmall),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class PagesSidebar extends StatelessWidget {
|
class PagesSidebar extends StatelessWidget {
|
||||||
const PagesSidebar({super.key});
|
const PagesSidebar({
|
||||||
|
super.key,
|
||||||
|
required this.documentRef,
|
||||||
|
required this.controller,
|
||||||
|
required this.currentPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
final PdfDocumentRefData? documentRef;
|
||||||
|
final PdfViewerController controller;
|
||||||
|
final int currentPage;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Card(margin: EdgeInsets.zero, child: const PdfPagesOverview());
|
if (documentRef == null) {
|
||||||
|
return Card(margin: EdgeInsets.zero, child: const SizedBox.shrink());
|
||||||
|
}
|
||||||
|
|
||||||
|
return Card(
|
||||||
|
margin: EdgeInsets.zero,
|
||||||
|
child: ThumbnailsView(
|
||||||
|
documentRef: documentRef!,
|
||||||
|
controller: controller,
|
||||||
|
currentPage: currentPage,
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,14 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:pdf_signature/l10n/app_localizations.dart';
|
import 'package:pdf_signature/l10n/app_localizations.dart';
|
||||||
|
|
||||||
import 'pdf_page_overlays.dart';
|
import 'pdf_page_overlays.dart';
|
||||||
|
import 'package:pdf_signature/data/repositories/signature_asset_repository.dart';
|
||||||
|
// using only adjusted overlay, no direct model imports needed
|
||||||
|
import '../../signature/widgets/signature_drag_data.dart';
|
||||||
|
import '../view_model/pdf_view_model.dart';
|
||||||
|
|
||||||
/// Mocked continuous viewer for tests or platforms without real viewer.
|
/// Mocked continuous viewer for tests or platforms without real viewer.
|
||||||
class PdfMockContinuousList extends ConsumerWidget {
|
@visibleForTesting
|
||||||
|
class PdfMockContinuousList extends ConsumerStatefulWidget {
|
||||||
const PdfMockContinuousList({
|
const PdfMockContinuousList({
|
||||||
super.key,
|
super.key,
|
||||||
required this.pageSize,
|
required this.pageSize,
|
||||||
|
|
@ -36,14 +41,27 @@ class PdfMockContinuousList extends ConsumerWidget {
|
||||||
final ValueChanged<int?>? onSelectPlaced;
|
final ValueChanged<int?>? onSelectPlaced;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
ConsumerState<PdfMockContinuousList> createState() =>
|
||||||
|
_PdfMockContinuousListState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PdfMockContinuousListState extends ConsumerState<PdfMockContinuousList> {
|
||||||
|
Rect _activeRect = const Rect.fromLTWH(0.2, 0.2, 0.3, 0.15); // normalized
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final pageSize = widget.pageSize;
|
||||||
|
final count = widget.count;
|
||||||
|
final pageKeyBuilder = widget.pageKeyBuilder;
|
||||||
|
final pendingPage = widget.pendingPage;
|
||||||
|
final scrollToPage = widget.scrollToPage;
|
||||||
|
final clearPending = widget.clearPending;
|
||||||
|
final assets = ref.watch(signatureAssetRepositoryProvider);
|
||||||
if (pendingPage != null) {
|
if (pendingPage != null) {
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
final p = pendingPage;
|
final p = pendingPage;
|
||||||
if (p != null) {
|
clearPending?.call();
|
||||||
clearPending?.call();
|
scheduleMicrotask(() => scrollToPage(p));
|
||||||
scheduleMicrotask(() => scrollToPage(p));
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -62,45 +80,143 @@ class PdfMockContinuousList extends ConsumerWidget {
|
||||||
child: Stack(
|
child: Stack(
|
||||||
key: ValueKey('page_stack_$pageNum'),
|
key: ValueKey('page_stack_$pageNum'),
|
||||||
children: [
|
children: [
|
||||||
Container(
|
DragTarget<SignatureDragData>(
|
||||||
color: Colors.grey.shade200,
|
onAcceptWithDetails: (details) {
|
||||||
child: Center(
|
final dragData = details.data;
|
||||||
child: Builder(
|
final offset = details.offset;
|
||||||
builder: (ctx) {
|
final renderBox =
|
||||||
String label;
|
context.findRenderObject() as RenderBox?;
|
||||||
try {
|
if (renderBox != null) {
|
||||||
label = AppLocalizations.of(
|
final localPosition = renderBox.globalToLocal(offset);
|
||||||
ctx,
|
final normalizedX =
|
||||||
).pageInfo(pageNum, count);
|
localPosition.dx / renderBox.size.width;
|
||||||
} catch (_) {
|
final normalizedY =
|
||||||
label = 'Page $pageNum of $count';
|
localPosition.dy / renderBox.size.height;
|
||||||
}
|
|
||||||
return Text(
|
// Create a default rect for the signature (can be adjusted later)
|
||||||
label,
|
final rect = Rect.fromLTWH(
|
||||||
style: const TextStyle(
|
(normalizedX - 0.1).clamp(
|
||||||
fontSize: 24,
|
0.0,
|
||||||
color: Colors.black54,
|
0.8,
|
||||||
),
|
), // Center horizontally with some margin
|
||||||
);
|
(normalizedY - 0.05).clamp(
|
||||||
},
|
0.0,
|
||||||
),
|
0.9,
|
||||||
),
|
), // Center vertically with some margin
|
||||||
),
|
0.2, // Default width
|
||||||
Consumer(
|
0.1, // Default height
|
||||||
builder: (context, ref, _) {
|
);
|
||||||
final visible = ref.watch(signatureVisibilityProvider);
|
|
||||||
return visible
|
// Add placement to the document
|
||||||
? PdfPageOverlays(
|
ref
|
||||||
pageSize: pageSize,
|
.read(pdfViewModelProvider.notifier)
|
||||||
pageNumber: pageNum,
|
.addPlacement(
|
||||||
onDragSignature: onDragSignature,
|
page: pageNum,
|
||||||
onResizeSignature: onResizeSignature,
|
rect: rect,
|
||||||
onConfirmSignature: onConfirmSignature,
|
asset: dragData.card?.asset,
|
||||||
onClearActiveOverlay: onClearActiveOverlay,
|
rotationDeg: dragData.card?.rotationDeg ?? 0.0,
|
||||||
onSelectPlaced: onSelectPlaced,
|
graphicAdjust: dragData.card?.graphicAdjust,
|
||||||
)
|
);
|
||||||
: const SizedBox.shrink();
|
}
|
||||||
},
|
},
|
||||||
|
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)
|
||||||
|
LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
final left =
|
||||||
|
_activeRect.left * constraints.maxWidth;
|
||||||
|
final top =
|
||||||
|
_activeRect.top * constraints.maxHeight;
|
||||||
|
final width =
|
||||||
|
_activeRect.width * constraints.maxWidth;
|
||||||
|
final height =
|
||||||
|
_activeRect.height * constraints.maxHeight;
|
||||||
|
// Publish rect for tests/other UI to observe
|
||||||
|
return Stack(
|
||||||
|
children: [
|
||||||
|
Positioned(
|
||||||
|
left: left,
|
||||||
|
top: top,
|
||||||
|
width: width,
|
||||||
|
height: height,
|
||||||
|
child: GestureDetector(
|
||||||
|
key: const Key('signature_overlay'),
|
||||||
|
// Removed onPanUpdate to allow scrolling
|
||||||
|
child: DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
border: Border.all(
|
||||||
|
color: Colors.red,
|
||||||
|
width: 2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: const SizedBox.expand(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// resize handle bottom-right
|
||||||
|
Positioned(
|
||||||
|
left: left + width - 14,
|
||||||
|
top: top + height - 14,
|
||||||
|
width: 14,
|
||||||
|
height: 14,
|
||||||
|
child: GestureDetector(
|
||||||
|
key: const Key('signature_handle'),
|
||||||
|
// Removed onPanUpdate to allow scrolling
|
||||||
|
child: DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
border: Border.all(color: Colors.red),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,11 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:pdf_signature/l10n/app_localizations.dart';
|
import 'package:pdf_signature/l10n/app_localizations.dart';
|
||||||
import 'package:pdfrx/pdfrx.dart';
|
// Real viewer removed in migration; mock continuous list is used in tests.
|
||||||
|
|
||||||
import 'package:pdf_signature/data/repositories/signature_card_repository.dart';
|
import 'pdf_viewer_widget.dart';
|
||||||
import 'package:pdf_signature/data/repositories/document_repository.dart';
|
import 'package:pdfrx/pdfrx.dart';
|
||||||
import '../../signature/widgets/signature_drag_data.dart';
|
import '../view_model/pdf_view_model.dart';
|
||||||
import 'pdf_mock_continuous_list.dart';
|
|
||||||
import 'pdf_page_overlays.dart';
|
|
||||||
|
|
||||||
class PdfPageArea extends ConsumerStatefulWidget {
|
class PdfPageArea extends ConsumerStatefulWidget {
|
||||||
const PdfPageArea({
|
const PdfPageArea({
|
||||||
|
|
@ -18,24 +16,26 @@ class PdfPageArea extends ConsumerStatefulWidget {
|
||||||
required this.onConfirmSignature,
|
required this.onConfirmSignature,
|
||||||
required this.onClearActiveOverlay,
|
required this.onClearActiveOverlay,
|
||||||
required this.onSelectPlaced,
|
required this.onSelectPlaced,
|
||||||
this.viewerController,
|
required this.controller,
|
||||||
});
|
});
|
||||||
|
|
||||||
final Size pageSize;
|
final Size pageSize;
|
||||||
final PdfViewerController? viewerController;
|
// viewerController removed in migration
|
||||||
final ValueChanged<Offset> onDragSignature;
|
final ValueChanged<Offset> onDragSignature;
|
||||||
final ValueChanged<Offset> onResizeSignature;
|
final ValueChanged<Offset> onResizeSignature;
|
||||||
final VoidCallback onConfirmSignature;
|
final VoidCallback onConfirmSignature;
|
||||||
final VoidCallback onClearActiveOverlay;
|
final VoidCallback onClearActiveOverlay;
|
||||||
final ValueChanged<int?> onSelectPlaced;
|
final ValueChanged<int?> onSelectPlaced;
|
||||||
|
final PdfViewerController controller;
|
||||||
@override
|
@override
|
||||||
ConsumerState<PdfPageArea> createState() => _PdfPageAreaState();
|
ConsumerState<PdfPageArea> createState() => _PdfPageAreaState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _PdfPageAreaState extends ConsumerState<PdfPageArea> {
|
class _PdfPageAreaState extends ConsumerState<PdfPageArea> {
|
||||||
final Map<int, GlobalKey> _pageKeys = {};
|
final Map<int, GlobalKey> _pageKeys = {};
|
||||||
late final PdfViewerController _viewerController =
|
// Real viewer controller removed; keep placeholder for API compatibility
|
||||||
widget.viewerController ?? PdfViewerController();
|
// ignore: unused_field
|
||||||
|
late final Object _viewerController = Object();
|
||||||
// Guards to avoid scroll feedback between provider and viewer
|
// Guards to avoid scroll feedback between provider and viewer
|
||||||
int? _programmaticTargetPage;
|
int? _programmaticTargetPage;
|
||||||
bool _suppressProviderListen = false;
|
bool _suppressProviderListen = false;
|
||||||
|
|
@ -43,6 +43,7 @@ class _PdfPageAreaState extends ConsumerState<PdfPageArea> {
|
||||||
int? _pendingPage; // pending target for mock ensureVisible retry
|
int? _pendingPage; // pending target for mock ensureVisible retry
|
||||||
int _scrollRetryCount = 0;
|
int _scrollRetryCount = 0;
|
||||||
static const int _maxScrollRetries = 50;
|
static const int _maxScrollRetries = 50;
|
||||||
|
int? _lastListenedPage;
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
|
@ -50,10 +51,7 @@ class _PdfPageAreaState extends ConsumerState<PdfPageArea> {
|
||||||
// is instructed to align to the provider's current page once ready.
|
// is instructed to align to the provider's current page once ready.
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
final pdf = ref.read(documentRepositoryProvider);
|
// initial scroll not needed; controller handles positioning
|
||||||
if (pdf.pickedPdfPath != null && pdf.loaded) {
|
|
||||||
_scrollToPage(pdf.currentPage);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -67,46 +65,8 @@ class _PdfPageAreaState extends ConsumerState<PdfPageArea> {
|
||||||
void _scrollToPage(int page) {
|
void _scrollToPage(int page) {
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
final pdf = ref.read(documentRepositoryProvider);
|
_programmaticTargetPage = page;
|
||||||
const isContinuous = true;
|
// Mock continuous: try ensureVisible on the page container
|
||||||
|
|
||||||
// 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) {
|
||||||
|
|
@ -126,6 +86,8 @@ class _PdfPageAreaState extends ConsumerState<PdfPageArea> {
|
||||||
.clamp(position.minScrollExtent, position.maxScrollExtent)
|
.clamp(position.minScrollExtent, position.maxScrollExtent)
|
||||||
.toDouble();
|
.toDouble();
|
||||||
position.jumpTo(newPixels);
|
position.jumpTo(newPixels);
|
||||||
|
_visiblePage = page;
|
||||||
|
_programmaticTargetPage = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
|
|
@ -136,6 +98,8 @@ class _PdfPageAreaState extends ConsumerState<PdfPageArea> {
|
||||||
duration: Duration.zero,
|
duration: Duration.zero,
|
||||||
curve: Curves.linear,
|
curve: Curves.linear,
|
||||||
);
|
);
|
||||||
|
_visiblePage = page;
|
||||||
|
_programmaticTargetPage = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
|
|
@ -155,23 +119,22 @@ class _PdfPageAreaState extends ConsumerState<PdfPageArea> {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final pdf = ref.watch(documentRepositoryProvider);
|
final pdfViewModel = ref.watch(pdfViewModelProvider);
|
||||||
|
final pdf = pdfViewModel.document;
|
||||||
const pageViewMode = 'continuous';
|
const pageViewMode = 'continuous';
|
||||||
|
// React to PdfViewModel currentPage changes. With ChangeNotifierProvider,
|
||||||
// React to provider currentPage changes (e.g., user tapped overview)
|
// prev/next are the same instance, so compare to a local cache.
|
||||||
ref.listen(documentRepositoryProvider, (prev, next) {
|
ref.listen(pdfViewModelProvider, (prev, next) {
|
||||||
if (_suppressProviderListen) return;
|
if (_suppressProviderListen) return;
|
||||||
if ((prev?.currentPage != next.currentPage)) {
|
final target = next.currentPage;
|
||||||
final target = next.currentPage;
|
if (_lastListenedPage == target) return;
|
||||||
// If we're already navigating to this target, ignore; otherwise allow new target.
|
_lastListenedPage = target;
|
||||||
if (_programmaticTargetPage != null &&
|
if (_programmaticTargetPage != null &&
|
||||||
_programmaticTargetPage == target) {
|
_programmaticTargetPage == target) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Only navigate if target differs from what viewer shows
|
if (_visiblePage != target) {
|
||||||
if (_visiblePage != target) {
|
_scrollToPage(target);
|
||||||
_scrollToPage(target);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
// No page view mode switching; always continuous.
|
// No page view mode switching; always continuous.
|
||||||
|
|
@ -187,184 +150,22 @@ class _PdfPageAreaState extends ConsumerState<PdfPageArea> {
|
||||||
return Center(child: Text(text));
|
return Center(child: Text(text));
|
||||||
}
|
}
|
||||||
|
|
||||||
final useMock = ref.watch(useMockViewerProvider);
|
|
||||||
final isContinuous = pageViewMode == 'continuous';
|
final isContinuous = pageViewMode == 'continuous';
|
||||||
|
|
||||||
// Mock continuous: ListView with prebuilt children, no controller
|
// Use real PDF viewer
|
||||||
if (useMock && isContinuous) {
|
if (isContinuous) {
|
||||||
final count = pdf.pageCount > 0 ? pdf.pageCount : 1;
|
return PdfViewerWidget(
|
||||||
return PdfMockContinuousList(
|
|
||||||
pageSize: widget.pageSize,
|
pageSize: widget.pageSize,
|
||||||
count: count,
|
onDragSignature: widget.onDragSignature,
|
||||||
pageKeyBuilder: _pageKey,
|
onResizeSignature: widget.onResizeSignature,
|
||||||
scrollToPage: _scrollToPage,
|
|
||||||
pendingPage: _pendingPage,
|
|
||||||
clearPending: () {
|
|
||||||
_pendingPage = null;
|
|
||||||
_scrollRetryCount = 0;
|
|
||||||
},
|
|
||||||
onDragSignature: (delta) => widget.onDragSignature(delta),
|
|
||||||
onResizeSignature: (delta) => widget.onResizeSignature(delta),
|
|
||||||
onConfirmSignature: widget.onConfirmSignature,
|
onConfirmSignature: widget.onConfirmSignature,
|
||||||
onClearActiveOverlay: widget.onClearActiveOverlay,
|
onClearActiveOverlay: widget.onClearActiveOverlay,
|
||||||
onSelectPlaced: widget.onSelectPlaced,
|
onSelectPlaced: widget.onSelectPlaced,
|
||||||
|
pageKeyBuilder: _pageKey,
|
||||||
|
scrollToPage: _scrollToPage,
|
||||||
|
controller: widget.controller,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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(documentRepositoryProvider.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(documentRepositoryProvider).currentPage) {
|
|
||||||
_suppressProviderListen = true;
|
|
||||||
ref.read(documentRepositoryProvider.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(documentRepositoryProvider).currentPage) {
|
|
||||||
_suppressProviderListen = true;
|
|
||||||
ref.read(documentRepositoryProvider.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.asset != null) {
|
|
||||||
// Set current overlay to use this asset
|
|
||||||
ref
|
|
||||||
.read(signatureProvider.notifier)
|
|
||||||
.setImageFromLibrary(asset: data.asset!);
|
|
||||||
}
|
|
||||||
ref.read(signatureProvider.notifier).placeAtCenter(Offset(cx, cy));
|
|
||||||
ref
|
|
||||||
.read(documentRepositoryProvider.notifier)
|
|
||||||
.setSignedPage(ref.read(documentRepositoryProvider).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,10 +1,12 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:pdf_signature/ui/features/pdf/view_model/pdf_view_model.dart';
|
||||||
import 'package:pdf_signature/data/repositories/signature_card_repository.dart';
|
|
||||||
import '../../../../domain/models/model.dart';
|
|
||||||
import 'package:pdf_signature/data/repositories/document_repository.dart';
|
import 'package:pdf_signature/data/repositories/document_repository.dart';
|
||||||
|
|
||||||
|
import '../../../../domain/models/model.dart';
|
||||||
import 'signature_overlay.dart';
|
import 'signature_overlay.dart';
|
||||||
|
import '../../signature/widgets/signature_drag_data.dart';
|
||||||
|
import '../../signature/view_model/dragging_signature_view_model.dart';
|
||||||
|
|
||||||
/// Builds all overlays for a given page: placed signatures and the active one.
|
/// Builds all overlays for a given page: placed signatures and the active one.
|
||||||
class PdfPageOverlays extends ConsumerWidget {
|
class PdfPageOverlays extends ConsumerWidget {
|
||||||
|
|
@ -29,46 +31,116 @@ class PdfPageOverlays extends ConsumerWidget {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final pdfViewModel = ref.watch(pdfViewModelProvider);
|
||||||
|
// Subscribe to document changes to rebuild overlays
|
||||||
final pdf = ref.watch(documentRepositoryProvider);
|
final pdf = ref.watch(documentRepositoryProvider);
|
||||||
final sig = ref.watch(signatureCardProvider);
|
|
||||||
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 ?? 0.0,
|
||||||
|
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 (SignatureCardStateNotifier.pageSize).
|
||||||
final uiRect = placed[i].rect;
|
final p = placed[i];
|
||||||
|
final uiRect = p.rect;
|
||||||
widgets.add(
|
widgets.add(
|
||||||
SignatureOverlay(
|
SignatureOverlay(
|
||||||
pageSize: pageSize,
|
pageSize: pageSize,
|
||||||
rect: uiRect,
|
rect: uiRect,
|
||||||
sig: sig,
|
placement: p,
|
||||||
pageNumber: pageNumber,
|
|
||||||
placedIndex: i,
|
placedIndex: i,
|
||||||
onSelectPlaced: onSelectPlaced,
|
pageNumber: pageNumber,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final currentRect = ref.watch(currentRectProvider);
|
// TODO:Add active overlay if present and not using mock (mock has its own)
|
||||||
final editingEnabled = ref.watch(editingEnabledProvider);
|
|
||||||
final showActive =
|
|
||||||
currentRect != null &&
|
|
||||||
editingEnabled &&
|
|
||||||
(pdf.signedPage == null || pdf.signedPage == pageNumber) &&
|
|
||||||
pdf.currentPage == pageNumber;
|
|
||||||
|
|
||||||
if (showActive) {
|
final useMock = pdfViewModel.useMockViewer;
|
||||||
|
if (!useMock && activeRect != null) {
|
||||||
widgets.add(
|
widgets.add(
|
||||||
SignatureOverlay(
|
LayoutBuilder(
|
||||||
pageSize: pageSize,
|
builder: (context, constraints) {
|
||||||
rect: currentRect,
|
final left = activeRect.left * constraints.maxWidth;
|
||||||
sig: sig,
|
final top = activeRect.top * constraints.maxHeight;
|
||||||
pageNumber: pageNumber,
|
final width = activeRect.width * constraints.maxWidth;
|
||||||
onDragSignature: onDragSignature,
|
final height = activeRect.height * constraints.maxHeight;
|
||||||
onResizeSignature: onResizeSignature,
|
return Stack(
|
||||||
onConfirmSignature: onConfirmSignature,
|
children: [
|
||||||
onClearActiveOverlay: onClearActiveOverlay,
|
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(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,99 +0,0 @@
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
||||||
import 'package:pdfrx/pdfrx.dart';
|
|
||||||
|
|
||||||
import 'package:pdf_signature/data/repositories/document_repository.dart';
|
|
||||||
|
|
||||||
class PdfPagesOverview extends ConsumerWidget {
|
|
||||||
const PdfPagesOverview({super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
|
||||||
final pdf = ref.watch(documentRepositoryProvider);
|
|
||||||
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(documentRepositoryProvider.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(documentRepositoryProvider.notifier)
|
|
||||||
.setPageCount(pages.length);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return buildList(
|
|
||||||
pages.length,
|
|
||||||
item:
|
|
||||||
(i) => PdfPageView(
|
|
||||||
document: document,
|
|
||||||
pageNumber: i + 1,
|
|
||||||
alignment: Alignment.center,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return const SizedBox.shrink();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,27 +1,31 @@
|
||||||
import 'dart:typed_data';
|
|
||||||
import 'package:file_selector/file_selector.dart' as fs;
|
import 'package:file_selector/file_selector.dart' as fs;
|
||||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:pdf_signature/data/repositories/preferences_repository.dart';
|
import 'package:pdf_signature/data/repositories/preferences_repository.dart';
|
||||||
import 'package:pdf_signature/domain/models/preferences.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:image/image.dart' as img;
|
import 'package:pdfrx/pdfrx.dart';
|
||||||
import 'package:pdf_signature/data/repositories/signature_card_repository.dart';
|
|
||||||
import 'package:pdf_signature/data/repositories/document_repository.dart';
|
|
||||||
import 'package:pdf_signature/data/repositories/signature_asset_repository.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 'ui_services.dart';
|
||||||
|
import '../view_model/pdf_view_model.dart';
|
||||||
|
|
||||||
class PdfSignatureHomePage extends ConsumerStatefulWidget {
|
class PdfSignatureHomePage extends ConsumerStatefulWidget {
|
||||||
const PdfSignatureHomePage({super.key});
|
final Future<void> Function() onPickPdf;
|
||||||
|
final VoidCallback onClosePdf;
|
||||||
|
final fs.XFile currentFile;
|
||||||
|
|
||||||
|
const PdfSignatureHomePage({
|
||||||
|
super.key,
|
||||||
|
required this.onPickPdf,
|
||||||
|
required this.onClosePdf,
|
||||||
|
required this.currentFile,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
ConsumerState<PdfSignatureHomePage> createState() =>
|
ConsumerState<PdfSignatureHomePage> createState() =>
|
||||||
|
|
@ -29,8 +33,7 @@ class PdfSignatureHomePage extends ConsumerStatefulWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
|
class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
|
||||||
static const Size _pageSize = SignatureCardStateNotifier.pageSize;
|
static const Size _pageSize = Size(676, 960 / 1.4142);
|
||||||
final PdfViewerController _viewerController = PdfViewerController();
|
|
||||||
bool _showPagesSidebar = true;
|
bool _showPagesSidebar = true;
|
||||||
bool _showSignaturesSidebar = true;
|
bool _showSignaturesSidebar = true;
|
||||||
int _zoomLevel = 100; // percentage for display only
|
int _zoomLevel = 100; // percentage for display only
|
||||||
|
|
@ -45,32 +48,46 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
|
||||||
final double _pagesMax = 250;
|
final double _pagesMax = 250;
|
||||||
final double _signaturesMin = 140;
|
final double _signaturesMin = 140;
|
||||||
final double _signaturesMax = 250;
|
final double _signaturesMax = 250;
|
||||||
|
late PdfViewModel _viewModel;
|
||||||
|
|
||||||
// Exposed for tests to trigger the invalid-file SnackBar without UI.
|
// Exposed for tests to trigger the invalid-file SnackBar without UI.
|
||||||
@visibleForTesting
|
@visibleForTesting
|
||||||
void debugShowInvalidSignatureSnackBar() {
|
void debugShowInvalidSignatureSnackBar() {
|
||||||
ref.read(signatureProvider.notifier).setInvalidSelected(context);
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(AppLocalizations.of(context).invalidOrUnsupportedFile),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _pickPdf() async {
|
Future<void> _pickPdf() async {
|
||||||
final typeGroup = const fs.XTypeGroup(label: 'PDF', extensions: ['pdf']);
|
await widget.onPickPdf();
|
||||||
final file = await fs.openFile(acceptedTypeGroups: [typeGroup]);
|
}
|
||||||
if (file != null) {
|
|
||||||
Uint8List? bytes;
|
void _closePdf() {
|
||||||
try {
|
widget.onClosePdf();
|
||||||
bytes = await file.readAsBytes();
|
|
||||||
} catch (_) {
|
|
||||||
bytes = null;
|
|
||||||
}
|
|
||||||
ref
|
|
||||||
.read(documentRepositoryProvider.notifier)
|
|
||||||
.openPicked(path: file.path, bytes: bytes);
|
|
||||||
ref.read(signatureProvider.notifier).resetForNewPage();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _jumpToPage(int page) {
|
void _jumpToPage(int page) {
|
||||||
ref.read(documentRepositoryProvider.notifier).jumpTo(page);
|
final controller = _viewModel.controller;
|
||||||
|
final current = _viewModel.currentPage;
|
||||||
|
final pdf = _viewModel.document;
|
||||||
|
int target;
|
||||||
|
if (page == -1) {
|
||||||
|
target = (current - 1).clamp(1, pdf.pageCount);
|
||||||
|
} else {
|
||||||
|
target = page.clamp(1, pdf.pageCount);
|
||||||
|
}
|
||||||
|
// Update reactive page providers so UI/tests reflect navigation even if controller is a stub
|
||||||
|
if (current != target) {
|
||||||
|
// Also notify view model (if used elsewhere) via its public API
|
||||||
|
try {
|
||||||
|
_viewModel.jumpToPage(target);
|
||||||
|
} catch (_) {
|
||||||
|
// ignore if provider not available
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (controller.isReady) controller.goToPage(pageNumber: target);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<Uint8List?> _loadSignatureFromFile() async {
|
Future<Uint8List?> _loadSignatureFromFile() async {
|
||||||
|
|
@ -82,31 +99,23 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
|
||||||
final file = await fs.openFile(acceptedTypeGroups: [typeGroup]);
|
final file = await fs.openFile(acceptedTypeGroups: [typeGroup]);
|
||||||
if (file == null) return null;
|
if (file == null) return null;
|
||||||
final bytes = await file.readAsBytes();
|
final bytes = await file.readAsBytes();
|
||||||
final sig = ref.read(signatureProvider.notifier);
|
|
||||||
sig.setImageBytes(bytes);
|
|
||||||
final p = ref.read(documentRepositoryProvider);
|
|
||||||
if (p.loaded) {
|
|
||||||
ref
|
|
||||||
.read(documentRepositoryProvider.notifier)
|
|
||||||
.setSignedPage(p.currentPage);
|
|
||||||
}
|
|
||||||
return bytes;
|
return bytes;
|
||||||
}
|
}
|
||||||
|
|
||||||
void _confirmSignature() {
|
void _confirmSignature() {
|
||||||
ref.read(signatureProvider.notifier).confirmCurrentSignature(ref);
|
// In simplified UI, confirmation is a no-op
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onDragSignature(Offset delta) {
|
void _onDragSignature(Offset delta) {
|
||||||
ref.read(signatureProvider.notifier).drag(delta);
|
// In simplified UI, interactive overlay disabled
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onResizeSignature(Offset delta) {
|
void _onResizeSignature(Offset delta) {
|
||||||
ref.read(signatureProvider.notifier).resize(delta);
|
// In simplified UI, interactive overlay disabled
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onSelectPlaced(int? index) {
|
void _onSelectPlaced(int? index) {
|
||||||
ref.read(documentRepositoryProvider.notifier).selectPlacement(index);
|
// In simplified UI, selection is a no-op for tests
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<Uint8List?> _openDrawCanvas() async {
|
Future<Uint8List?> _openDrawCanvas() async {
|
||||||
|
|
@ -114,16 +123,10 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
|
||||||
context: context,
|
context: context,
|
||||||
isScrollControlled: true,
|
isScrollControlled: true,
|
||||||
enableDrag: false,
|
enableDrag: false,
|
||||||
builder: (_) => const DrawCanvas(),
|
builder: (_) => const DrawCanvas(closeOnConfirmImmediately: false),
|
||||||
);
|
);
|
||||||
if (result != null && result.isNotEmpty) {
|
if (result != null && result.isNotEmpty) {
|
||||||
ref.read(signatureProvider.notifier).setImageBytes(result);
|
// In simplified UI, adding to library isn't implemented
|
||||||
final p = ref.read(documentRepositoryProvider);
|
|
||||||
if (p.loaded) {
|
|
||||||
ref
|
|
||||||
.read(documentRepositoryProvider.notifier)
|
|
||||||
.setSignedPage(p.currentPage);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
@ -131,10 +134,9 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
|
||||||
Future<void> _saveSignedPdf() async {
|
Future<void> _saveSignedPdf() async {
|
||||||
ref.read(exportingProvider.notifier).state = true;
|
ref.read(exportingProvider.notifier).state = true;
|
||||||
try {
|
try {
|
||||||
final pdf = ref.read(documentRepositoryProvider);
|
final pdf = _viewModel.document;
|
||||||
final sig = ref.read(signatureProvider);
|
|
||||||
final messenger = ScaffoldMessenger.of(context);
|
final messenger = ScaffoldMessenger.of(context);
|
||||||
if (!pdf.loaded || sig.rect == null) {
|
if (!pdf.loaded) {
|
||||||
messenger.showSnackBar(
|
messenger.showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text(AppLocalizations.of(context).nothingToSaveYet),
|
content: Text(AppLocalizations.of(context).nothingToSaveYet),
|
||||||
|
|
@ -145,121 +147,26 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
|
||||||
final exporter = ref.read(exportServiceProvider);
|
final exporter = ref.read(exportServiceProvider);
|
||||||
|
|
||||||
// get DPI from preferences
|
// get DPI from preferences
|
||||||
final targetDpi = ref
|
final targetDpi = ref.read(preferencesRepositoryProvider).exportDpi;
|
||||||
.read(preferencesRepositoryProvider)
|
|
||||||
.select((p) => p.exportDpi);
|
|
||||||
final useMock = ref.read(useMockViewerProvider);
|
|
||||||
bool ok = false;
|
bool ok = false;
|
||||||
String? savedPath;
|
String? savedPath;
|
||||||
// Helper to apply rotation to bytes for export (single-signature path only)
|
|
||||||
Uint8List? _rotatedForExport(Uint8List? src, double deg) {
|
|
||||||
if (src == null || src.isEmpty) return src;
|
|
||||||
final r = deg % 360;
|
|
||||||
if (r == 0) return src;
|
|
||||||
try {
|
|
||||||
final decoded = img.decodeImage(src);
|
|
||||||
if (decoded == null) return src;
|
|
||||||
final out = img.copyRotate(
|
|
||||||
decoded,
|
|
||||||
angle: r,
|
|
||||||
interpolation: img.Interpolation.linear,
|
|
||||||
);
|
|
||||||
return Uint8List.fromList(img.encodePng(out, level: 6));
|
|
||||||
} catch (_) {
|
|
||||||
return src;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (kIsWeb) {
|
if (!kIsWeb) {
|
||||||
Uint8List? src = pdf.pickedPdfBytes;
|
|
||||||
if (src != null) {
|
|
||||||
final processed = ref.read(processedSignatureImageProvider);
|
|
||||||
final rotated = _rotatedForExport(
|
|
||||||
processed ?? sig.imageBytes,
|
|
||||||
sig.rotation,
|
|
||||||
);
|
|
||||||
final bytes = await exporter.exportSignedPdfFromBytes(
|
|
||||||
srcBytes: src,
|
|
||||||
signedPage: pdf.signedPage,
|
|
||||||
signatureRectUi: sig.rect,
|
|
||||||
uiPageSize: SignatureCardStateNotifier.pageSize,
|
|
||||||
signatureImageBytes: rotated,
|
|
||||||
placementsByPage: pdf.placementsByPage,
|
|
||||||
libraryBytes: {
|
|
||||||
for (final a in ref.read(signatureAssetRepositoryProvider))
|
|
||||||
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 pick = ref.read(savePathPickerProvider);
|
||||||
final path = await pick();
|
final path = await pick();
|
||||||
if (path == null || path.trim().isEmpty) return;
|
if (path == null || path.trim().isEmpty) return;
|
||||||
final fullPath = _ensurePdfExtension(path.trim());
|
final fullPath = _ensurePdfExtension(path.trim());
|
||||||
savedPath = fullPath;
|
savedPath = fullPath;
|
||||||
if (pdf.pickedPdfBytes != null) {
|
final src = pdf.pickedPdfBytes ?? Uint8List(0);
|
||||||
final processed = ref.read(processedSignatureImageProvider);
|
final out = await exporter.exportSignedPdfFromBytes(
|
||||||
final rotated = _rotatedForExport(
|
srcBytes: src,
|
||||||
processed ?? sig.imageBytes,
|
uiPageSize: _pageSize,
|
||||||
sig.rotation,
|
signatureImageBytes: null,
|
||||||
);
|
placementsByPage: pdf.placementsByPage,
|
||||||
final out = await exporter.exportSignedPdfFromBytes(
|
targetDpi: targetDpi,
|
||||||
srcBytes: pdf.pickedPdfBytes!,
|
);
|
||||||
signedPage: pdf.signedPage,
|
if (out != null) {
|
||||||
signatureRectUi: sig.rect,
|
ok = await exporter.saveBytesToFile(bytes: out, outputPath: fullPath);
|
||||||
uiPageSize: SignatureCardStateNotifier.pageSize,
|
|
||||||
signatureImageBytes: rotated,
|
|
||||||
placementsByPage: pdf.placementsByPage,
|
|
||||||
libraryBytes: {
|
|
||||||
for (final a in ref.read(signatureAssetRepositoryProvider))
|
|
||||||
a.id: a.bytes,
|
|
||||||
},
|
|
||||||
targetDpi: targetDpi,
|
|
||||||
);
|
|
||||||
if (useMock) {
|
|
||||||
ok = out != null;
|
|
||||||
} else if (out != null) {
|
|
||||||
ok = await exporter.saveBytesToFile(
|
|
||||||
bytes: out,
|
|
||||||
outputPath: fullPath,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} 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: SignatureCardStateNotifier.pageSize,
|
|
||||||
signatureImageBytes: rotated,
|
|
||||||
placementsByPage: pdf.placementsByPage,
|
|
||||||
libraryBytes: {
|
|
||||||
for (final a in ref.read(signatureAssetRepositoryProvider))
|
|
||||||
a.id: a.bytes,
|
|
||||||
},
|
|
||||||
targetDpi: targetDpi,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!kIsWeb) {
|
if (!kIsWeb) {
|
||||||
|
|
@ -278,20 +185,6 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
if (ok) {
|
|
||||||
messenger.showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
content: Text(AppLocalizations.of(context).downloadStarted),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
messenger.showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
content: Text(AppLocalizations.of(context).failedToGeneratePdf),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
ref.read(exportingProvider.notifier).state = false;
|
ref.read(exportingProvider.notifier).state = false;
|
||||||
|
|
@ -303,10 +196,37 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
|
||||||
return name;
|
return name;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
|
@ -315,7 +235,26 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
|
||||||
builder:
|
builder:
|
||||||
(context, area) => Offstage(
|
(context, area) => Offstage(
|
||||||
offstage: !_showPagesSidebar,
|
offstage: !_showPagesSidebar,
|
||||||
child: const PagesSidebar(),
|
child: Consumer(
|
||||||
|
builder: (context, ref, child) {
|
||||||
|
final pdfViewModel = ref.watch(pdfViewModelProvider);
|
||||||
|
final pdf = pdfViewModel.document;
|
||||||
|
|
||||||
|
final documentRef =
|
||||||
|
pdf.loaded && pdf.pickedPdfBytes != null
|
||||||
|
? PdfDocumentRefData(
|
||||||
|
pdf.pickedPdfBytes!,
|
||||||
|
sourceName: 'document.pdf',
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return PagesSidebar(
|
||||||
|
documentRef: documentRef,
|
||||||
|
controller: _viewModel.controller,
|
||||||
|
currentPage: _viewModel.currentPage,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Area(
|
Area(
|
||||||
|
|
@ -323,17 +262,13 @@ 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,
|
onDragSignature: _onDragSignature,
|
||||||
onResizeSignature: _onResizeSignature,
|
onResizeSignature: _onResizeSignature,
|
||||||
onConfirmSignature: _confirmSignature,
|
onConfirmSignature: _confirmSignature,
|
||||||
onClearActiveOverlay:
|
onClearActiveOverlay: () {},
|
||||||
() =>
|
|
||||||
ref
|
|
||||||
.read(signatureProvider.notifier)
|
|
||||||
.clearActiveOverlay(),
|
|
||||||
onSelectPlaced: _onSelectPlaced,
|
onSelectPlaced: _onSelectPlaced,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -360,6 +295,7 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_viewModel.controller.removeListener(_onControllerChanged);
|
||||||
_splitController.dispose();
|
_splitController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
@ -393,6 +329,10 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
return _buildScaffold(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildScaffold(BuildContext context) {
|
||||||
final isExporting = ref.watch(exportingProvider);
|
final isExporting = ref.watch(exportingProvider);
|
||||||
final l = AppLocalizations.of(context);
|
final l = AppLocalizations.of(context);
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
|
|
@ -406,25 +346,42 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
|
||||||
PdfToolbar(
|
PdfToolbar(
|
||||||
disabled: isExporting,
|
disabled: isExporting,
|
||||||
onPickPdf: _pickPdf,
|
onPickPdf: _pickPdf,
|
||||||
|
onClosePdf: _closePdf,
|
||||||
onJumpToPage: _jumpToPage,
|
onJumpToPage: _jumpToPage,
|
||||||
onZoomOut: () {
|
onZoomOut: () {
|
||||||
if (_viewerController.isReady) {
|
if (_viewModel.controller.isReady) {
|
||||||
_viewerController.zoomDown();
|
_viewModel.controller.zoomDown();
|
||||||
|
// Update display zoom level after controller zoom
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_zoomLevel = (_viewModel.controller.currentZoom *
|
||||||
|
100)
|
||||||
|
.round()
|
||||||
|
.clamp(10, 800);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
setState(() {
|
|
||||||
_zoomLevel = (_zoomLevel - 10).clamp(10, 800);
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
onZoomIn: () {
|
onZoomIn: () {
|
||||||
if (_viewerController.isReady) {
|
if (_viewModel.controller.isReady) {
|
||||||
_viewerController.zoomUp();
|
_viewModel.controller.zoomUp();
|
||||||
|
// Update display zoom level after controller zoom
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_zoomLevel = (_viewModel.controller.currentZoom *
|
||||||
|
100)
|
||||||
|
.round()
|
||||||
|
.clamp(10, 800);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
setState(() {
|
|
||||||
_zoomLevel = (_zoomLevel + 10).clamp(10, 800);
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
zoomLevel: _zoomLevel,
|
zoomLevel: _zoomLevel,
|
||||||
fileName: ref.watch(documentRepositoryProvider).pickedPdfPath,
|
filePath: widget.currentFile.path,
|
||||||
showPagesSidebar: _showPagesSidebar,
|
showPagesSidebar: _showPagesSidebar,
|
||||||
showSignaturesSidebar: _showSignaturesSidebar,
|
showSignaturesSidebar: _showSignaturesSidebar,
|
||||||
onTogglePagesSidebar:
|
onTogglePagesSidebar:
|
||||||
|
|
@ -438,6 +395,24 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
|
||||||
_applySidebarVisibility();
|
_applySidebarVisibility();
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
// Expose a compact signature drawer trigger area for tests when sidebar hidden
|
||||||
|
if (!_showSignaturesSidebar)
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: SizedBox(
|
||||||
|
height:
|
||||||
|
0, // zero-height container exposing buttons offstage
|
||||||
|
width: 0,
|
||||||
|
child: Offstage(
|
||||||
|
offstage: true,
|
||||||
|
child: SignaturesSidebar(
|
||||||
|
onLoadSignatureFromFile: _loadSignatureFromFile,
|
||||||
|
onOpenDrawCanvas: _openDrawCanvas,
|
||||||
|
onSave: _saveSignedPdf,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: MultiSplitView(
|
child: MultiSplitView(
|
||||||
|
|
@ -472,7 +447,3 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension on PreferencesState {
|
|
||||||
select(Function(dynamic p) param0) {}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -3,18 +3,19 @@ import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:pdf_signature/l10n/app_localizations.dart';
|
import 'package:pdf_signature/l10n/app_localizations.dart';
|
||||||
|
|
||||||
import 'package:pdf_signature/data/repositories/document_repository.dart';
|
import 'package:pdf_signature/ui/features/pdf/view_model/pdf_view_model.dart';
|
||||||
|
|
||||||
class PdfToolbar extends ConsumerStatefulWidget {
|
class PdfToolbar extends ConsumerStatefulWidget {
|
||||||
const PdfToolbar({
|
const PdfToolbar({
|
||||||
super.key,
|
super.key,
|
||||||
required this.disabled,
|
required this.disabled,
|
||||||
required this.onPickPdf,
|
required this.onPickPdf,
|
||||||
|
required this.onClosePdf,
|
||||||
required this.onJumpToPage,
|
required this.onJumpToPage,
|
||||||
required this.onZoomOut,
|
required this.onZoomOut,
|
||||||
required this.onZoomIn,
|
required this.onZoomIn,
|
||||||
this.zoomLevel,
|
this.zoomLevel,
|
||||||
this.fileName,
|
this.filePath,
|
||||||
required this.showPagesSidebar,
|
required this.showPagesSidebar,
|
||||||
required this.showSignaturesSidebar,
|
required this.showSignaturesSidebar,
|
||||||
required this.onTogglePagesSidebar,
|
required this.onTogglePagesSidebar,
|
||||||
|
|
@ -23,8 +24,9 @@ class PdfToolbar extends ConsumerStatefulWidget {
|
||||||
|
|
||||||
final bool disabled;
|
final bool disabled;
|
||||||
final VoidCallback onPickPdf;
|
final VoidCallback onPickPdf;
|
||||||
|
final VoidCallback onClosePdf;
|
||||||
final ValueChanged<int> onJumpToPage;
|
final ValueChanged<int> onJumpToPage;
|
||||||
final String? fileName;
|
final String? filePath;
|
||||||
final VoidCallback onZoomOut;
|
final VoidCallback onZoomOut;
|
||||||
final VoidCallback onZoomIn;
|
final VoidCallback onZoomIn;
|
||||||
// Current zoom level as a percentage (e.g., 100 for 100%)
|
// Current zoom level as a percentage (e.g., 100 for 100%)
|
||||||
|
|
@ -55,9 +57,11 @@ class _PdfToolbarState extends ConsumerState<PdfToolbar> {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final pdf = ref.watch(documentRepositoryProvider);
|
final pdfViewModel = ref.watch(pdfViewModelProvider);
|
||||||
|
final pdf = pdfViewModel.document;
|
||||||
|
final currentPage = pdfViewModel.currentPage;
|
||||||
final l = AppLocalizations.of(context);
|
final l = AppLocalizations.of(context);
|
||||||
final pageInfo = l.pageInfo(pdf.currentPage, pdf.pageCount);
|
final pageInfo = l.pageInfo(currentPage, pdf.pageCount);
|
||||||
|
|
||||||
return LayoutBuilder(
|
return LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
|
|
@ -81,9 +85,9 @@ class _PdfToolbarState extends ConsumerState<PdfToolbar> {
|
||||||
ConstrainedBox(
|
ConstrainedBox(
|
||||||
constraints: const BoxConstraints(maxWidth: 220),
|
constraints: const BoxConstraints(maxWidth: 220),
|
||||||
child: Text(
|
child: Text(
|
||||||
// if filename not null
|
// if filePath not null
|
||||||
widget.fileName != null
|
widget.filePath != null
|
||||||
? widget.fileName!
|
? widget.filePath!
|
||||||
: 'No file selected',
|
: 'No file selected',
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
|
|
@ -92,6 +96,12 @@ class _PdfToolbarState extends ConsumerState<PdfToolbar> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (pdf.loaded) ...[
|
if (pdf.loaded) ...[
|
||||||
|
IconButton(
|
||||||
|
key: const Key('btn_close_pdf'),
|
||||||
|
onPressed: widget.disabled ? null : widget.onClosePdf,
|
||||||
|
icon: const Icon(Icons.close),
|
||||||
|
tooltip: l.close,
|
||||||
|
),
|
||||||
Wrap(
|
Wrap(
|
||||||
spacing: 8,
|
spacing: 8,
|
||||||
children: [
|
children: [
|
||||||
|
|
@ -103,8 +113,7 @@ class _PdfToolbarState extends ConsumerState<PdfToolbar> {
|
||||||
onPressed:
|
onPressed:
|
||||||
widget.disabled
|
widget.disabled
|
||||||
? null
|
? null
|
||||||
: () =>
|
: () => widget.onJumpToPage(-1),
|
||||||
widget.onJumpToPage(pdf.currentPage - 1),
|
|
||||||
icon: const Icon(Icons.chevron_left),
|
icon: const Icon(Icons.chevron_left),
|
||||||
tooltip: l.prev,
|
tooltip: l.prev,
|
||||||
),
|
),
|
||||||
|
|
@ -115,8 +124,7 @@ class _PdfToolbarState extends ConsumerState<PdfToolbar> {
|
||||||
onPressed:
|
onPressed:
|
||||||
widget.disabled
|
widget.disabled
|
||||||
? null
|
? null
|
||||||
: () =>
|
: () => widget.onJumpToPage(currentPage + 1),
|
||||||
widget.onJumpToPage(pdf.currentPage + 1),
|
|
||||||
icon: const Icon(Icons.chevron_right),
|
icon: const Icon(Icons.chevron_right),
|
||||||
tooltip: l.next,
|
tooltip: l.next,
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,177 @@
|
||||||
|
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,
|
||||||
|
required this.onDragSignature,
|
||||||
|
required this.onResizeSignature,
|
||||||
|
required this.onConfirmSignature,
|
||||||
|
required this.onClearActiveOverlay,
|
||||||
|
required this.onSelectPlaced,
|
||||||
|
this.pageKeyBuilder,
|
||||||
|
this.scrollToPage,
|
||||||
|
required this.controller,
|
||||||
|
});
|
||||||
|
|
||||||
|
final Size pageSize;
|
||||||
|
final ValueChanged<Offset> onDragSignature;
|
||||||
|
final ValueChanged<Offset> onResizeSignature;
|
||||||
|
final VoidCallback onConfirmSignature;
|
||||||
|
final VoidCallback onClearActiveOverlay;
|
||||||
|
final ValueChanged<int?> onSelectPlaced;
|
||||||
|
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) {},
|
||||||
|
onDragSignature: widget.onDragSignature,
|
||||||
|
onResizeSignature: widget.onResizeSignature,
|
||||||
|
onConfirmSignature: widget.onConfirmSignature,
|
||||||
|
onClearActiveOverlay: widget.onClearActiveOverlay,
|
||||||
|
onSelectPlaced: widget.onSelectPlaced,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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(
|
||||||
|
pageNumber.toString(),
|
||||||
|
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(
|
||||||
|
pageNumber.toString(),
|
||||||
|
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,
|
||||||
|
onDragSignature: widget.onDragSignature,
|
||||||
|
onResizeSignature: widget.onResizeSignature,
|
||||||
|
onConfirmSignature: widget.onConfirmSignature,
|
||||||
|
onClearActiveOverlay: widget.onClearActiveOverlay,
|
||||||
|
onSelectPlaced: widget.onSelectPlaced,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,284 +1,169 @@
|
||||||
import 'dart:math' as math;
|
|
||||||
import 'dart:typed_data';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_box_transform/flutter_box_transform.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import '../../../../domain/models/model.dart';
|
||||||
|
import '../../signature/widgets/rotated_signature_image.dart';
|
||||||
|
import '../../signature/view_model/signature_view_model.dart';
|
||||||
|
import '../view_model/pdf_view_model.dart';
|
||||||
import 'package:pdf_signature/l10n/app_localizations.dart';
|
import 'package:pdf_signature/l10n/app_localizations.dart';
|
||||||
|
|
||||||
import '../../../../domain/models/model.dart';
|
/// Minimal overlay widget for rendering a placed signature.
|
||||||
import 'package:pdf_signature/data/repositories/signature_card_repository.dart';
|
|
||||||
import 'package:pdf_signature/data/repositories/document_repository.dart';
|
|
||||||
import 'package:pdf_signature/data/repositories/signature_asset_repository.dart';
|
|
||||||
import 'image_editor_dialog.dart';
|
|
||||||
import '../../signature/widgets/rotated_signature_image.dart';
|
|
||||||
|
|
||||||
/// Renders a single signature overlay (either interactive or placed) on a page.
|
|
||||||
class SignatureOverlay extends ConsumerWidget {
|
class SignatureOverlay extends ConsumerWidget {
|
||||||
const SignatureOverlay({
|
const SignatureOverlay({
|
||||||
super.key,
|
super.key,
|
||||||
required this.pageSize,
|
required this.pageSize,
|
||||||
required this.rect,
|
required this.rect,
|
||||||
required this.sig,
|
required this.placement,
|
||||||
|
required this.placedIndex,
|
||||||
required this.pageNumber,
|
required this.pageNumber,
|
||||||
this.placedIndex,
|
|
||||||
this.onDragSignature,
|
|
||||||
this.onResizeSignature,
|
|
||||||
this.onConfirmSignature,
|
|
||||||
this.onClearActiveOverlay,
|
|
||||||
this.onSelectPlaced,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
final Size pageSize;
|
final Size pageSize; // not used directly, kept for API symmetry
|
||||||
final Rect rect;
|
final Rect rect; // normalized 0..1 values (left, top, width, height)
|
||||||
final SignatureCard sig;
|
final SignaturePlacement placement;
|
||||||
|
final int placedIndex;
|
||||||
final int pageNumber;
|
final int pageNumber;
|
||||||
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 processedBytes = ref
|
||||||
|
.watch(signatureViewModelProvider)
|
||||||
|
.getProcessedBytes(placement.asset, placement.graphicAdjust);
|
||||||
return LayoutBuilder(
|
return LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
final scaleX = constraints.maxWidth / pageSize.width;
|
final pageW = constraints.maxWidth;
|
||||||
final scaleY = constraints.maxHeight / pageSize.height;
|
final pageH = constraints.maxHeight;
|
||||||
final left = rect.left * scaleX;
|
final rectPx = Rect.fromLTWH(
|
||||||
final top = rect.top * scaleY;
|
rect.left * pageW,
|
||||||
final width = rect.width * scaleX;
|
rect.top * pageH,
|
||||||
final height = rect.height * scaleY;
|
rect.width * pageW,
|
||||||
|
rect.height * pageH,
|
||||||
|
);
|
||||||
|
|
||||||
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(
|
||||||
|
bytes: processedBytes,
|
||||||
|
rotationDeg: placement.rotationDeg,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
// Invisible overlay for right-click context menu
|
||||||
Positioned(
|
Positioned(
|
||||||
left: left,
|
left: rectPx.left,
|
||||||
top: top,
|
top: rectPx.top,
|
||||||
width: width,
|
width: rectPx.width,
|
||||||
height: height,
|
height: rectPx.height,
|
||||||
child: _buildContent(context, ref, scaleX, scaleY),
|
child: GestureDetector(
|
||||||
|
behavior: HitTestBehavior.translucent,
|
||||||
|
onSecondaryTapDown: (details) 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(
|
||||||
|
details.globalPosition.dx,
|
||||||
|
details.globalPosition.dy,
|
||||||
|
details.globalPosition.dx,
|
||||||
|
details.globalPosition.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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildContent(
|
|
||||||
BuildContext context,
|
|
||||||
WidgetRef ref,
|
|
||||||
double scaleX,
|
|
||||||
double scaleY,
|
|
||||||
) {
|
|
||||||
final selectedIdx =
|
|
||||||
ref.read(documentRepositoryProvider).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;
|
|
||||||
|
|
||||||
// Instead of DecoratedBox, use a Stack to control layering
|
|
||||||
Widget content = Stack(
|
|
||||||
alignment: Alignment.center,
|
|
||||||
children: [
|
|
||||||
// Background layer (semi-transparent color)
|
|
||||||
Positioned.fill(
|
|
||||||
child: Container(
|
|
||||||
color: Color.fromRGBO(
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
0.05 + math.min(0.25, (sig.graphicAdjust.contrast - 1.0).abs()),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
// Signature image layer
|
|
||||||
_SignatureImage(
|
|
||||||
interactive: interactive,
|
|
||||||
placedIndex: placedIndex,
|
|
||||||
pageNumber: pageNumber,
|
|
||||||
sig: sig,
|
|
||||||
),
|
|
||||||
// Border layer (on top, using Positioned.fill with a transparent background)
|
|
||||||
Positioned.fill(
|
|
||||||
child: Container(
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
border: Border.all(color: borderColor, width: borderWidth),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
// Resize handle (only for interactive mode, on top of everything)
|
|
||||||
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, ref, null),
|
|
||||||
onLongPressStart:
|
|
||||||
(d) => _showActiveMenu(context, d.globalPosition, ref, null),
|
|
||||||
child: content,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
content = GestureDetector(
|
|
||||||
key: Key('placed_signature_${placedIndex ?? 'x'}'),
|
|
||||||
behavior: HitTestBehavior.opaque,
|
|
||||||
onTap: () => onSelectPlaced?.call(placedIndex),
|
|
||||||
onSecondaryTapDown: (d) {
|
|
||||||
if (placedIndex != null) {
|
|
||||||
_showActiveMenu(context, d.globalPosition, ref, placedIndex);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onLongPressStart: (d) {
|
|
||||||
if (placedIndex != null) {
|
|
||||||
_showActiveMenu(context, d.globalPosition, ref, placedIndex);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
child: content,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return content;
|
|
||||||
}
|
|
||||||
|
|
||||||
void _showActiveMenu(
|
|
||||||
BuildContext context,
|
|
||||||
Offset globalPos,
|
|
||||||
WidgetRef ref,
|
|
||||||
int? placedIndex,
|
|
||||||
) {
|
|
||||||
showMenu<String>(
|
|
||||||
context: context,
|
|
||||||
position: RelativeRect.fromLTRB(
|
|
||||||
globalPos.dx,
|
|
||||||
globalPos.dy,
|
|
||||||
globalPos.dx,
|
|
||||||
globalPos.dy,
|
|
||||||
),
|
|
||||||
items: [
|
|
||||||
// if not placed, show Adjust and Confirm option
|
|
||||||
if (placedIndex == null) ...[
|
|
||||||
PopupMenuItem<String>(
|
|
||||||
key: const Key('ctx_active_confirm'),
|
|
||||||
value: 'confirm',
|
|
||||||
child: Text(AppLocalizations.of(context).confirm),
|
|
||||||
),
|
|
||||||
PopupMenuItem<String>(
|
|
||||||
key: const Key('ctx_active_adjust'),
|
|
||||||
value: 'adjust',
|
|
||||||
child: Text(AppLocalizations.of(context).adjustGraphic),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
PopupMenuItem<String>(
|
|
||||||
key: const Key('ctx_active_delete'),
|
|
||||||
value: 'delete',
|
|
||||||
child: Text(AppLocalizations.of(context).delete),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
).then((choice) {
|
|
||||||
if (choice == 'confirm') {
|
|
||||||
if (placedIndex == null) {
|
|
||||||
onConfirmSignature?.call();
|
|
||||||
}
|
|
||||||
// For placed, confirm does nothing
|
|
||||||
} else if (choice == 'delete') {
|
|
||||||
if (placedIndex == null) {
|
|
||||||
onClearActiveOverlay?.call();
|
|
||||||
} else {
|
|
||||||
ref
|
|
||||||
.read(documentRepositoryProvider.notifier)
|
|
||||||
.removePlacement(page: pageNumber, index: placedIndex);
|
|
||||||
}
|
|
||||||
} else if (choice == 'adjust') {
|
|
||||||
showDialog(context: context, builder: (_) => const ImageEditorDialog());
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 SignatureCard sig;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
|
||||||
Uint8List? bytes;
|
|
||||||
if (interactive) {
|
|
||||||
final processed = ref.watch(processedSignatureImageProvider);
|
|
||||||
bytes = processed ?? sig.asset.bytes;
|
|
||||||
} else if (placedIndex != null) {
|
|
||||||
final placementList =
|
|
||||||
ref.read(documentRepositoryProvider).placementsByPage[pageNumber];
|
|
||||||
final placement =
|
|
||||||
(placementList != null && placedIndex! < placementList.length)
|
|
||||||
? placementList[placedIndex!]
|
|
||||||
: null;
|
|
||||||
final imgId = (placement?.asset)?.id;
|
|
||||||
if (imgId != null && imgId.isNotEmpty) {
|
|
||||||
final lib = ref.watch(signatureAssetRepositoryProvider);
|
|
||||||
for (final a in lib) {
|
|
||||||
if (a.id == imgId) {
|
|
||||||
bytes = a.bytes;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
bytes ??= ref.read(processedSignatureImageProvider) ?? sig.asset.bytes;
|
|
||||||
}
|
|
||||||
|
|
||||||
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.rotationDeg;
|
|
||||||
} else if (placedIndex != null) {
|
|
||||||
final placementList =
|
|
||||||
ref.read(documentRepositoryProvider).placementsByPage[pageNumber];
|
|
||||||
if (placementList != null && placedIndex! < placementList.length) {
|
|
||||||
rotationDeg = placementList[placedIndex!].rotationDeg;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return RotatedSignatureImage(bytes: bytes, rotationDeg: rotationDeg);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,8 @@ 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 'signature_drawer.dart';
|
import '../../signature/widgets/signature_drawer.dart';
|
||||||
|
import 'ui_services.dart';
|
||||||
|
|
||||||
class SignaturesSidebar extends ConsumerWidget {
|
class SignaturesSidebar extends ConsumerWidget {
|
||||||
const SignaturesSidebar({
|
const SignaturesSidebar({
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
import 'package:file_selector/file_selector.dart' as fs;
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:pdf_signature/data/services/export_service.dart';
|
||||||
|
|
||||||
|
/// Global exporting flag used to disable parts of the UI during long tasks.
|
||||||
|
final exportingProvider = StateProvider<bool>((ref) => false);
|
||||||
|
|
||||||
|
/// Provider for the export service. Can be overridden in tests.
|
||||||
|
final exportServiceProvider = Provider<ExportService>((ref) => ExportService());
|
||||||
|
|
||||||
|
/// Provider for a function that picks a save path. Tests may override.
|
||||||
|
final savePathPickerProvider = Provider<Future<String?> Function()>((ref) {
|
||||||
|
return () async {
|
||||||
|
// Desktop save dialog with PDF filter; mobile platforms may not support this.
|
||||||
|
final group = fs.XTypeGroup(label: 'PDF', extensions: ['pdf']);
|
||||||
|
final location = await fs.getSaveLocation(
|
||||||
|
acceptedTypeGroups: [group],
|
||||||
|
suggestedName: 'signed.pdf',
|
||||||
|
confirmButtonText: 'Save',
|
||||||
|
);
|
||||||
|
return location?.path; // null if user cancels
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
class PreferencesViewModel {
|
||||||
|
final Ref ref;
|
||||||
|
|
||||||
|
PreferencesViewModel(this.ref);
|
||||||
|
|
||||||
|
// Add methods as needed
|
||||||
|
}
|
||||||
|
|
||||||
|
final preferencesViewModelProvider = Provider<PreferencesViewModel>((ref) {
|
||||||
|
return PreferencesViewModel(ref);
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
/// Global flag indicating whether a signature card is currently being dragged.
|
||||||
|
final isDraggingSignatureViewModelProvider = StateProvider<bool>(
|
||||||
|
(ref) => false,
|
||||||
|
);
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
import 'dart:typed_data';
|
||||||
|
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;
|
||||||
|
|
||||||
|
class SignatureViewModel {
|
||||||
|
final Ref ref;
|
||||||
|
|
||||||
|
SignatureViewModel(this.ref);
|
||||||
|
|
||||||
|
Uint8List getProcessedBytes(
|
||||||
|
domain.SignatureAsset asset,
|
||||||
|
domain.GraphicAdjust adjust,
|
||||||
|
) {
|
||||||
|
final notifier = ref.read(repo.signatureCardRepositoryProvider.notifier);
|
||||||
|
return notifier.getProcessedBytes(asset, adjust);
|
||||||
|
}
|
||||||
|
|
||||||
|
repo.DisplaySignatureData getDisplaySignatureData(
|
||||||
|
domain.SignatureAsset asset,
|
||||||
|
domain.GraphicAdjust adjust,
|
||||||
|
) {
|
||||||
|
final notifier = ref.read(repo.signatureCardRepositoryProvider.notifier);
|
||||||
|
return notifier.getDisplayData(asset, adjust);
|
||||||
|
}
|
||||||
|
|
||||||
|
void clearCache() {
|
||||||
|
final notifier = ref.read(repo.signatureCardRepositoryProvider.notifier);
|
||||||
|
notifier.clearProcessedCache();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final signatureViewModelProvider = Provider<SignatureViewModel>((ref) {
|
||||||
|
return SignatureViewModel(ref);
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,293 @@
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
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';
|
||||||
|
|
||||||
|
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 double _rotation;
|
||||||
|
|
||||||
|
// Cached image data
|
||||||
|
late Uint8List _originalBytes; // Original asset bytes (never mutated)
|
||||||
|
Uint8List?
|
||||||
|
_processedBgRemovedBytes; // Cached brightness/contrast adjusted then bg-removed bytes
|
||||||
|
img.Image? _decodedBase; // Decoded original for processing
|
||||||
|
|
||||||
|
// 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 = widget.initialRotation;
|
||||||
|
_originalBytes = widget.asset.bytes;
|
||||||
|
// Decode lazily only if/when background removal is needed
|
||||||
|
if (_bgRemoval) {
|
||||||
|
_scheduleBgRemovalReprocess(immediate: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Uint8List get _displayBytes =>
|
||||||
|
_bgRemoval
|
||||||
|
? (_processedBgRemovedBytes ?? _originalBytes)
|
||||||
|
: _originalBytes;
|
||||||
|
|
||||||
|
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() {
|
||||||
|
_decodedBase ??= img.decodeImage(_originalBytes);
|
||||||
|
final base = _decodedBase;
|
||||||
|
if (base == null) return;
|
||||||
|
// 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
|
||||||
|
const int threshold = 240;
|
||||||
|
if (!working.hasAlpha) {
|
||||||
|
working = working.convert(numChannels: 4);
|
||||||
|
}
|
||||||
|
for (int y = 0; y < working.height; y++) {
|
||||||
|
for (int x = 0; x < working.width; x++) {
|
||||||
|
final p = working.getPixel(x, y);
|
||||||
|
final r = p.r, g = p.g, b = p.b;
|
||||||
|
if (r >= threshold && g >= threshold && b >= threshold) {
|
||||||
|
working.setPixelRgba(x, y, r, g, b, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
final bytes = Uint8List.fromList(img.encodePng(working));
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _processedBgRemovedBytes = bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
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() {
|
||||||
|
_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:
|
||||||
|
_bgRemoval
|
||||||
|
? RotatedSignatureImage(
|
||||||
|
bytes: _displayBytes,
|
||||||
|
rotationDeg: _rotation,
|
||||||
|
)
|
||||||
|
: ColorFiltered(
|
||||||
|
colorFilter: _currentColorFilter(),
|
||||||
|
child: RotatedSignatureImage(
|
||||||
|
bytes: _displayBytes,
|
||||||
|
rotationDeg: _rotation,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
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: Slider(
|
||||||
|
key: const Key('sld_rotation'),
|
||||||
|
min: -180,
|
||||||
|
max: 180,
|
||||||
|
divisions: 72,
|
||||||
|
value: _rotation,
|
||||||
|
onChanged: (v) => setState(() => _rotation = v),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text('${_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(
|
||||||
|
ImageEditorResult(
|
||||||
|
rotation: _rotation,
|
||||||
|
graphicAdjust: domain.GraphicAdjust(
|
||||||
|
contrast: _contrast,
|
||||||
|
brightness: _brightness,
|
||||||
|
bgRemoval: _bgRemoval,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
MaterialLocalizations.of(context).closeButtonLabel,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -32,7 +32,9 @@ class _RotatedSignatureImageState extends State<RotatedSignatureImage> {
|
||||||
ImageStreamListener? _listener;
|
ImageStreamListener? _listener;
|
||||||
double? _derivedAspectRatio; // width / height
|
double? _derivedAspectRatio; // width / height
|
||||||
|
|
||||||
MemoryImage get _provider => MemoryImage(widget.bytes);
|
MemoryImage get _provider {
|
||||||
|
return MemoryImage(widget.bytes);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void didChangeDependencies() {
|
void didChangeDependencies() {
|
||||||
|
|
@ -43,7 +45,8 @@ class _RotatedSignatureImageState extends State<RotatedSignatureImage> {
|
||||||
@override
|
@override
|
||||||
void didUpdateWidget(covariant RotatedSignatureImage oldWidget) {
|
void didUpdateWidget(covariant RotatedSignatureImage oldWidget) {
|
||||||
super.didUpdateWidget(oldWidget);
|
super.didUpdateWidget(oldWidget);
|
||||||
if (!identical(oldWidget.bytes, widget.bytes)) {
|
if (!identical(oldWidget.bytes, widget.bytes) ||
|
||||||
|
oldWidget.rotationDeg != widget.rotationDeg) {
|
||||||
_derivedAspectRatio = null;
|
_derivedAspectRatio = null;
|
||||||
_resolveImage();
|
_resolveImage();
|
||||||
}
|
}
|
||||||
|
|
@ -58,13 +61,23 @@ class _RotatedSignatureImageState extends State<RotatedSignatureImage> {
|
||||||
void _resolveImage() {
|
void _resolveImage() {
|
||||||
_unlisten();
|
_unlisten();
|
||||||
// Decode synchronously to get aspect ratio
|
// Decode synchronously to get aspect ratio
|
||||||
final decoded = img.decodePng(widget.bytes);
|
// Guard against empty / invalid bytes that some simplified tests may inject.
|
||||||
if (decoded != null) {
|
if (widget.bytes.isEmpty) {
|
||||||
final w = decoded.width;
|
_setAspectRatio(1.0); // assume square to avoid layout exceptions
|
||||||
final h = decoded.height;
|
return;
|
||||||
if (w > 0 && h > 0) {
|
}
|
||||||
_setAspectRatio(w / h);
|
try {
|
||||||
|
final decoded = img.decodePng(widget.bytes);
|
||||||
|
if (decoded != null) {
|
||||||
|
final w = decoded.width;
|
||||||
|
final h = decoded.height;
|
||||||
|
if (w > 0 && h > 0) {
|
||||||
|
_setAspectRatio(w / h);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// Swallow decode errors for test-provided dummy data; assume square.
|
||||||
|
_setAspectRatio(1.0);
|
||||||
}
|
}
|
||||||
final stream = _provider.resolve(createLocalImageConfiguration(context));
|
final stream = _provider.resolve(createLocalImageConfiguration(context));
|
||||||
_stream = stream;
|
_stream = stream;
|
||||||
|
|
@ -102,6 +115,13 @@ class _RotatedSignatureImageState extends State<RotatedSignatureImage> {
|
||||||
filterQuality: widget.filterQuality,
|
filterQuality: widget.filterQuality,
|
||||||
alignment: widget.alignment,
|
alignment: widget.alignment,
|
||||||
semanticLabel: widget.semanticLabel,
|
semanticLabel: widget.semanticLabel,
|
||||||
|
errorBuilder: (context, error, stackTrace) {
|
||||||
|
// Return a placeholder for invalid images
|
||||||
|
return Container(
|
||||||
|
color: Colors.grey[300],
|
||||||
|
child: const Icon(Icons.broken_image, color: Colors.grey),
|
||||||
|
);
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
if (angle != 0.0) {
|
if (angle != 0.0) {
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,13 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:pdf_signature/domain/models/model.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:pdf_signature/domain/models/model.dart' as domain;
|
||||||
import 'signature_drag_data.dart';
|
import 'signature_drag_data.dart';
|
||||||
import 'rotated_signature_image.dart';
|
import 'rotated_signature_image.dart';
|
||||||
import 'package:pdf_signature/l10n/app_localizations.dart';
|
import 'package:pdf_signature/l10n/app_localizations.dart';
|
||||||
|
import '../view_model/signature_view_model.dart';
|
||||||
|
import '../view_model/dragging_signature_view_model.dart';
|
||||||
|
|
||||||
class SignatureCard extends StatelessWidget {
|
class SignatureCard extends ConsumerWidget {
|
||||||
const SignatureCard({
|
const SignatureCard({
|
||||||
super.key,
|
super.key,
|
||||||
required this.asset,
|
required this.asset,
|
||||||
|
|
@ -14,23 +17,35 @@ class SignatureCard extends StatelessWidget {
|
||||||
this.onAdjust,
|
this.onAdjust,
|
||||||
this.useCurrentBytesForDrag = false,
|
this.useCurrentBytesForDrag = false,
|
||||||
this.rotationDeg = 0.0,
|
this.rotationDeg = 0.0,
|
||||||
|
this.graphicAdjust = const domain.GraphicAdjust(),
|
||||||
});
|
});
|
||||||
final SignatureAsset asset;
|
final domain.SignatureAsset asset;
|
||||||
final bool disabled;
|
final bool disabled;
|
||||||
final VoidCallback onDelete;
|
final VoidCallback onDelete;
|
||||||
final VoidCallback? onTap;
|
final VoidCallback? onTap;
|
||||||
final VoidCallback? onAdjust;
|
final VoidCallback? onAdjust;
|
||||||
final bool useCurrentBytesForDrag;
|
final bool useCurrentBytesForDrag;
|
||||||
final double rotationDeg;
|
final double rotationDeg;
|
||||||
|
final domain.GraphicAdjust graphicAdjust;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final displayData = ref
|
||||||
|
.watch(signatureViewModelProvider)
|
||||||
|
.getDisplaySignatureData(asset, graphicAdjust);
|
||||||
// Fit inside 96x64 with 6px padding using the shared rotated image widget
|
// Fit inside 96x64 with 6px padding using the shared rotated image widget
|
||||||
const boxW = 96.0, boxH = 64.0, pad = 6.0;
|
const boxW = 96.0, boxH = 64.0, pad = 6.0;
|
||||||
Widget img = RotatedSignatureImage(
|
Widget coreImage = RotatedSignatureImage(
|
||||||
bytes: asset.bytes,
|
bytes: displayData.bytes,
|
||||||
rotationDeg: rotationDeg,
|
rotationDeg: rotationDeg,
|
||||||
);
|
);
|
||||||
|
Widget img =
|
||||||
|
(displayData.colorMatrix != null)
|
||||||
|
? ColorFiltered(
|
||||||
|
colorFilter: ColorFilter.matrix(displayData.colorMatrix!),
|
||||||
|
child: coreImage,
|
||||||
|
)
|
||||||
|
: coreImage;
|
||||||
Widget base = SizedBox(
|
Widget base = SizedBox(
|
||||||
width: 96,
|
width: 96,
|
||||||
height: 64,
|
height: 64,
|
||||||
|
|
@ -142,7 +157,19 @@ class SignatureCard extends StatelessWidget {
|
||||||
data:
|
data:
|
||||||
useCurrentBytesForDrag
|
useCurrentBytesForDrag
|
||||||
? const SignatureDragData()
|
? const SignatureDragData()
|
||||||
: SignatureDragData(asset: asset),
|
: SignatureDragData(
|
||||||
|
card: domain.SignatureCard(
|
||||||
|
asset: asset,
|
||||||
|
rotationDeg: rotationDeg,
|
||||||
|
graphicAdjust: graphicAdjust,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onDragStarted: () {
|
||||||
|
ref.read(isDraggingSignatureViewModelProvider.notifier).state = true;
|
||||||
|
},
|
||||||
|
onDragEnd: (_) {
|
||||||
|
ref.read(isDraggingSignatureViewModelProvider.notifier).state = false;
|
||||||
|
},
|
||||||
feedback: Opacity(
|
feedback: Opacity(
|
||||||
opacity: 0.9,
|
opacity: 0.9,
|
||||||
child: ConstrainedBox(
|
child: ConstrainedBox(
|
||||||
|
|
@ -157,10 +184,21 @@ class SignatureCard extends StatelessWidget {
|
||||||
),
|
),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(6.0),
|
padding: const EdgeInsets.all(6.0),
|
||||||
child: RotatedSignatureImage(
|
child:
|
||||||
bytes: asset.bytes,
|
(displayData.colorMatrix != null)
|
||||||
rotationDeg: rotationDeg,
|
? ColorFiltered(
|
||||||
),
|
colorFilter: ColorFilter.matrix(
|
||||||
|
displayData.colorMatrix!,
|
||||||
|
),
|
||||||
|
child: RotatedSignatureImage(
|
||||||
|
bytes: displayData.bytes,
|
||||||
|
rotationDeg: rotationDeg,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: RotatedSignatureImage(
|
||||||
|
bytes: displayData.bytes,
|
||||||
|
rotationDeg: rotationDeg,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import 'package:pdf_signature/domain/models/model.dart';
|
import 'package:pdf_signature/domain/models/model.dart';
|
||||||
|
|
||||||
class SignatureDragData {
|
class SignatureDragData {
|
||||||
final SignatureAsset? asset; // null means use current processed signature
|
final SignatureCard? card; // null means use current processed signature
|
||||||
const SignatureDragData({this.asset});
|
const SignatureDragData({this.card});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,13 @@ 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:pdf_signature/domain/models/model.dart' as model;
|
// Direct model construction is needed for creating SignatureAssets
|
||||||
|
|
||||||
import 'package:pdf_signature/data/repositories/signature_card_repository.dart';
|
|
||||||
import 'package:pdf_signature/data/repositories/signature_asset_repository.dart';
|
import 'package:pdf_signature/data/repositories/signature_asset_repository.dart';
|
||||||
|
import 'package:pdf_signature/data/repositories/signature_card_repository.dart';
|
||||||
|
import 'package:pdf_signature/domain/models/model.dart' hide SignatureCard;
|
||||||
import 'image_editor_dialog.dart';
|
import 'image_editor_dialog.dart';
|
||||||
import '../../signature/widgets/signature_card.dart';
|
import 'signature_card.dart';
|
||||||
|
|
||||||
/// Data for drag-and-drop is in signature_drag_data.dart
|
/// Data for drag-and-drop is in signature_drag_data.dart
|
||||||
|
|
||||||
|
|
@ -33,53 +34,50 @@ class _SignatureDrawerState extends ConsumerState<SignatureDrawer> {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final l = AppLocalizations.of(context);
|
final l = AppLocalizations.of(context);
|
||||||
final sig = ref.watch(signatureProvider);
|
final library = ref.watch(signatureCardRepositoryProvider);
|
||||||
final processed = ref.watch(processedSignatureImageProvider);
|
// Exporting flag lives in ui_services; keep drawer interactive regardless here.
|
||||||
final bytes = processed ?? sig.imageBytes;
|
final isExporting = false;
|
||||||
final library = ref.watch(signatureAssetRepositoryProvider);
|
|
||||||
final isExporting = ref.watch(exportingProvider);
|
|
||||||
final disabled = widget.disabled || isExporting;
|
final disabled = widget.disabled || isExporting;
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
if (library.isNotEmpty) ...[
|
if (library.isNotEmpty) ...[
|
||||||
for (final a in library) ...[
|
for (final card in library) ...[
|
||||||
Card(
|
Card(
|
||||||
margin: EdgeInsets.zero,
|
margin: EdgeInsets.zero,
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(12),
|
padding: const EdgeInsets.all(12),
|
||||||
child: SignatureCard(
|
child: SignatureCard(
|
||||||
key: ValueKey('sig_card_${a.id}'),
|
key: ValueKey('sig_card_${library.indexOf(card)}'),
|
||||||
asset:
|
asset: card.asset,
|
||||||
(sig.asset?.id == a.id)
|
rotationDeg: card.rotationDeg,
|
||||||
? model.SignatureAsset(
|
graphicAdjust: card.graphicAdjust,
|
||||||
id: a.id,
|
|
||||||
bytes: (processed ?? a.bytes),
|
|
||||||
name: a.name,
|
|
||||||
)
|
|
||||||
: a,
|
|
||||||
rotationDeg: (sig.asset?.id == a.id) ? sig.rotation : 0.0,
|
|
||||||
disabled: disabled,
|
disabled: disabled,
|
||||||
onDelete:
|
onDelete:
|
||||||
() => ref
|
() => ref
|
||||||
.read(signatureAssetRepositoryProvider.notifier)
|
.read(signatureCardRepositoryProvider.notifier)
|
||||||
.remove(a.id),
|
.remove(card),
|
||||||
onAdjust: () async {
|
onAdjust: () async {
|
||||||
ref
|
|
||||||
.read(signatureProvider.notifier)
|
|
||||||
.setImageFromLibrary(asset: a);
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
await showDialog(
|
final result = await showDialog<ImageEditorResult>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (_) => const ImageEditorDialog(),
|
barrierDismissible: false,
|
||||||
|
builder:
|
||||||
|
(_) => ImageEditorDialog(
|
||||||
|
asset: card.asset,
|
||||||
|
initialRotation: card.rotationDeg,
|
||||||
|
initialGraphicAdjust: card.graphicAdjust,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
if (result != null && mounted) {
|
||||||
|
ref
|
||||||
|
.read(signatureCardRepositoryProvider.notifier)
|
||||||
|
.update(card, result.rotation, result.graphicAdjust);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onTap: () {
|
onTap: () {
|
||||||
// Never reassign placed signatures via tap; only set active overlay source
|
// state = const Rect.fromLTWH(0.2, 0.2, 0.3, 0.15);
|
||||||
ref
|
|
||||||
.read(signatureProvider.notifier)
|
|
||||||
.setImageFromLibrary(asset: a);
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -92,32 +90,7 @@ class _SignatureDrawerState extends ConsumerState<SignatureDrawer> {
|
||||||
margin: EdgeInsets.zero,
|
margin: EdgeInsets.zero,
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(12),
|
padding: const EdgeInsets.all(12),
|
||||||
child:
|
child: Text(l.noSignatureLoaded),
|
||||||
bytes == null
|
|
||||||
? Text(l.noSignatureLoaded)
|
|
||||||
: SignatureCard(
|
|
||||||
asset: model.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(
|
Card(
|
||||||
|
|
@ -144,28 +117,24 @@ class _SignatureDrawerState extends ConsumerState<SignatureDrawer> {
|
||||||
: () async {
|
: () async {
|
||||||
final loaded =
|
final loaded =
|
||||||
await widget.onLoadSignatureFromFile();
|
await widget.onLoadSignatureFromFile();
|
||||||
final b =
|
final b = loaded;
|
||||||
loaded ??
|
|
||||||
ref.read(processedSignatureImageProvider) ??
|
|
||||||
ref.read(signatureProvider).imageBytes;
|
|
||||||
if (b != null) {
|
if (b != null) {
|
||||||
final id = ref
|
final asset = SignatureAsset(
|
||||||
|
bytes: b,
|
||||||
|
name: 'image',
|
||||||
|
);
|
||||||
|
ref
|
||||||
.read(
|
.read(
|
||||||
signatureAssetRepositoryProvider
|
signatureAssetRepositoryProvider
|
||||||
.notifier,
|
.notifier,
|
||||||
)
|
)
|
||||||
.add(b, name: 'image');
|
.add(b, name: 'image');
|
||||||
final asset = ref
|
ref
|
||||||
.read(
|
.read(
|
||||||
signatureAssetRepositoryProvider
|
signatureCardRepositoryProvider
|
||||||
.notifier,
|
.notifier,
|
||||||
)
|
)
|
||||||
.byId(id);
|
.addWithAsset(asset, 0.0);
|
||||||
if (asset != null) {
|
|
||||||
ref
|
|
||||||
.read(signatureProvider.notifier)
|
|
||||||
.setImageFromLibrary(asset: asset);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
icon: const Icon(Icons.image_outlined),
|
icon: const Icon(Icons.image_outlined),
|
||||||
|
|
@ -178,28 +147,24 @@ class _SignatureDrawerState extends ConsumerState<SignatureDrawer> {
|
||||||
? null
|
? null
|
||||||
: () async {
|
: () async {
|
||||||
final drawn = await widget.onOpenDrawCanvas();
|
final drawn = await widget.onOpenDrawCanvas();
|
||||||
final b =
|
final b = drawn;
|
||||||
drawn ??
|
|
||||||
ref.read(processedSignatureImageProvider) ??
|
|
||||||
ref.read(signatureProvider).imageBytes;
|
|
||||||
if (b != null) {
|
if (b != null) {
|
||||||
final id = ref
|
final asset = SignatureAsset(
|
||||||
|
bytes: b,
|
||||||
|
name: 'drawing',
|
||||||
|
);
|
||||||
|
ref
|
||||||
.read(
|
.read(
|
||||||
signatureAssetRepositoryProvider
|
signatureAssetRepositoryProvider
|
||||||
.notifier,
|
.notifier,
|
||||||
)
|
)
|
||||||
.add(b, name: 'drawing');
|
.add(b, name: 'drawing');
|
||||||
final asset = ref
|
ref
|
||||||
.read(
|
.read(
|
||||||
signatureAssetRepositoryProvider
|
signatureCardRepositoryProvider
|
||||||
.notifier,
|
.notifier,
|
||||||
)
|
)
|
||||||
.byId(id);
|
.addWithAsset(asset, 0.0);
|
||||||
if (asset != null) {
|
|
||||||
ref
|
|
||||||
.read(signatureProvider.notifier)
|
|
||||||
.setImageFromLibrary(asset: asset);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
icon: const Icon(Icons.gesture),
|
icon: const Icon(Icons.gesture),
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
import 'dart:typed_data';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:pdf_signature/ui/features/pdf/view_model/pdf_view_model.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:pdf_signature/routing/router.dart';
|
||||||
|
|
||||||
|
class WelcomeViewModel {
|
||||||
|
final Ref ref;
|
||||||
|
final GoRouter router;
|
||||||
|
|
||||||
|
WelcomeViewModel(this.ref, this.router);
|
||||||
|
|
||||||
|
Future<void> openPdf({required String path, Uint8List? bytes}) async {
|
||||||
|
// Use PdfSessionViewModel to open and navigate.
|
||||||
|
final session = ref.read(pdfSessionViewModelProvider(router));
|
||||||
|
await session.openPdf(path: path, bytes: bytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final welcomeViewModelProvider = Provider<WelcomeViewModel>((ref) {
|
||||||
|
final router = ref.read(routerProvider);
|
||||||
|
return WelcomeViewModel(ref, router);
|
||||||
|
});
|
||||||
|
|
@ -1,16 +1,11 @@
|
||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
|
|
||||||
import 'package:desktop_drop/desktop_drop.dart';
|
import 'package:desktop_drop/desktop_drop.dart';
|
||||||
import 'package:file_selector/file_selector.dart' as fs;
|
|
||||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
import 'package:flutter/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/l10n/app_localizations.dart';
|
import 'package:pdf_signature/l10n/app_localizations.dart';
|
||||||
|
|
||||||
import 'package:pdf_signature/data/repositories/signature_card_repository.dart';
|
|
||||||
import 'package:pdf_signature/data/repositories/document_repository.dart';
|
|
||||||
// Settings dialog is provided via global AppBar in MyApp
|
|
||||||
|
|
||||||
// Abstraction to make drop handling testable without constructing
|
// Abstraction to make drop handling testable without constructing
|
||||||
// platform-specific DropItem types in widget tests.
|
// platform-specific DropItem types in widget tests.
|
||||||
abstract class DropReadable {
|
abstract class DropReadable {
|
||||||
|
|
@ -35,7 +30,8 @@ typedef Reader = T Function<T>(ProviderListenable<T> provider);
|
||||||
|
|
||||||
// Select first .pdf file (case-insensitive) or fall back to first entry.
|
// Select first .pdf file (case-insensitive) or fall back to first entry.
|
||||||
Future<void> handleDroppedFiles(
|
Future<void> handleDroppedFiles(
|
||||||
Reader read,
|
Future<void> Function({String? path, Uint8List? bytes, String? fileName})
|
||||||
|
onOpenPdf,
|
||||||
Iterable<DropReadable> files,
|
Iterable<DropReadable> files,
|
||||||
) async {
|
) async {
|
||||||
if (files.isEmpty) return;
|
if (files.isEmpty) return;
|
||||||
|
|
@ -50,14 +46,23 @@ Future<void> handleDroppedFiles(
|
||||||
bytes = null;
|
bytes = null;
|
||||||
}
|
}
|
||||||
final String path = pdf.path ?? pdf.name;
|
final String path = pdf.path ?? pdf.name;
|
||||||
read(
|
await onOpenPdf(path: path, bytes: bytes);
|
||||||
documentRepositoryProvider.notifier,
|
|
||||||
).openPicked(path: path, bytes: bytes);
|
|
||||||
read(signatureProvider.notifier).resetForNewPage();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class WelcomeScreen extends ConsumerStatefulWidget {
|
class WelcomeScreen extends ConsumerStatefulWidget {
|
||||||
const WelcomeScreen({super.key});
|
final Future<void> Function() onPickPdf;
|
||||||
|
final Future<void> Function({
|
||||||
|
String? path,
|
||||||
|
Uint8List? bytes,
|
||||||
|
String? fileName,
|
||||||
|
})
|
||||||
|
onOpenPdf;
|
||||||
|
|
||||||
|
const WelcomeScreen({
|
||||||
|
super.key,
|
||||||
|
required this.onPickPdf,
|
||||||
|
required this.onOpenPdf,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
ConsumerState<WelcomeScreen> createState() => _WelcomeScreenState();
|
ConsumerState<WelcomeScreen> createState() => _WelcomeScreenState();
|
||||||
|
|
@ -67,20 +72,7 @@ class _WelcomeScreenState extends ConsumerState<WelcomeScreen> {
|
||||||
bool _dragging = false;
|
bool _dragging = false;
|
||||||
|
|
||||||
Future<void> _pickPdf() async {
|
Future<void> _pickPdf() async {
|
||||||
final typeGroup = const fs.XTypeGroup(label: 'PDF', extensions: ['pdf']);
|
await widget.onPickPdf();
|
||||||
final file = await fs.openFile(acceptedTypeGroups: [typeGroup]);
|
|
||||||
if (file != null) {
|
|
||||||
Uint8List? bytes;
|
|
||||||
try {
|
|
||||||
bytes = await file.readAsBytes();
|
|
||||||
} catch (_) {
|
|
||||||
bytes = null;
|
|
||||||
}
|
|
||||||
ref
|
|
||||||
.read(documentRepositoryProvider.notifier)
|
|
||||||
.openPicked(path: file.path, bytes: bytes);
|
|
||||||
ref.read(signatureProvider.notifier).resetForNewPage();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -120,7 +112,7 @@ class _WelcomeScreenState extends ConsumerState<WelcomeScreen> {
|
||||||
final adapters = desktopFiles.map<DropReadable>(
|
final adapters = desktopFiles.map<DropReadable>(
|
||||||
(f) => _DropReadableFromDesktop(f),
|
(f) => _DropReadableFromDesktop(f),
|
||||||
);
|
);
|
||||||
await handleDroppedFiles(ref.read, adapters);
|
await handleDroppedFiles(widget.onOpenPdf, adapters);
|
||||||
},
|
},
|
||||||
child: AnimatedContainer(
|
child: AnimatedContainer(
|
||||||
duration: const Duration(milliseconds: 150),
|
duration: const Duration(milliseconds: 150),
|
||||||
|
|
|
||||||
|
|
@ -1,68 +1,68 @@
|
||||||
{
|
{
|
||||||
"images" : [
|
"info": {
|
||||||
{
|
"version": 1,
|
||||||
"size" : "16x16",
|
"author": "xcode"
|
||||||
"idiom" : "mac",
|
|
||||||
"filename" : "app_icon_16.png",
|
|
||||||
"scale" : "1x"
|
|
||||||
},
|
},
|
||||||
{
|
"images": [
|
||||||
"size" : "16x16",
|
{
|
||||||
"idiom" : "mac",
|
"size": "16x16",
|
||||||
"filename" : "app_icon_32.png",
|
"idiom": "mac",
|
||||||
"scale" : "2x"
|
"filename": "app_icon_16.png",
|
||||||
},
|
"scale": "1x"
|
||||||
{
|
},
|
||||||
"size" : "32x32",
|
{
|
||||||
"idiom" : "mac",
|
"size": "16x16",
|
||||||
"filename" : "app_icon_32.png",
|
"idiom": "mac",
|
||||||
"scale" : "1x"
|
"filename": "app_icon_32.png",
|
||||||
},
|
"scale": "2x"
|
||||||
{
|
},
|
||||||
"size" : "32x32",
|
{
|
||||||
"idiom" : "mac",
|
"size": "32x32",
|
||||||
"filename" : "app_icon_64.png",
|
"idiom": "mac",
|
||||||
"scale" : "2x"
|
"filename": "app_icon_32.png",
|
||||||
},
|
"scale": "1x"
|
||||||
{
|
},
|
||||||
"size" : "128x128",
|
{
|
||||||
"idiom" : "mac",
|
"size": "32x32",
|
||||||
"filename" : "app_icon_128.png",
|
"idiom": "mac",
|
||||||
"scale" : "1x"
|
"filename": "app_icon_64.png",
|
||||||
},
|
"scale": "2x"
|
||||||
{
|
},
|
||||||
"size" : "128x128",
|
{
|
||||||
"idiom" : "mac",
|
"size": "128x128",
|
||||||
"filename" : "app_icon_256.png",
|
"idiom": "mac",
|
||||||
"scale" : "2x"
|
"filename": "app_icon_128.png",
|
||||||
},
|
"scale": "1x"
|
||||||
{
|
},
|
||||||
"size" : "256x256",
|
{
|
||||||
"idiom" : "mac",
|
"size": "128x128",
|
||||||
"filename" : "app_icon_256.png",
|
"idiom": "mac",
|
||||||
"scale" : "1x"
|
"filename": "app_icon_256.png",
|
||||||
},
|
"scale": "2x"
|
||||||
{
|
},
|
||||||
"size" : "256x256",
|
{
|
||||||
"idiom" : "mac",
|
"size": "256x256",
|
||||||
"filename" : "app_icon_512.png",
|
"idiom": "mac",
|
||||||
"scale" : "2x"
|
"filename": "app_icon_256.png",
|
||||||
},
|
"scale": "1x"
|
||||||
{
|
},
|
||||||
"size" : "512x512",
|
{
|
||||||
"idiom" : "mac",
|
"size": "256x256",
|
||||||
"filename" : "app_icon_512.png",
|
"idiom": "mac",
|
||||||
"scale" : "1x"
|
"filename": "app_icon_512.png",
|
||||||
},
|
"scale": "2x"
|
||||||
{
|
},
|
||||||
"size" : "512x512",
|
{
|
||||||
"idiom" : "mac",
|
"size": "512x512",
|
||||||
"filename" : "app_icon_1024.png",
|
"idiom": "mac",
|
||||||
"scale" : "2x"
|
"filename": "app_icon_512.png",
|
||||||
}
|
"scale": "1x"
|
||||||
],
|
},
|
||||||
"info" : {
|
{
|
||||||
"version" : 1,
|
"size": "512x512",
|
||||||
"author" : "xcode"
|
"idiom": "mac",
|
||||||
}
|
"filename": "app_icon_1024.png",
|
||||||
|
"scale": "2x"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
Before Width: | Height: | Size: 101 KiB After Width: | Height: | Size: 8.1 KiB |
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 520 B After Width: | Height: | Size: 992 B |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 1.6 KiB |
25
pubspec.yaml
|
|
@ -57,6 +57,9 @@ dependencies:
|
||||||
share_plus: ^11.1.0
|
share_plus: ^11.1.0
|
||||||
logging: ^1.3.0
|
logging: ^1.3.0
|
||||||
riverpod_annotation: ^2.6.1
|
riverpod_annotation: ^2.6.1
|
||||||
|
colorfilter_generator: ^0.0.8
|
||||||
|
flutter_box_transform: ^0.4.7
|
||||||
|
# ml_linalg: ^13.12.6
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|
@ -70,6 +73,7 @@ dev_dependencies:
|
||||||
freezed: ^3.0.0
|
freezed: ^3.0.0
|
||||||
custom_lint: ^0.7.6
|
custom_lint: ^0.7.6
|
||||||
riverpod_lint: ^2.6.5
|
riverpod_lint: ^2.6.5
|
||||||
|
go_router_builder: ^4.0.1
|
||||||
|
|
||||||
# The "flutter_lints" package below contains a set of recommended lints to
|
# The "flutter_lints" package below contains a set of recommended lints to
|
||||||
# encourage good coding practices. The lint set provided by the package is
|
# encourage good coding practices. The lint set provided by the package is
|
||||||
|
|
@ -80,6 +84,8 @@ dev_dependencies:
|
||||||
msix: ^3.16.12
|
msix: ^3.16.12
|
||||||
json_serializable: ^6.11.0
|
json_serializable: ^6.11.0
|
||||||
dead_code_analyzer: ^1.1.0
|
dead_code_analyzer: ^1.1.0
|
||||||
|
faker_dart: ^0.2.3
|
||||||
|
flutter_launcher_icons: "^0.14.4"
|
||||||
|
|
||||||
# For information on the generic Dart part of this file, see the
|
# For information on the generic Dart part of this file, see the
|
||||||
# following page: https://dart.dev/tools/pub/pubspec
|
# following page: https://dart.dev/tools/pub/pubspec
|
||||||
|
|
@ -123,3 +129,22 @@ flutter:
|
||||||
#
|
#
|
||||||
# For details regarding fonts from package dependencies,
|
# For details regarding fonts from package dependencies,
|
||||||
# see https://flutter.dev/to/font-from-package
|
# see https://flutter.dev/to/font-from-package
|
||||||
|
|
||||||
|
|
||||||
|
flutter_launcher_icons:
|
||||||
|
android: "launcher_icon"
|
||||||
|
ios: true
|
||||||
|
image_path: "assets/icon/pdf_signature-icon.png"
|
||||||
|
min_sdk_android: 21 # android min sdk min:16, default 21
|
||||||
|
web:
|
||||||
|
generate: true
|
||||||
|
image_path: "assets/icon/pdf_signature-icon.png"
|
||||||
|
background_color: "#hexcode"
|
||||||
|
theme_color: "#hexcode"
|
||||||
|
windows:
|
||||||
|
generate: true
|
||||||
|
image_path: "assets/icon/pdf_signature-icon.png"
|
||||||
|
icon_size: 48 # min:48, max:256, default: 48
|
||||||
|
macos:
|
||||||
|
generate: true
|
||||||
|
image_path: "assets/icon/pdf_signature-icon.png"
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,63 @@
|
||||||
|
import 'dart:typed_data';
|
||||||
|
import 'dart:ui';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:pdf_signature/ui/features/pdf/view_model/pdf_view_model.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
import 'package:pdf_signature/app.dart';
|
||||||
|
import 'package:pdf_signature/data/repositories/preferences_repository.dart';
|
||||||
|
import 'package:pdf_signature/data/repositories/document_repository.dart';
|
||||||
|
import 'package:pdf_signature/ui/features/pdf/widgets/ui_services.dart';
|
||||||
|
import 'package:pdf_signature/data/services/export_service.dart';
|
||||||
|
import 'package:pdf_signature/domain/models/model.dart';
|
||||||
|
|
||||||
|
class FakeExportService extends ExportService {
|
||||||
|
bool exported = false;
|
||||||
|
@override
|
||||||
|
Future<Uint8List?> exportSignedPdfFromBytes({
|
||||||
|
Map<String, Uint8List>? libraryBytes,
|
||||||
|
required Uint8List srcBytes,
|
||||||
|
required Size uiPageSize,
|
||||||
|
required Uint8List? signatureImageBytes,
|
||||||
|
Map<int, List<SignaturePlacement>>? placementsByPage,
|
||||||
|
double targetDpi = 144.0,
|
||||||
|
}) async => Uint8List.fromList([1, 2, 3]);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> saveBytesToFile({
|
||||||
|
required Uint8List bytes,
|
||||||
|
required String outputPath,
|
||||||
|
}) async {
|
||||||
|
exported = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<ProviderContainer> pumpApp(
|
||||||
|
WidgetTester tester, {
|
||||||
|
Map<String, Object> initialPrefs = const {},
|
||||||
|
}) async {
|
||||||
|
SharedPreferences.setMockInitialValues(initialPrefs);
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final fakeExport = FakeExportService();
|
||||||
|
final container = ProviderContainer(
|
||||||
|
overrides: [
|
||||||
|
preferencesRepositoryProvider.overrideWith(
|
||||||
|
(ref) => PreferencesStateNotifier(prefs),
|
||||||
|
),
|
||||||
|
documentRepositoryProvider.overrideWith(
|
||||||
|
(ref) => DocumentStateNotifier()..openSample(),
|
||||||
|
),
|
||||||
|
pdfViewModelProvider.overrideWith(
|
||||||
|
(ref) => PdfViewModel(ref, useMockViewer: true),
|
||||||
|
),
|
||||||
|
exportServiceProvider.overrideWith((ref) => fakeExport),
|
||||||
|
savePathPickerProvider.overrideWith((ref) => () async => 'out.pdf'),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
await tester.pumpWidget(
|
||||||
|
UncontrolledProviderScope(container: container, child: const MyApp()),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
return container;
|
||||||
|
}
|
||||||
|
|
@ -2,10 +2,24 @@ import 'dart:typed_data';
|
||||||
import 'dart:ui';
|
import 'dart:ui';
|
||||||
|
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
/// A tiny shared world for BDD steps to share state within a scenario.
|
/// A tiny shared world for BDD steps to share state within a scenario.
|
||||||
class TestWorld {
|
class TestWorld {
|
||||||
static ProviderContainer? container;
|
static ProviderContainer? _container;
|
||||||
|
static ProviderContainer? get container => _container;
|
||||||
|
static set container(ProviderContainer? value) {
|
||||||
|
_container = value;
|
||||||
|
if (value != null) {
|
||||||
|
// Ensure any container created during a test is disposed at teardown
|
||||||
|
addTearDown(() {
|
||||||
|
try {
|
||||||
|
_container?.dispose();
|
||||||
|
} catch (_) {}
|
||||||
|
_container = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Signature helpers
|
// Signature helpers
|
||||||
static Offset? prevCenter;
|
static Offset? prevCenter;
|
||||||
|
|
@ -103,9 +117,7 @@ class MockSignatureNotifier extends StateNotifier<MockSignatureState> {
|
||||||
contrast: state.contrast,
|
contrast: state.contrast,
|
||||||
brightness: state.brightness,
|
brightness: state.brightness,
|
||||||
);
|
);
|
||||||
// Mock processing: just set the processed image to the same bytes
|
// Processing now happens locally in widgets, not stored in repository
|
||||||
TestWorld.container?.read(processedSignatureImageProvider.notifier).state =
|
|
||||||
bytes;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void setBgRemoval(bool value) {
|
void setBgRemoval(bool value) {
|
||||||
|
|
@ -117,6 +129,7 @@ class MockSignatureNotifier extends StateNotifier<MockSignatureState> {
|
||||||
contrast: state.contrast,
|
contrast: state.contrast,
|
||||||
brightness: state.brightness,
|
brightness: state.brightness,
|
||||||
);
|
);
|
||||||
|
// Processing now happens locally in widgets
|
||||||
}
|
}
|
||||||
|
|
||||||
void clearImage() {
|
void clearImage() {
|
||||||
|
|
@ -139,6 +152,7 @@ class MockSignatureNotifier extends StateNotifier<MockSignatureState> {
|
||||||
contrast: value,
|
contrast: value,
|
||||||
brightness: state.brightness,
|
brightness: state.brightness,
|
||||||
);
|
);
|
||||||
|
// Processing now happens locally in widgets
|
||||||
}
|
}
|
||||||
|
|
||||||
void setBrightness(double value) {
|
void setBrightness(double value) {
|
||||||
|
|
@ -150,6 +164,7 @@ class MockSignatureNotifier extends StateNotifier<MockSignatureState> {
|
||||||
contrast: state.contrast,
|
contrast: state.contrast,
|
||||||
brightness: value,
|
brightness: value,
|
||||||
);
|
);
|
||||||
|
// Processing now happens locally in widgets
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -162,6 +177,3 @@ final signatureProvider =
|
||||||
final currentRectProvider = StateProvider<Rect?>((ref) => null);
|
final currentRectProvider = StateProvider<Rect?>((ref) => null);
|
||||||
final editingEnabledProvider = StateProvider<bool>((ref) => false);
|
final editingEnabledProvider = StateProvider<bool>((ref) => false);
|
||||||
final aspectLockedProvider = StateProvider<bool>((ref) => false);
|
final aspectLockedProvider = StateProvider<bool>((ref) => false);
|
||||||
final processedSignatureImageProvider = StateProvider<Uint8List?>(
|
|
||||||
(ref) => null,
|
|
||||||
);
|
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ Future<void> aDocumentIsOpenAndContainsAtLeastOneSignaturePlacement(
|
||||||
TestWorld.container = container;
|
TestWorld.container = container;
|
||||||
container
|
container
|
||||||
.read(documentRepositoryProvider.notifier)
|
.read(documentRepositoryProvider.notifier)
|
||||||
.openPicked(path: 'test.pdf', pageCount: 5);
|
.openPicked(pageCount: 5);
|
||||||
container
|
container
|
||||||
.read(documentRepositoryProvider.notifier)
|
.read(documentRepositoryProvider.notifier)
|
||||||
.addPlacement(
|
.addPlacement(
|
||||||
|
|
|
||||||
|
|
@ -13,28 +13,29 @@ aDocumentIsOpenAndContainsMultiplePlacedSignaturePlacementsAcrossPages(
|
||||||
) async {
|
) async {
|
||||||
final container = TestWorld.container ?? ProviderContainer();
|
final container = TestWorld.container ?? ProviderContainer();
|
||||||
TestWorld.container = container;
|
TestWorld.container = container;
|
||||||
container
|
container.read(documentRepositoryProvider.notifier).openPicked(pageCount: 5);
|
||||||
.read(documentRepositoryProvider.notifier)
|
|
||||||
.openPicked(path: 'multi.pdf', pageCount: 5);
|
|
||||||
container
|
container
|
||||||
.read(documentRepositoryProvider.notifier)
|
.read(documentRepositoryProvider.notifier)
|
||||||
.addPlacement(
|
.addPlacement(
|
||||||
page: 1,
|
page: 1,
|
||||||
rect: Rect.fromLTWH(10, 10, 100, 50),
|
rect: Rect.fromLTWH(0.1, 0.1, 0.2, 0.1),
|
||||||
asset: SignatureAsset(bytes: Uint8List(0), name: 'sig1.png'),
|
asset: SignatureAsset(bytes: Uint8List(0), name: 'sig1.png'),
|
||||||
);
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
container
|
container
|
||||||
.read(documentRepositoryProvider.notifier)
|
.read(documentRepositoryProvider.notifier)
|
||||||
.addPlacement(
|
.addPlacement(
|
||||||
page: 2,
|
page: 2,
|
||||||
rect: Rect.fromLTWH(20, 20, 100, 50),
|
rect: Rect.fromLTWH(0.2, 0.2, 0.2, 0.1),
|
||||||
asset: SignatureAsset(bytes: Uint8List(0), name: 'sig2.png'),
|
asset: SignatureAsset(bytes: Uint8List(0), name: 'sig2.png'),
|
||||||
);
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
container
|
container
|
||||||
.read(documentRepositoryProvider.notifier)
|
.read(documentRepositoryProvider.notifier)
|
||||||
.addPlacement(
|
.addPlacement(
|
||||||
page: 3,
|
page: 3,
|
||||||
rect: Rect.fromLTWH(30, 30, 100, 50),
|
rect: Rect.fromLTWH(0.3, 0.3, 0.2, 0.1),
|
||||||
asset: SignatureAsset(bytes: Uint8List(0), name: 'sig3.png'),
|
asset: SignatureAsset(bytes: Uint8List(0), name: 'sig3.png'),
|
||||||
);
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,6 @@ Future<void> aDocumentIsOpenWithNoSignaturePlacementsPlaced(
|
||||||
TestWorld.container = container;
|
TestWorld.container = container;
|
||||||
container
|
container
|
||||||
.read(documentRepositoryProvider.notifier)
|
.read(documentRepositoryProvider.notifier)
|
||||||
.openPicked(path: 'empty.pdf', pageCount: 5);
|
.openPicked(pageCount: 5);
|
||||||
// No placements added
|
// No placements added
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,21 @@
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:pdf_signature/data/repositories/document_repository.dart';
|
import 'package:pdf_signature/data/repositories/document_repository.dart';
|
||||||
|
import 'package:pdf_signature/ui/features/pdf/view_model/pdf_view_model.dart';
|
||||||
import '_world.dart';
|
import '_world.dart';
|
||||||
|
|
||||||
/// Usage: a document page is selected for signing
|
/// Usage: a document page is selected for signing
|
||||||
Future<void> aDocumentPageIsSelectedForSigning(WidgetTester tester) async {
|
Future<void> aDocumentPageIsSelectedForSigning(WidgetTester tester) async {
|
||||||
final container = TestWorld.container ?? ProviderContainer();
|
final container = TestWorld.container ?? ProviderContainer();
|
||||||
TestWorld.container = container;
|
TestWorld.container = container;
|
||||||
container.read(documentRepositoryProvider.notifier).jumpTo(1);
|
// Ensure a document is open
|
||||||
|
final repo = container.read(documentRepositoryProvider.notifier);
|
||||||
|
if (!container.read(documentRepositoryProvider).loaded) {
|
||||||
|
repo.openPicked(pageCount: 5);
|
||||||
|
}
|
||||||
|
// Ensure current page is 1 for consistent subsequent steps
|
||||||
|
try {
|
||||||
|
container.read(pdfViewModelProvider.notifier).jumpToPage(1);
|
||||||
|
} catch (_) {}
|
||||||
|
repo.jumpTo(1);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,31 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import '../_test_helper.dart';
|
||||||
import '_world.dart';
|
import '_world.dart';
|
||||||
|
|
||||||
/// Usage: a drawn signature exists in the canvas
|
/// Usage: a drawn signature exists in the canvas
|
||||||
Future<void> aDrawnSignatureExistsInTheCanvas(WidgetTester tester) async {
|
Future<void> aDrawnSignatureExistsInTheCanvas(WidgetTester tester) async {
|
||||||
final container = TestWorld.container ?? ProviderContainer();
|
// Tap the draw signature button to open the dialog
|
||||||
final sigN = container.read(signatureProvider.notifier);
|
if (find.byType(MaterialApp).evaluate().isEmpty) {
|
||||||
sigN.setStrokes([
|
final container = await pumpApp(tester);
|
||||||
[const Offset(0, 0), const Offset(1, 1)],
|
TestWorld.container = container;
|
||||||
]);
|
}
|
||||||
|
// Ensure button exists
|
||||||
|
expect(find.byKey(const Key('btn_drawer_draw_signature')), findsOneWidget);
|
||||||
|
await tester.tap(find.byKey(const Key('btn_drawer_draw_signature')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Now the DrawCanvas dialog should be open
|
||||||
|
expect(find.byKey(const Key('draw_canvas')), findsOneWidget);
|
||||||
|
|
||||||
|
// Simulate drawing strokes on the canvas
|
||||||
|
final canvas = find.byKey(const Key('hand_signature_pad'));
|
||||||
|
expect(canvas, findsOneWidget);
|
||||||
|
|
||||||
|
// Draw a simple stroke
|
||||||
|
await tester.drag(canvas, const Offset(50, 50));
|
||||||
|
await tester.drag(canvas, const Offset(100, 100));
|
||||||
|
await tester.drag(canvas, const Offset(150, 150));
|
||||||
|
|
||||||
|
// Do not confirm, so the canvas has strokes but is not closed
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@ import 'package:pdf_signature/data/repositories/document_repository.dart';
|
||||||
import 'package:pdf_signature/data/repositories/signature_card_repository.dart';
|
import 'package:pdf_signature/data/repositories/signature_card_repository.dart';
|
||||||
import 'package:pdf_signature/data/repositories/signature_asset_repository.dart';
|
import 'package:pdf_signature/data/repositories/signature_asset_repository.dart';
|
||||||
import 'package:pdf_signature/domain/models/model.dart';
|
import 'package:pdf_signature/domain/models/model.dart';
|
||||||
|
|
||||||
|
import 'package:pdf_signature/ui/features/pdf/view_model/pdf_view_model.dart';
|
||||||
import '_world.dart';
|
import '_world.dart';
|
||||||
|
|
||||||
/// Usage: a multi-page document is open
|
/// Usage: a multi-page document is open
|
||||||
|
|
@ -13,10 +15,15 @@ Future<void> aMultipageDocumentIsOpen(WidgetTester tester) async {
|
||||||
container.read(signatureAssetRepositoryProvider.notifier).state = [];
|
container.read(signatureAssetRepositoryProvider.notifier).state = [];
|
||||||
container.read(documentRepositoryProvider.notifier).state =
|
container.read(documentRepositoryProvider.notifier).state =
|
||||||
Document.initial();
|
Document.initial();
|
||||||
container.read(signatureCardProvider.notifier).state = [
|
container.read(signatureCardRepositoryProvider.notifier).state = [
|
||||||
SignatureCard.initial(),
|
CachedSignatureCard.initial(),
|
||||||
];
|
];
|
||||||
container
|
container.read(documentRepositoryProvider.notifier).openPicked(pageCount: 5);
|
||||||
.read(documentRepositoryProvider.notifier)
|
// Reset page state providers
|
||||||
.openPicked(path: 'mock.pdf', pageCount: 5);
|
try {
|
||||||
|
container.read(pdfViewModelProvider.notifier).jumpToPage(1);
|
||||||
|
} catch (_) {}
|
||||||
|
try {
|
||||||
|
container.read(pdfViewModelProvider.notifier).jumpToPage(1);
|
||||||
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
|
|
|
||||||