Compare commits

...

22 Commits

Author SHA1 Message Date
insleker 0f7d840e48 feat: enhance PDF thumbnail navigation and selection logic 2025-09-18 18:08:33 +08:00
insleker 41eea5f00c feat: change app icon 2025-09-18 16:30:57 +08:00
insleker 5ad4d6136f feat: add locking and unlocking functionality for signature placements 2025-09-18 14:44:47 +08:00
insleker 69d5a9a248 feat: implement signature drag-and-drop functionality and enhance PDF page overlays 2025-09-18 12:50:14 +08:00
insleker 2043bfc14c feat: enhance signature img processing performance 2025-09-18 00:14:56 +08:00
insleker feaf7aee9f refactor: update PDF view model and routing for improved session management 2025-09-17 20:46:11 +08:00
insleker 6652de28bf feat: add zoom level listener and scroll thumbs to PDF viewer 2025-09-17 17:03:07 +08:00
insleker 994c1b2569 fix: DrawCanvas create signatureCard functionality 2025-09-17 14:51:16 +08:00
insleker 26a0c93390 feat: implement image processing and caching in signatureCard
repository
2025-09-17 08:16:31 +08:00
insleker 80cf115ab3 feat: add background remove feature in image editor dialog 2025-09-15 20:09:27 +08:00
insleker 8f3039f99e fix: graphic adjust dialog has to show image preview 2025-09-12 22:44:00 +08:00
insleker 461c8f6ae5 feat: pass base test of viewmodel API migration 2025-09-12 21:40:00 +08:00
insleker 5549f08b4c feat: migrate pdf state to viewmodel abstraction 2025-09-12 18:59:27 +08:00
insleker 7336ca4d57 fix: thumbnail not shown actualy pdf page 2025-09-12 12:29:23 +08:00
insleker c82bb7fa2a feat: pass widget test 2025-09-12 08:19:03 +08:00
insleker 00e2e1deb4 feat: pass base test after document API change 2025-09-11 22:04:37 +08:00
insleker c46aca1331 feat: remove currentPage in Document model 2025-09-11 20:54:31 +08:00
insleker 545d3ad688 fix: signature card repository wrong API 2025-09-11 17:52:50 +08:00
insleker 4d2cd09adf feat: partially implement new view of UI 2025-09-11 00:13:47 +08:00
insleker 189bc7e6e6 feat: partially implement integration test 2025-09-10 22:17:36 +08:00
insleker f0a8e25890 feat: partially implement UI widget and implement test 2025-09-10 21:55:02 +08:00
insleker b0a3ff1f57 feat: partially implement new ui widget 2025-09-10 20:22:36 +08:00
177 changed files with 5555 additions and 2028 deletions

View File

@ -1,27 +1,52 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="UTF-8"?>
<svg
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
viewBox="0 0 64 64"
width="64"
height="64"
role="img"
aria-labelledby="title desc"
>
<title id="title">PDF Signature</title>
<desc id="desc">An app icon showing a PDF page with a folded corner and a handwritten signature.</desc>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"> <!-- Background tile -->
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools --> <rect x="4" y="4" width="56" height="56" rx="12" fill="#2563EB" />
<svg version="1.1" id="_x32_" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
viewBox="0 0 512 512" xml:space="preserve"> <!-- Paper with folded corner -->
<style type="text/css"> <g>
.st0{fill:#000000;} <path
</style> d="M20 16h18l10 10v22c0 2.2-1.8 4-4 4H20c-2.2 0-4-1.8-4-4V20c0-2.2 1.8-4 4-4z"
<g> fill="#FFFFFF"
<path class="st0" d="M56.007,114.35c-5.535-5.539-14.51-5.539-20.045,0L4.148,146.159c-5.531,5.539-5.531,14.506,0,20.046 />
l20.622,20.621l51.859-51.855L56.007,114.35z"/> <path
<polygon class="st0" points="286.422,396.623 268.742,327.077 216.884,378.94 "/> d="M38 16v8c0 3.3 2.7 6 6 6h8l-14-14z"
<path class="st0" d="M258.136,316.475L86.058,144.397L34.2,196.26l172.073,172.077L258.136,316.475z M87.468,166.56 fill="#F3F4F6"
l149.919,149.922l-11.784,11.78L75.684,178.348L87.468,166.56z"/> />
<rect x="195.662" y="132.491" class="st0" width="29.356" height="28.017"/> </g>
<rect x="195.662" y="200.693" class="st0" width="29.356" height="28.009"/>
<rect x="256.69" y="132.491" class="st0" width="173.056" height="28.017"/> <!-- Signature stroke -->
<rect x="256.69" y="200.693" class="st0" width="173.056" height="28.009"/> <path
<rect x="288.598" y="268.894" class="st0" width="141.148" height="28.01"/> d="M18 42c3-2 6-2.2 8.5 0 2.5 2.2 4.8 1.8 8.2-1.2 3.4-3 6.9-5.3 9.4-2.8 1.2 1.2 0.5 3.2-1.2 3.6-3.5 0.9 3.3-6.8 6.4-4.6 2 1.4-1.5 6.7-4.8 7.8-4.6 1.6-10.9-0.6-13.8-0.6-4.4 0-7.5 2.4-12 2.8"
<path class="st0" d="M429.817,11.059H195.582c-45.32,0-82.182,36.858-82.182,82.179v32.726l30.427,30.435V93.238 fill="none"
c0-28.586,23.178-51.752,51.755-51.752h234.235c28.594,0,51.756,23.166,51.756,51.752v254.042h-80.097 stroke="#1F2937"
c-23.822,0-43.124,19.318-43.124,43.132v80.101h-162.77c-28.578,0-51.755-23.166-51.755-51.752v-37.072l-6.234-1.587l6.234-6.235 stroke-width="2.5"
v-22.202L113.4,321.239v97.522c0,45.313,36.862,82.179,82.182,82.179h162.77h12.598l8.917-8.913l123.224-123.224l8.909-8.912 stroke-linecap="round"
v-12.61V93.238C512,47.917,475.138,11.059,429.817,11.059z"/> stroke-linejoin="round"
</g> />
<!-- Subtle page shadow for depth (kept minimal for clarity) -->
<path
d="M20 16h18l10 10v1H44c-4.4 0-8-3.6-8-8v-3H20c-1.1 0-2 .9-2 2v0c0-1.1.9-2 2-2z"
fill="#000"
opacity=".05"
/>
<!-- Optional PDF label dots (very subtle) -->
<g fill="#E53935" opacity=".9">
<circle cx="24" cy="28" r="1" />
<circle cx="28" cy="28" r="1" />
<circle cx="32" cy="28" r="1" />
</g>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

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

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

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

View File

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

View File

@ -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",

View File

@ -12,6 +12,7 @@ Refs:
## Welcome / First screen ## Welcome / First screen
Purpose: let the user open a PDF quickly via drag & drop or file picker. Purpose: let the user open a PDF quickly via drag & drop or file picker.
Route: root Route: root
Design notes: Design notes:
@ -29,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):

Binary file not shown.

View File

@ -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);
});
} }

View File

@ -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);
});
}

View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 295 B

After

Width:  |  Height:  |  Size: 1012 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 B

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 450 B

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 282 B

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 462 B

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 704 B

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 B

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 586 B

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 862 B

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 862 B

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 762 B

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

@ -2,11 +2,9 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_localized_locales/flutter_localized_locales.dart'; import 'package:flutter_localized_locales/flutter_localized_locales.dart';
import 'package:pdf_signature/l10n/app_localizations.dart'; import 'package:pdf_signature/l10n/app_localizations.dart';
import 'package:pdf_signature/ui/features/pdf/widgets/pdf_screen.dart'; import 'package:pdf_signature/routing/router.dart';
import 'package:pdf_signature/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();
}
}

View File

@ -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,

View File

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

View File

@ -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;
}
} }

View File

@ -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;
}
}

View File

@ -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,
); );

View File

@ -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;
} }

View File

@ -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;
}
} }

View File

@ -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"
} }

View File

@ -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": {}
} }

View File

@ -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"
} }

View File

@ -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"
} }

View File

@ -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": "ロック解除"
} }

View File

@ -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": "잠금 해제"
} }

View File

@ -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": "Відмкнути"
} }

View File

@ -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": "解锁"
} }

View File

@ -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": "解锁"
} }

View File

@ -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": "解鎖"
} }

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

@ -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;
});

View File

@ -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);
});

View File

@ -1,17 +1,30 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:pdf_signature/l10n/app_localizations.dart'; import 'package:pdf_signature/l10n/app_localizations.dart';
import '../../../../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),
), ),
], ],
), ),

View File

@ -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(

View File

@ -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,
),
),
],
),
],
),
),
),
),
);
}
}

View File

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

View File

@ -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),
),
),
),
),
],
);
},
),
],
), ),
], ],
), ),

View File

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

View File

@ -1,10 +1,12 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:pdf_signature/ui/features/pdf/view_model/pdf_view_model.dart';
import 'package:pdf_signature/data/repositories/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(),
),
),
),
],
);
},
), ),
); );
} }

View File

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

View File

@ -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) {}
}

View File

@ -3,18 +3,19 @@ import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:pdf_signature/l10n/app_localizations.dart'; import 'package:pdf_signature/l10n/app_localizations.dart';
import '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,
), ),

View File

@ -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,
),
];
},
),
);
}
}

View File

@ -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);
}
} }

View File

@ -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({

View File

@ -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
};
});

View File

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

View File

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

View File

@ -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);
});

View File

@ -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,
),
),
],
),
],
),
),
),
),
);
}
}

View File

@ -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) {

View File

@ -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,
),
), ),
), ),
), ),

View File

@ -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});
} }

View File

@ -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),

View File

@ -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);
});

View File

@ -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),

View File

@ -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"
}
]
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 520 B

After

Width:  |  Height:  |  Size: 992 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@ -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"

View File

@ -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;
}

View File

@ -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,
);

View File

@ -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(

View File

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

View File

@ -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
} }

View File

@ -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);
} }

View File

@ -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
} }

View File

@ -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 (_) {}
} }

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