Compare commits
8 Commits
2043bfc14c
...
0c38178502
| Author | SHA1 | Date |
|---|---|---|
|
|
0c38178502 | |
|
|
eee75f6fdb | |
|
|
34f6abad32 | |
|
|
0f7d840e48 | |
|
|
a08f93e8d4 | |
|
|
41eea5f00c | |
|
|
5ad4d6136f | |
|
|
69d5a9a248 |
|
|
@ -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>
|
/>
|
||||||
</svg>
|
|
||||||
|
<!-- Subtle page shadow for depth (kept minimal for clarity) -->
|
||||||
|
<path
|
||||||
|
d="M20 16h18l10 10v1H44c-4.4 0-8-3.6-8-8v-3H20c-1.1 0-2 .9-2 2v0c0-1.1.9-2 2-2z"
|
||||||
|
fill="#000"
|
||||||
|
opacity=".05"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Optional PDF label dots (very subtle) -->
|
||||||
|
<g fill="#E53935" opacity=".9">
|
||||||
|
<circle cx="24" cy="28" r="1" />
|
||||||
|
<circle cx="28" cy="28" r="1" />
|
||||||
|
<circle cx="32" cy="28" r="1" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.4 KiB |
|
|
@ -23,6 +23,7 @@ flutter analyze
|
||||||
flutter test
|
flutter test
|
||||||
# > run integration tests
|
# > run integration tests
|
||||||
flutter test integration_test/ -d <device_id>
|
flutter test integration_test/ -d <device_id>
|
||||||
|
# dart run tool/run_integration_tests.dart --device=linux
|
||||||
|
|
||||||
# dart run tool/gen_view_wireframe_md.dart
|
# dart run tool/gen_view_wireframe_md.dart
|
||||||
# flutter pub run dead_code_analyzer
|
# flutter pub run dead_code_analyzer
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
<application
|
<application
|
||||||
android:label="pdf_signature"
|
android:label="pdf_signature"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
android:icon="@mipmap/ic_launcher">
|
android:icon="@mipmap/launcher_icon">
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
|
|
|
||||||
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
|
@ -0,0 +1,52 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||||
|
viewBox="0 0 64 64"
|
||||||
|
width="64"
|
||||||
|
height="64"
|
||||||
|
role="img"
|
||||||
|
aria-labelledby="title desc"
|
||||||
|
>
|
||||||
|
<title id="title">PDF Signature</title>
|
||||||
|
<desc id="desc">An app icon showing a PDF page with a folded corner and a handwritten signature.</desc>
|
||||||
|
|
||||||
|
<!-- Background tile -->
|
||||||
|
<rect x="4" y="4" width="56" height="56" rx="12" fill="#2563EB" />
|
||||||
|
|
||||||
|
<!-- Paper with folded corner -->
|
||||||
|
<g>
|
||||||
|
<path
|
||||||
|
d="M20 16h18l10 10v22c0 2.2-1.8 4-4 4H20c-2.2 0-4-1.8-4-4V20c0-2.2 1.8-4 4-4z"
|
||||||
|
fill="#FFFFFF"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M38 16v8c0 3.3 2.7 6 6 6h8l-14-14z"
|
||||||
|
fill="#F3F4F6"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Signature stroke -->
|
||||||
|
<path
|
||||||
|
d="M18 42c3-2 6-2.2 8.5 0 2.5 2.2 4.8 1.8 8.2-1.2 3.4-3 6.9-5.3 9.4-2.8 1.2 1.2 0.5 3.2-1.2 3.6-3.5 0.9 3.3-6.8 6.4-4.6 2 1.4-1.5 6.7-4.8 7.8-4.6 1.6-10.9-0.6-13.8-0.6-4.4 0-7.5 2.4-12 2.8"
|
||||||
|
fill="none"
|
||||||
|
stroke="#1F2937"
|
||||||
|
stroke-width="2.5"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Subtle page shadow for depth (kept minimal for clarity) -->
|
||||||
|
<path
|
||||||
|
d="M20 16h18l10 10v1H44c-4.4 0-8-3.6-8-8v-3H20c-1.1 0-2 .9-2 2v0c0-1.1.9-2 2-2z"
|
||||||
|
fill="#000"
|
||||||
|
opacity=".05"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Optional PDF label dots (very subtle) -->
|
||||||
|
<g fill="#E53935" opacity=".9">
|
||||||
|
<circle cx="24" cy="28" r="1" />
|
||||||
|
<circle cx="28" cy="28" r="1" />
|
||||||
|
<circle cx="32" cy="28" r="1" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.4 KiB |
|
|
@ -396,75 +396,6 @@
|
||||||
"link": null,
|
"link": null,
|
||||||
"locked": false
|
"locked": false
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"id": "P2kfltnFMgp1Hpns5eRsk",
|
|
||||||
"type": "text",
|
|
||||||
"x": 109.57327992864577,
|
|
||||||
"y": 337.2651308292386,
|
|
||||||
"width": 88.30944720085046,
|
|
||||||
"height": 24.379859477817877,
|
|
||||||
"angle": 0,
|
|
||||||
"strokeColor": "#374151",
|
|
||||||
"backgroundColor": "#ffffff",
|
|
||||||
"fillStyle": "solid",
|
|
||||||
"strokeWidth": 1,
|
|
||||||
"strokeStyle": "solid",
|
|
||||||
"roughness": 1,
|
|
||||||
"opacity": 100,
|
|
||||||
"groupIds": [
|
|
||||||
"nQmqS53zA9IffPy8AAZwV"
|
|
||||||
],
|
|
||||||
"frameId": null,
|
|
||||||
"index": "a9",
|
|
||||||
"roundness": null,
|
|
||||||
"seed": 1154314520,
|
|
||||||
"version": 112,
|
|
||||||
"versionNonce": 1095921782,
|
|
||||||
"isDeleted": false,
|
|
||||||
"boundElements": [],
|
|
||||||
"updated": 1756647235527,
|
|
||||||
"link": null,
|
|
||||||
"locked": false,
|
|
||||||
"text": "Page view:",
|
|
||||||
"fontSize": 18.059155168753982,
|
|
||||||
"fontFamily": 6,
|
|
||||||
"textAlign": "left",
|
|
||||||
"verticalAlign": "top",
|
|
||||||
"containerId": null,
|
|
||||||
"originalText": "Page view:",
|
|
||||||
"autoResize": true,
|
|
||||||
"lineHeight": 1.35
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "vmM82c6vkYHi9E8_orBEx",
|
|
||||||
"type": "rectangle",
|
|
||||||
"x": 233.72997171382946,
|
|
||||||
"y": 328.23555324486165,
|
|
||||||
"width": 338.60915941413714,
|
|
||||||
"height": 36.118310337507964,
|
|
||||||
"angle": 0,
|
|
||||||
"strokeColor": "#6b7280",
|
|
||||||
"backgroundColor": "#ffffff",
|
|
||||||
"fillStyle": "solid",
|
|
||||||
"strokeWidth": 1,
|
|
||||||
"strokeStyle": "solid",
|
|
||||||
"roughness": 1,
|
|
||||||
"opacity": 100,
|
|
||||||
"groupIds": [
|
|
||||||
"nQmqS53zA9IffPy8AAZwV"
|
|
||||||
],
|
|
||||||
"frameId": null,
|
|
||||||
"index": "aA",
|
|
||||||
"roundness": null,
|
|
||||||
"seed": 288329240,
|
|
||||||
"version": 110,
|
|
||||||
"versionNonce": 128154090,
|
|
||||||
"isDeleted": false,
|
|
||||||
"boundElements": [],
|
|
||||||
"updated": 1756647235527,
|
|
||||||
"link": null,
|
|
||||||
"locked": false
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"id": "Q0v5ejctIV2msui0iDFEg",
|
"id": "Q0v5ejctIV2msui0iDFEg",
|
||||||
"type": "rectangle",
|
"type": "rectangle",
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ Refs:
|
||||||
## Welcome / First screen
|
## Welcome / First screen
|
||||||
|
|
||||||
Purpose: let the user open a PDF quickly via drag & drop or file picker.
|
Purpose: let the user open a PDF quickly via drag & drop or file picker.
|
||||||
|
|
||||||
Route: root
|
Route: root
|
||||||
|
|
||||||
Design notes:
|
Design notes:
|
||||||
|
|
@ -29,8 +30,8 @@ Purpose: provide basic configuration before/after opening a PDF.
|
||||||
Route: root --> settings
|
Route: root --> settings
|
||||||
|
|
||||||
Design notes:
|
Design notes:
|
||||||
- Opened via "Configure" button in the top bar.
|
- Opened via "Configure" button in the right of top bar.
|
||||||
- Modal with simple sections (e.g., General, Display).
|
- Model with simple sections (e.g., General, Display).
|
||||||
- Primary action to save, secondary to cancel.
|
- Primary action to save, secondary to cancel.
|
||||||
|
|
||||||
Illustration:
|
Illustration:
|
||||||
|
|
@ -61,6 +62,7 @@ Design notes:
|
||||||
- There is an empty card with "new signature" prompt and 2 buttons: "from file" and "draw".
|
- There is an empty card with "new signature" prompt and 2 buttons: "from file" and "draw".
|
||||||
- "from file" opens a file picker to select an image as a signature card.
|
- "from file" opens a file picker to select an image as a signature card.
|
||||||
- "draw" opens a simple drawing interface (draw canvas) to create a signature card.
|
- "draw" opens a simple drawing interface (draw canvas) to create a signature card.
|
||||||
|
- There is a button at bottom to export PDF with placed signatures.
|
||||||
- Interaction: drag a signature card from the right drawer onto the currently visible page to place it.
|
- Interaction: drag a signature card from the right drawer onto the currently visible page to place it.
|
||||||
|
|
||||||
Signature controls (after placing on page):
|
Signature controls (after placing on page):
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ 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/domain/models/model.dart';
|
||||||
import 'package:pdf_signature/ui/features/pdf/view_model/pdf_view_model.dart';
|
import 'package:pdf_signature/ui/features/pdf/view_model/pdf_view_model.dart';
|
||||||
import 'package:pdf_signature/ui/features/pdf/widgets/ui_services.dart';
|
import 'package:pdf_signature/ui/features/pdf/view_model/pdf_export_view_model.dart';
|
||||||
import 'package:pdf_signature/ui/features/pdf/widgets/pages_sidebar.dart';
|
import 'package:pdf_signature/ui/features/pdf/widgets/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:shared_preferences/shared_preferences.dart';
|
||||||
|
|
@ -30,6 +30,30 @@ class RecordingExporter extends ExportService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Lightweight fake exporter to avoid invoking heavy rasterization during tests
|
||||||
|
class LightweightExporter extends ExportService {
|
||||||
|
@override
|
||||||
|
Future<Uint8List?> exportSignedPdfFromBytes({
|
||||||
|
required Uint8List srcBytes,
|
||||||
|
required Size uiPageSize,
|
||||||
|
required Uint8List? signatureImageBytes,
|
||||||
|
Map<int, List<SignaturePlacement>>? placementsByPage,
|
||||||
|
Map<String, Uint8List>? libraryBytes,
|
||||||
|
double targetDpi = 144.0,
|
||||||
|
}) async {
|
||||||
|
// Return minimal non-empty bytes; content isn't used further in tests
|
||||||
|
return Uint8List.fromList([1, 2, 3]);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> saveBytesToFile({
|
||||||
|
required Uint8List bytes,
|
||||||
|
required String outputPath,
|
||||||
|
}) async {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
|
@ -53,9 +77,15 @@ void main() {
|
||||||
pdfViewModelProvider.overrideWith(
|
pdfViewModelProvider.overrideWith(
|
||||||
(ref) => PdfViewModel(ref, useMockViewer: false),
|
(ref) => PdfViewModel(ref, useMockViewer: false),
|
||||||
),
|
),
|
||||||
exportServiceProvider.overrideWith((_) => fake),
|
pdfExportViewModelProvider.overrideWith(
|
||||||
savePathPickerProvider.overrideWith(
|
(ref) => PdfExportViewModel(
|
||||||
(_) => () async => 'C:/tmp/output.pdf',
|
ref,
|
||||||
|
exporter: fake,
|
||||||
|
savePathPicker: () async {
|
||||||
|
final dir = Directory.systemTemp.createTempSync('pdfsig_');
|
||||||
|
return '${dir.path}/output.pdf';
|
||||||
|
},
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
child: MaterialApp(
|
child: MaterialApp(
|
||||||
|
|
@ -225,13 +255,13 @@ void main() {
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
final ctx = tester.element(find.byType(PdfSignatureHomePage));
|
final ctx = tester.element(find.byType(PdfSignatureHomePage));
|
||||||
final container = ProviderScope.containerOf(ctx);
|
final container = ProviderScope.containerOf(ctx);
|
||||||
expect(container.read(pdfViewModelProvider), 1);
|
expect(container.read(pdfViewModelProvider).currentPage, 1);
|
||||||
container.read(pdfViewModelProvider.notifier).jumpToPage(2);
|
container.read(pdfViewModelProvider.notifier).jumpToPage(2);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(container.read(pdfViewModelProvider), 2);
|
expect(container.read(pdfViewModelProvider).currentPage, 2);
|
||||||
container.read(pdfViewModelProvider.notifier).jumpToPage(3);
|
container.read(pdfViewModelProvider.notifier).jumpToPage(3);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(container.read(pdfViewModelProvider), 3);
|
expect(container.read(pdfViewModelProvider).currentPage, 3);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('PDF View: zoom in/out', (tester) async {
|
testWidgets('PDF View: zoom in/out', (tester) async {
|
||||||
|
|
@ -319,7 +349,7 @@ void main() {
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
final ctx = tester.element(find.byType(PdfSignatureHomePage));
|
final ctx = tester.element(find.byType(PdfSignatureHomePage));
|
||||||
final container = ProviderScope.containerOf(ctx);
|
final container = ProviderScope.containerOf(ctx);
|
||||||
expect(container.read(pdfViewModelProvider), 1);
|
expect(container.read(pdfViewModelProvider).currentPage, 1);
|
||||||
|
|
||||||
final pagesSidebar = find.byType(PagesSidebar);
|
final pagesSidebar = find.byType(PagesSidebar);
|
||||||
expect(pagesSidebar, findsOneWidget);
|
expect(pagesSidebar, findsOneWidget);
|
||||||
|
|
@ -332,7 +362,7 @@ void main() {
|
||||||
expect(page3Thumb, findsOneWidget);
|
expect(page3Thumb, findsOneWidget);
|
||||||
await tester.tap(page3Thumb);
|
await tester.tap(page3Thumb);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(container.read(pdfViewModelProvider), 3);
|
expect(container.read(pdfViewModelProvider).currentPage, 3);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('PDF View: thumbnails scroll and select', (tester) async {
|
testWidgets('PDF View: thumbnails scroll and select', (tester) async {
|
||||||
|
|
@ -371,15 +401,78 @@ void main() {
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
final ctx = tester.element(find.byType(PdfSignatureHomePage));
|
final ctx = tester.element(find.byType(PdfSignatureHomePage));
|
||||||
final container = ProviderScope.containerOf(ctx);
|
final container = ProviderScope.containerOf(ctx);
|
||||||
expect(container.read(pdfViewModelProvider), 1);
|
expect(container.read(pdfViewModelProvider).currentPage, 1);
|
||||||
final sidebar = find.byType(PagesSidebar);
|
final sidebar = find.byType(PagesSidebar);
|
||||||
expect(sidebar, findsOneWidget);
|
expect(sidebar, findsOneWidget);
|
||||||
await tester.drag(sidebar, const Offset(0, -200));
|
await tester.drag(sidebar, const Offset(0, -200));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(find.text('1'), findsOneWidget);
|
expect(find.text('1'), findsOneWidget);
|
||||||
expect(container.read(pdfViewModelProvider), 1);
|
expect(container.read(pdfViewModelProvider).currentPage, 1);
|
||||||
await tester.tap(find.text('2'));
|
await tester.tap(find.text('2'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(container.read(pdfViewModelProvider), 2);
|
expect(container.read(pdfViewModelProvider).currentPage, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('PDF View: tap viewer after export does not crash', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
final pdfBytes =
|
||||||
|
await File('integration_test/data/sample-local-pdf.pdf').readAsBytes();
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
ProviderScope(
|
||||||
|
overrides: [
|
||||||
|
preferencesRepositoryProvider.overrideWith(
|
||||||
|
(ref) => PreferencesStateNotifier(prefs),
|
||||||
|
),
|
||||||
|
documentRepositoryProvider.overrideWith(
|
||||||
|
(ref) =>
|
||||||
|
DocumentStateNotifier()
|
||||||
|
..openPicked(pageCount: 3, bytes: pdfBytes),
|
||||||
|
),
|
||||||
|
pdfViewModelProvider.overrideWith(
|
||||||
|
(ref) => PdfViewModel(ref, useMockViewer: false),
|
||||||
|
),
|
||||||
|
pdfExportViewModelProvider.overrideWith(
|
||||||
|
(ref) => PdfExportViewModel(
|
||||||
|
ref,
|
||||||
|
exporter: LightweightExporter(),
|
||||||
|
savePathPicker: () async {
|
||||||
|
final dir = Directory.systemTemp.createTempSync(
|
||||||
|
'pdfsig_after_',
|
||||||
|
);
|
||||||
|
return '${dir.path}/output-after-export.pdf';
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
child: MaterialApp(
|
||||||
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||||
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
|
locale: const Locale('en'),
|
||||||
|
home: PdfSignatureHomePage(
|
||||||
|
onPickPdf: () async {},
|
||||||
|
onClosePdf: () {},
|
||||||
|
currentFile: fs.XFile('test.pdf'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Trigger export
|
||||||
|
await tester.tap(find.byKey(const Key('btn_save_pdf')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Tap on the page area; should not crash
|
||||||
|
final pageArea = find.byKey(const ValueKey('pdf_page_area'));
|
||||||
|
expect(pageArea, findsOneWidget);
|
||||||
|
await tester.tap(pageArea);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Still present and responsive
|
||||||
|
expect(pageArea, findsOneWidget);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -171,6 +171,36 @@ void main() {
|
||||||
final pagesSidebar = find.byType(PagesSidebar);
|
final pagesSidebar = find.byType(PagesSidebar);
|
||||||
expect(pagesSidebar, findsOneWidget);
|
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
|
// Scroll to make page 3 thumbnail visible
|
||||||
await tester.drag(pagesSidebar, const Offset(0, -300));
|
await tester.drag(pagesSidebar, const Offset(0, -300));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
@ -181,6 +211,8 @@ void main() {
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(container.read(pdfViewModelProvider).currentPage, 3);
|
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 {
|
testWidgets('PDF View: thumbnails scroll and select', (tester) async {
|
||||||
|
|
@ -247,7 +279,7 @@ void main() {
|
||||||
expect(container.read(pdfViewModelProvider).currentPage, 2);
|
expect(container.read(pdfViewModelProvider).currentPage, 2);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('PDF View: scroll thumbnails to reveal and select last page', (
|
testWidgets('PDF View: scroll thumb to reveal and select last page', (
|
||||||
tester,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
final pdfBytes =
|
final pdfBytes =
|
||||||
|
|
@ -308,6 +340,4 @@ void main() {
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(container.read(pdfViewModelProvider).currentPage, 3);
|
expect(container.read(pdfViewModelProvider).currentPage, 3);
|
||||||
});
|
});
|
||||||
|
|
||||||
//TODO: Scroll Thumbs
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -427,7 +427,7 @@
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
|
||||||
CLANG_ANALYZER_NONNULL = YES;
|
CLANG_ANALYZER_NONNULL = YES;
|
||||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
||||||
CLANG_CXX_LIBRARY = "libc++";
|
CLANG_CXX_LIBRARY = "libc++";
|
||||||
|
|
@ -484,7 +484,7 @@
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
|
||||||
CLANG_ANALYZER_NONNULL = YES;
|
CLANG_ANALYZER_NONNULL = YES;
|
||||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
||||||
CLANG_CXX_LIBRARY = "libc++";
|
CLANG_CXX_LIBRARY = "libc++";
|
||||||
|
|
|
||||||
|
|
@ -1,122 +1 @@
|
||||||
{
|
{"images":[{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@3x.png","scale":"3x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@3x.png","scale":"3x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@3x.png","scale":"3x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@1x.png","scale":"1x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@3x.png","scale":"3x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@1x.png","scale":"1x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@1x.png","scale":"1x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@1x.png","scale":"1x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@2x.png","scale":"2x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@1x.png","scale":"1x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@2x.png","scale":"2x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@1x.png","scale":"1x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@2x.png","scale":"2x"},{"size":"83.5x83.5","idiom":"ipad","filename":"Icon-App-83.5x83.5@2x.png","scale":"2x"},{"size":"1024x1024","idiom":"ios-marketing","filename":"Icon-App-1024x1024@1x.png","scale":"1x"}],"info":{"version":1,"author":"xcode"}}
|
||||||
"images" : [
|
|
||||||
{
|
|
||||||
"size" : "20x20",
|
|
||||||
"idiom" : "iphone",
|
|
||||||
"filename" : "Icon-App-20x20@2x.png",
|
|
||||||
"scale" : "2x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "20x20",
|
|
||||||
"idiom" : "iphone",
|
|
||||||
"filename" : "Icon-App-20x20@3x.png",
|
|
||||||
"scale" : "3x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "29x29",
|
|
||||||
"idiom" : "iphone",
|
|
||||||
"filename" : "Icon-App-29x29@1x.png",
|
|
||||||
"scale" : "1x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "29x29",
|
|
||||||
"idiom" : "iphone",
|
|
||||||
"filename" : "Icon-App-29x29@2x.png",
|
|
||||||
"scale" : "2x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "29x29",
|
|
||||||
"idiom" : "iphone",
|
|
||||||
"filename" : "Icon-App-29x29@3x.png",
|
|
||||||
"scale" : "3x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "40x40",
|
|
||||||
"idiom" : "iphone",
|
|
||||||
"filename" : "Icon-App-40x40@2x.png",
|
|
||||||
"scale" : "2x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "40x40",
|
|
||||||
"idiom" : "iphone",
|
|
||||||
"filename" : "Icon-App-40x40@3x.png",
|
|
||||||
"scale" : "3x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "60x60",
|
|
||||||
"idiom" : "iphone",
|
|
||||||
"filename" : "Icon-App-60x60@2x.png",
|
|
||||||
"scale" : "2x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "60x60",
|
|
||||||
"idiom" : "iphone",
|
|
||||||
"filename" : "Icon-App-60x60@3x.png",
|
|
||||||
"scale" : "3x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "20x20",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"filename" : "Icon-App-20x20@1x.png",
|
|
||||||
"scale" : "1x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "20x20",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"filename" : "Icon-App-20x20@2x.png",
|
|
||||||
"scale" : "2x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "29x29",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"filename" : "Icon-App-29x29@1x.png",
|
|
||||||
"scale" : "1x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "29x29",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"filename" : "Icon-App-29x29@2x.png",
|
|
||||||
"scale" : "2x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "40x40",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"filename" : "Icon-App-40x40@1x.png",
|
|
||||||
"scale" : "1x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "40x40",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"filename" : "Icon-App-40x40@2x.png",
|
|
||||||
"scale" : "2x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "76x76",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"filename" : "Icon-App-76x76@1x.png",
|
|
||||||
"scale" : "1x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "76x76",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"filename" : "Icon-App-76x76@2x.png",
|
|
||||||
"scale" : "2x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "83.5x83.5",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"filename" : "Icon-App-83.5x83.5@2x.png",
|
|
||||||
"scale" : "2x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "1024x1024",
|
|
||||||
"idiom" : "ios-marketing",
|
|
||||||
"filename" : "Icon-App-1024x1024@1x.png",
|
|
||||||
"scale" : "1x"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"info" : {
|
|
||||||
"version" : 1,
|
|
||||||
"author" : "xcode"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 8.1 KiB |
|
Before Width: | Height: | Size: 295 B After Width: | Height: | Size: 1012 B |
|
Before Width: | Height: | Size: 406 B After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 450 B After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 282 B After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 462 B After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 704 B After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 406 B After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 586 B After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 862 B After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 862 B After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 762 B After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 2.2 KiB |
|
|
@ -117,14 +117,15 @@ 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;
|
||||||
|
final h = r.height * heightPts;
|
||||||
|
|
||||||
// Process the signature asset with its graphic adjustments
|
// Process the signature asset with its graphic adjustments
|
||||||
Uint8List? bytes = placement.asset.bytes;
|
Uint8List bytes = placement.asset.bytes;
|
||||||
if (bytes != null && bytes.isNotEmpty) {
|
if (bytes.isNotEmpty) {
|
||||||
try {
|
try {
|
||||||
// Decode the image
|
// Decode the image
|
||||||
final decoded = img.decodeImage(bytes);
|
final decoded = img.decodeImage(bytes);
|
||||||
|
|
@ -155,9 +156,11 @@ class ExportService {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use fallback if no bytes available
|
// Use fallback if no bytes available
|
||||||
bytes ??= signatureImageBytes;
|
if (bytes.isEmpty && signatureImageBytes != null) {
|
||||||
|
bytes = signatureImageBytes;
|
||||||
|
}
|
||||||
|
|
||||||
if (bytes != null && bytes.isNotEmpty) {
|
if (bytes.isNotEmpty) {
|
||||||
pw.MemoryImage? imgObj;
|
pw.MemoryImage? imgObj;
|
||||||
try {
|
try {
|
||||||
imgObj = pw.MemoryImage(bytes);
|
imgObj = pw.MemoryImage(bytes);
|
||||||
|
|
@ -229,14 +232,15 @@ 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;
|
||||||
|
final h = r.height * heightPts;
|
||||||
|
|
||||||
// Process the signature asset with its graphic adjustments
|
// Process the signature asset with its graphic adjustments
|
||||||
Uint8List? bytes = placement.asset.bytes;
|
Uint8List bytes = placement.asset.bytes;
|
||||||
if (bytes != null && bytes.isNotEmpty) {
|
if (bytes.isNotEmpty) {
|
||||||
try {
|
try {
|
||||||
// Decode the image
|
// Decode the image
|
||||||
final decoded = img.decodeImage(bytes);
|
final decoded = img.decodeImage(bytes);
|
||||||
|
|
@ -267,9 +271,11 @@ class ExportService {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use fallback if no bytes available
|
// Use fallback if no bytes available
|
||||||
bytes ??= signatureImageBytes;
|
if (bytes.isEmpty && signatureImageBytes != null) {
|
||||||
|
bytes = signatureImageBytes;
|
||||||
|
}
|
||||||
|
|
||||||
if (bytes != null && bytes.isNotEmpty) {
|
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
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@
|
||||||
"image": "Bild",
|
"image": "Bild",
|
||||||
"invalidOrUnsupportedFile": "Ungültige oder nicht unterstützte Datei",
|
"invalidOrUnsupportedFile": "Ungültige oder nicht unterstützte Datei",
|
||||||
"language": "Sprache",
|
"language": "Sprache",
|
||||||
|
"lock": "Sperren",
|
||||||
"loadSignatureFromFile": "Signatur aus Datei laden",
|
"loadSignatureFromFile": "Signatur aus Datei laden",
|
||||||
"lockAspectRatio": "Seitenverhältnis sperren",
|
"lockAspectRatio": "Seitenverhältnis sperren",
|
||||||
"longPressOrRightClickTheSignatureToConfirmOrDelete": "Drücken Sie lange oder klicken Sie mit der rechten Maustaste auf die Signatur, um sie zu bestätigen oder zu löschen.",
|
"longPressOrRightClickTheSignatureToConfirmOrDelete": "Drücken Sie lange oder klicken Sie mit der rechten Maustaste auf die Signatur, um sie zu bestätigen oder zu löschen.",
|
||||||
|
|
@ -46,5 +47,6 @@
|
||||||
"themeDark": "Dunkel",
|
"themeDark": "Dunkel",
|
||||||
"themeLight": "Hell",
|
"themeLight": "Hell",
|
||||||
"themeSystem": "System",
|
"themeSystem": "System",
|
||||||
"undo": "Rückgängig"
|
"undo": "Rückgängig",
|
||||||
|
"unlock": "Entsperren"
|
||||||
}
|
}
|
||||||
|
|
@ -55,6 +55,8 @@
|
||||||
"@invalidOrUnsupportedFile": {},
|
"@invalidOrUnsupportedFile": {},
|
||||||
"language": "Language",
|
"language": "Language",
|
||||||
"@language": {},
|
"@language": {},
|
||||||
|
"lock": "Lock",
|
||||||
|
"@lock": {},
|
||||||
"loadSignatureFromFile": "Load Signature from file",
|
"loadSignatureFromFile": "Load Signature from file",
|
||||||
"@loadSignatureFromFile": {},
|
"@loadSignatureFromFile": {},
|
||||||
"lockAspectRatio": "Lock aspect ratio",
|
"lockAspectRatio": "Lock aspect ratio",
|
||||||
|
|
@ -119,5 +121,7 @@
|
||||||
"themeSystem": "System",
|
"themeSystem": "System",
|
||||||
"@themeSystem": {},
|
"@themeSystem": {},
|
||||||
"undo": "Undo",
|
"undo": "Undo",
|
||||||
"@undo": {}
|
"@undo": {},
|
||||||
|
"unlock": "Unlock",
|
||||||
|
"@unlock": {}
|
||||||
}
|
}
|
||||||
|
|
@ -23,6 +23,7 @@
|
||||||
"image": "Imagen",
|
"image": "Imagen",
|
||||||
"invalidOrUnsupportedFile": "Archivo inválido o no compatible",
|
"invalidOrUnsupportedFile": "Archivo inválido o no compatible",
|
||||||
"language": "Idioma",
|
"language": "Idioma",
|
||||||
|
"lock": "Bloquear",
|
||||||
"loadSignatureFromFile": "Cargar firma desde archivo",
|
"loadSignatureFromFile": "Cargar firma desde archivo",
|
||||||
"lockAspectRatio": "Bloquear relación de aspecto",
|
"lockAspectRatio": "Bloquear relación de aspecto",
|
||||||
"longPressOrRightClickTheSignatureToConfirmOrDelete": "Pulse prolongadamente o haga clic derecho en la firma para confirmar o eliminar.",
|
"longPressOrRightClickTheSignatureToConfirmOrDelete": "Pulse prolongadamente o haga clic derecho en la firma para confirmar o eliminar.",
|
||||||
|
|
@ -46,5 +47,6 @@
|
||||||
"themeDark": "Oscuro",
|
"themeDark": "Oscuro",
|
||||||
"themeLight": "Claro",
|
"themeLight": "Claro",
|
||||||
"themeSystem": "Sistema",
|
"themeSystem": "Sistema",
|
||||||
"undo": "Deshacer"
|
"undo": "Deshacer",
|
||||||
|
"unlock": "Desbloquear"
|
||||||
}
|
}
|
||||||
|
|
@ -23,6 +23,7 @@
|
||||||
"image": "Image",
|
"image": "Image",
|
||||||
"invalidOrUnsupportedFile": "Fichier invalide ou non pris en charge",
|
"invalidOrUnsupportedFile": "Fichier invalide ou non pris en charge",
|
||||||
"language": "Langue",
|
"language": "Langue",
|
||||||
|
"lock": "Verrouiller",
|
||||||
"loadSignatureFromFile": "Charger une signature depuis un fichier",
|
"loadSignatureFromFile": "Charger une signature depuis un fichier",
|
||||||
"lockAspectRatio": "Verrouiller le ratio largeur/hauteur",
|
"lockAspectRatio": "Verrouiller le ratio largeur/hauteur",
|
||||||
"longPressOrRightClickTheSignatureToConfirmOrDelete": "Appuyez longuement ou cliquez droit sur la signature pour la confirmer ou la supprimer.",
|
"longPressOrRightClickTheSignatureToConfirmOrDelete": "Appuyez longuement ou cliquez droit sur la signature pour la confirmer ou la supprimer.",
|
||||||
|
|
@ -46,5 +47,6 @@
|
||||||
"themeDark": "Sombre",
|
"themeDark": "Sombre",
|
||||||
"themeLight": "Clair",
|
"themeLight": "Clair",
|
||||||
"themeSystem": "Système",
|
"themeSystem": "Système",
|
||||||
"undo": "Annuler"
|
"undo": "Annuler",
|
||||||
|
"unlock": "Déverrouiller"
|
||||||
}
|
}
|
||||||
|
|
@ -23,6 +23,7 @@
|
||||||
"image": "画像",
|
"image": "画像",
|
||||||
"invalidOrUnsupportedFile": "無効なファイルまたはサポートされていないファイル",
|
"invalidOrUnsupportedFile": "無効なファイルまたはサポートされていないファイル",
|
||||||
"language": "言語",
|
"language": "言語",
|
||||||
|
"lock": "ロック",
|
||||||
"loadSignatureFromFile": "ファイルから署名を読み込む",
|
"loadSignatureFromFile": "ファイルから署名を読み込む",
|
||||||
"lockAspectRatio": "アスペクト比をロック",
|
"lockAspectRatio": "アスペクト比をロック",
|
||||||
"longPressOrRightClickTheSignatureToConfirmOrDelete": "署名を長押しまたは右クリックして、確認または削除します。",
|
"longPressOrRightClickTheSignatureToConfirmOrDelete": "署名を長押しまたは右クリックして、確認または削除します。",
|
||||||
|
|
@ -46,5 +47,6 @@
|
||||||
"themeDark": "ダーク",
|
"themeDark": "ダーク",
|
||||||
"themeLight": "ライト",
|
"themeLight": "ライト",
|
||||||
"themeSystem": "システム",
|
"themeSystem": "システム",
|
||||||
"undo": "元に戻す"
|
"undo": "元に戻す",
|
||||||
|
"unlock": "ロック解除"
|
||||||
}
|
}
|
||||||
|
|
@ -23,6 +23,7 @@
|
||||||
"image": "이미지",
|
"image": "이미지",
|
||||||
"invalidOrUnsupportedFile": "잘못된 파일이거나 지원되지 않는 파일입니다.",
|
"invalidOrUnsupportedFile": "잘못된 파일이거나 지원되지 않는 파일입니다.",
|
||||||
"language": "언어",
|
"language": "언어",
|
||||||
|
"lock": "잠금",
|
||||||
"loadSignatureFromFile": "파일에서 서명 불러오기",
|
"loadSignatureFromFile": "파일에서 서명 불러오기",
|
||||||
"lockAspectRatio": "종횡비 고정",
|
"lockAspectRatio": "종횡비 고정",
|
||||||
"longPressOrRightClickTheSignatureToConfirmOrDelete": "서명을 길게 누르거나 마우스 오른쪽 버튼을 클릭하여 확인하거나 삭제합니다.",
|
"longPressOrRightClickTheSignatureToConfirmOrDelete": "서명을 길게 누르거나 마우스 오른쪽 버튼을 클릭하여 확인하거나 삭제합니다.",
|
||||||
|
|
@ -46,5 +47,6 @@
|
||||||
"themeDark": "다크",
|
"themeDark": "다크",
|
||||||
"themeLight": "라이트",
|
"themeLight": "라이트",
|
||||||
"themeSystem": "시스템",
|
"themeSystem": "시스템",
|
||||||
"undo": "실행 취소"
|
"undo": "실행 취소",
|
||||||
|
"unlock": "잠금 해제"
|
||||||
}
|
}
|
||||||
|
|
@ -23,6 +23,7 @@
|
||||||
"image": "Зображення",
|
"image": "Зображення",
|
||||||
"invalidOrUnsupportedFile": "Недійсний або непідтримуваний файл",
|
"invalidOrUnsupportedFile": "Недійсний або непідтримуваний файл",
|
||||||
"language": "Мова",
|
"language": "Мова",
|
||||||
|
"lock": "Замкнути",
|
||||||
"loadSignatureFromFile": "Завантажити підпис з файлу",
|
"loadSignatureFromFile": "Завантажити підпис з файлу",
|
||||||
"lockAspectRatio": "Зафіксувати співвідношення сторін",
|
"lockAspectRatio": "Зафіксувати співвідношення сторін",
|
||||||
"longPressOrRightClickTheSignatureToConfirmOrDelete": "Довго натисніть або клацніть правою кнопкою миші на підпис, щоб підтвердити або видалити.",
|
"longPressOrRightClickTheSignatureToConfirmOrDelete": "Довго натисніть або клацніть правою кнопкою миші на підпис, щоб підтвердити або видалити.",
|
||||||
|
|
@ -46,5 +47,6 @@
|
||||||
"themeDark": "Темна",
|
"themeDark": "Темна",
|
||||||
"themeLight": "Світла",
|
"themeLight": "Світла",
|
||||||
"themeSystem": "Системна",
|
"themeSystem": "Системна",
|
||||||
"undo": "Відмінити"
|
"undo": "Відмінити",
|
||||||
|
"unlock": "Відмкнути"
|
||||||
}
|
}
|
||||||
|
|
@ -24,6 +24,7 @@
|
||||||
"image": "圖片",
|
"image": "圖片",
|
||||||
"invalidOrUnsupportedFile": "無效或不支援的檔案",
|
"invalidOrUnsupportedFile": "無效或不支援的檔案",
|
||||||
"language": "語言",
|
"language": "語言",
|
||||||
|
"lock": "锁定",
|
||||||
"loadSignatureFromFile": "從檔案載入簽名",
|
"loadSignatureFromFile": "從檔案載入簽名",
|
||||||
"lockAspectRatio": "鎖定長寬比",
|
"lockAspectRatio": "鎖定長寬比",
|
||||||
"longPressOrRightClickTheSignatureToConfirmOrDelete": "長按或右鍵點擊簽名以確認或刪除。",
|
"longPressOrRightClickTheSignatureToConfirmOrDelete": "長按或右鍵點擊簽名以確認或刪除。",
|
||||||
|
|
@ -47,5 +48,6 @@
|
||||||
"themeDark": "深色",
|
"themeDark": "深色",
|
||||||
"themeLight": "淺色",
|
"themeLight": "淺色",
|
||||||
"themeSystem": "系統",
|
"themeSystem": "系統",
|
||||||
"undo": "復原"
|
"undo": "復原",
|
||||||
|
"unlock": "解锁"
|
||||||
}
|
}
|
||||||
|
|
@ -23,6 +23,7 @@
|
||||||
"image": "图片",
|
"image": "图片",
|
||||||
"invalidOrUnsupportedFile": "无效或不支持的文件",
|
"invalidOrUnsupportedFile": "无效或不支持的文件",
|
||||||
"language": "语言",
|
"language": "语言",
|
||||||
|
"lock": "锁定",
|
||||||
"loadSignatureFromFile": "从文件加载签名",
|
"loadSignatureFromFile": "从文件加载签名",
|
||||||
"lockAspectRatio": "锁定纵横比",
|
"lockAspectRatio": "锁定纵横比",
|
||||||
"longPressOrRightClickTheSignatureToConfirmOrDelete": "长按或右键单击签名以确认或删除。",
|
"longPressOrRightClickTheSignatureToConfirmOrDelete": "长按或右键单击签名以确认或删除。",
|
||||||
|
|
@ -46,5 +47,6 @@
|
||||||
"themeDark": "深色",
|
"themeDark": "深色",
|
||||||
"themeLight": "浅色",
|
"themeLight": "浅色",
|
||||||
"themeSystem": "系统",
|
"themeSystem": "系统",
|
||||||
"undo": "撤销"
|
"undo": "撤销",
|
||||||
|
"unlock": "解锁"
|
||||||
}
|
}
|
||||||
|
|
@ -24,6 +24,7 @@
|
||||||
"image": "圖片",
|
"image": "圖片",
|
||||||
"invalidOrUnsupportedFile": "無效或不支援的檔案",
|
"invalidOrUnsupportedFile": "無效或不支援的檔案",
|
||||||
"language": "語言",
|
"language": "語言",
|
||||||
|
"lock": "鎖定",
|
||||||
"loadSignatureFromFile": "從檔案載入簽名",
|
"loadSignatureFromFile": "從檔案載入簽名",
|
||||||
"lockAspectRatio": "鎖定長寬比",
|
"lockAspectRatio": "鎖定長寬比",
|
||||||
"longPressOrRightClickTheSignatureToConfirmOrDelete": "長按或右鍵點擊簽名以確認或刪除。",
|
"longPressOrRightClickTheSignatureToConfirmOrDelete": "長按或右鍵點擊簽名以確認或刪除。",
|
||||||
|
|
@ -47,5 +48,6 @@
|
||||||
"themeDark": "深色",
|
"themeDark": "深色",
|
||||||
"themeLight": "淺色",
|
"themeLight": "淺色",
|
||||||
"themeSystem": "系統",
|
"themeSystem": "系統",
|
||||||
"undo": "復原"
|
"undo": "復原",
|
||||||
|
"unlock": "解鎖"
|
||||||
}
|
}
|
||||||
|
|
@ -1,5 +1,15 @@
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
import 'package:pdf_signature/app.dart';
|
import 'package:pdf_signature/app.dart';
|
||||||
export 'package:pdf_signature/app.dart';
|
export 'package:pdf_signature/app.dart';
|
||||||
|
|
||||||
void main() => runApp(const MyApp());
|
void main() {
|
||||||
|
// Ensure Flutter bindings are initialized before platform channel usage
|
||||||
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
// Disable right-click context menu on web using Flutter API
|
||||||
|
if (kIsWeb) {
|
||||||
|
BrowserContextMenu.disableContextMenu();
|
||||||
|
}
|
||||||
|
runApp(const MyApp());
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,53 @@
|
||||||
|
import 'package:file_selector/file_selector.dart' as fs;
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:pdf_signature/data/services/export_service.dart';
|
||||||
|
|
||||||
|
/// ViewModel for export-related UI state and helpers.
|
||||||
|
class PdfExportViewModel extends ChangeNotifier {
|
||||||
|
final Ref ref;
|
||||||
|
bool _exporting = false;
|
||||||
|
|
||||||
|
// Dependencies (injectable via constructor for tests)
|
||||||
|
final ExportService _exporter;
|
||||||
|
final Future<String?> Function() _savePathPicker;
|
||||||
|
|
||||||
|
PdfExportViewModel(
|
||||||
|
this.ref, {
|
||||||
|
ExportService? exporter,
|
||||||
|
Future<String?> Function()? savePathPicker,
|
||||||
|
}) : _exporter = exporter ?? ExportService(),
|
||||||
|
_savePathPicker = savePathPicker ?? _defaultSavePathPicker;
|
||||||
|
|
||||||
|
bool get exporting => _exporting;
|
||||||
|
|
||||||
|
void setExporting(bool value) {
|
||||||
|
if (_exporting == value) return;
|
||||||
|
_exporting = value;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the export service (overridable in tests via constructor).
|
||||||
|
ExportService get exporter => _exporter;
|
||||||
|
|
||||||
|
/// Show save dialog and return the chosen path (null if canceled).
|
||||||
|
Future<String?> pickSavePath() async {
|
||||||
|
return _savePathPicker();
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<String?> _defaultSavePathPicker() async {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final pdfExportViewModelProvider = ChangeNotifierProvider<PdfExportViewModel>((
|
||||||
|
ref,
|
||||||
|
) {
|
||||||
|
return PdfExportViewModel(ref);
|
||||||
|
});
|
||||||
|
|
@ -14,20 +14,27 @@ class PdfViewModel extends ChangeNotifier {
|
||||||
PdfViewerController get controller => _controller;
|
PdfViewerController get controller => _controller;
|
||||||
int _currentPage = 1;
|
int _currentPage = 1;
|
||||||
late final bool _useMockViewer;
|
late final bool _useMockViewer;
|
||||||
|
bool _isDisposed = false;
|
||||||
|
|
||||||
// Active rect for signature placement overlay
|
// Active rect for signature placement overlay
|
||||||
Rect? _activeRect;
|
Rect? _activeRect;
|
||||||
Rect? get activeRect => _activeRect;
|
Rect? get activeRect => _activeRect;
|
||||||
set activeRect(Rect? value) {
|
set activeRect(Rect? value) {
|
||||||
_activeRect = value;
|
_activeRect = value;
|
||||||
notifyListeners();
|
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);
|
// const bool.fromEnvironment('FLUTTER_TEST', defaultValue: false);
|
||||||
PdfViewModel(this.ref, {bool? useMockViewer})
|
PdfViewModel(this.ref, {bool? useMockViewer})
|
||||||
: _useMockViewer =
|
: _useMockViewer =
|
||||||
useMockViewer ??
|
useMockViewer ??
|
||||||
bool.fromEnvironment('FLUTTER_TEST', defaultValue: false);
|
const bool.fromEnvironment('FLUTTER_TEST', defaultValue: false);
|
||||||
|
|
||||||
bool get useMockViewer => _useMockViewer;
|
bool get useMockViewer => _useMockViewer;
|
||||||
|
|
||||||
|
|
@ -35,11 +42,15 @@ class PdfViewModel extends ChangeNotifier {
|
||||||
|
|
||||||
set currentPage(int value) {
|
set currentPage(int value) {
|
||||||
_currentPage = value.clamp(1, document.pageCount);
|
_currentPage = value.clamp(1, document.pageCount);
|
||||||
|
if (!_isDisposed) {
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Document get document => ref.watch(documentRepositoryProvider);
|
// 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) {
|
void jumpToPage(int page) {
|
||||||
currentPage = page;
|
currentPage = page;
|
||||||
|
|
@ -61,7 +72,9 @@ class PdfViewModel extends ChangeNotifier {
|
||||||
|
|
||||||
// Allow repositories to request a UI refresh without mutating provider state
|
// Allow repositories to request a UI refresh without mutating provider state
|
||||||
void notifyPlacementsChanged() {
|
void notifyPlacementsChanged() {
|
||||||
notifyListeners();
|
if (!_isDisposed) {
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Document repository methods
|
// Document repository methods
|
||||||
|
|
@ -107,6 +120,11 @@ class PdfViewModel extends ChangeNotifier {
|
||||||
ref
|
ref
|
||||||
.read(documentRepositoryProvider.notifier)
|
.read(documentRepositoryProvider.notifier)
|
||||||
.removePlacement(page: page, index: index);
|
.removePlacement(page: page, index: index);
|
||||||
|
// Also remove from locked placements if it was locked
|
||||||
|
_lockedPlacements.remove(_placementKey(page, index));
|
||||||
|
if (!_isDisposed) {
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void updatePlacementRect({
|
void updatePlacementRect({
|
||||||
|
|
@ -129,6 +147,39 @@ class PdfViewModel extends ChangeNotifier {
|
||||||
.assetOfPlacement(page: page, index: index);
|
.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({
|
Future<void> exportDocument({
|
||||||
required String outputPath,
|
required String outputPath,
|
||||||
required Size uiPageSize,
|
required Size uiPageSize,
|
||||||
|
|
@ -174,6 +225,12 @@ class PdfViewModel extends ChangeNotifier {
|
||||||
void clearAllSignatureCards() {
|
void clearAllSignatureCards() {
|
||||||
ref.read(signatureCardRepositoryProvider.notifier).clearAll();
|
ref.read(signatureCardRepositoryProvider.notifier).clearAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_isDisposed = true;
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final pdfViewModelProvider = ChangeNotifierProvider<PdfViewModel>((ref) {
|
final pdfViewModelProvider = ChangeNotifierProvider<PdfViewModel>((ref) {
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,8 @@ class ThumbnailsView extends ConsumerWidget {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
|
// Access view model to detect mock viewer mode
|
||||||
|
final viewModel = ref.read(pdfViewModelProvider);
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
color: theme.colorScheme.surface,
|
color: theme.colorScheme.surface,
|
||||||
|
|
@ -34,16 +36,24 @@ class ThumbnailsView extends ConsumerWidget {
|
||||||
final isSelected = currentPage == pageNumber;
|
final isSelected = currentPage == pageNumber;
|
||||||
return InkWell(
|
return InkWell(
|
||||||
onTap: () {
|
onTap: () {
|
||||||
// Update both controller and provider page
|
// For real viewer: navigate first and wait for onPageChanged
|
||||||
controller.goToPage(
|
// to update provider when the page is actually reached.
|
||||||
pageNumber: pageNumber,
|
// For mock/unready: update provider immediately to drive scroll.
|
||||||
anchor: PdfPageAnchor.top,
|
final isRealViewer = !viewModel.useMockViewer;
|
||||||
);
|
if (isRealViewer && controller.isReady) {
|
||||||
try {
|
controller.goToPage(
|
||||||
ref
|
pageNumber: pageNumber,
|
||||||
.read(pdfViewModelProvider.notifier)
|
anchor: PdfPageAnchor.top,
|
||||||
.jumpToPage(pageNumber);
|
);
|
||||||
} catch (_) {}
|
// 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(
|
child: DecoratedBox(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
|
|
|
||||||
|
|
@ -123,7 +123,7 @@ class _PdfMockContinuousListState extends ConsumerState<PdfMockContinuousList> {
|
||||||
return Container(
|
return Container(
|
||||||
color:
|
color:
|
||||||
candidateData.isNotEmpty
|
candidateData.isNotEmpty
|
||||||
? Colors.blue.withOpacity(0.3)
|
? Colors.blue.withValues(alpha: 0.3)
|
||||||
: Colors.grey.shade200,
|
: Colors.grey.shade200,
|
||||||
child: Center(
|
child: Center(
|
||||||
child: Builder(
|
child: Builder(
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@ import 'package:pdf_signature/data/repositories/document_repository.dart';
|
||||||
|
|
||||||
import '../../../../domain/models/model.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 {
|
||||||
|
|
@ -37,6 +39,61 @@ class PdfPageOverlays extends ConsumerWidget {
|
||||||
final activeRect = pdfViewModel.activeRect;
|
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 p = placed[i];
|
final p = placed[i];
|
||||||
|
|
@ -47,6 +104,7 @@ class PdfPageOverlays extends ConsumerWidget {
|
||||||
rect: uiRect,
|
rect: uiRect,
|
||||||
placement: p,
|
placement: p,
|
||||||
placedIndex: i,
|
placedIndex: i,
|
||||||
|
pageNumber: pageNumber,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -54,7 +112,9 @@ class PdfPageOverlays extends ConsumerWidget {
|
||||||
// TODO:Add active overlay if present and not using mock (mock has its own)
|
// TODO:Add active overlay if present and not using mock (mock has its own)
|
||||||
|
|
||||||
final useMock = pdfViewModel.useMockViewer;
|
final useMock = pdfViewModel.useMockViewer;
|
||||||
if (!useMock && activeRect != null) {
|
if (!useMock &&
|
||||||
|
activeRect != null &&
|
||||||
|
pageNumber == pdfViewModel.currentPage) {
|
||||||
widgets.add(
|
widgets.add(
|
||||||
LayoutBuilder(
|
LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,8 @@ 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_export_view_model.dart';
|
||||||
|
import 'package:pdf_signature/utils/download.dart';
|
||||||
import '../view_model/pdf_view_model.dart';
|
import '../view_model/pdf_view_model.dart';
|
||||||
|
|
||||||
class PdfSignatureHomePage extends ConsumerStatefulWidget {
|
class PdfSignatureHomePage extends ConsumerStatefulWidget {
|
||||||
|
|
@ -132,7 +133,7 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _saveSignedPdf() async {
|
Future<void> _saveSignedPdf() async {
|
||||||
ref.read(exportingProvider.notifier).state = true;
|
ref.read(pdfExportViewModelProvider.notifier).setExporting(true);
|
||||||
try {
|
try {
|
||||||
final pdf = _viewModel.document;
|
final pdf = _viewModel.document;
|
||||||
final messenger = ScaffoldMessenger.of(context);
|
final messenger = ScaffoldMessenger.of(context);
|
||||||
|
|
@ -144,7 +145,7 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final exporter = ref.read(exportServiceProvider);
|
final exporter = ref.read(pdfExportViewModelProvider).exporter;
|
||||||
|
|
||||||
// get DPI from preferences
|
// get DPI from preferences
|
||||||
final targetDpi = ref.read(preferencesRepositoryProvider).exportDpi;
|
final targetDpi = ref.read(preferencesRepositoryProvider).exportDpi;
|
||||||
|
|
@ -152,8 +153,7 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
|
||||||
String? savedPath;
|
String? savedPath;
|
||||||
|
|
||||||
if (!kIsWeb) {
|
if (!kIsWeb) {
|
||||||
final pick = ref.read(savePathPickerProvider);
|
final path = await ref.read(pdfExportViewModelProvider).pickSavePath();
|
||||||
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;
|
||||||
|
|
@ -168,6 +168,21 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
|
||||||
if (out != null) {
|
if (out != null) {
|
||||||
ok = await exporter.saveBytesToFile(bytes: out, outputPath: fullPath);
|
ok = await exporter.saveBytesToFile(bytes: out, outputPath: fullPath);
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// Web: export and trigger browser download
|
||||||
|
final src = pdf.pickedPdfBytes ?? Uint8List(0);
|
||||||
|
final out = await exporter.exportSignedPdfFromBytes(
|
||||||
|
srcBytes: src,
|
||||||
|
uiPageSize: _pageSize,
|
||||||
|
signatureImageBytes: null,
|
||||||
|
placementsByPage: pdf.placementsByPage,
|
||||||
|
targetDpi: targetDpi,
|
||||||
|
);
|
||||||
|
if (out != null) {
|
||||||
|
// Use a sensible default filename (cannot prompt path on web)
|
||||||
|
ok = await downloadBytes(out, filename: 'signed.pdf');
|
||||||
|
savedPath = 'signed.pdf';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (!kIsWeb) {
|
if (!kIsWeb) {
|
||||||
if (ok) {
|
if (ok) {
|
||||||
|
|
@ -185,9 +200,22 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// Web: show a toast-like confirmation
|
||||||
|
messenger.showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
ok
|
||||||
|
? AppLocalizations.of(
|
||||||
|
context,
|
||||||
|
).savedWithPath(savedPath ?? 'signed.pdf')
|
||||||
|
: AppLocalizations.of(context).failedToSavePdf,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
ref.read(exportingProvider.notifier).state = false;
|
ref.read(pdfExportViewModelProvider.notifier).setExporting(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -333,7 +361,7 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildScaffold(BuildContext context) {
|
Widget _buildScaffold(BuildContext context) {
|
||||||
final isExporting = ref.watch(exportingProvider);
|
final isExporting = ref.watch(pdfExportViewModelProvider).exporting;
|
||||||
final l = AppLocalizations.of(context);
|
final l = AppLocalizations.of(context);
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
body: Padding(
|
body: Padding(
|
||||||
|
|
|
||||||
|
|
@ -117,15 +117,6 @@ class _PdfViewerWidgetState extends ConsumerState<PdfViewerWidget> {
|
||||||
},
|
},
|
||||||
viewerOverlayBuilder: (context, size, handle) {
|
viewerOverlayBuilder: (context, size, handle) {
|
||||||
return [
|
return [
|
||||||
PdfPageOverlays(
|
|
||||||
pageSize: widget.pageSize,
|
|
||||||
pageNumber: pdfViewModel.currentPage,
|
|
||||||
onDragSignature: widget.onDragSignature,
|
|
||||||
onResizeSignature: widget.onResizeSignature,
|
|
||||||
onConfirmSignature: widget.onConfirmSignature,
|
|
||||||
onClearActiveOverlay: widget.onClearActiveOverlay,
|
|
||||||
onSelectPlaced: widget.onSelectPlaced,
|
|
||||||
),
|
|
||||||
// Vertical scroll thumb on the right
|
// Vertical scroll thumb on the right
|
||||||
PdfViewerScrollThumb(
|
PdfViewerScrollThumb(
|
||||||
controller: widget.controller,
|
controller: widget.controller,
|
||||||
|
|
@ -136,7 +127,7 @@ class _PdfViewerWidgetState extends ConsumerState<PdfViewerWidget> {
|
||||||
color: Colors.black.withValues(alpha: 0.7),
|
color: Colors.black.withValues(alpha: 0.7),
|
||||||
child: Center(
|
child: Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
pageNumber.toString(),
|
'Pg $pageNumber',
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
|
|
@ -155,7 +146,7 @@ class _PdfViewerWidgetState extends ConsumerState<PdfViewerWidget> {
|
||||||
color: Colors.black.withValues(alpha: 0.7),
|
color: Colors.black.withValues(alpha: 0.7),
|
||||||
child: Center(
|
child: Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
pageNumber.toString(),
|
'Pg $pageNumber',
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
|
|
@ -166,6 +157,20 @@ class _PdfViewerWidgetState extends ConsumerState<PdfViewerWidget> {
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
|
// Per-page overlays to enable page-specific drag targets and placed signatures
|
||||||
|
pageOverlaysBuilder: (context, pageRect, page) {
|
||||||
|
return [
|
||||||
|
PdfPageOverlays(
|
||||||
|
pageSize: Size(pageRect.width, pageRect.height),
|
||||||
|
pageNumber: page.pageNumber,
|
||||||
|
onDragSignature: widget.onDragSignature,
|
||||||
|
onResizeSignature: widget.onResizeSignature,
|
||||||
|
onConfirmSignature: widget.onConfirmSignature,
|
||||||
|
onClearActiveOverlay: widget.onClearActiveOverlay,
|
||||||
|
onSelectPlaced: widget.onSelectPlaced,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,11 @@
|
||||||
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 '../../../../domain/models/model.dart';
|
||||||
import '../../signature/widgets/rotated_signature_image.dart';
|
import '../../signature/widgets/rotated_signature_image.dart';
|
||||||
import '../../signature/view_model/signature_view_model.dart';
|
import '../../signature/view_model/signature_view_model.dart';
|
||||||
|
import '../view_model/pdf_view_model.dart';
|
||||||
|
import 'package:pdf_signature/l10n/app_localizations.dart';
|
||||||
|
|
||||||
/// Minimal overlay widget for rendering a placed signature.
|
/// Minimal overlay widget for rendering a placed signature.
|
||||||
class SignatureOverlay extends ConsumerWidget {
|
class SignatureOverlay extends ConsumerWidget {
|
||||||
|
|
@ -12,12 +15,14 @@ class SignatureOverlay extends ConsumerWidget {
|
||||||
required this.rect,
|
required this.rect,
|
||||||
required this.placement,
|
required this.placement,
|
||||||
required this.placedIndex,
|
required this.placedIndex,
|
||||||
|
required this.pageNumber,
|
||||||
});
|
});
|
||||||
|
|
||||||
final Size pageSize; // not used directly, kept for API symmetry
|
final Size pageSize; // not used directly, kept for API symmetry
|
||||||
final Rect rect; // normalized 0..1 values (left, top, width, height)
|
final Rect rect; // normalized 0..1 values (left, top, width, height)
|
||||||
final SignaturePlacement placement;
|
final SignaturePlacement placement;
|
||||||
final int placedIndex;
|
final int placedIndex;
|
||||||
|
final int pageNumber;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
|
@ -26,29 +31,128 @@ class SignatureOverlay extends ConsumerWidget {
|
||||||
.getProcessedBytes(placement.asset, placement.graphicAdjust);
|
.getProcessedBytes(placement.asset, placement.graphicAdjust);
|
||||||
return LayoutBuilder(
|
return LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
final left = rect.left * constraints.maxWidth;
|
final pageW = constraints.maxWidth;
|
||||||
final top = rect.top * constraints.maxHeight;
|
final pageH = constraints.maxHeight;
|
||||||
final width = rect.width * constraints.maxWidth;
|
final rectPx = Rect.fromLTWH(
|
||||||
final height = rect.height * constraints.maxHeight;
|
rect.left * pageW,
|
||||||
|
rect.top * pageH,
|
||||||
|
rect.width * pageW,
|
||||||
|
rect.height * pageH,
|
||||||
|
);
|
||||||
|
|
||||||
|
Future<void> _showContextMenu(Offset position) async {
|
||||||
|
final pdfViewModel = ref.read(pdfViewModelProvider.notifier);
|
||||||
|
final isLocked = ref
|
||||||
|
.watch(pdfViewModelProvider)
|
||||||
|
.isPlacementLocked(page: pageNumber, index: placedIndex);
|
||||||
|
final selected = await showMenu<String>(
|
||||||
|
context: context,
|
||||||
|
position: RelativeRect.fromLTRB(
|
||||||
|
position.dx,
|
||||||
|
position.dy,
|
||||||
|
position.dx,
|
||||||
|
position.dy,
|
||||||
|
),
|
||||||
|
items: [
|
||||||
|
PopupMenuItem(
|
||||||
|
key: const Key('mi_placement_lock'),
|
||||||
|
value: isLocked ? 'unlock' : 'lock',
|
||||||
|
child: Text(
|
||||||
|
isLocked
|
||||||
|
? AppLocalizations.of(context).unlock
|
||||||
|
: AppLocalizations.of(context).lock,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
PopupMenuItem(
|
||||||
|
key: const Key('mi_placement_delete'),
|
||||||
|
value: 'delete',
|
||||||
|
child: Text(AppLocalizations.of(context).delete),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
if (selected == 'lock') {
|
||||||
|
pdfViewModel.lockPlacement(page: pageNumber, index: placedIndex);
|
||||||
|
} else if (selected == 'unlock') {
|
||||||
|
pdfViewModel.unlockPlacement(page: pageNumber, index: placedIndex);
|
||||||
|
} else if (selected == 'delete') {
|
||||||
|
pdfViewModel.removePlacement(page: pageNumber, index: placedIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return Stack(
|
return Stack(
|
||||||
children: [
|
children: [
|
||||||
Positioned(
|
TransformableBox(
|
||||||
key: Key('placed_signature_$placedIndex'),
|
key: Key('placed_signature_$placedIndex'),
|
||||||
left: left,
|
rect: rectPx,
|
||||||
top: top,
|
flip: Flip.none,
|
||||||
width: width,
|
// Keep the box within page bounds
|
||||||
height: height,
|
clampingRect: Rect.fromLTWH(0, 0, pageW, pageH),
|
||||||
child: DecoratedBox(
|
// Disable flips for signatures to avoid mirrored signatures
|
||||||
decoration: BoxDecoration(
|
allowFlippingWhileResizing: false,
|
||||||
border: Border.all(color: Colors.red, width: 2),
|
allowContentFlipping: false,
|
||||||
),
|
onChanged:
|
||||||
child: FittedBox(
|
ref
|
||||||
fit: BoxFit.contain,
|
.watch(pdfViewModelProvider)
|
||||||
child: RotatedSignatureImage(
|
.isPlacementLocked(
|
||||||
bytes: processedBytes,
|
page: pageNumber,
|
||||||
rotationDeg: placement.rotationDeg,
|
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(
|
||||||
|
left: rectPx.left,
|
||||||
|
top: rectPx.top,
|
||||||
|
width: rectPx.width,
|
||||||
|
height: rectPx.height,
|
||||||
|
child: GestureDetector(
|
||||||
|
behavior: HitTestBehavior.translucent,
|
||||||
|
onSecondaryTapDown:
|
||||||
|
(details) => _showContextMenu(details.globalPosition),
|
||||||
|
onLongPressStart:
|
||||||
|
(details) => _showContextMenu(details.globalPosition),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ 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/widgets/signature_drawer.dart';
|
import '../../signature/widgets/signature_drawer.dart';
|
||||||
import 'ui_services.dart';
|
import '../view_model/pdf_export_view_model.dart';
|
||||||
|
|
||||||
class SignaturesSidebar extends ConsumerWidget {
|
class SignaturesSidebar extends ConsumerWidget {
|
||||||
const SignaturesSidebar({
|
const SignaturesSidebar({
|
||||||
|
|
@ -21,7 +21,7 @@ class SignaturesSidebar extends ConsumerWidget {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final l = AppLocalizations.of(context);
|
final l = AppLocalizations.of(context);
|
||||||
final isExporting = ref.watch(exportingProvider);
|
final isExporting = ref.watch(pdfExportViewModelProvider).exporting;
|
||||||
return AbsorbPointer(
|
return AbsorbPointer(
|
||||||
absorbing: isExporting,
|
absorbing: isExporting,
|
||||||
child: Card(
|
child: Card(
|
||||||
|
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
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 => null;
|
|
||||||
});
|
|
||||||
|
|
@ -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,
|
||||||
|
);
|
||||||
|
|
@ -5,6 +5,7 @@ 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/signature_view_model.dart';
|
||||||
|
import '../view_model/dragging_signature_view_model.dart';
|
||||||
|
|
||||||
class SignatureCard extends ConsumerWidget {
|
class SignatureCard extends ConsumerWidget {
|
||||||
const SignatureCard({
|
const SignatureCard({
|
||||||
|
|
@ -26,6 +27,34 @@ class SignatureCard extends ConsumerWidget {
|
||||||
final bool useCurrentBytesForDrag;
|
final bool useCurrentBytesForDrag;
|
||||||
final double rotationDeg;
|
final double rotationDeg;
|
||||||
final domain.GraphicAdjust graphicAdjust;
|
final domain.GraphicAdjust graphicAdjust;
|
||||||
|
Future<void> _showContextMenu(BuildContext context, Offset position) async {
|
||||||
|
final selected = await showMenu<String>(
|
||||||
|
context: context,
|
||||||
|
position: RelativeRect.fromLTRB(
|
||||||
|
position.dx,
|
||||||
|
position.dy,
|
||||||
|
position.dx,
|
||||||
|
position.dy,
|
||||||
|
),
|
||||||
|
items: [
|
||||||
|
PopupMenuItem(
|
||||||
|
key: const Key('mi_signature_adjust'),
|
||||||
|
value: 'adjust',
|
||||||
|
child: Text(AppLocalizations.of(context).adjustGraphic),
|
||||||
|
),
|
||||||
|
PopupMenuItem(
|
||||||
|
key: const Key('mi_signature_delete'),
|
||||||
|
value: 'delete',
|
||||||
|
child: Text(AppLocalizations.of(context).delete),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
if (selected == 'adjust') {
|
||||||
|
onAdjust?.call();
|
||||||
|
} else if (selected == 'delete') {
|
||||||
|
onDelete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
|
@ -90,65 +119,11 @@ class SignatureCard extends ConsumerWidget {
|
||||||
onSecondaryTapDown:
|
onSecondaryTapDown:
|
||||||
disabled
|
disabled
|
||||||
? null
|
? null
|
||||||
: (details) async {
|
: (details) => _showContextMenu(context, details.globalPosition),
|
||||||
final selected = await showMenu<String>(
|
|
||||||
context: context,
|
|
||||||
position: RelativeRect.fromLTRB(
|
|
||||||
details.globalPosition.dx,
|
|
||||||
details.globalPosition.dy,
|
|
||||||
details.globalPosition.dx,
|
|
||||||
details.globalPosition.dy,
|
|
||||||
),
|
|
||||||
items: [
|
|
||||||
PopupMenuItem(
|
|
||||||
key: const Key('mi_signature_adjust'),
|
|
||||||
value: 'adjust',
|
|
||||||
child: Text(AppLocalizations.of(context).adjustGraphic),
|
|
||||||
),
|
|
||||||
PopupMenuItem(
|
|
||||||
key: const Key('mi_signature_delete'),
|
|
||||||
value: 'delete',
|
|
||||||
child: Text(AppLocalizations.of(context).delete),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
if (selected == 'adjust') {
|
|
||||||
onAdjust?.call();
|
|
||||||
} else if (selected == 'delete') {
|
|
||||||
onDelete();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onLongPressStart:
|
onLongPressStart:
|
||||||
disabled
|
disabled
|
||||||
? null
|
? null
|
||||||
: (details) async {
|
: (details) => _showContextMenu(context, details.globalPosition),
|
||||||
final selected = await showMenu<String>(
|
|
||||||
context: context,
|
|
||||||
position: RelativeRect.fromLTRB(
|
|
||||||
details.globalPosition.dx,
|
|
||||||
details.globalPosition.dy,
|
|
||||||
details.globalPosition.dx,
|
|
||||||
details.globalPosition.dy,
|
|
||||||
),
|
|
||||||
items: [
|
|
||||||
PopupMenuItem(
|
|
||||||
key: const Key('mi_signature_adjust'),
|
|
||||||
value: 'adjust',
|
|
||||||
child: Text(AppLocalizations.of(context).adjustGraphic),
|
|
||||||
),
|
|
||||||
PopupMenuItem(
|
|
||||||
key: const Key('mi_signature_delete'),
|
|
||||||
value: 'delete',
|
|
||||||
child: Text(AppLocalizations.of(context).delete),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
if (selected == 'adjust') {
|
|
||||||
onAdjust?.call();
|
|
||||||
} else if (selected == 'delete') {
|
|
||||||
onDelete();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
child: child,
|
child: child,
|
||||||
);
|
);
|
||||||
if (disabled) return child;
|
if (disabled) return child;
|
||||||
|
|
@ -163,6 +138,12 @@ class SignatureCard extends ConsumerWidget {
|
||||||
graphicAdjust: graphicAdjust,
|
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(
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import 'package:pdf_signature/data/repositories/signature_card_repository.dart';
|
||||||
import 'package:pdf_signature/domain/models/model.dart' hide SignatureCard;
|
import 'package:pdf_signature/domain/models/model.dart' hide SignatureCard;
|
||||||
import 'image_editor_dialog.dart';
|
import 'image_editor_dialog.dart';
|
||||||
import 'signature_card.dart';
|
import 'signature_card.dart';
|
||||||
|
import '../../pdf/view_model/pdf_view_model.dart';
|
||||||
|
|
||||||
/// Data for drag-and-drop is in signature_drag_data.dart
|
/// Data for drag-and-drop is in signature_drag_data.dart
|
||||||
|
|
||||||
|
|
@ -77,7 +78,11 @@ class _SignatureDrawerState extends ConsumerState<SignatureDrawer> {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onTap: () {
|
onTap: () {
|
||||||
// state = const Rect.fromLTWH(0.2, 0.2, 0.3, 0.15);
|
// Activate a default overlay rectangle on the current page
|
||||||
|
// so integration tests can find and size the active overlay.
|
||||||
|
ref
|
||||||
|
.read(pdfViewModelProvider.notifier)
|
||||||
|
.activeRect = const Rect.fromLTWH(0.2, 0.2, 0.3, 0.15);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'download_stub.dart' if (dart.library.html) 'download_web.dart' as impl;
|
||||||
|
|
||||||
|
/// Initiates a platform-appropriate download/save operation.
|
||||||
|
///
|
||||||
|
/// On Web: triggers a browser download with the provided filename.
|
||||||
|
/// On non-Web: returns false (no-op). Use your existing IO save flow instead.
|
||||||
|
Future<bool> downloadBytes(Uint8List bytes, {required String filename}) {
|
||||||
|
return impl.downloadBytes(bytes, filename: filename);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
Future<bool> downloadBytes(Uint8List bytes, {required String filename}) async {
|
||||||
|
// Not supported on non-web. Return false so caller can fallback to file save.
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
// ignore: avoid_web_libraries_in_flutter
|
||||||
|
import 'dart:html' as html;
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
Future<bool> downloadBytes(Uint8List bytes, {required String filename}) async {
|
||||||
|
try {
|
||||||
|
final blob = html.Blob([bytes], 'application/pdf');
|
||||||
|
final url = html.Url.createObjectUrlFromBlob(blob);
|
||||||
|
final anchor =
|
||||||
|
html.document.createElement('a') as html.AnchorElement
|
||||||
|
..href = url
|
||||||
|
..download = filename
|
||||||
|
..style.display = 'none';
|
||||||
|
html.document.body?.children.add(anchor);
|
||||||
|
anchor.click();
|
||||||
|
anchor.remove();
|
||||||
|
html.Url.revokeObjectUrl(url);
|
||||||
|
return true;
|
||||||
|
} catch (_) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,68 +1,68 @@
|
||||||
{
|
{
|
||||||
"images" : [
|
"info": {
|
||||||
{
|
"version": 1,
|
||||||
"size" : "16x16",
|
"author": "xcode"
|
||||||
"idiom" : "mac",
|
|
||||||
"filename" : "app_icon_16.png",
|
|
||||||
"scale" : "1x"
|
|
||||||
},
|
},
|
||||||
{
|
"images": [
|
||||||
"size" : "16x16",
|
{
|
||||||
"idiom" : "mac",
|
"size": "16x16",
|
||||||
"filename" : "app_icon_32.png",
|
"idiom": "mac",
|
||||||
"scale" : "2x"
|
"filename": "app_icon_16.png",
|
||||||
},
|
"scale": "1x"
|
||||||
{
|
},
|
||||||
"size" : "32x32",
|
{
|
||||||
"idiom" : "mac",
|
"size": "16x16",
|
||||||
"filename" : "app_icon_32.png",
|
"idiom": "mac",
|
||||||
"scale" : "1x"
|
"filename": "app_icon_32.png",
|
||||||
},
|
"scale": "2x"
|
||||||
{
|
},
|
||||||
"size" : "32x32",
|
{
|
||||||
"idiom" : "mac",
|
"size": "32x32",
|
||||||
"filename" : "app_icon_64.png",
|
"idiom": "mac",
|
||||||
"scale" : "2x"
|
"filename": "app_icon_32.png",
|
||||||
},
|
"scale": "1x"
|
||||||
{
|
},
|
||||||
"size" : "128x128",
|
{
|
||||||
"idiom" : "mac",
|
"size": "32x32",
|
||||||
"filename" : "app_icon_128.png",
|
"idiom": "mac",
|
||||||
"scale" : "1x"
|
"filename": "app_icon_64.png",
|
||||||
},
|
"scale": "2x"
|
||||||
{
|
},
|
||||||
"size" : "128x128",
|
{
|
||||||
"idiom" : "mac",
|
"size": "128x128",
|
||||||
"filename" : "app_icon_256.png",
|
"idiom": "mac",
|
||||||
"scale" : "2x"
|
"filename": "app_icon_128.png",
|
||||||
},
|
"scale": "1x"
|
||||||
{
|
},
|
||||||
"size" : "256x256",
|
{
|
||||||
"idiom" : "mac",
|
"size": "128x128",
|
||||||
"filename" : "app_icon_256.png",
|
"idiom": "mac",
|
||||||
"scale" : "1x"
|
"filename": "app_icon_256.png",
|
||||||
},
|
"scale": "2x"
|
||||||
{
|
},
|
||||||
"size" : "256x256",
|
{
|
||||||
"idiom" : "mac",
|
"size": "256x256",
|
||||||
"filename" : "app_icon_512.png",
|
"idiom": "mac",
|
||||||
"scale" : "2x"
|
"filename": "app_icon_256.png",
|
||||||
},
|
"scale": "1x"
|
||||||
{
|
},
|
||||||
"size" : "512x512",
|
{
|
||||||
"idiom" : "mac",
|
"size": "256x256",
|
||||||
"filename" : "app_icon_512.png",
|
"idiom": "mac",
|
||||||
"scale" : "1x"
|
"filename": "app_icon_512.png",
|
||||||
},
|
"scale": "2x"
|
||||||
{
|
},
|
||||||
"size" : "512x512",
|
{
|
||||||
"idiom" : "mac",
|
"size": "512x512",
|
||||||
"filename" : "app_icon_1024.png",
|
"idiom": "mac",
|
||||||
"scale" : "2x"
|
"filename": "app_icon_512.png",
|
||||||
}
|
"scale": "1x"
|
||||||
],
|
},
|
||||||
"info" : {
|
{
|
||||||
"version" : 1,
|
"size": "512x512",
|
||||||
"author" : "xcode"
|
"idiom": "mac",
|
||||||
}
|
"filename": "app_icon_1024.png",
|
||||||
}
|
"scale": "2x"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
Before Width: | Height: | Size: 101 KiB After Width: | Height: | Size: 8.1 KiB |
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 520 B After Width: | Height: | Size: 992 B |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 1.6 KiB |
22
pubspec.yaml
|
|
@ -58,6 +58,8 @@ dependencies:
|
||||||
logging: ^1.3.0
|
logging: ^1.3.0
|
||||||
riverpod_annotation: ^2.6.1
|
riverpod_annotation: ^2.6.1
|
||||||
colorfilter_generator: ^0.0.8
|
colorfilter_generator: ^0.0.8
|
||||||
|
flutter_box_transform: ^0.4.7
|
||||||
|
# disable_web_context_menu: ^1.1.0
|
||||||
# ml_linalg: ^13.12.6
|
# ml_linalg: ^13.12.6
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
|
|
@ -84,6 +86,7 @@ dev_dependencies:
|
||||||
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
|
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
|
||||||
|
|
@ -127,3 +130,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"
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import 'package:pdf_signature/app.dart';
|
import 'package:pdf_signature/app.dart';
|
||||||
import 'package:pdf_signature/data/repositories/preferences_repository.dart';
|
import 'package:pdf_signature/data/repositories/preferences_repository.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/widgets/ui_services.dart';
|
import 'package:pdf_signature/ui/features/pdf/view_model/pdf_export_view_model.dart';
|
||||||
import 'package:pdf_signature/data/services/export_service.dart';
|
import 'package:pdf_signature/data/services/export_service.dart';
|
||||||
import 'package:pdf_signature/domain/models/model.dart';
|
import 'package:pdf_signature/domain/models/model.dart';
|
||||||
|
|
||||||
|
|
@ -51,8 +51,13 @@ Future<ProviderContainer> pumpApp(
|
||||||
pdfViewModelProvider.overrideWith(
|
pdfViewModelProvider.overrideWith(
|
||||||
(ref) => PdfViewModel(ref, useMockViewer: true),
|
(ref) => PdfViewModel(ref, useMockViewer: true),
|
||||||
),
|
),
|
||||||
exportServiceProvider.overrideWith((ref) => fakeExport),
|
pdfExportViewModelProvider.overrideWith(
|
||||||
savePathPickerProvider.overrideWith((ref) => () async => 'out.pdf'),
|
(ref) => PdfExportViewModel(
|
||||||
|
ref,
|
||||||
|
exporter: fakeExport,
|
||||||
|
savePathPicker: () async => 'out.pdf',
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
import 'package:pdf_signature/data/services/export_service.dart';
|
import 'package:pdf_signature/data/services/export_service.dart';
|
||||||
import 'package:pdf_signature/ui/features/pdf/widgets/ui_services.dart';
|
import 'package:pdf_signature/ui/features/pdf/view_model/pdf_export_view_model.dart';
|
||||||
import 'package:pdf_signature/data/repositories/preferences_repository.dart';
|
import 'package:pdf_signature/data/repositories/preferences_repository.dart';
|
||||||
import 'package:pdf_signature/ui/features/pdf/view_model/pdf_view_model.dart';
|
import 'package:pdf_signature/ui/features/pdf/view_model/pdf_view_model.dart';
|
||||||
|
|
||||||
|
|
@ -62,9 +62,12 @@ void main() {
|
||||||
pdfViewModelProvider.overrideWith(
|
pdfViewModelProvider.overrideWith(
|
||||||
(ref) => PdfViewModel(ref, useMockViewer: true),
|
(ref) => PdfViewModel(ref, useMockViewer: true),
|
||||||
),
|
),
|
||||||
exportServiceProvider.overrideWith((_) => fake),
|
pdfExportViewModelProvider.overrideWith(
|
||||||
savePathPickerProvider.overrideWith(
|
(ref) => PdfExportViewModel(
|
||||||
(_) => () async => 'C:/tmp/output.pdf',
|
ref,
|
||||||
|
exporter: fake,
|
||||||
|
savePathPicker: () async => 'C:/tmp/output.pdf',
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
child: MaterialApp(
|
child: MaterialApp(
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import 'package:image/image.dart' as img;
|
||||||
|
|
||||||
import 'package:pdf_signature/ui/features/pdf/widgets/pdf_screen.dart';
|
import 'package:pdf_signature/ui/features/pdf/widgets/pdf_screen.dart';
|
||||||
import 'package:pdf_signature/ui/features/pdf/view_model/pdf_view_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/view_model/pdf_export_view_model.dart';
|
||||||
import 'package:pdf_signature/data/repositories/document_repository.dart';
|
import 'package:pdf_signature/data/repositories/document_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/data/repositories/signature_card_repository.dart';
|
||||||
|
|
@ -26,7 +26,9 @@ Future<void> pumpWithOpenPdf(WidgetTester tester) async {
|
||||||
pdfViewModelProvider.overrideWith(
|
pdfViewModelProvider.overrideWith(
|
||||||
(ref) => PdfViewModel(ref, useMockViewer: true),
|
(ref) => PdfViewModel(ref, useMockViewer: true),
|
||||||
),
|
),
|
||||||
exportingProvider.overrideWith((ref) => false),
|
pdfExportViewModelProvider.overrideWith(
|
||||||
|
(ref) => PdfExportViewModel(ref),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
child: MaterialApp(
|
child: MaterialApp(
|
||||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||||
|
|
@ -398,7 +400,9 @@ Future<void> pumpWithOpenPdfAndSig(WidgetTester tester) async {
|
||||||
pdfViewModelProvider.overrideWith(
|
pdfViewModelProvider.overrideWith(
|
||||||
(ref) => PdfViewModel(ref, useMockViewer: true),
|
(ref) => PdfViewModel(ref, useMockViewer: true),
|
||||||
),
|
),
|
||||||
exportingProvider.overrideWith((ref) => false),
|
pdfExportViewModelProvider.overrideWith(
|
||||||
|
(ref) => PdfExportViewModel(ref),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
child: MaterialApp(
|
child: MaterialApp(
|
||||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,727 @@
|
||||||
|
import 'dart:ui' as ui;
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/gestures.dart' show kSecondaryMouseButton;
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:flutter_box_transform/flutter_box_transform.dart';
|
||||||
|
import 'package:image/image.dart' as img;
|
||||||
|
|
||||||
|
import 'package:pdf_signature/ui/features/pdf/widgets/signature_overlay.dart';
|
||||||
|
import 'package:pdf_signature/ui/features/pdf/view_model/pdf_view_model.dart';
|
||||||
|
import 'package:pdf_signature/data/repositories/document_repository.dart';
|
||||||
|
import 'package:pdf_signature/domain/models/model.dart';
|
||||||
|
import 'package:pdf_signature/l10n/app_localizations.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
late ProviderContainer container;
|
||||||
|
late SignatureAsset testAsset;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
// Create a test signature asset
|
||||||
|
final canvas = img.Image(width: 60, height: 30);
|
||||||
|
img.fill(canvas, color: img.ColorUint8.rgb(255, 255, 255));
|
||||||
|
img.drawLine(
|
||||||
|
canvas,
|
||||||
|
x1: 5,
|
||||||
|
y1: 15,
|
||||||
|
x2: 55,
|
||||||
|
y2: 15,
|
||||||
|
color: img.ColorUint8.rgb(0, 0, 0),
|
||||||
|
);
|
||||||
|
final bytes = img.encodePng(canvas);
|
||||||
|
testAsset = SignatureAsset(bytes: bytes, name: 'test_signature.png');
|
||||||
|
|
||||||
|
container = ProviderContainer(
|
||||||
|
overrides: [
|
||||||
|
documentRepositoryProvider.overrideWith(
|
||||||
|
(ref) => DocumentStateNotifier()..openSample(),
|
||||||
|
),
|
||||||
|
pdfViewModelProvider.overrideWith(
|
||||||
|
(ref) => PdfViewModel(ref, useMockViewer: true),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() {
|
||||||
|
container.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
group('SignatureOverlay', () {
|
||||||
|
testWidgets('shows red border when unlocked', (tester) async {
|
||||||
|
// Add a signature placement
|
||||||
|
container
|
||||||
|
.read(documentRepositoryProvider.notifier)
|
||||||
|
.addPlacement(
|
||||||
|
page: 1,
|
||||||
|
rect: const Rect.fromLTWH(0.1, 0.1, 0.2, 0.1),
|
||||||
|
asset: testAsset,
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
UncontrolledProviderScope(
|
||||||
|
container: container,
|
||||||
|
child: MaterialApp(
|
||||||
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||||
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
|
home: Scaffold(
|
||||||
|
body: SignatureOverlay(
|
||||||
|
pageSize: const Size(400, 560),
|
||||||
|
rect: const Rect.fromLTWH(0.1, 0.1, 0.2, 0.1),
|
||||||
|
placement: SignaturePlacement(
|
||||||
|
rect: const Rect.fromLTWH(0.1, 0.1, 0.2, 0.1),
|
||||||
|
asset: testAsset,
|
||||||
|
),
|
||||||
|
placedIndex: 0,
|
||||||
|
pageNumber: 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Find the signature border DecoratedBox (with thicker border)
|
||||||
|
final transformableBox = find.byType(TransformableBox);
|
||||||
|
final allDecoratedBoxes = find.descendant(
|
||||||
|
of: transformableBox,
|
||||||
|
matching: find.byType(DecoratedBox),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Find the one with the thicker border (width 2.0) which is the signature border
|
||||||
|
DecoratedBox? signatureBorderBox;
|
||||||
|
for (final finder in allDecoratedBoxes.evaluate()) {
|
||||||
|
final widget = finder.widget as DecoratedBox;
|
||||||
|
final decoration = widget.decoration;
|
||||||
|
if (decoration is BoxDecoration &&
|
||||||
|
decoration.border is Border &&
|
||||||
|
(decoration.border as Border).top.width == 2.0) {
|
||||||
|
signatureBorderBox = widget;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(signatureBorderBox, isNotNull);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
(signatureBorderBox!.decoration as BoxDecoration).border,
|
||||||
|
isA<Border>().having(
|
||||||
|
(border) => border.top.color,
|
||||||
|
'border color',
|
||||||
|
Colors.red,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('shows green border when locked', (tester) async {
|
||||||
|
// Add and lock a signature placement
|
||||||
|
container
|
||||||
|
.read(documentRepositoryProvider.notifier)
|
||||||
|
.addPlacement(
|
||||||
|
page: 1,
|
||||||
|
rect: const Rect.fromLTWH(0.1, 0.1, 0.2, 0.1),
|
||||||
|
asset: testAsset,
|
||||||
|
);
|
||||||
|
|
||||||
|
container
|
||||||
|
.read(pdfViewModelProvider.notifier)
|
||||||
|
.lockPlacement(page: 1, index: 0);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
UncontrolledProviderScope(
|
||||||
|
container: container,
|
||||||
|
child: MaterialApp(
|
||||||
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||||
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
|
home: Scaffold(
|
||||||
|
body: SignatureOverlay(
|
||||||
|
pageSize: const Size(400, 560),
|
||||||
|
rect: const Rect.fromLTWH(0.1, 0.1, 0.2, 0.1),
|
||||||
|
placement: SignaturePlacement(
|
||||||
|
rect: const Rect.fromLTWH(0.1, 0.1, 0.2, 0.1),
|
||||||
|
asset: testAsset,
|
||||||
|
),
|
||||||
|
placedIndex: 0,
|
||||||
|
pageNumber: 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Find the signature border DecoratedBox (with thicker border)
|
||||||
|
final transformableBox = find.byType(TransformableBox);
|
||||||
|
final allDecoratedBoxes = find.descendant(
|
||||||
|
of: transformableBox,
|
||||||
|
matching: find.byType(DecoratedBox),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Find the one with the thicker border (width 2.0) which is the signature border
|
||||||
|
DecoratedBox? signatureBorderBox;
|
||||||
|
for (final finder in allDecoratedBoxes.evaluate()) {
|
||||||
|
final widget = finder.widget as DecoratedBox;
|
||||||
|
final decoration = widget.decoration;
|
||||||
|
if (decoration is BoxDecoration &&
|
||||||
|
decoration.border is Border &&
|
||||||
|
(decoration.border as Border).top.width == 2.0) {
|
||||||
|
signatureBorderBox = widget;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(signatureBorderBox, isNotNull);
|
||||||
|
|
||||||
|
final decoratedBoxWidget = signatureBorderBox!;
|
||||||
|
|
||||||
|
expect(
|
||||||
|
(decoratedBoxWidget.decoration as BoxDecoration).border,
|
||||||
|
isA<Border>().having(
|
||||||
|
(border) => border.top.color,
|
||||||
|
'border color',
|
||||||
|
Colors.green,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('shows context menu on right-click', (tester) async {
|
||||||
|
// Add a signature placement
|
||||||
|
container
|
||||||
|
.read(documentRepositoryProvider.notifier)
|
||||||
|
.addPlacement(
|
||||||
|
page: 1,
|
||||||
|
rect: const Rect.fromLTWH(0.1, 0.1, 0.2, 0.1),
|
||||||
|
asset: testAsset,
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
UncontrolledProviderScope(
|
||||||
|
container: container,
|
||||||
|
child: MaterialApp(
|
||||||
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||||
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
|
home: Scaffold(
|
||||||
|
body: SignatureOverlay(
|
||||||
|
pageSize: const Size(400, 560),
|
||||||
|
rect: const Rect.fromLTWH(0.1, 0.1, 0.2, 0.1),
|
||||||
|
placement: SignaturePlacement(
|
||||||
|
rect: const Rect.fromLTWH(0.1, 0.1, 0.2, 0.1),
|
||||||
|
asset: testAsset,
|
||||||
|
),
|
||||||
|
placedIndex: 0,
|
||||||
|
pageNumber: 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Find the TransformableBox which contains our overlay
|
||||||
|
final transformableBox = find.byType(TransformableBox);
|
||||||
|
expect(transformableBox, findsOneWidget);
|
||||||
|
|
||||||
|
// Simulate right-click on the signature overlay
|
||||||
|
final center = tester.getCenter(transformableBox);
|
||||||
|
final TestGesture mouse = await tester.createGesture(
|
||||||
|
kind: ui.PointerDeviceKind.mouse,
|
||||||
|
buttons: kSecondaryMouseButton,
|
||||||
|
);
|
||||||
|
await mouse.addPointer(location: center);
|
||||||
|
addTearDown(mouse.removePointer);
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
await mouse.down(center);
|
||||||
|
await tester.pump(const Duration(milliseconds: 50));
|
||||||
|
await mouse.up();
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Verify context menu appears with lock option
|
||||||
|
expect(find.byKey(const Key('mi_placement_lock')), findsOneWidget);
|
||||||
|
expect(find.byKey(const Key('mi_placement_delete')), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('lock menu item shows "Lock (Confirm)" when unlocked', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
// Add a signature placement (unlocked by default)
|
||||||
|
container
|
||||||
|
.read(documentRepositoryProvider.notifier)
|
||||||
|
.addPlacement(
|
||||||
|
page: 1,
|
||||||
|
rect: const Rect.fromLTWH(0.1, 0.1, 0.2, 0.1),
|
||||||
|
asset: testAsset,
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
UncontrolledProviderScope(
|
||||||
|
container: container,
|
||||||
|
child: MaterialApp(
|
||||||
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||||
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
|
home: Scaffold(
|
||||||
|
body: SignatureOverlay(
|
||||||
|
pageSize: const Size(400, 560),
|
||||||
|
rect: const Rect.fromLTWH(0.1, 0.1, 0.2, 0.1),
|
||||||
|
placement: SignaturePlacement(
|
||||||
|
rect: const Rect.fromLTWH(0.1, 0.1, 0.2, 0.1),
|
||||||
|
asset: testAsset,
|
||||||
|
),
|
||||||
|
placedIndex: 0,
|
||||||
|
pageNumber: 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Simulate right-click
|
||||||
|
final transformableBox = find.byType(TransformableBox);
|
||||||
|
final center = tester.getCenter(transformableBox);
|
||||||
|
final TestGesture mouse = await tester.createGesture(
|
||||||
|
kind: ui.PointerDeviceKind.mouse,
|
||||||
|
buttons: kSecondaryMouseButton,
|
||||||
|
);
|
||||||
|
await mouse.addPointer(location: center);
|
||||||
|
addTearDown(mouse.removePointer);
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
await mouse.down(center);
|
||||||
|
await tester.pump(const Duration(milliseconds: 50));
|
||||||
|
await mouse.up();
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Check that menu shows "Lock (Confirm)" for unlocked state
|
||||||
|
final lockMenuItem = find.byKey(const Key('mi_placement_lock'));
|
||||||
|
expect(lockMenuItem, findsOneWidget);
|
||||||
|
|
||||||
|
final popupMenuItem = tester.widget<PopupMenuItem<String>>(lockMenuItem);
|
||||||
|
expect(popupMenuItem.value, 'lock');
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('lock menu item shows "Unlock" when locked', (tester) async {
|
||||||
|
// Add and lock a signature placement
|
||||||
|
container
|
||||||
|
.read(documentRepositoryProvider.notifier)
|
||||||
|
.addPlacement(
|
||||||
|
page: 1,
|
||||||
|
rect: const Rect.fromLTWH(0.1, 0.1, 0.2, 0.1),
|
||||||
|
asset: testAsset,
|
||||||
|
);
|
||||||
|
|
||||||
|
container
|
||||||
|
.read(pdfViewModelProvider.notifier)
|
||||||
|
.lockPlacement(page: 1, index: 0);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
UncontrolledProviderScope(
|
||||||
|
container: container,
|
||||||
|
child: MaterialApp(
|
||||||
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||||
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
|
home: Scaffold(
|
||||||
|
body: SignatureOverlay(
|
||||||
|
pageSize: const Size(400, 560),
|
||||||
|
rect: const Rect.fromLTWH(0.1, 0.1, 0.2, 0.1),
|
||||||
|
placement: SignaturePlacement(
|
||||||
|
rect: const Rect.fromLTWH(0.1, 0.1, 0.2, 0.1),
|
||||||
|
asset: testAsset,
|
||||||
|
),
|
||||||
|
placedIndex: 0,
|
||||||
|
pageNumber: 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Simulate right-click
|
||||||
|
final transformableBox = find.byType(TransformableBox);
|
||||||
|
final center = tester.getCenter(transformableBox);
|
||||||
|
final TestGesture mouse = await tester.createGesture(
|
||||||
|
kind: ui.PointerDeviceKind.mouse,
|
||||||
|
buttons: kSecondaryMouseButton,
|
||||||
|
);
|
||||||
|
await mouse.addPointer(location: center);
|
||||||
|
addTearDown(mouse.removePointer);
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
await mouse.down(center);
|
||||||
|
await tester.pump(const Duration(milliseconds: 50));
|
||||||
|
await mouse.up();
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Check that menu shows "Unlock" for locked state
|
||||||
|
final lockMenuItem = find.byKey(const Key('mi_placement_lock'));
|
||||||
|
expect(lockMenuItem, findsOneWidget);
|
||||||
|
|
||||||
|
final popupMenuItem = tester.widget<PopupMenuItem<String>>(lockMenuItem);
|
||||||
|
expect(popupMenuItem.value, 'unlock');
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('shows green border when placement is locked via view model', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
// Add a signature placement
|
||||||
|
container
|
||||||
|
.read(documentRepositoryProvider.notifier)
|
||||||
|
.addPlacement(
|
||||||
|
page: 1,
|
||||||
|
rect: const Rect.fromLTWH(0.1, 0.1, 0.2, 0.1),
|
||||||
|
asset: testAsset,
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
UncontrolledProviderScope(
|
||||||
|
container: container,
|
||||||
|
child: MaterialApp(
|
||||||
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||||
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
|
home: Scaffold(
|
||||||
|
body: SignatureOverlay(
|
||||||
|
pageSize: const Size(400, 560),
|
||||||
|
rect: const Rect.fromLTWH(0.1, 0.1, 0.2, 0.1),
|
||||||
|
placement: SignaturePlacement(
|
||||||
|
rect: const Rect.fromLTWH(0.1, 0.1, 0.2, 0.1),
|
||||||
|
asset: testAsset,
|
||||||
|
),
|
||||||
|
placedIndex: 0,
|
||||||
|
pageNumber: 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Initially should be unlocked (red border)
|
||||||
|
final transformableBox = find.byType(TransformableBox);
|
||||||
|
final allDecoratedBoxes = find.descendant(
|
||||||
|
of: transformableBox,
|
||||||
|
matching: find.byType(DecoratedBox),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Find the one with the thicker border (width 2.0) which is the signature border
|
||||||
|
DecoratedBox? initialSignatureBorderBox;
|
||||||
|
for (final finder in allDecoratedBoxes.evaluate()) {
|
||||||
|
final widget = finder.widget as DecoratedBox;
|
||||||
|
final decoration = widget.decoration;
|
||||||
|
if (decoration is BoxDecoration &&
|
||||||
|
decoration.border is Border &&
|
||||||
|
(decoration.border as Border).top.width == 2.0) {
|
||||||
|
initialSignatureBorderBox = widget;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(initialSignatureBorderBox, isNotNull);
|
||||||
|
expect(
|
||||||
|
(initialSignatureBorderBox!.decoration as BoxDecoration).border,
|
||||||
|
isA<Border>().having(
|
||||||
|
(border) => border.top.color,
|
||||||
|
'border color',
|
||||||
|
Colors.red,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Lock the placement via view model
|
||||||
|
container
|
||||||
|
.read(pdfViewModelProvider.notifier)
|
||||||
|
.lockPlacement(page: 1, index: 0);
|
||||||
|
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Should now be locked (green border)
|
||||||
|
final allDecoratedBoxesAfter = find.descendant(
|
||||||
|
of: transformableBox,
|
||||||
|
matching: find.byType(DecoratedBox),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Find the one with the thicker border (width 2.0) which is the signature border
|
||||||
|
DecoratedBox? updatedSignatureBorderBox;
|
||||||
|
for (final finder in allDecoratedBoxesAfter.evaluate()) {
|
||||||
|
final widget = finder.widget as DecoratedBox;
|
||||||
|
final decoration = widget.decoration;
|
||||||
|
if (decoration is BoxDecoration &&
|
||||||
|
decoration.border is Border &&
|
||||||
|
(decoration.border as Border).top.width == 2.0) {
|
||||||
|
updatedSignatureBorderBox = widget;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(updatedSignatureBorderBox, isNotNull);
|
||||||
|
expect(
|
||||||
|
(updatedSignatureBorderBox!.decoration as BoxDecoration).border,
|
||||||
|
isA<Border>().having(
|
||||||
|
(border) => border.top.color,
|
||||||
|
'border color',
|
||||||
|
Colors.green,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('locked signature cannot be dragged or resized', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
// Add and lock a signature placement
|
||||||
|
container
|
||||||
|
.read(documentRepositoryProvider.notifier)
|
||||||
|
.addPlacement(
|
||||||
|
page: 1,
|
||||||
|
rect: const Rect.fromLTWH(0.1, 0.1, 0.2, 0.1),
|
||||||
|
asset: testAsset,
|
||||||
|
);
|
||||||
|
|
||||||
|
container
|
||||||
|
.read(pdfViewModelProvider.notifier)
|
||||||
|
.lockPlacement(page: 1, index: 0);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
UncontrolledProviderScope(
|
||||||
|
container: container,
|
||||||
|
child: MaterialApp(
|
||||||
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||||
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
|
home: Scaffold(
|
||||||
|
body: SignatureOverlay(
|
||||||
|
pageSize: const Size(400, 560),
|
||||||
|
rect: const Rect.fromLTWH(0.1, 0.1, 0.2, 0.1),
|
||||||
|
placement: SignaturePlacement(
|
||||||
|
rect: const Rect.fromLTWH(0.1, 0.1, 0.2, 0.1),
|
||||||
|
asset: testAsset,
|
||||||
|
),
|
||||||
|
placedIndex: 0,
|
||||||
|
pageNumber: 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Verify the TransformableBox has onChanged set to null (disabled)
|
||||||
|
final transformableBox = find.byType(TransformableBox);
|
||||||
|
expect(transformableBox, findsOneWidget);
|
||||||
|
|
||||||
|
// Since onChanged is null for locked placements, dragging should not work
|
||||||
|
// This is tested implicitly by the fact that the onChanged callback is null
|
||||||
|
// when isPlacementLocked returns true
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('can unlock signature placement via context menu', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
// Add and lock a signature placement
|
||||||
|
container
|
||||||
|
.read(documentRepositoryProvider.notifier)
|
||||||
|
.addPlacement(
|
||||||
|
page: 1,
|
||||||
|
rect: const Rect.fromLTWH(0.1, 0.1, 0.2, 0.1),
|
||||||
|
asset: testAsset,
|
||||||
|
);
|
||||||
|
|
||||||
|
container
|
||||||
|
.read(pdfViewModelProvider.notifier)
|
||||||
|
.lockPlacement(page: 1, index: 0);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
UncontrolledProviderScope(
|
||||||
|
container: container,
|
||||||
|
child: MaterialApp(
|
||||||
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||||
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
|
home: Scaffold(
|
||||||
|
body: SignatureOverlay(
|
||||||
|
pageSize: const Size(400, 560),
|
||||||
|
rect: const Rect.fromLTWH(0.1, 0.1, 0.2, 0.1),
|
||||||
|
placement: SignaturePlacement(
|
||||||
|
rect: const Rect.fromLTWH(0.1, 0.1, 0.2, 0.1),
|
||||||
|
asset: testAsset,
|
||||||
|
),
|
||||||
|
placedIndex: 0,
|
||||||
|
pageNumber: 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Simulate right-click and select unlock
|
||||||
|
final transformableBox = find.byType(TransformableBox);
|
||||||
|
final center = tester.getCenter(transformableBox);
|
||||||
|
final TestGesture mouse = await tester.createGesture(
|
||||||
|
kind: ui.PointerDeviceKind.mouse,
|
||||||
|
buttons: kSecondaryMouseButton,
|
||||||
|
);
|
||||||
|
await mouse.addPointer(location: center);
|
||||||
|
addTearDown(mouse.removePointer);
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Instead of trying to tap the menu, directly call unlock on the view model
|
||||||
|
container
|
||||||
|
.read(pdfViewModelProvider.notifier)
|
||||||
|
.unlockPlacement(page: 1, index: 0);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Should now be unlocked (red border)
|
||||||
|
final allDecoratedBoxesAfterUnlock = find.descendant(
|
||||||
|
of: transformableBox,
|
||||||
|
matching: find.byType(DecoratedBox),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Find the one with the thicker border (width 2.0) which is the signature border
|
||||||
|
DecoratedBox? unlockedSignatureBorderBox;
|
||||||
|
for (final finder in allDecoratedBoxesAfterUnlock.evaluate()) {
|
||||||
|
final widget = finder.widget as DecoratedBox;
|
||||||
|
final decoration = widget.decoration;
|
||||||
|
if (decoration is BoxDecoration &&
|
||||||
|
decoration.border is Border &&
|
||||||
|
(decoration.border as Border).top.width == 2.0) {
|
||||||
|
unlockedSignatureBorderBox = widget;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(unlockedSignatureBorderBox, isNotNull);
|
||||||
|
|
||||||
|
final updatedWidget = unlockedSignatureBorderBox!;
|
||||||
|
expect(
|
||||||
|
(updatedWidget.decoration as BoxDecoration).border,
|
||||||
|
isA<Border>().having(
|
||||||
|
(border) => border.top.color,
|
||||||
|
'border color',
|
||||||
|
Colors.red,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('can delete signature placement via context menu', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
// Add a signature placement
|
||||||
|
container
|
||||||
|
.read(documentRepositoryProvider.notifier)
|
||||||
|
.addPlacement(
|
||||||
|
page: 1,
|
||||||
|
rect: const Rect.fromLTWH(0.1, 0.1, 0.2, 0.1),
|
||||||
|
asset: testAsset,
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
UncontrolledProviderScope(
|
||||||
|
container: container,
|
||||||
|
child: MaterialApp(
|
||||||
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||||
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
|
home: Scaffold(
|
||||||
|
body: SignatureOverlay(
|
||||||
|
pageSize: const Size(400, 560),
|
||||||
|
rect: const Rect.fromLTWH(0.1, 0.1, 0.2, 0.1),
|
||||||
|
placement: SignaturePlacement(
|
||||||
|
rect: const Rect.fromLTWH(0.1, 0.1, 0.2, 0.1),
|
||||||
|
asset: testAsset,
|
||||||
|
),
|
||||||
|
placedIndex: 0,
|
||||||
|
pageNumber: 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Verify signature is initially present
|
||||||
|
expect(find.byType(TransformableBox), findsOneWidget);
|
||||||
|
|
||||||
|
// Simulate right-click and select delete
|
||||||
|
final transformableBox = find.byType(TransformableBox);
|
||||||
|
final center = tester.getCenter(transformableBox);
|
||||||
|
final TestGesture mouse = await tester.createGesture(
|
||||||
|
kind: ui.PointerDeviceKind.mouse,
|
||||||
|
buttons: kSecondaryMouseButton,
|
||||||
|
);
|
||||||
|
await mouse.addPointer(location: center);
|
||||||
|
addTearDown(mouse.removePointer);
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
await mouse.down(center);
|
||||||
|
await tester.pump(const Duration(milliseconds: 50));
|
||||||
|
await mouse.up();
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Tap the delete menu item
|
||||||
|
await tester.tap(find.byKey(const Key('mi_placement_delete')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Check that the placement was removed from the repository
|
||||||
|
final placements = container
|
||||||
|
.read(documentRepositoryProvider.notifier)
|
||||||
|
.placementsOn(1);
|
||||||
|
expect(placements.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('locked signature cannot be dragged or resized', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
// Add and lock a signature placement
|
||||||
|
container
|
||||||
|
.read(documentRepositoryProvider.notifier)
|
||||||
|
.addPlacement(
|
||||||
|
page: 1,
|
||||||
|
rect: const Rect.fromLTWH(0.1, 0.1, 0.2, 0.1),
|
||||||
|
asset: testAsset,
|
||||||
|
);
|
||||||
|
|
||||||
|
container
|
||||||
|
.read(pdfViewModelProvider.notifier)
|
||||||
|
.lockPlacement(page: 1, index: 0);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
UncontrolledProviderScope(
|
||||||
|
container: container,
|
||||||
|
child: MaterialApp(
|
||||||
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||||
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
|
home: Scaffold(
|
||||||
|
body: SignatureOverlay(
|
||||||
|
pageSize: const Size(400, 560),
|
||||||
|
rect: const Rect.fromLTWH(0.1, 0.1, 0.2, 0.1),
|
||||||
|
placement: SignaturePlacement(
|
||||||
|
rect: const Rect.fromLTWH(0.1, 0.1, 0.2, 0.1),
|
||||||
|
asset: testAsset,
|
||||||
|
),
|
||||||
|
placedIndex: 0,
|
||||||
|
pageNumber: 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Verify the TransformableBox has onChanged set to null (disabled)
|
||||||
|
final transformableBox = find.byType(TransformableBox);
|
||||||
|
expect(transformableBox, findsOneWidget);
|
||||||
|
|
||||||
|
// Since onChanged is null for locked placements, dragging should not work
|
||||||
|
// This is tested implicitly by the fact that the onChanged callback is null
|
||||||
|
// when isPlacementLocked returns true
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,120 @@
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
/// Runs each integration test file sequentially to avoid multi-app start issues on desktop.
|
||||||
|
///
|
||||||
|
/// Usage:
|
||||||
|
/// dart tool/run_integration_tests.dart [--device=<id>] [--reporter=<name>] [--pattern=<glob>]
|
||||||
|
///
|
||||||
|
/// Defaults:
|
||||||
|
/// --device=linux
|
||||||
|
/// --reporter=compact
|
||||||
|
/// --pattern=*.dart (all files in integration_test/)
|
||||||
|
Future<int> main(List<String> args) async {
|
||||||
|
String device = 'linux';
|
||||||
|
String reporter = 'compact';
|
||||||
|
String pattern = '*.dart';
|
||||||
|
|
||||||
|
for (int i = 0; i < args.length; i++) {
|
||||||
|
final a = args[i];
|
||||||
|
if (a.startsWith('--device=')) {
|
||||||
|
device = a.substring(a.indexOf('=') + 1);
|
||||||
|
} else if (a == '--device' || a == '-d') {
|
||||||
|
if (i + 1 < args.length) {
|
||||||
|
device = args[++i];
|
||||||
|
}
|
||||||
|
} else if (a.startsWith('-d=')) {
|
||||||
|
device = a.substring(a.indexOf('=') + 1);
|
||||||
|
} else if (a.startsWith('--reporter=')) {
|
||||||
|
reporter = a.substring(a.indexOf('=') + 1);
|
||||||
|
} else if (a == '--reporter' || a == '-r') {
|
||||||
|
if (i + 1 < args.length) {
|
||||||
|
reporter = args[++i];
|
||||||
|
}
|
||||||
|
} else if (a.startsWith('--pattern=')) {
|
||||||
|
pattern = a.substring(a.indexOf('=') + 1);
|
||||||
|
} else if (a == '--pattern') {
|
||||||
|
if (i + 1 < args.length) {
|
||||||
|
pattern = args[++i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final dir = Directory('integration_test');
|
||||||
|
if (!await dir.exists()) {
|
||||||
|
stderr.writeln('integration_test/ not found. Run from the project root.');
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
final files =
|
||||||
|
(await dir
|
||||||
|
.list()
|
||||||
|
.where((e) => e is File && e.path.endsWith('.dart'))
|
||||||
|
.cast<File>()
|
||||||
|
.toList())
|
||||||
|
..sort((a, b) => a.path.compareTo(b.path));
|
||||||
|
|
||||||
|
List<File> selected;
|
||||||
|
if (pattern == '*.dart') {
|
||||||
|
selected = files;
|
||||||
|
} else {
|
||||||
|
// very simple glob: supports prefix/suffix match
|
||||||
|
if (pattern.startsWith('*')) {
|
||||||
|
final suffix = pattern.substring(1);
|
||||||
|
selected = files.where((f) => f.path.endsWith(suffix)).toList();
|
||||||
|
} else if (pattern.endsWith('*')) {
|
||||||
|
final prefix = pattern.substring(0, pattern.length - 1);
|
||||||
|
selected =
|
||||||
|
files
|
||||||
|
.where(
|
||||||
|
(f) => f.path
|
||||||
|
.split(Platform.pathSeparator)
|
||||||
|
.last
|
||||||
|
.startsWith(prefix),
|
||||||
|
)
|
||||||
|
.toList();
|
||||||
|
} else {
|
||||||
|
selected = files.where((f) => f.path.contains(pattern)).toList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selected.isEmpty) {
|
||||||
|
stderr.writeln('No integration tests matched pattern: $pattern');
|
||||||
|
return 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
stdout.writeln(
|
||||||
|
'Running ${selected.length} integration test file(s) sequentially...',
|
||||||
|
);
|
||||||
|
final results = <String, int>{};
|
||||||
|
|
||||||
|
for (final f in selected) {
|
||||||
|
final rel = f.path;
|
||||||
|
stdout.writeln('\n=== Running: $rel ===');
|
||||||
|
final args = <String>['test', rel, '-d', device, '-r', reporter];
|
||||||
|
final proc = await Process.start('flutter', args);
|
||||||
|
// Pipe output live
|
||||||
|
unawaited(proc.stdout.transform(utf8.decoder).forEach(stdout.write));
|
||||||
|
unawaited(proc.stderr.transform(utf8.decoder).forEach(stderr.write));
|
||||||
|
final code = await proc.exitCode;
|
||||||
|
results[rel] = code;
|
||||||
|
if (code == 0) {
|
||||||
|
stdout.writeln('=== PASSED: $rel ===');
|
||||||
|
} else {
|
||||||
|
stderr.writeln('=== FAILED (exit $code): $rel ===');
|
||||||
|
}
|
||||||
|
// Small pause between launches to let desktop/device settle
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 300));
|
||||||
|
}
|
||||||
|
|
||||||
|
stdout.writeln('\nSummary:');
|
||||||
|
var failures = 0;
|
||||||
|
for (final entry in results.entries) {
|
||||||
|
final status = entry.value == 0 ? 'PASS' : 'FAIL(${entry.value})';
|
||||||
|
stdout.writeln(' - ${entry.key}: $status');
|
||||||
|
if (entry.value != 0) failures += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return failures == 0 ? 0 : 1;
|
||||||
|
}
|
||||||
BIN
web/favicon.png
|
Before Width: | Height: | Size: 917 B After Width: | Height: | Size: 992 B |
|
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 8.1 KiB After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 3.6 KiB |
|
|
@ -3,8 +3,8 @@
|
||||||
"short_name": "pdf_signature",
|
"short_name": "pdf_signature",
|
||||||
"start_url": ".",
|
"start_url": ".",
|
||||||
"display": "standalone",
|
"display": "standalone",
|
||||||
"background_color": "#0175C2",
|
"background_color": "#hexcode",
|
||||||
"theme_color": "#0175C2",
|
"theme_color": "#hexcode",
|
||||||
"description": "A new Flutter project.",
|
"description": "A new Flutter project.",
|
||||||
"orientation": "portrait-primary",
|
"orientation": "portrait-primary",
|
||||||
"prefer_related_applications": false,
|
"prefer_related_applications": false,
|
||||||
|
|
@ -32,4 +32,4 @@
|
||||||
"purpose": "maskable"
|
"purpose": "maskable"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 1.3 KiB |