Compare commits
No commits in common. "353aa883d7fb25d0a06dc66a7c7e9c7579c9f279" and "c7922cff23d54656cc4b6117253a7533dba740b0" have entirely different histories.
353aa883d7
...
c7922cff23
12
CHANGELOG.md
12
CHANGELOG.md
|
|
@ -1,12 +0,0 @@
|
||||||
|
|
||||||
## 1.1.1
|
|
||||||
|
|
||||||
## 1.1.0
|
|
||||||
|
|
||||||
* refactor to clear domain models
|
|
||||||
* follow MVVM
|
|
||||||
|
|
||||||
## 1.0.0
|
|
||||||
|
|
||||||
* basic implementation
|
|
||||||
* support localization
|
|
||||||
|
|
@ -20,7 +20,6 @@ RUN --mount=type=cache,target=/root/.pub-cache \
|
||||||
rm -rf .dart_tool build && \
|
rm -rf .dart_tool build && \
|
||||||
flutter pub get && \
|
flutter pub get && \
|
||||||
flutter gen-l10n && \
|
flutter gen-l10n && \
|
||||||
flutter pub run build_runner build --delete-conflicting-outputs && \
|
|
||||||
flutter build web --release -O4 --wasm
|
flutter build web --release -O4 --wasm
|
||||||
|
|
||||||
# Stage 2: Caddy (Alpine) to serve static files with SPA fallback
|
# Stage 2: Caddy (Alpine) to serve static files with SPA fallback
|
||||||
|
|
|
||||||
|
|
@ -23,7 +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 (necessary for linux)
|
# 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
|
||||||
|
|
@ -37,7 +37,6 @@ flutter run -d <device_id>
|
||||||
#### Windows
|
#### Windows
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
dart run pdfrx:remove_wasm_modules
|
|
||||||
flutter build windows
|
flutter build windows
|
||||||
# create windows installer
|
# create windows installer
|
||||||
flutter pub run msix:create
|
flutter pub run msix:create
|
||||||
|
|
@ -71,7 +70,6 @@ Access your app at [http://localhost:8080](http://localhost:8080)
|
||||||
For Linux
|
For Linux
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
dart run pdfrx:remove_wasm_modules
|
|
||||||
flutter build linux
|
flutter build linux
|
||||||
cp -r build/linux/x64/release/bundle/ AppDir
|
cp -r build/linux/x64/release/bundle/ AppDir
|
||||||
appimagetool-x86_64.AppImage AppDir
|
appimagetool-x86_64.AppImage AppDir
|
||||||
|
|
|
||||||
|
|
@ -94,5 +94,3 @@ Some rule of thumb:
|
||||||
* whole app use its image object as image representation.
|
* whole app use its image object as image representation.
|
||||||
* aware that minimize, encode/decode usage, because its has poor performance on web
|
* aware that minimize, encode/decode usage, because its has poor performance on web
|
||||||
* `ColorFilterGenerator` can not replace `adjustColor` due to custom background removal algorithm need at last stage. It is GPU based, and offscreen-render then readback is not ideal.
|
* `ColorFilterGenerator` can not replace `adjustColor` due to custom background removal algorithm need at last stage. It is GPU based, and offscreen-render then readback is not ideal.
|
||||||
* [responsive_framework]
|
|
||||||
* RWD support
|
|
||||||
|
|
|
||||||
|
|
@ -1,35 +1,70 @@
|
||||||
import 'dart:io';
|
import 'dart:typed_data';
|
||||||
|
|
||||||
import 'package:file_selector/file_selector.dart' as fs;
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:integration_test/integration_test.dart';
|
import 'package:integration_test/integration_test.dart';
|
||||||
import 'package:pdf_signature/data/repositories/document_repository.dart';
|
import 'dart:io';
|
||||||
import 'package:pdf_signature/data/repositories/preferences_repository.dart';
|
import 'package:file_selector/file_selector.dart' as fs;
|
||||||
|
|
||||||
import 'package:pdf_signature/data/services/export_service.dart';
|
import 'package:pdf_signature/data/services/export_service.dart';
|
||||||
import 'package:pdf_signature/l10n/app_localizations.dart';
|
|
||||||
import 'package:pdf_signature/ui/features/pdf/view_model/pdf_export_view_model.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/document_repository.dart';
|
||||||
|
import 'package:pdf_signature/domain/models/model.dart';
|
||||||
import 'package:pdf_signature/ui/features/pdf/view_model/pdf_view_model.dart';
|
import 'package:pdf_signature/ui/features/pdf/view_model/pdf_view_model.dart';
|
||||||
|
import 'package:pdf_signature/ui/features/pdf/view_model/pdf_export_view_model.dart';
|
||||||
import 'package:pdf_signature/ui/features/pdf/widgets/pages_sidebar.dart';
|
import 'package:pdf_signature/ui/features/pdf/widgets/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:pdf_signature/ui/features/pdf/widgets/pdf_viewer_widget.dart';
|
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
import 'package:image/image.dart' as img;
|
||||||
|
import 'package:pdf_signature/data/repositories/preferences_repository.dart';
|
||||||
|
import 'package:pdf_signature/l10n/app_localizations.dart';
|
||||||
|
|
||||||
// Note: We use the real ExportService via the repository; no mocks here.
|
class RecordingExporter extends ExportService {
|
||||||
|
bool called = false;
|
||||||
|
@override
|
||||||
|
Future<bool> saveBytesToFile({required bytes, required outputPath}) async {
|
||||||
|
called = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lightweight fake exporter to avoid invoking heavy rasterization during tests
|
||||||
|
class LightweightExporter extends ExportService {
|
||||||
|
@override
|
||||||
|
Future<Uint8List?> exportSignedPdfFromBytes({
|
||||||
|
required Uint8List srcBytes,
|
||||||
|
required Size uiPageSize,
|
||||||
|
required Uint8List? signatureImageBytes,
|
||||||
|
Map<int, List<SignaturePlacement>>? placementsByPage,
|
||||||
|
Map<String, img.Image>? libraryImages,
|
||||||
|
double targetDpi = 144.0,
|
||||||
|
}) async {
|
||||||
|
// Return minimal non-empty bytes; content isn't used further in tests
|
||||||
|
return Uint8List.fromList([1, 2, 3]);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> saveBytesToFile({
|
||||||
|
required Uint8List bytes,
|
||||||
|
required String outputPath,
|
||||||
|
}) async {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||||
binding.framePolicy = LiveTestWidgetsFlutterBindingFramePolicy.fullyLive;
|
|
||||||
|
|
||||||
testWidgets('Save uses file selector (via provider) and injected exporter', (
|
testWidgets('Save uses file selector (via provider) and injected exporter', (
|
||||||
tester,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
|
final fake = RecordingExporter();
|
||||||
SharedPreferences.setMockInitialValues({});
|
SharedPreferences.setMockInitialValues({});
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
final pdfBytes =
|
|
||||||
await File('integration_test/data/sample-local-pdf.pdf').readAsBytes();
|
|
||||||
|
|
||||||
|
// For this test, we don't need the PDF bytes since it's not loaded
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
ProviderScope(
|
ProviderScope(
|
||||||
overrides: [
|
overrides: [
|
||||||
|
|
@ -37,18 +72,15 @@ void main() {
|
||||||
(ref) => PreferencesStateNotifier(prefs),
|
(ref) => PreferencesStateNotifier(prefs),
|
||||||
),
|
),
|
||||||
documentRepositoryProvider.overrideWith(
|
documentRepositoryProvider.overrideWith(
|
||||||
(ref) =>
|
(ref) => DocumentStateNotifier()..openPicked(pageCount: 3),
|
||||||
DocumentStateNotifier(service: ExportService())
|
|
||||||
..openPicked(pageCount: 3, bytes: pdfBytes),
|
|
||||||
),
|
),
|
||||||
pdfViewModelProvider.overrideWith(
|
pdfViewModelProvider.overrideWith(
|
||||||
(ref) => PdfViewModel(ref, useMockViewer: false),
|
(ref) => PdfViewModel(ref, useMockViewer: false),
|
||||||
),
|
),
|
||||||
// Disable overlays to avoid long-lived overlay animations in CI
|
|
||||||
viewerOverlaysEnabledProvider.overrideWith((ref) => false),
|
|
||||||
pdfExportViewModelProvider.overrideWith(
|
pdfExportViewModelProvider.overrideWith(
|
||||||
(ref) => PdfExportViewModel(
|
(ref) => PdfExportViewModel(
|
||||||
ref,
|
ref,
|
||||||
|
exporter: fake,
|
||||||
savePathPicker: () async {
|
savePathPicker: () async {
|
||||||
final dir = Directory.systemTemp.createTempSync('pdfsig_');
|
final dir = Directory.systemTemp.createTempSync('pdfsig_');
|
||||||
return '${dir.path}/output.pdf';
|
return '${dir.path}/output.pdf';
|
||||||
|
|
@ -59,7 +91,7 @@ void main() {
|
||||||
child: MaterialApp(
|
child: MaterialApp(
|
||||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||||
supportedLocales: AppLocalizations.supportedLocales,
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
locale: const Locale('en'),
|
locale: Locale('en'),
|
||||||
home: PdfSignatureHomePage(
|
home: PdfSignatureHomePage(
|
||||||
onPickPdf: () async {},
|
onPickPdf: () async {},
|
||||||
onClosePdf: () {},
|
onClosePdf: () {},
|
||||||
|
|
@ -78,14 +110,30 @@ void main() {
|
||||||
expect(find.textContaining('Saved:'), findsOneWidget);
|
expect(find.textContaining('Saved:'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('Export completes successfully (FOSS path)', (tester) async {
|
// Helper to build a simple in-memory PNG as a signature image
|
||||||
// Verify the exporter completes and shows SnackBar using the single
|
Uint8List _makeSig() {
|
||||||
// FOSS path (pdfrx render + pdf compose) on all platforms.
|
final canvas = img.Image(width: 80, height: 40);
|
||||||
|
img.fill(canvas, color: img.ColorUint8.rgb(255, 255, 255));
|
||||||
|
img.drawLine(
|
||||||
|
canvas,
|
||||||
|
x1: 6,
|
||||||
|
y1: 20,
|
||||||
|
x2: 74,
|
||||||
|
y2: 20,
|
||||||
|
color: img.ColorUint8.rgb(0, 0, 0),
|
||||||
|
);
|
||||||
|
return Uint8List.fromList(img.encodePng(canvas));
|
||||||
|
}
|
||||||
|
|
||||||
|
testWidgets('E2E (integration): place and confirm keeps size', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
final sigBytes = _makeSig();
|
||||||
final pdfBytes =
|
final pdfBytes =
|
||||||
await File('integration_test/data/sample-local-pdf.pdf').readAsBytes();
|
await File('integration_test/data/sample-local-pdf.pdf').readAsBytes();
|
||||||
|
|
||||||
SharedPreferences.setMockInitialValues({});
|
SharedPreferences.setMockInitialValues({});
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
ProviderScope(
|
ProviderScope(
|
||||||
overrides: [
|
overrides: [
|
||||||
|
|
@ -94,28 +142,31 @@ void main() {
|
||||||
),
|
),
|
||||||
documentRepositoryProvider.overrideWith(
|
documentRepositoryProvider.overrideWith(
|
||||||
(ref) =>
|
(ref) =>
|
||||||
DocumentStateNotifier(service: ExportService())
|
DocumentStateNotifier()
|
||||||
..openPicked(pageCount: 3, bytes: pdfBytes),
|
..openPicked(pageCount: 3, bytes: pdfBytes),
|
||||||
),
|
),
|
||||||
|
signatureAssetRepositoryProvider.overrideWith((ref) {
|
||||||
|
final c = SignatureAssetRepository();
|
||||||
|
c.addImage(img.decodeImage(sigBytes)!, name: 'image');
|
||||||
|
return c;
|
||||||
|
}),
|
||||||
|
signatureCardRepositoryProvider.overrideWith((ref) {
|
||||||
|
final cardRepo = SignatureCardStateNotifier();
|
||||||
|
final asset = SignatureAsset(
|
||||||
|
sigImage: img.decodeImage(sigBytes)!,
|
||||||
|
name: 'image',
|
||||||
|
);
|
||||||
|
cardRepo.addWithAsset(asset, 0.0);
|
||||||
|
return cardRepo;
|
||||||
|
}),
|
||||||
pdfViewModelProvider.overrideWith(
|
pdfViewModelProvider.overrideWith(
|
||||||
(ref) => PdfViewModel(ref, useMockViewer: false),
|
(ref) => PdfViewModel(ref, useMockViewer: false),
|
||||||
),
|
),
|
||||||
pdfExportViewModelProvider.overrideWith(
|
|
||||||
(ref) => PdfExportViewModel(
|
|
||||||
ref,
|
|
||||||
savePathPicker: () async {
|
|
||||||
final dir = Directory.systemTemp.createTempSync(
|
|
||||||
'pdfsig_linux_',
|
|
||||||
);
|
|
||||||
return '${dir.path}/out.pdf';
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
child: MaterialApp(
|
child: MaterialApp(
|
||||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||||
supportedLocales: AppLocalizations.supportedLocales,
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
locale: const Locale('en'),
|
locale: Locale('en'),
|
||||||
home: PdfSignatureHomePage(
|
home: PdfSignatureHomePage(
|
||||||
onPickPdf: () async {},
|
onPickPdf: () async {},
|
||||||
onClosePdf: () {},
|
onClosePdf: () {},
|
||||||
|
|
@ -124,26 +175,47 @@ void main() {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
await tester.pump();
|
|
||||||
|
|
||||||
await tester.tap(find.byKey(const Key('btn_save_pdf')));
|
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(find.textContaining('Saved:'), findsOneWidget);
|
final card = find.byKey(const Key('gd_signature_card_area')).first;
|
||||||
});
|
await tester.tap(card);
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
testWidgets('E2E (integration): place and confirm keeps size', (
|
final active = find.byKey(const Key('signature_overlay'));
|
||||||
tester,
|
expect(active, findsOneWidget);
|
||||||
) async {
|
final sizeBefore = tester.getSize(active);
|
||||||
// Skip in integration environment: overlay interaction was refactored
|
|
||||||
// and this check is covered by widget tests.
|
|
||||||
}, skip: true);
|
|
||||||
|
|
||||||
testWidgets('E2E (integration): programmatic placement size matches', (
|
await tester.ensureVisible(active);
|
||||||
tester,
|
await tester.pumpAndSettle();
|
||||||
) async {
|
// Programmatically simulate confirm: add placement with current rect and bound image, then clear active overlay.
|
||||||
// Skip in integration run; covered by lower-level widget tests.
|
final ctx = tester.element(find.byType(PdfSignatureHomePage));
|
||||||
return;
|
final container = ProviderScope.containerOf(ctx);
|
||||||
|
final r = container.read(pdfViewModelProvider).activeRect!;
|
||||||
|
final lib = container.read(signatureAssetRepositoryProvider);
|
||||||
|
final asset = lib.isNotEmpty ? lib.first : null;
|
||||||
|
final currentPage = container.read(pdfViewModelProvider).currentPage;
|
||||||
|
container
|
||||||
|
.read(documentRepositoryProvider.notifier)
|
||||||
|
.addPlacement(page: currentPage, rect: r, asset: asset);
|
||||||
|
// Clear active overlay by hiding signatures temporarily
|
||||||
|
// Note: signatureVisibilityProvider was removed in migration
|
||||||
|
// container.read(signatureVisibilityProvider.notifier).state = false;
|
||||||
|
await tester.pump();
|
||||||
|
// container.read(signatureVisibilityProvider.notifier).state = true;
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
final placed = find.byKey(const Key('placed_signature_0'));
|
||||||
|
expect(placed, findsOneWidget);
|
||||||
|
final sizeAfter = tester.getSize(placed);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
(sizeAfter.width - sizeBefore.width).abs() < sizeBefore.width * 0.15,
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
(sizeAfter.height - sizeBefore.height).abs() < sizeBefore.height * 0.15,
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---- PDF view interaction tests (merged from pdf_view_test.dart) ----
|
// ---- PDF view interaction tests (merged from pdf_view_test.dart) ----
|
||||||
|
|
@ -162,9 +234,9 @@ void main() {
|
||||||
(ref) => PreferencesStateNotifier(prefs),
|
(ref) => PreferencesStateNotifier(prefs),
|
||||||
),
|
),
|
||||||
documentRepositoryProvider.overrideWith(
|
documentRepositoryProvider.overrideWith(
|
||||||
(ref) => DocumentStateNotifier(
|
(ref) =>
|
||||||
service: ExportService(enableRaster: false),
|
DocumentStateNotifier()
|
||||||
)..openPicked(pageCount: 3, bytes: pdfBytes),
|
..openPicked(pageCount: 3, bytes: pdfBytes),
|
||||||
),
|
),
|
||||||
pdfViewModelProvider.overrideWith(
|
pdfViewModelProvider.overrideWith(
|
||||||
(ref) => PdfViewModel(ref, useMockViewer: false),
|
(ref) => PdfViewModel(ref, useMockViewer: false),
|
||||||
|
|
@ -173,7 +245,7 @@ void main() {
|
||||||
child: MaterialApp(
|
child: MaterialApp(
|
||||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||||
supportedLocales: AppLocalizations.supportedLocales,
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
locale: const Locale('en'),
|
locale: Locale('en'),
|
||||||
home: PdfSignatureHomePage(
|
home: PdfSignatureHomePage(
|
||||||
onPickPdf: () async {},
|
onPickPdf: () async {},
|
||||||
onClosePdf: () {},
|
onClosePdf: () {},
|
||||||
|
|
@ -208,9 +280,9 @@ void main() {
|
||||||
(ref) => PreferencesStateNotifier(prefs),
|
(ref) => PreferencesStateNotifier(prefs),
|
||||||
),
|
),
|
||||||
documentRepositoryProvider.overrideWith(
|
documentRepositoryProvider.overrideWith(
|
||||||
(ref) => DocumentStateNotifier(
|
(ref) =>
|
||||||
service: ExportService(enableRaster: false),
|
DocumentStateNotifier()
|
||||||
)..openPicked(pageCount: 3, bytes: pdfBytes),
|
..openPicked(pageCount: 3, bytes: pdfBytes),
|
||||||
),
|
),
|
||||||
pdfViewModelProvider.overrideWith(
|
pdfViewModelProvider.overrideWith(
|
||||||
(ref) => PdfViewModel(ref, useMockViewer: false),
|
(ref) => PdfViewModel(ref, useMockViewer: false),
|
||||||
|
|
@ -219,7 +291,7 @@ void main() {
|
||||||
child: MaterialApp(
|
child: MaterialApp(
|
||||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||||
supportedLocales: AppLocalizations.supportedLocales,
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
locale: const Locale('en'),
|
locale: Locale('en'),
|
||||||
home: PdfSignatureHomePage(
|
home: PdfSignatureHomePage(
|
||||||
onPickPdf: () async {},
|
onPickPdf: () async {},
|
||||||
onClosePdf: () {},
|
onClosePdf: () {},
|
||||||
|
|
@ -257,9 +329,9 @@ void main() {
|
||||||
(ref) => PreferencesStateNotifier(prefs),
|
(ref) => PreferencesStateNotifier(prefs),
|
||||||
),
|
),
|
||||||
documentRepositoryProvider.overrideWith(
|
documentRepositoryProvider.overrideWith(
|
||||||
(ref) => DocumentStateNotifier(
|
(ref) =>
|
||||||
service: ExportService(enableRaster: false),
|
DocumentStateNotifier()
|
||||||
)..openPicked(pageCount: 3, bytes: pdfBytes),
|
..openPicked(pageCount: 3, bytes: pdfBytes),
|
||||||
),
|
),
|
||||||
pdfViewModelProvider.overrideWith(
|
pdfViewModelProvider.overrideWith(
|
||||||
(ref) => PdfViewModel(ref, useMockViewer: false),
|
(ref) => PdfViewModel(ref, useMockViewer: false),
|
||||||
|
|
@ -268,7 +340,7 @@ void main() {
|
||||||
child: MaterialApp(
|
child: MaterialApp(
|
||||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||||
supportedLocales: AppLocalizations.supportedLocales,
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
locale: const Locale('en'),
|
locale: Locale('en'),
|
||||||
home: PdfSignatureHomePage(
|
home: PdfSignatureHomePage(
|
||||||
onPickPdf: () async {},
|
onPickPdf: () async {},
|
||||||
onClosePdf: () {},
|
onClosePdf: () {},
|
||||||
|
|
@ -310,7 +382,7 @@ void main() {
|
||||||
),
|
),
|
||||||
documentRepositoryProvider.overrideWith(
|
documentRepositoryProvider.overrideWith(
|
||||||
(ref) =>
|
(ref) =>
|
||||||
DocumentStateNotifier(service: ExportService())
|
DocumentStateNotifier()
|
||||||
..openPicked(pageCount: 3, bytes: pdfBytes),
|
..openPicked(pageCount: 3, bytes: pdfBytes),
|
||||||
),
|
),
|
||||||
pdfViewModelProvider.overrideWith(
|
pdfViewModelProvider.overrideWith(
|
||||||
|
|
@ -320,7 +392,7 @@ void main() {
|
||||||
child: MaterialApp(
|
child: MaterialApp(
|
||||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||||
supportedLocales: AppLocalizations.supportedLocales,
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
locale: const Locale('en'),
|
locale: Locale('en'),
|
||||||
home: PdfSignatureHomePage(
|
home: PdfSignatureHomePage(
|
||||||
onPickPdf: () async {},
|
onPickPdf: () async {},
|
||||||
onClosePdf: () {},
|
onClosePdf: () {},
|
||||||
|
|
@ -344,13 +416,11 @@ void main() {
|
||||||
expect(container.read(pdfViewModelProvider).currentPage, 2);
|
expect(container.read(pdfViewModelProvider).currentPage, 2);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets(
|
testWidgets('PDF View: tap viewer after export does not crash', (
|
||||||
'PDF View: tap viewer after export does not crash',
|
tester,
|
||||||
(tester) async {
|
) async {
|
||||||
final pdfBytes =
|
final pdfBytes =
|
||||||
await File(
|
await File('integration_test/data/sample-local-pdf.pdf').readAsBytes();
|
||||||
'integration_test/data/sample-local-pdf.pdf',
|
|
||||||
).readAsBytes();
|
|
||||||
SharedPreferences.setMockInitialValues({});
|
SharedPreferences.setMockInitialValues({});
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
|
||||||
|
|
@ -362,18 +432,16 @@ void main() {
|
||||||
),
|
),
|
||||||
documentRepositoryProvider.overrideWith(
|
documentRepositoryProvider.overrideWith(
|
||||||
(ref) =>
|
(ref) =>
|
||||||
DocumentStateNotifier(service: ExportService())
|
DocumentStateNotifier()
|
||||||
..openPicked(pageCount: 3, bytes: pdfBytes),
|
..openPicked(pageCount: 3, bytes: pdfBytes),
|
||||||
),
|
),
|
||||||
pdfViewModelProvider.overrideWith(
|
pdfViewModelProvider.overrideWith(
|
||||||
(ref) => PdfViewModel(ref, useMockViewer: false),
|
(ref) => PdfViewModel(ref, useMockViewer: false),
|
||||||
),
|
),
|
||||||
// Disable overlays to reduce post-export timers/animations.
|
|
||||||
viewerOverlaysEnabledProvider.overrideWith((ref) => false),
|
|
||||||
// Override only save path picker to avoid native dialogs; use real exporter
|
|
||||||
pdfExportViewModelProvider.overrideWith(
|
pdfExportViewModelProvider.overrideWith(
|
||||||
(ref) => PdfExportViewModel(
|
(ref) => PdfExportViewModel(
|
||||||
ref,
|
ref,
|
||||||
|
exporter: LightweightExporter(),
|
||||||
savePathPicker: () async {
|
savePathPicker: () async {
|
||||||
final dir = Directory.systemTemp.createTempSync(
|
final dir = Directory.systemTemp.createTempSync(
|
||||||
'pdfsig_after_',
|
'pdfsig_after_',
|
||||||
|
|
@ -398,44 +466,16 @@ void main() {
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
// Trigger export
|
// Trigger export
|
||||||
debugPrint('[AFTER_EXPORT] Tap save to start export');
|
|
||||||
await tester.tap(find.byKey(const Key('btn_save_pdf')));
|
await tester.tap(find.byKey(const Key('btn_save_pdf')));
|
||||||
// Wait for export to complete using a real async wait so the test harness
|
await tester.pumpAndSettle();
|
||||||
// doesn't expect frame settling.
|
|
||||||
await tester.runAsync(() async {
|
// Tap on the page area; should not crash
|
||||||
final deadline = DateTime.now().add(const Duration(seconds: 6));
|
final pageArea = find.byKey(const ValueKey('pdf_page_area'));
|
||||||
while (DateTime.now().isBefore(deadline)) {
|
expect(pageArea, findsOneWidget);
|
||||||
try {
|
await tester.tap(pageArea);
|
||||||
final container = ProviderScope.containerOf(
|
await tester.pumpAndSettle();
|
||||||
tester.element(find.byType(PdfSignatureHomePage)),
|
|
||||||
);
|
// Still present and responsive
|
||||||
final exporting =
|
expect(pageArea, findsOneWidget);
|
||||||
container.read(pdfExportViewModelProvider).exporting;
|
|
||||||
if (!exporting) break;
|
|
||||||
} catch (_) {
|
|
||||||
// If widget unmounted, just stop waiting.
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
await Future<void>.delayed(const Duration(milliseconds: 100));
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
// Tap the viewer after export finished to ensure no crash
|
|
||||||
final viewer = find.byKey(const ValueKey('pdf_page_area'));
|
|
||||||
expect(viewer, findsOneWidget);
|
|
||||||
await tester.tap(viewer);
|
|
||||||
await tester.pump(const Duration(milliseconds: 150));
|
|
||||||
// Hard-unmount the app to stop any viewer timers/animations
|
|
||||||
await tester.pumpWidget(const SizedBox.shrink());
|
|
||||||
await tester.pump(const Duration(milliseconds: 250));
|
|
||||||
await tester.pump(const Duration(milliseconds: 250));
|
|
||||||
// Give async zone a brief chance to flush background timers
|
|
||||||
await tester.runAsync(() async {
|
|
||||||
await Future<void>.delayed(const Duration(milliseconds: 250));
|
|
||||||
});
|
|
||||||
debugPrint('[AFTER_EXPORT] Test end reached (no crash)');
|
|
||||||
// Ensure the test registers a completed assertion.
|
|
||||||
expect(true, isTrue);
|
|
||||||
},
|
|
||||||
timeout: const Timeout(Duration(minutes: 2)),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
14
lib/app.dart
14
lib/app.dart
|
|
@ -5,7 +5,6 @@ import 'package:pdf_signature/l10n/app_localizations.dart';
|
||||||
import 'package:pdf_signature/routing/router.dart';
|
import 'package:pdf_signature/routing/router.dart';
|
||||||
import 'package:pdf_signature/ui/features/preferences/widgets/settings_screen.dart';
|
import 'package:pdf_signature/ui/features/preferences/widgets/settings_screen.dart';
|
||||||
import 'data/repositories/preferences_repository.dart';
|
import 'data/repositories/preferences_repository.dart';
|
||||||
import 'package:responsive_framework/responsive_framework.dart';
|
|
||||||
|
|
||||||
class MyApp extends StatelessWidget {
|
class MyApp extends StatelessWidget {
|
||||||
const MyApp({super.key});
|
const MyApp({super.key});
|
||||||
|
|
@ -57,7 +56,7 @@ class MyApp extends StatelessWidget {
|
||||||
routerConfig: ref.watch(routerProvider),
|
routerConfig: ref.watch(routerProvider),
|
||||||
builder: (context, child) {
|
builder: (context, child) {
|
||||||
final router = ref.watch(routerProvider);
|
final router = ref.watch(routerProvider);
|
||||||
final content = Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: Text(AppLocalizations.of(context).appTitle),
|
title: Text(AppLocalizations.of(context).appTitle),
|
||||||
actions: [
|
actions: [
|
||||||
|
|
@ -79,17 +78,6 @@ class MyApp extends StatelessWidget {
|
||||||
),
|
),
|
||||||
body: child,
|
body: child,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Apply Responsive Framework globally for layout and scrolling.
|
|
||||||
return ResponsiveBreakpoints.builder(
|
|
||||||
child: ClampingScrollWrapper.builder(context, content),
|
|
||||||
breakpoints: const [
|
|
||||||
Breakpoint(start: 0, end: 450, name: MOBILE),
|
|
||||||
Breakpoint(start: 451, end: 800, name: TABLET),
|
|
||||||
Breakpoint(start: 801, end: 1920, name: DESKTOP),
|
|
||||||
Breakpoint(start: 1921, end: double.infinity, name: '4K'),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import 'dart:isolate';
|
import 'dart:typed_data';
|
||||||
import 'package:flutter/foundation.dart';
|
|
||||||
import 'package:image/image.dart' as img;
|
import 'package:image/image.dart' as img;
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
@ -8,11 +7,9 @@ import 'package:pdf_signature/data/services/export_service.dart';
|
||||||
import '../../domain/models/model.dart';
|
import '../../domain/models/model.dart';
|
||||||
|
|
||||||
class DocumentStateNotifier extends StateNotifier<Document> {
|
class DocumentStateNotifier extends StateNotifier<Document> {
|
||||||
DocumentStateNotifier({ExportService? service})
|
DocumentStateNotifier() : super(Document.initial());
|
||||||
: _service = service ?? ExportService(),
|
|
||||||
super(Document.initial());
|
|
||||||
|
|
||||||
final ExportService _service;
|
final ExportService _service = ExportService();
|
||||||
|
|
||||||
@visibleForTesting
|
@visibleForTesting
|
||||||
void openSample() {
|
void openSample() {
|
||||||
|
|
@ -138,61 +135,21 @@ class DocumentStateNotifier extends StateNotifier<Document> {
|
||||||
return list[index].asset;
|
return list[index].asset;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<bool> exportDocument({
|
Future<void> exportDocument({
|
||||||
required String outputPath,
|
required String outputPath,
|
||||||
required Size uiPageSize,
|
required Size uiPageSize,
|
||||||
required Uint8List? signatureImageBytes,
|
required Uint8List? signatureImageBytes,
|
||||||
double targetDpi = 144.0,
|
|
||||||
}) async {
|
}) async {
|
||||||
final bytes = await exportDocumentToBytes(
|
if (!state.loaded || state.pickedPdfBytes == null) return;
|
||||||
uiPageSize: uiPageSize,
|
final bytes = await _service.exportSignedPdfFromBytes(
|
||||||
signatureImageBytes: signatureImageBytes,
|
|
||||||
targetDpi: targetDpi,
|
|
||||||
);
|
|
||||||
|
|
||||||
Future<void> _ = Future<void>.delayed(Duration.zero);
|
|
||||||
|
|
||||||
if (bytes == null) return false;
|
|
||||||
final ok = await _service.saveBytesToFile(
|
|
||||||
bytes: bytes,
|
|
||||||
outputPath: outputPath,
|
|
||||||
);
|
|
||||||
return ok;
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<Uint8List?> exportDocumentToBytes({
|
|
||||||
required Size uiPageSize,
|
|
||||||
required Uint8List? signatureImageBytes,
|
|
||||||
double targetDpi = 144.0,
|
|
||||||
}) async {
|
|
||||||
if (!state.loaded || state.pickedPdfBytes == null) return null;
|
|
||||||
// Experimental: run export in a background isolate using `compute`.
|
|
||||||
// We serialize placements and signature assets to isolate-safe data.
|
|
||||||
try {
|
|
||||||
final args = _buildIsolateArgs(
|
|
||||||
srcBytes: state.pickedPdfBytes!,
|
srcBytes: state.pickedPdfBytes!,
|
||||||
uiPageSize: uiPageSize,
|
uiPageSize: uiPageSize,
|
||||||
signatureImageBytes: signatureImageBytes,
|
signatureImageBytes: signatureImageBytes,
|
||||||
placementsByPage: state.placementsByPage,
|
placementsByPage: state.placementsByPage,
|
||||||
targetDpi: targetDpi,
|
|
||||||
);
|
|
||||||
final result = await compute<_ExportIsolateArgs, Uint8List?>(
|
|
||||||
_exportInIsolate,
|
|
||||||
args,
|
|
||||||
);
|
|
||||||
if (result != null) return result;
|
|
||||||
} catch (_) {
|
|
||||||
// Fall back to main-isolate export if isolate fails (e.g., engine limitations).
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback on main isolate
|
|
||||||
return await _service.exportSignedPdfFromBytes(
|
|
||||||
srcBytes: state.pickedPdfBytes!,
|
|
||||||
uiPageSize: uiPageSize,
|
|
||||||
signatureImageBytes: signatureImageBytes,
|
|
||||||
placementsByPage: state.placementsByPage,
|
|
||||||
targetDpi: targetDpi,
|
|
||||||
);
|
);
|
||||||
|
if (bytes == null) return;
|
||||||
|
_service.saveBytesToFile(bytes: bytes, outputPath: outputPath);
|
||||||
|
// await
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -200,129 +157,3 @@ final documentRepositoryProvider =
|
||||||
StateNotifierProvider<DocumentStateNotifier, Document>(
|
StateNotifierProvider<DocumentStateNotifier, Document>(
|
||||||
(ref) => DocumentStateNotifier(),
|
(ref) => DocumentStateNotifier(),
|
||||||
);
|
);
|
||||||
|
|
||||||
/// --- Isolate helpers of DocumentRepository ---
|
|
||||||
/// Following are helpers to transfer data to/from an isolate for export.
|
|
||||||
|
|
||||||
class _ExportIsolateArgs {
|
|
||||||
final TransferableTypedData src;
|
|
||||||
final double pageW;
|
|
||||||
final double pageH;
|
|
||||||
final double targetDpi;
|
|
||||||
final List<_IsoPagePlacements> pages;
|
|
||||||
final TransferableTypedData? signatureImageBytes; // not used currently
|
|
||||||
_ExportIsolateArgs({
|
|
||||||
required this.src,
|
|
||||||
required this.pageW,
|
|
||||||
required this.pageH,
|
|
||||||
required this.targetDpi,
|
|
||||||
required this.pages,
|
|
||||||
required this.signatureImageBytes,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
class _IsoPagePlacements {
|
|
||||||
final int page;
|
|
||||||
final List<_IsoPlacement> items;
|
|
||||||
_IsoPagePlacements(this.page, this.items);
|
|
||||||
}
|
|
||||||
|
|
||||||
class _IsoPlacement {
|
|
||||||
final double l, t, w, h;
|
|
||||||
final double rot;
|
|
||||||
final double contrast, brightness;
|
|
||||||
final bool bgRemoval;
|
|
||||||
final TransferableTypedData assetPng;
|
|
||||||
_IsoPlacement({
|
|
||||||
required this.l,
|
|
||||||
required this.t,
|
|
||||||
required this.w,
|
|
||||||
required this.h,
|
|
||||||
required this.rot,
|
|
||||||
required this.contrast,
|
|
||||||
required this.brightness,
|
|
||||||
required this.bgRemoval,
|
|
||||||
required this.assetPng,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
_ExportIsolateArgs _buildIsolateArgs({
|
|
||||||
required Uint8List srcBytes,
|
|
||||||
required Size uiPageSize,
|
|
||||||
required Uint8List? signatureImageBytes,
|
|
||||||
required Map<int, List<SignaturePlacement>> placementsByPage,
|
|
||||||
required double targetDpi,
|
|
||||||
}) {
|
|
||||||
final pages = <_IsoPagePlacements>[];
|
|
||||||
placementsByPage.forEach((page, items) {
|
|
||||||
final isoItems = <_IsoPlacement>[];
|
|
||||||
for (final p in items) {
|
|
||||||
// Encode the asset image to PNG for transfer; small count expected.
|
|
||||||
final png = Uint8List.fromList(img.encodePng(p.asset.sigImage, level: 3));
|
|
||||||
isoItems.add(
|
|
||||||
_IsoPlacement(
|
|
||||||
l: p.rect.left,
|
|
||||||
t: p.rect.top,
|
|
||||||
w: p.rect.width,
|
|
||||||
h: p.rect.height,
|
|
||||||
rot: p.rotationDeg,
|
|
||||||
contrast: p.graphicAdjust.contrast,
|
|
||||||
brightness: p.graphicAdjust.brightness,
|
|
||||||
bgRemoval: p.graphicAdjust.bgRemoval,
|
|
||||||
assetPng: TransferableTypedData.fromList([png]),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
pages.add(_IsoPagePlacements(page, isoItems));
|
|
||||||
});
|
|
||||||
return _ExportIsolateArgs(
|
|
||||||
src: TransferableTypedData.fromList([srcBytes]),
|
|
||||||
pageW: uiPageSize.width,
|
|
||||||
pageH: uiPageSize.height,
|
|
||||||
targetDpi: targetDpi,
|
|
||||||
pages: pages,
|
|
||||||
signatureImageBytes:
|
|
||||||
signatureImageBytes == null
|
|
||||||
? null
|
|
||||||
: TransferableTypedData.fromList([signatureImageBytes]),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<Uint8List?> _exportInIsolate(_ExportIsolateArgs args) async {
|
|
||||||
// Rebuild placements
|
|
||||||
final placementsByPage = <int, List<SignaturePlacement>>{};
|
|
||||||
for (final page in args.pages) {
|
|
||||||
final list = <SignaturePlacement>[];
|
|
||||||
for (final it in page.items) {
|
|
||||||
final bytes = it.assetPng.materialize().asUint8List();
|
|
||||||
final decoded = img.decodePng(bytes);
|
|
||||||
if (decoded == null) continue;
|
|
||||||
final asset = SignatureAsset(sigImage: decoded);
|
|
||||||
list.add(
|
|
||||||
SignaturePlacement(
|
|
||||||
rect: Rect.fromLTWH(it.l, it.t, it.w, it.h),
|
|
||||||
asset: asset,
|
|
||||||
rotationDeg: it.rot,
|
|
||||||
graphicAdjust: GraphicAdjust(
|
|
||||||
contrast: it.contrast,
|
|
||||||
brightness: it.brightness,
|
|
||||||
bgRemoval: it.bgRemoval,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (list.isNotEmpty) {
|
|
||||||
placementsByPage[page.page] = list;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
final src = args.src.materialize().asUint8List();
|
|
||||||
final service = ExportService();
|
|
||||||
return await service.exportSignedPdfFromBytes(
|
|
||||||
srcBytes: src,
|
|
||||||
uiPageSize: Size(args.pageW, args.pageH),
|
|
||||||
signatureImageBytes: args.signatureImageBytes?.materialize().asUint8List(),
|
|
||||||
placementsByPage: placementsByPage,
|
|
||||||
targetDpi: args.targetDpi,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,13 @@
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'dart:async';
|
|
||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
|
import 'package:flutter/rendering.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
import 'package:image/image.dart' as img;
|
|
||||||
import 'package:pdf/widgets.dart' as pw;
|
import 'package:pdf/widgets.dart' as pw;
|
||||||
import 'package:pdf/pdf.dart' as pdf;
|
import 'package:pdf/pdf.dart' as pdf;
|
||||||
import 'package:pdfrx_engine/pdfrx_engine.dart' as engine;
|
import 'package:printing/printing.dart' as printing;
|
||||||
|
import 'package:image/image.dart' as img;
|
||||||
import '../../domain/models/model.dart';
|
import '../../domain/models/model.dart';
|
||||||
|
// math moved to utils in rot
|
||||||
import '../../utils/rotation_utils.dart' as rot;
|
import '../../utils/rotation_utils.dart' as rot;
|
||||||
import '../../utils/background_removal.dart' as br;
|
import '../../utils/background_removal.dart' as br;
|
||||||
|
|
||||||
|
|
@ -17,32 +18,32 @@ import '../../utils/background_removal.dart' as br;
|
||||||
// cannot import/modify existing PDF pages. If/when a suitable FOSS library exists, wire it here.
|
// cannot import/modify existing PDF pages. If/when a suitable FOSS library exists, wire it here.
|
||||||
|
|
||||||
class ExportService {
|
class ExportService {
|
||||||
ExportService({this.enableRaster = true});
|
/// Compose a new PDF from source PDF bytes; returns the resulting PDF bytes.
|
||||||
// Deprecated: retained for API compatibility. Raster is no longer used.
|
|
||||||
final bool enableRaster;
|
|
||||||
|
|
||||||
/// Compose a new PDF by rendering source pages to images (FOSS path via pdfrx)
|
|
||||||
/// and overlaying signature images at normalized rects. Returns resulting bytes.
|
|
||||||
Future<Uint8List?> exportSignedPdfFromBytes({
|
Future<Uint8List?> exportSignedPdfFromBytes({
|
||||||
required Uint8List srcBytes,
|
required Uint8List srcBytes,
|
||||||
required Size uiPageSize, // not used in this implementation
|
required Size uiPageSize,
|
||||||
required Uint8List?
|
required Uint8List? signatureImageBytes,
|
||||||
signatureImageBytes, // not used; placements carry images
|
|
||||||
Map<int, List<SignaturePlacement>>? placementsByPage,
|
Map<int, List<SignaturePlacement>>? placementsByPage,
|
||||||
Map<String, img.Image>? libraryImages,
|
Map<String, img.Image>? libraryImages,
|
||||||
double targetDpi = 144.0,
|
double targetDpi = 144.0,
|
||||||
}) async {
|
}) async {
|
||||||
// Caches per call
|
// Per-call caches to avoid redundant decode/encode and image embedding work
|
||||||
final Map<String, img.Image> _baseImageCache = <String, img.Image>{};
|
final Map<String, img.Image> _baseImageCache = <String, img.Image>{};
|
||||||
final Map<String, img.Image> _processedImageCache = <String, img.Image>{};
|
final Map<String, img.Image> _processedImageCache = <String, img.Image>{};
|
||||||
final Map<String, Uint8List> _encodedPngCache = <String, Uint8List>{};
|
final Map<String, Uint8List> _encodedPngCache = <String, Uint8List>{};
|
||||||
|
final Map<String, pw.MemoryImage> _memoryImageCache =
|
||||||
|
<String, pw.MemoryImage>{};
|
||||||
final Map<String, double> _aspectRatioCache = <String, double>{};
|
final Map<String, double> _aspectRatioCache = <String, double>{};
|
||||||
|
|
||||||
|
// Returns a stable-ish cache key for bytes within this process (not content-hash, but good enough per-call)
|
||||||
String _baseKeyForImage(img.Image im) =>
|
String _baseKeyForImage(img.Image im) =>
|
||||||
'im:${identityHashCode(im)}:${im.width}x${im.height}';
|
'im:${identityHashCode(im)}:${im.width}x${im.height}';
|
||||||
String _adjustKey(GraphicAdjust adj) =>
|
String _adjustKey(GraphicAdjust adj) =>
|
||||||
'c=${adj.contrast}|b=${adj.brightness}|bg=${adj.bgRemoval}';
|
'c=${adj.contrast}|b=${adj.brightness}|bg=${adj.bgRemoval}';
|
||||||
|
|
||||||
|
// Removed: PNG signature helper is no longer needed; we always encode to PNG explicitly.
|
||||||
|
|
||||||
|
// Resolve base (unprocessed) image for a placement, considering library override.
|
||||||
img.Image _getBaseImage(SignaturePlacement placement) {
|
img.Image _getBaseImage(SignaturePlacement placement) {
|
||||||
final libKey = placement.asset.name;
|
final libKey = placement.asset.name;
|
||||||
if (libKey != null && libraryImages != null) {
|
if (libKey != null && libraryImages != null) {
|
||||||
|
|
@ -57,6 +58,7 @@ class ExportService {
|
||||||
return placement.asset.sigImage;
|
return placement.asset.sigImage;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get processed image for a placement, with caching.
|
||||||
img.Image _getProcessedImage(SignaturePlacement placement) {
|
img.Image _getProcessedImage(SignaturePlacement placement) {
|
||||||
final base = _getBaseImage(placement);
|
final base = _getBaseImage(placement);
|
||||||
final key =
|
final key =
|
||||||
|
|
@ -72,15 +74,14 @@ class ExportService {
|
||||||
brightness: adj.brightness,
|
brightness: adj.brightness,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Future<void> _ = Future<void>.delayed(Duration.zero);
|
|
||||||
if (adj.bgRemoval) {
|
if (adj.bgRemoval) {
|
||||||
processed = br.removeNearWhiteBackground(processed, threshold: 240);
|
processed = br.removeNearWhiteBackground(processed, threshold: 240);
|
||||||
}
|
}
|
||||||
Future<void> _ = Future<void>.delayed(Duration.zero);
|
|
||||||
_processedImageCache[key] = processed;
|
_processedImageCache[key] = processed;
|
||||||
return processed;
|
return processed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get PNG bytes for the processed image, caching the encoding.
|
||||||
Uint8List _getProcessedPng(SignaturePlacement placement) {
|
Uint8List _getProcessedPng(SignaturePlacement placement) {
|
||||||
final base = _getBaseImage(placement);
|
final base = _getBaseImage(placement);
|
||||||
final key =
|
final key =
|
||||||
|
|
@ -93,6 +94,20 @@ class ExportService {
|
||||||
return png;
|
return png;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Wrap bytes in a pw.MemoryImage with caching.
|
||||||
|
pw.MemoryImage? _getMemoryImage(Uint8List bytes, String key) {
|
||||||
|
final cached = _memoryImageCache[key];
|
||||||
|
if (cached != null) return cached;
|
||||||
|
try {
|
||||||
|
final imgObj = pw.MemoryImage(bytes);
|
||||||
|
_memoryImageCache[key] = imgObj;
|
||||||
|
return imgObj;
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute and cache aspect ratio (width/height) for given image
|
||||||
double? _getAspectRatioFromImage(img.Image image) {
|
double? _getAspectRatioFromImage(img.Image image) {
|
||||||
final key = _baseKeyForImage(image);
|
final key = _baseKeyForImage(image);
|
||||||
final c = _aspectRatioCache[key];
|
final c = _aspectRatioCache[key];
|
||||||
|
|
@ -103,55 +118,30 @@ class ExportService {
|
||||||
return ar;
|
return ar;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize engine (safe to call multiple times)
|
|
||||||
try {
|
|
||||||
await engine.pdfrxInitialize();
|
|
||||||
} catch (_) {}
|
|
||||||
|
|
||||||
// Open source document from memory; if not supported, write temp file
|
|
||||||
engine.PdfDocument? doc;
|
|
||||||
try {
|
|
||||||
doc = await engine.PdfDocument.openData(srcBytes);
|
|
||||||
} catch (_) {
|
|
||||||
final tmp = File(
|
|
||||||
'${Directory.systemTemp.path}/pdfrx_src_${DateTime.now().millisecondsSinceEpoch}.pdf',
|
|
||||||
);
|
|
||||||
await tmp.writeAsBytes(srcBytes, flush: true);
|
|
||||||
doc = await engine.PdfDocument.openFile(tmp.path);
|
|
||||||
try {
|
|
||||||
tmp.deleteSync();
|
|
||||||
} catch (_) {}
|
|
||||||
}
|
|
||||||
// doc is guaranteed to be assigned by either openData or openFile above
|
|
||||||
|
|
||||||
final out = pw.Document(version: pdf.PdfVersion.pdf_1_4, compress: false);
|
final out = pw.Document(version: pdf.PdfVersion.pdf_1_4, compress: false);
|
||||||
final pages = doc.pages;
|
int pageIndex = 0;
|
||||||
final scale = targetDpi / 72.0;
|
bool anyPage = false;
|
||||||
for (int i = 0; i < pages.length; i++) {
|
try {
|
||||||
// Cooperative yield between pages so the UI can animate the spinner.
|
await for (final raster in printing.Printing.raster(
|
||||||
await Future<void>.delayed(Duration.zero);
|
srcBytes,
|
||||||
final page = pages[i];
|
dpi: targetDpi,
|
||||||
final pageIndex = i + 1;
|
)) {
|
||||||
final widthPts = page.width;
|
anyPage = true;
|
||||||
final heightPts = page.height;
|
pageIndex++;
|
||||||
|
final widthPx = raster.width;
|
||||||
|
final heightPx = raster.height;
|
||||||
|
final widthPts = widthPx * 72.0 / targetDpi;
|
||||||
|
final heightPts = heightPx * 72.0 / targetDpi;
|
||||||
|
|
||||||
// Render background image via engine
|
final bgPng = await raster.toPng();
|
||||||
final imgPage = await page.render(
|
final bgImg = pw.MemoryImage(bgPng);
|
||||||
fullWidth: widthPts * scale,
|
|
||||||
fullHeight: heightPts * scale,
|
|
||||||
);
|
|
||||||
if (imgPage == null) continue;
|
|
||||||
final bgImage = imgPage.createImageNF();
|
|
||||||
imgPage.dispose();
|
|
||||||
// Lower compression for background snapshot too.
|
|
||||||
final bgPng = Uint8List.fromList(img.encodePng(bgImage, level: 1));
|
|
||||||
final _ = Future<void>.delayed(Duration.zero);
|
|
||||||
final bgMem = pw.MemoryImage(bgPng);
|
|
||||||
|
|
||||||
|
final hasMulti =
|
||||||
|
(placementsByPage != null && placementsByPage.isNotEmpty);
|
||||||
final pagePlacements =
|
final pagePlacements =
|
||||||
(placementsByPage ??
|
hasMulti
|
||||||
const <int, List<SignaturePlacement>>{})[pageIndex] ??
|
? (placementsByPage[pageIndex] ?? const <SignaturePlacement>[])
|
||||||
const <SignaturePlacement>[];
|
: const <SignaturePlacement>[];
|
||||||
|
|
||||||
out.addPage(
|
out.addPage(
|
||||||
pw.Page(
|
pw.Page(
|
||||||
|
|
@ -165,26 +155,35 @@ class ExportService {
|
||||||
left: 0,
|
left: 0,
|
||||||
top: 0,
|
top: 0,
|
||||||
child: pw.Image(
|
child: pw.Image(
|
||||||
bgMem,
|
bgImg,
|
||||||
width: widthPts,
|
width: widthPts,
|
||||||
height: heightPts,
|
height: heightPts,
|
||||||
fit: pw.BoxFit.fill,
|
fit: pw.BoxFit.fill,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
// Multi-placement stamping: per-placement image from libraryBytes
|
||||||
for (final placement in pagePlacements) {
|
if (hasMulti && pagePlacements.isNotEmpty) {
|
||||||
|
for (var i = 0; i < pagePlacements.length; i++) {
|
||||||
|
final placement = pagePlacements[i];
|
||||||
final r = placement.rect;
|
final r = placement.rect;
|
||||||
|
// rect is stored in normalized units (0..1) relative to page
|
||||||
final left = r.left * widthPts;
|
final left = r.left * widthPts;
|
||||||
final top = r.top * heightPts;
|
final top = r.top * heightPts;
|
||||||
final w = r.width * widthPts;
|
final w = r.width * widthPts;
|
||||||
final h = r.height * heightPts;
|
final h = r.height * heightPts;
|
||||||
|
|
||||||
|
// Get processed image and embed as MemoryImage (cached)
|
||||||
final processedPng = _getProcessedPng(placement);
|
final processedPng = _getProcessedPng(placement);
|
||||||
if (processedPng.isEmpty) continue;
|
|
||||||
final memImg = pw.MemoryImage(processedPng);
|
|
||||||
final angle = rot.radians(placement.rotationDeg);
|
|
||||||
final baseImage = _getBaseImage(placement);
|
final baseImage = _getBaseImage(placement);
|
||||||
|
final memKey =
|
||||||
|
'${_baseKeyForImage(baseImage)}|${_adjustKey(placement.graphicAdjust)}';
|
||||||
|
if (processedPng.isNotEmpty) {
|
||||||
|
final imgObj = _getMemoryImage(processedPng, memKey);
|
||||||
|
if (imgObj != null) {
|
||||||
|
// Align with RotatedSignatureImage: counterclockwise positive
|
||||||
|
final angle = rot.radians(placement.rotationDeg);
|
||||||
|
// Use AR from base image
|
||||||
final ar = _getAspectRatioFromImage(baseImage);
|
final ar = _getAspectRatioFromImage(baseImage);
|
||||||
final scaleToFit = rot.scaleToFitForAngle(angle, ar: ar);
|
final scaleToFit = rot.scaleToFitForAngle(angle, ar: ar);
|
||||||
|
|
||||||
|
|
@ -201,29 +200,112 @@ class ExportService {
|
||||||
scale: scaleToFit,
|
scale: scaleToFit,
|
||||||
child: pw.Transform.rotate(
|
child: pw.Transform.rotate(
|
||||||
angle: angle,
|
angle: angle,
|
||||||
child: pw.Image(memImg),
|
child: pw.Image(imgObj),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
// Yield occasionally within large placement lists to keep UI responsive.
|
}
|
||||||
// ignore: unused_local_variable
|
}
|
||||||
final _ = Future<void>.delayed(Duration.zero);
|
}
|
||||||
}
|
}
|
||||||
return pw.Stack(children: children);
|
return pw.Stack(children: children);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
final _ = Future<void>.delayed(Duration.zero);
|
}
|
||||||
|
} catch (e) {
|
||||||
|
anyPage = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
final bytes = await out.save();
|
if (!anyPage) {
|
||||||
doc.dispose();
|
// Fallback as A4 blank page with optional signature
|
||||||
return bytes;
|
final widthPts = pdf.PdfPageFormat.a4.width;
|
||||||
|
final heightPts = pdf.PdfPageFormat.a4.height;
|
||||||
|
|
||||||
|
final hasMulti =
|
||||||
|
(placementsByPage != null && placementsByPage.isNotEmpty);
|
||||||
|
final pagePlacements =
|
||||||
|
hasMulti
|
||||||
|
? (placementsByPage[1] ?? const <SignaturePlacement>[])
|
||||||
|
: const <SignaturePlacement>[];
|
||||||
|
|
||||||
|
out.addPage(
|
||||||
|
pw.Page(
|
||||||
|
pageTheme: pw.PageTheme(
|
||||||
|
margin: pw.EdgeInsets.zero,
|
||||||
|
pageFormat: pdf.PdfPageFormat(widthPts, heightPts),
|
||||||
|
),
|
||||||
|
build: (ctx) {
|
||||||
|
final children = <pw.Widget>[
|
||||||
|
pw.Container(
|
||||||
|
width: widthPts,
|
||||||
|
height: heightPts,
|
||||||
|
color: pdf.PdfColors.white,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
if (hasMulti && pagePlacements.isNotEmpty) {
|
||||||
|
for (var i = 0; i < pagePlacements.length; i++) {
|
||||||
|
final placement = pagePlacements[i];
|
||||||
|
final r = placement.rect;
|
||||||
|
// rect is stored in normalized units (0..1) relative to page
|
||||||
|
final left = r.left * widthPts;
|
||||||
|
final top = r.top * heightPts;
|
||||||
|
final w = r.width * widthPts;
|
||||||
|
final h = r.height * heightPts;
|
||||||
|
|
||||||
|
final processedPng = _getProcessedPng(placement);
|
||||||
|
final baseImage = _getBaseImage(placement);
|
||||||
|
final memKey =
|
||||||
|
'${_baseKeyForImage(baseImage)}|${_adjustKey(placement.graphicAdjust)}';
|
||||||
|
if (processedPng.isNotEmpty) {
|
||||||
|
final imgObj = _getMemoryImage(processedPng, memKey);
|
||||||
|
if (imgObj != null) {
|
||||||
|
final angle = rot.radians(placement.rotationDeg);
|
||||||
|
final ar = _getAspectRatioFromImage(baseImage);
|
||||||
|
final scaleToFit = rot.scaleToFitForAngle(angle, ar: ar);
|
||||||
|
|
||||||
|
children.add(
|
||||||
|
pw.Positioned(
|
||||||
|
left: left,
|
||||||
|
top: top,
|
||||||
|
child: pw.SizedBox(
|
||||||
|
width: w,
|
||||||
|
height: h,
|
||||||
|
child: pw.FittedBox(
|
||||||
|
fit: pw.BoxFit.contain,
|
||||||
|
child: pw.Transform.scale(
|
||||||
|
scale: scaleToFit,
|
||||||
|
child: pw.Transform.rotate(
|
||||||
|
angle: angle,
|
||||||
|
child: pw.Image(imgObj),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return pw.Stack(children: children);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await out.save();
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Helper: write bytes returned from [exportSignedPdfFromBytes] to a file path.
|
||||||
Future<bool> saveBytesToFile({
|
Future<bool> saveBytesToFile({
|
||||||
required Uint8List bytes,
|
required Uint8List bytes,
|
||||||
required String outputPath,
|
required String outputPath,
|
||||||
|
|
@ -236,4 +318,6 @@ class ExportService {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Background removal implemented in utils/background_removal.dart
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,11 +8,6 @@ void main() {
|
||||||
// Ensure Flutter bindings are initialized before platform channel usage
|
// Ensure Flutter bindings are initialized before platform channel usage
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
// Disable right-click context menu on web using Flutter API
|
// Disable right-click context menu on web using Flutter API
|
||||||
if (kReleaseMode) {
|
|
||||||
debugPrint = (String? message, {int? wrapWidth}) {
|
|
||||||
// Empty implementation in release mode, effectively disabling debugPrint
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (kIsWeb) {
|
if (kIsWeb) {
|
||||||
BrowserContextMenu.disableContextMenu();
|
BrowserContextMenu.disableContextMenu();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,7 @@
|
||||||
import 'package:file_selector/file_selector.dart' as fs;
|
import 'package:file_selector/file_selector.dart' as fs;
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:pdf_signature/data/repositories/document_repository.dart';
|
import 'package:pdf_signature/data/services/export_service.dart';
|
||||||
|
|
||||||
/// ViewModel for export-related UI state and helpers.
|
/// ViewModel for export-related UI state and helpers.
|
||||||
class PdfExportViewModel extends ChangeNotifier {
|
class PdfExportViewModel extends ChangeNotifier {
|
||||||
|
|
@ -10,6 +9,7 @@ class PdfExportViewModel extends ChangeNotifier {
|
||||||
bool _exporting = false;
|
bool _exporting = false;
|
||||||
|
|
||||||
// Dependencies (injectable via constructor for tests)
|
// Dependencies (injectable via constructor for tests)
|
||||||
|
final ExportService _exporter;
|
||||||
// Zero-arg picker retained for backward compatibility with tests.
|
// Zero-arg picker retained for backward compatibility with tests.
|
||||||
final Future<String?> Function() _savePathPicker;
|
final Future<String?> Function() _savePathPicker;
|
||||||
// Preferred picker that accepts a suggested filename.
|
// Preferred picker that accepts a suggested filename.
|
||||||
|
|
@ -18,10 +18,12 @@ class PdfExportViewModel extends ChangeNotifier {
|
||||||
|
|
||||||
PdfExportViewModel(
|
PdfExportViewModel(
|
||||||
this.ref, {
|
this.ref, {
|
||||||
|
ExportService? exporter,
|
||||||
Future<String?> Function()? savePathPicker,
|
Future<String?> Function()? savePathPicker,
|
||||||
Future<String?> Function(String suggestedName)?
|
Future<String?> Function(String suggestedName)?
|
||||||
savePathPickerWithSuggestedName,
|
savePathPickerWithSuggestedName,
|
||||||
}) : _savePathPicker = savePathPicker ?? _defaultSavePathPicker,
|
}) : _exporter = exporter ?? ExportService(),
|
||||||
|
_savePathPicker = savePathPicker ?? _defaultSavePathPicker,
|
||||||
// Prefer provided suggested-name picker; otherwise, if only zero-arg
|
// Prefer provided suggested-name picker; otherwise, if only zero-arg
|
||||||
// picker is given (tests), wrap it; else use default that honors name.
|
// picker is given (tests), wrap it; else use default that honors name.
|
||||||
_savePathPickerWithSuggestedName =
|
_savePathPickerWithSuggestedName =
|
||||||
|
|
@ -38,22 +40,8 @@ class PdfExportViewModel extends ChangeNotifier {
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Perform export via document repository. Returns true on success.
|
/// Get the export service (overridable in tests via constructor).
|
||||||
Future<bool> exportToPath({
|
ExportService get exporter => _exporter;
|
||||||
required String outputPath,
|
|
||||||
required Size uiPageSize,
|
|
||||||
required Uint8List? signatureImageBytes,
|
|
||||||
double targetDpi = 144.0,
|
|
||||||
}) async {
|
|
||||||
return await ref
|
|
||||||
.read(documentRepositoryProvider.notifier)
|
|
||||||
.exportDocument(
|
|
||||||
outputPath: outputPath,
|
|
||||||
uiPageSize: uiPageSize,
|
|
||||||
signatureImageBytes: signatureImageBytes,
|
|
||||||
targetDpi: targetDpi,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Show save dialog and return the chosen path (null if canceled).
|
/// Show save dialog and return the chosen path (null if canceled).
|
||||||
Future<String?> pickSavePath() async {
|
Future<String?> pickSavePath() async {
|
||||||
|
|
|
||||||
|
|
@ -43,8 +43,6 @@ class PdfViewModel extends ChangeNotifier {
|
||||||
|
|
||||||
set currentPage(int value) {
|
set currentPage(int value) {
|
||||||
_currentPage = value.clamp(1, document.pageCount);
|
_currentPage = value.clamp(1, document.pageCount);
|
||||||
// ignore: avoid_print
|
|
||||||
debugPrint('PdfViewModel.currentPage set to $_currentPage');
|
|
||||||
if (!_isDisposed) {
|
if (!_isDisposed) {
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
@ -56,8 +54,6 @@ class PdfViewModel extends ChangeNotifier {
|
||||||
Document get document => ref.read(documentRepositoryProvider);
|
Document get document => ref.read(documentRepositoryProvider);
|
||||||
|
|
||||||
void jumpToPage(int page) {
|
void jumpToPage(int page) {
|
||||||
// ignore: avoid_print
|
|
||||||
debugPrint('PdfViewModel.jumpToPage ' + page.toString());
|
|
||||||
currentPage = page;
|
currentPage = page;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -40,29 +40,11 @@ class ThumbnailsView extends ConsumerWidget {
|
||||||
// to update provider when the page is actually reached.
|
// to update provider when the page is actually reached.
|
||||||
// For mock/unready: update provider immediately to drive scroll.
|
// For mock/unready: update provider immediately to drive scroll.
|
||||||
final isRealViewer = !viewModel.useMockViewer;
|
final isRealViewer = !viewModel.useMockViewer;
|
||||||
// Debug trace for navigation taps
|
|
||||||
// ignore: avoid_print
|
|
||||||
debugPrint(
|
|
||||||
'PagesSidebar.onTap page=$pageNumber isRealViewer=$isRealViewer controllerReady=${controller.isReady}',
|
|
||||||
);
|
|
||||||
if (isRealViewer && controller.isReady) {
|
if (isRealViewer && controller.isReady) {
|
||||||
try {
|
|
||||||
controller.goToPage(
|
controller.goToPage(
|
||||||
pageNumber: pageNumber,
|
pageNumber: pageNumber,
|
||||||
anchor: PdfPageAnchor.top,
|
anchor: PdfPageAnchor.top,
|
||||||
);
|
);
|
||||||
// ignore: avoid_print
|
|
||||||
debugPrint(
|
|
||||||
'controller.goToPage invoked for page=$pageNumber',
|
|
||||||
);
|
|
||||||
} catch (e, st) {
|
|
||||||
// ignore: avoid_print
|
|
||||||
debugPrint(
|
|
||||||
'[ERR] controller.goToPage exception: ' + e.toString(),
|
|
||||||
);
|
|
||||||
// ignore: avoid_print
|
|
||||||
debugPrint(st.toString());
|
|
||||||
}
|
|
||||||
// Do not set provider here; let onPageChanged handle it
|
// Do not set provider here; let onPageChanged handle it
|
||||||
} else {
|
} else {
|
||||||
// In tests or when controller isn't ready, drive state directly
|
// In tests or when controller isn't ready, drive state directly
|
||||||
|
|
@ -70,8 +52,6 @@ class ThumbnailsView extends ConsumerWidget {
|
||||||
ref
|
ref
|
||||||
.read(pdfViewModelProvider.notifier)
|
.read(pdfViewModelProvider.notifier)
|
||||||
.jumpToPage(pageNumber);
|
.jumpToPage(pageNumber);
|
||||||
// ignore: avoid_print
|
|
||||||
debugPrint('jumpToPage set directly to $pageNumber');
|
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -93,11 +73,8 @@ class ThumbnailsView extends ConsumerWidget {
|
||||||
padding: const EdgeInsets.all(6),
|
padding: const EdgeInsets.all(6),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
ConstrainedBox(
|
SizedBox(
|
||||||
constraints: const BoxConstraints(maxHeight: 180),
|
height: 180,
|
||||||
child: AspectRatio(
|
|
||||||
// A4 portrait aspect: width:height ≈ 1:1.4142
|
|
||||||
aspectRatio: 1 / 1.4142,
|
|
||||||
child: ClipRRect(
|
child: ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(4),
|
borderRadius: BorderRadius.circular(4),
|
||||||
child: PdfPageView(
|
child: PdfPageView(
|
||||||
|
|
@ -107,7 +84,6 @@ class ThumbnailsView extends ConsumerWidget {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text('$pageNumber', style: theme.textTheme.bodySmall),
|
Text('$pageNumber', style: theme.textTheme.bodySmall),
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ import 'package:pdf_signature/l10n/app_localizations.dart';
|
||||||
import 'pdf_viewer_widget.dart';
|
import 'pdf_viewer_widget.dart';
|
||||||
import 'package:pdfrx/pdfrx.dart';
|
import 'package:pdfrx/pdfrx.dart';
|
||||||
import '../view_model/pdf_view_model.dart';
|
import '../view_model/pdf_view_model.dart';
|
||||||
import '../view_model/pdf_export_view_model.dart';
|
|
||||||
|
|
||||||
class PdfPageArea extends ConsumerStatefulWidget {
|
class PdfPageArea extends ConsumerStatefulWidget {
|
||||||
const PdfPageArea({
|
const PdfPageArea({
|
||||||
|
|
@ -39,8 +38,10 @@ class _PdfPageAreaState extends ConsumerState<PdfPageArea> {
|
||||||
super.initState();
|
super.initState();
|
||||||
// If app starts in continuous mode with a loaded PDF, ensure the viewer
|
// If app starts in continuous mode with a loaded PDF, ensure the viewer
|
||||||
// is instructed to align to the provider's current page once ready.
|
// is instructed to align to the provider's current page once ready.
|
||||||
// Do not schedule mock scroll sync in real viewer mode.
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
// In mock mode, scrolling is driven on demand when currentPage changes.
|
if (!mounted) return;
|
||||||
|
// initial scroll not needed; controller handles positioning
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// No dispose required for PdfViewerController (managed by owner if any)
|
// No dispose required for PdfViewerController (managed by owner if any)
|
||||||
|
|
@ -53,9 +54,6 @@ class _PdfPageAreaState extends ConsumerState<PdfPageArea> {
|
||||||
void _scrollToPage(int page) {
|
void _scrollToPage(int page) {
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
// Only valid in mock viewer mode; skip otherwise
|
|
||||||
final useMock = ref.read(pdfViewModelProvider).useMockViewer;
|
|
||||||
if (!useMock) return;
|
|
||||||
_programmaticTargetPage = page;
|
_programmaticTargetPage = page;
|
||||||
// Mock continuous: try ensureVisible on the page container
|
// Mock continuous: try ensureVisible on the page container
|
||||||
// Mock continuous: try ensureVisible on the page container
|
// Mock continuous: try ensureVisible on the page container
|
||||||
|
|
@ -116,13 +114,6 @@ class _PdfPageAreaState extends ConsumerState<PdfPageArea> {
|
||||||
// React to PdfViewModel currentPage changes. With ChangeNotifierProvider,
|
// React to PdfViewModel currentPage changes. With ChangeNotifierProvider,
|
||||||
// prev/next are the same instance, so compare to a local cache.
|
// prev/next are the same instance, so compare to a local cache.
|
||||||
ref.listen(pdfViewModelProvider, (prev, next) {
|
ref.listen(pdfViewModelProvider, (prev, next) {
|
||||||
// Only perform manual scrolling in mock viewer mode. In real viewer mode,
|
|
||||||
// PdfViewerController + onPageChanged keep things in sync, and attempting
|
|
||||||
// to scroll here (without mock page keys) creates repeated frame
|
|
||||||
// callbacks that never find targets, leading to hangs.
|
|
||||||
if (!next.useMockViewer) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (_suppressProviderListen) return;
|
if (_suppressProviderListen) return;
|
||||||
final target = next.currentPage;
|
final target = next.currentPage;
|
||||||
if (_lastListenedPage == target) return;
|
if (_lastListenedPage == target) return;
|
||||||
|
|
@ -152,18 +143,11 @@ class _PdfPageAreaState extends ConsumerState<PdfPageArea> {
|
||||||
|
|
||||||
// Use real PDF viewer
|
// Use real PDF viewer
|
||||||
if (isContinuous) {
|
if (isContinuous) {
|
||||||
// While exporting, fully detach the viewer to avoid background activity
|
|
||||||
// and ensure a clean re-initialization afterward.
|
|
||||||
final exporting = ref.watch(pdfExportViewModelProvider).exporting;
|
|
||||||
if (exporting) {
|
|
||||||
return const SizedBox.expand(key: Key('exporting_viewer_placeholder'));
|
|
||||||
}
|
|
||||||
return PdfViewerWidget(
|
return PdfViewerWidget(
|
||||||
pageSize: widget.pageSize,
|
pageSize: widget.pageSize,
|
||||||
pageKeyBuilder: _pageKey,
|
pageKeyBuilder: _pageKey,
|
||||||
scrollToPage: _scrollToPage,
|
scrollToPage: _scrollToPage,
|
||||||
controller: widget.controller,
|
controller: widget.controller,
|
||||||
innerViewerKey: const ValueKey('viewer_idle'),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return const SizedBox.shrink();
|
return const SizedBox.shrink();
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@ import '../../../../domain/models/model.dart';
|
||||||
import 'signature_overlay.dart';
|
import 'signature_overlay.dart';
|
||||||
import '../../signature/widgets/signature_drag_data.dart';
|
import '../../signature/widgets/signature_drag_data.dart';
|
||||||
import '../../signature/view_model/dragging_signature_view_model.dart';
|
import '../../signature/view_model/dragging_signature_view_model.dart';
|
||||||
import 'pdf_viewer_widget.dart' show viewerOverlaysEnabledProvider;
|
|
||||||
|
|
||||||
/// 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 {
|
||||||
|
|
@ -32,10 +31,6 @@ class PdfPageOverlays extends ConsumerWidget {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final overlaysEnabled = ref.watch(viewerOverlaysEnabledProvider);
|
|
||||||
if (!overlaysEnabled) {
|
|
||||||
return const SizedBox.shrink();
|
|
||||||
}
|
|
||||||
final pdfViewModel = ref.watch(pdfViewModelProvider);
|
final pdfViewModel = ref.watch(pdfViewModelProvider);
|
||||||
// Subscribe to document changes to rebuild overlays
|
// Subscribe to document changes to rebuild overlays
|
||||||
final pdf = ref.watch(documentRepositoryProvider);
|
final pdf = ref.watch(documentRepositoryProvider);
|
||||||
|
|
|
||||||
|
|
@ -16,8 +16,6 @@ import '../view_model/pdf_export_view_model.dart';
|
||||||
import 'package:pdf_signature/utils/download.dart';
|
import 'package:pdf_signature/utils/download.dart';
|
||||||
import '../view_model/pdf_view_model.dart';
|
import '../view_model/pdf_view_model.dart';
|
||||||
import 'package:image/image.dart' as img;
|
import 'package:image/image.dart' as img;
|
||||||
import 'package:pdf_signature/data/repositories/document_repository.dart';
|
|
||||||
import 'package:responsive_framework/responsive_framework.dart';
|
|
||||||
|
|
||||||
class PdfSignatureHomePage extends ConsumerStatefulWidget {
|
class PdfSignatureHomePage extends ConsumerStatefulWidget {
|
||||||
final Future<void> Function() onPickPdf;
|
final Future<void> Function() onPickPdf;
|
||||||
|
|
@ -59,7 +57,6 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
|
||||||
final double _signaturesMin = 140;
|
final double _signaturesMin = 140;
|
||||||
final double _signaturesMax = 250;
|
final double _signaturesMax = 250;
|
||||||
late PdfViewModel _viewModel;
|
late PdfViewModel _viewModel;
|
||||||
bool? _lastCanShowPagesSidebar;
|
|
||||||
|
|
||||||
// Exposed for tests to trigger the invalid-file SnackBar without UI.
|
// Exposed for tests to trigger the invalid-file SnackBar without UI.
|
||||||
@visibleForTesting
|
@visibleForTesting
|
||||||
|
|
@ -147,34 +144,27 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _saveSignedPdf() async {
|
Future<void> _saveSignedPdf() async {
|
||||||
// Show exporting overlay and then run the heavy work asynchronously so
|
|
||||||
// the UI thread remains responsive to gestures like page navigation.
|
|
||||||
ref.read(pdfExportViewModelProvider.notifier).setExporting(true);
|
ref.read(pdfExportViewModelProvider.notifier).setExporting(true);
|
||||||
// ignore: avoid_print
|
|
||||||
debugPrint('_saveSignedPdf: exporting flag set true');
|
|
||||||
final weakContext = context;
|
|
||||||
Future<void>(() async {
|
|
||||||
try {
|
try {
|
||||||
// ignore: avoid_print
|
|
||||||
debugPrint('_saveSignedPdf: async export task started');
|
|
||||||
final pdf = _viewModel.document;
|
final pdf = _viewModel.document;
|
||||||
final messenger = ScaffoldMessenger.of(weakContext);
|
final messenger = ScaffoldMessenger.of(context);
|
||||||
if (!pdf.loaded) {
|
if (!pdf.loaded) {
|
||||||
// ignore: avoid_print
|
|
||||||
debugPrint('_saveSignedPdf: document not loaded');
|
|
||||||
messenger.showSnackBar(
|
messenger.showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text(AppLocalizations.of(weakContext).nothingToSaveYet),
|
content: Text(AppLocalizations.of(context).nothingToSaveYet),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
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;
|
||||||
bool ok = false;
|
bool ok = false;
|
||||||
String? savedPath;
|
String? savedPath;
|
||||||
|
|
||||||
// Derive a suggested filename based on the opened file.
|
// Derive a suggested filename based on the opened file. Prefer the
|
||||||
|
// provided display name if available (see Linux portal note above).
|
||||||
final display = widget.currentFileName;
|
final display = widget.currentFileName;
|
||||||
final originalName =
|
final originalName =
|
||||||
(display != null && display.trim().isNotEmpty)
|
(display != null && display.trim().isNotEmpty)
|
||||||
|
|
@ -193,68 +183,67 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
|
||||||
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;
|
||||||
// ignore: avoid_print
|
final src = pdf.pickedPdfBytes ?? Uint8List(0);
|
||||||
debugPrint('_saveSignedPdf: picked save path ' + fullPath);
|
final out = await exporter.exportSignedPdfFromBytes(
|
||||||
ok = await ref
|
srcBytes: src,
|
||||||
.read(pdfExportViewModelProvider)
|
|
||||||
.exportToPath(
|
|
||||||
outputPath: fullPath,
|
|
||||||
uiPageSize: _pageSize,
|
|
||||||
signatureImageBytes: null,
|
|
||||||
targetDpi: targetDpi,
|
|
||||||
);
|
|
||||||
// ignore: avoid_print
|
|
||||||
debugPrint('_saveSignedPdf: saveBytesToFile ok=' + ok.toString());
|
|
||||||
} else {
|
|
||||||
// Web: export and trigger browser download
|
|
||||||
final out = await ref
|
|
||||||
.read(documentRepositoryProvider.notifier)
|
|
||||||
.exportDocumentToBytes(
|
|
||||||
uiPageSize: _pageSize,
|
uiPageSize: _pageSize,
|
||||||
signatureImageBytes: null,
|
signatureImageBytes: null,
|
||||||
|
placementsByPage: pdf.placementsByPage,
|
||||||
targetDpi: targetDpi,
|
targetDpi: targetDpi,
|
||||||
);
|
);
|
||||||
if (out != null) {
|
if (out != null) {
|
||||||
|
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 suggested filename for browser download
|
||||||
ok = await downloadBytes(out, filename: suggested);
|
ok = await downloadBytes(out, filename: suggested);
|
||||||
savedPath = suggested;
|
savedPath = suggested;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!kIsWeb) {
|
if (!kIsWeb) {
|
||||||
|
if (ok) {
|
||||||
messenger.showSnackBar(
|
messenger.showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text(
|
content: Text(
|
||||||
ok
|
AppLocalizations.of(context).savedWithPath(savedPath ?? ''),
|
||||||
? AppLocalizations.of(
|
|
||||||
weakContext,
|
|
||||||
).savedWithPath(savedPath ?? '')
|
|
||||||
: AppLocalizations.of(weakContext).failedToSavePdf,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
// ignore: avoid_print
|
|
||||||
debugPrint('_saveSignedPdf: SnackBar shown ok=' + ok.toString());
|
|
||||||
} else {
|
} else {
|
||||||
|
messenger.showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(AppLocalizations.of(context).failedToSavePdf),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Web: show a toast-like confirmation
|
||||||
messenger.showSnackBar(
|
messenger.showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text(
|
content: Text(
|
||||||
ok
|
ok
|
||||||
? AppLocalizations.of(
|
? AppLocalizations.of(
|
||||||
weakContext,
|
context,
|
||||||
).savedWithPath(savedPath ?? 'signed.pdf')
|
).savedWithPath(savedPath ?? 'signed.pdf')
|
||||||
: AppLocalizations.of(weakContext).failedToSavePdf,
|
: AppLocalizations.of(context).failedToSavePdf,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) {
|
|
||||||
ref.read(pdfExportViewModelProvider.notifier).setExporting(false);
|
ref.read(pdfExportViewModelProvider.notifier).setExporting(false);
|
||||||
// ignore: avoid_print
|
|
||||||
debugPrint('_saveSignedPdf: exporting flag set false');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
String _ensurePdfExtension(String name) {
|
String _ensurePdfExtension(String name) {
|
||||||
if (!name.toLowerCase().endsWith('.pdf')) return '$name.pdf';
|
if (!name.toLowerCase().endsWith('.pdf')) return '$name.pdf';
|
||||||
|
|
@ -308,9 +297,7 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
|
||||||
max: _pagesMax,
|
max: _pagesMax,
|
||||||
builder:
|
builder:
|
||||||
(context, area) => Offstage(
|
(context, area) => Offstage(
|
||||||
offstage:
|
offstage: !_showPagesSidebar,
|
||||||
!(ResponsiveBreakpoints.of(context).largerThan(MOBILE) &&
|
|
||||||
_showPagesSidebar),
|
|
||||||
child: Consumer(
|
child: Consumer(
|
||||||
builder: (context, ref, child) {
|
builder: (context, ref, child) {
|
||||||
final pdfViewModel = ref.watch(pdfViewModelProvider);
|
final pdfViewModel = ref.watch(pdfViewModelProvider);
|
||||||
|
|
@ -364,24 +351,6 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
|
||||||
_applySidebarVisibility();
|
_applySidebarVisibility();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
|
||||||
void didChangeDependencies() {
|
|
||||||
super.didChangeDependencies();
|
|
||||||
// Detect breakpoint changes from Responsive Framework and update areas once.
|
|
||||||
bool canShowPagesSidebar = true;
|
|
||||||
try {
|
|
||||||
canShowPagesSidebar = ResponsiveBreakpoints.of(
|
|
||||||
context,
|
|
||||||
).largerThan(MOBILE);
|
|
||||||
} catch (_) {
|
|
||||||
canShowPagesSidebar = true;
|
|
||||||
}
|
|
||||||
if (_lastCanShowPagesSidebar != canShowPagesSidebar) {
|
|
||||||
_lastCanShowPagesSidebar = canShowPagesSidebar;
|
|
||||||
_applySidebarVisibility();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_viewModel.controller.removeListener(_onControllerChanged);
|
_viewModel.controller.removeListener(_onControllerChanged);
|
||||||
|
|
@ -390,67 +359,31 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
|
||||||
}
|
}
|
||||||
|
|
||||||
void _applySidebarVisibility() {
|
void _applySidebarVisibility() {
|
||||||
// Respect responsive layout: disable Pages sidebar on MOBILE.
|
|
||||||
bool canShowPagesSidebar = true;
|
|
||||||
try {
|
|
||||||
canShowPagesSidebar = ResponsiveBreakpoints.of(
|
|
||||||
context,
|
|
||||||
).largerThan(MOBILE);
|
|
||||||
} catch (_) {
|
|
||||||
// If ResponsiveBreakpoints isn't available yet (e.g., during early init),
|
|
||||||
// fall back to allowing sidebars to avoid crashes; builders also guard.
|
|
||||||
canShowPagesSidebar = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Left pages sidebar
|
// Left pages sidebar
|
||||||
final left = _splitController.areas[0];
|
final left = _splitController.areas[0];
|
||||||
final wantPagesVisible = _showPagesSidebar && canShowPagesSidebar;
|
if (_showPagesSidebar) {
|
||||||
final isPagesHidden =
|
|
||||||
(left.max == 1 && left.min == 0 && (left.size ?? 1) == 1);
|
|
||||||
if (wantPagesVisible) {
|
|
||||||
// Only expand if currently hidden; otherwise keep user's size.
|
|
||||||
if (isPagesHidden) {
|
|
||||||
left.max = _pagesMax;
|
left.max = _pagesMax;
|
||||||
left.min = _pagesMin;
|
left.min = _pagesMin;
|
||||||
left.size = _lastPagesWidth.clamp(_pagesMin, _pagesMax);
|
left.size = _lastPagesWidth.clamp(_pagesMin, _pagesMax);
|
||||||
} else {
|
} else {
|
||||||
left.max = _pagesMax;
|
|
||||||
left.min = _pagesMin;
|
|
||||||
// Preserve current size (user may have adjusted it).
|
|
||||||
_lastPagesWidth = left.size ?? _lastPagesWidth;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Only collapse if currently visible; remember current size for restore.
|
|
||||||
if (!isPagesHidden) {
|
|
||||||
_lastPagesWidth = left.size ?? _lastPagesWidth;
|
_lastPagesWidth = left.size ?? _lastPagesWidth;
|
||||||
left.min = 0;
|
left.min = 0;
|
||||||
left.max = 1;
|
left.max = 1;
|
||||||
left.size = 1; // effectively hidden
|
left.size = 1; // effectively hidden
|
||||||
}
|
}
|
||||||
}
|
|
||||||
// Right signatures sidebar
|
// Right signatures sidebar
|
||||||
final right = _splitController.areas[2];
|
final right = _splitController.areas[2];
|
||||||
final isSignaturesHidden =
|
|
||||||
(right.max == 1 && right.min == 0 && (right.size ?? 1) == 1);
|
|
||||||
if (_showSignaturesSidebar) {
|
if (_showSignaturesSidebar) {
|
||||||
if (isSignaturesHidden) {
|
|
||||||
right.max = _signaturesMax;
|
right.max = _signaturesMax;
|
||||||
right.min = _signaturesMin;
|
right.min = _signaturesMin;
|
||||||
right.size = _lastSignaturesWidth.clamp(_signaturesMin, _signaturesMax);
|
right.size = _lastSignaturesWidth.clamp(_signaturesMin, _signaturesMax);
|
||||||
} else {
|
} else {
|
||||||
right.max = _signaturesMax;
|
|
||||||
right.min = _signaturesMin;
|
|
||||||
_lastSignaturesWidth = right.size ?? _lastSignaturesWidth;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (!isSignaturesHidden) {
|
|
||||||
_lastSignaturesWidth = right.size ?? _lastSignaturesWidth;
|
_lastSignaturesWidth = right.size ?? _lastSignaturesWidth;
|
||||||
right.min = 0;
|
right.min = 0;
|
||||||
right.max = 1;
|
right.max = 1;
|
||||||
right.size = 1;
|
right.size = 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:pdf_signature/l10n/app_localizations.dart';
|
import 'package:pdf_signature/l10n/app_localizations.dart';
|
||||||
import 'package:responsive_framework/responsive_framework.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';
|
||||||
|
|
||||||
|
|
@ -68,16 +67,6 @@ class _PdfToolbarState extends ConsumerState<PdfToolbar> {
|
||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
final bool compact = constraints.maxWidth < 260;
|
final bool compact = constraints.maxWidth < 260;
|
||||||
final double gotoWidth = 50;
|
final double gotoWidth = 50;
|
||||||
final bool isLargerThanMobile = ResponsiveBreakpoints.of(
|
|
||||||
context,
|
|
||||||
).largerThan(MOBILE);
|
|
||||||
final String fileDisplay = () {
|
|
||||||
final path = widget.filePath;
|
|
||||||
if (path == null || path.isEmpty) return 'No file selected';
|
|
||||||
if (isLargerThanMobile) return path;
|
|
||||||
// Extract file name for mobile (supports both / and \ separators)
|
|
||||||
return path.split('/').last.split('\\').last;
|
|
||||||
}();
|
|
||||||
|
|
||||||
// Center content of the toolbar
|
// Center content of the toolbar
|
||||||
final center = Wrap(
|
final center = Wrap(
|
||||||
|
|
@ -93,17 +82,16 @@ class _PdfToolbarState extends ConsumerState<PdfToolbar> {
|
||||||
children: [
|
children: [
|
||||||
const Icon(Icons.insert_drive_file, size: 18),
|
const Icon(Icons.insert_drive_file, size: 18),
|
||||||
const SizedBox(width: 6),
|
const SizedBox(width: 6),
|
||||||
Flexible(
|
ConstrainedBox(
|
||||||
child: ConstrainedBox(
|
|
||||||
constraints: const BoxConstraints(maxWidth: 220),
|
constraints: const BoxConstraints(maxWidth: 220),
|
||||||
child: Text(
|
child: Text(
|
||||||
fileDisplay,
|
// if filePath not null
|
||||||
maxLines: 1,
|
widget.filePath != null
|
||||||
softWrap: false,
|
? widget.filePath!
|
||||||
|
: 'No file selected',
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -142,7 +130,6 @@ class _PdfToolbarState extends ConsumerState<PdfToolbar> {
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
if (ResponsiveBreakpoints.of(context).largerThan(MOBILE))
|
|
||||||
Wrap(
|
Wrap(
|
||||||
spacing: 6,
|
spacing: 6,
|
||||||
runSpacing: 4,
|
runSpacing: 4,
|
||||||
|
|
@ -175,8 +162,6 @@ class _PdfToolbarState extends ConsumerState<PdfToolbar> {
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
||||||
if (ResponsiveBreakpoints.of(context).largerThan(MOBILE)) ...[
|
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Wrap(
|
Wrap(
|
||||||
crossAxisAlignment: WrapCrossAlignment.center,
|
crossAxisAlignment: WrapCrossAlignment.center,
|
||||||
|
|
@ -189,9 +174,7 @@ class _PdfToolbarState extends ConsumerState<PdfToolbar> {
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
//if not null
|
//if not null
|
||||||
widget.zoomLevel != null
|
widget.zoomLevel != null ? '${widget.zoomLevel}%' : '',
|
||||||
? '${widget.zoomLevel}%'
|
|
||||||
: '',
|
|
||||||
style: const TextStyle(fontSize: 12),
|
style: const TextStyle(fontSize: 12),
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
|
|
@ -204,7 +187,6 @@ class _PdfToolbarState extends ConsumerState<PdfToolbar> {
|
||||||
),
|
),
|
||||||
SizedBox(width: 6),
|
SizedBox(width: 6),
|
||||||
],
|
],
|
||||||
],
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|
@ -212,7 +194,6 @@ class _PdfToolbarState extends ConsumerState<PdfToolbar> {
|
||||||
|
|
||||||
return Row(
|
return Row(
|
||||||
children: [
|
children: [
|
||||||
if (ResponsiveBreakpoints.of(context).largerThan(MOBILE)) ...[
|
|
||||||
IconButton(
|
IconButton(
|
||||||
key: const Key('btn_toggle_pages_sidebar'),
|
key: const Key('btn_toggle_pages_sidebar'),
|
||||||
tooltip: 'Toggle pages overview',
|
tooltip: 'Toggle pages overview',
|
||||||
|
|
@ -226,7 +207,6 @@ class _PdfToolbarState extends ConsumerState<PdfToolbar> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
],
|
|
||||||
Expanded(child: center),
|
Expanded(child: center),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
IconButton(
|
IconButton(
|
||||||
|
|
|
||||||
|
|
@ -6,10 +6,6 @@ import 'pdf_page_overlays.dart';
|
||||||
import './pdf_mock_continuous_list.dart';
|
import './pdf_mock_continuous_list.dart';
|
||||||
import '../view_model/pdf_view_model.dart';
|
import '../view_model/pdf_view_model.dart';
|
||||||
|
|
||||||
// Provider to control whether viewer overlays (like scroll thumbs) are enabled.
|
|
||||||
// Integration tests can override this to false to avoid long-running animations.
|
|
||||||
final viewerOverlaysEnabledProvider = Provider<bool>((ref) => true);
|
|
||||||
|
|
||||||
class PdfViewerWidget extends ConsumerStatefulWidget {
|
class PdfViewerWidget extends ConsumerStatefulWidget {
|
||||||
const PdfViewerWidget({
|
const PdfViewerWidget({
|
||||||
super.key,
|
super.key,
|
||||||
|
|
@ -17,15 +13,12 @@ class PdfViewerWidget extends ConsumerStatefulWidget {
|
||||||
this.pageKeyBuilder,
|
this.pageKeyBuilder,
|
||||||
this.scrollToPage,
|
this.scrollToPage,
|
||||||
required this.controller,
|
required this.controller,
|
||||||
this.innerViewerKey,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
final Size pageSize;
|
final Size pageSize;
|
||||||
final GlobalKey Function(int page)? pageKeyBuilder;
|
final GlobalKey Function(int page)? pageKeyBuilder;
|
||||||
final void Function(int page)? scrollToPage;
|
final void Function(int page)? scrollToPage;
|
||||||
final PdfViewerController controller;
|
final PdfViewerController controller;
|
||||||
// Optional key applied to the inner Pdfrx PdfViewer to force disposal/rebuild
|
|
||||||
final Key? innerViewerKey;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
ConsumerState<PdfViewerWidget> createState() => _PdfViewerWidgetState();
|
ConsumerState<PdfViewerWidget> createState() => _PdfViewerWidgetState();
|
||||||
|
|
@ -88,10 +81,11 @@ class _PdfViewerWidgetState extends ConsumerState<PdfViewerWidget> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final overlaysEnabled = ref.watch(viewerOverlaysEnabledProvider);
|
|
||||||
return PdfViewer(
|
return PdfViewer(
|
||||||
_documentRef!,
|
_documentRef!,
|
||||||
key: widget.innerViewerKey ?? const Key('pdf_continuous_mock_list'),
|
key: const Key(
|
||||||
|
'pdf_continuous_mock_list',
|
||||||
|
), // Keep the same key for test compatibility
|
||||||
controller: widget.controller,
|
controller: widget.controller,
|
||||||
params: PdfViewerParams(
|
params: PdfViewerParams(
|
||||||
onViewerReady: (document, controller) {
|
onViewerReady: (document, controller) {
|
||||||
|
|
@ -106,9 +100,7 @@ class _PdfViewerWidgetState extends ConsumerState<PdfViewerWidget> {
|
||||||
ref.read(pdfViewModelProvider.notifier).jumpToPage(page);
|
ref.read(pdfViewModelProvider.notifier).jumpToPage(page);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
viewerOverlayBuilder:
|
viewerOverlayBuilder: (context, size, handle) {
|
||||||
overlaysEnabled
|
|
||||||
? (context, size, handle) {
|
|
||||||
return [
|
return [
|
||||||
// Vertical scroll thumb on the right
|
// Vertical scroll thumb on the right
|
||||||
PdfViewerScrollThumb(
|
PdfViewerScrollThumb(
|
||||||
|
|
@ -116,8 +108,7 @@ class _PdfViewerWidgetState extends ConsumerState<PdfViewerWidget> {
|
||||||
orientation: ScrollbarOrientation.right,
|
orientation: ScrollbarOrientation.right,
|
||||||
thumbSize: const Size(40, 25),
|
thumbSize: const Size(40, 25),
|
||||||
thumbBuilder:
|
thumbBuilder:
|
||||||
(context, thumbSize, pageNumber, controller) =>
|
(context, thumbSize, pageNumber, controller) => Container(
|
||||||
Container(
|
|
||||||
color: Colors.black.withValues(alpha: 0.7),
|
color: Colors.black.withValues(alpha: 0.7),
|
||||||
child: Center(
|
child: Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
|
|
@ -136,8 +127,7 @@ class _PdfViewerWidgetState extends ConsumerState<PdfViewerWidget> {
|
||||||
orientation: ScrollbarOrientation.bottom,
|
orientation: ScrollbarOrientation.bottom,
|
||||||
thumbSize: const Size(40, 25),
|
thumbSize: const Size(40, 25),
|
||||||
thumbBuilder:
|
thumbBuilder:
|
||||||
(context, thumbSize, pageNumber, controller) =>
|
(context, thumbSize, pageNumber, controller) => Container(
|
||||||
Container(
|
|
||||||
color: Colors.black.withValues(alpha: 0.7),
|
color: Colors.black.withValues(alpha: 0.7),
|
||||||
child: Center(
|
child: Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
|
|
@ -151,8 +141,7 @@ class _PdfViewerWidgetState extends ConsumerState<PdfViewerWidget> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
}
|
},
|
||||||
: (context, size, handle) => const <Widget>[],
|
|
||||||
// Per-page overlays to enable page-specific drag targets and placed signatures
|
// Per-page overlays to enable page-specific drag targets and placed signatures
|
||||||
pageOverlaysBuilder: (context, pageRect, page) {
|
pageOverlaysBuilder: (context, pageRect, page) {
|
||||||
return [
|
return [
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
||||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||||
# In Windows, build-name is used as the major, minor, and patch parts
|
# In Windows, build-name is used as the major, minor, and patch parts
|
||||||
# of the product and file versions while build-number is used as the build suffix.
|
# of the product and file versions while build-number is used as the build suffix.
|
||||||
version: 1.1.0+1
|
version: 1.0.0+1
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.7.0
|
sdk: ^3.7.0
|
||||||
|
|
@ -41,25 +41,24 @@ dependencies:
|
||||||
path_provider: ^2.1.5
|
path_provider: ^2.1.5
|
||||||
pdfrx: ^2.1.9
|
pdfrx: ^2.1.9
|
||||||
pdf: ^3.10.8
|
pdf: ^3.10.8
|
||||||
# printing: ^5.14.2 # extension of pdf pkg
|
|
||||||
hand_signature: ^3.1.0+2
|
hand_signature: ^3.1.0+2
|
||||||
image: ^4.2.0
|
image: ^4.2.0
|
||||||
|
printing: ^5.14.2
|
||||||
result_dart: ^2.1.1
|
result_dart: ^2.1.1
|
||||||
go_router: ^16.2.0
|
go_router: ^16.2.0
|
||||||
flutter_localizations:
|
flutter_localizations:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
intl: any
|
intl: any
|
||||||
flutter_localized_locales: ^2.0.5
|
flutter_localized_locales: ^2.0.5
|
||||||
desktop_drop: ^0.6.1
|
desktop_drop: ^0.5.0
|
||||||
multi_split_view: ^3.6.1
|
multi_split_view: ^3.6.1
|
||||||
freezed_annotation: ^3.1.0
|
freezed_annotation: ^3.1.0
|
||||||
json_annotation: ^4.9.0
|
json_annotation: ^4.9.0
|
||||||
share_plus: ^12.0.0
|
share_plus: ^11.1.0
|
||||||
logging: ^1.3.0
|
logging: ^1.3.0
|
||||||
riverpod_annotation: ^2.6.1
|
riverpod_annotation: ^2.6.1
|
||||||
colorfilter_generator: ^0.0.8
|
colorfilter_generator: ^0.0.8
|
||||||
flutter_box_transform: ^0.4.7
|
flutter_box_transform: ^0.4.7
|
||||||
responsive_framework: ^1.5.1
|
|
||||||
# disable_web_context_menu: ^1.1.0
|
# disable_web_context_menu: ^1.1.0
|
||||||
# ml_linalg: ^13.12.6
|
# ml_linalg: ^13.12.6
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,7 @@ Future<ProviderContainer> pumpApp(
|
||||||
}) async {
|
}) async {
|
||||||
SharedPreferences.setMockInitialValues(initialPrefs);
|
SharedPreferences.setMockInitialValues(initialPrefs);
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final fakeExport = FakeExportService();
|
||||||
final container = ProviderContainer(
|
final container = ProviderContainer(
|
||||||
overrides: [
|
overrides: [
|
||||||
preferencesRepositoryProvider.overrideWith(
|
preferencesRepositoryProvider.overrideWith(
|
||||||
|
|
@ -52,7 +53,11 @@ Future<ProviderContainer> pumpApp(
|
||||||
(ref) => PdfViewModel(ref, useMockViewer: true),
|
(ref) => PdfViewModel(ref, useMockViewer: true),
|
||||||
),
|
),
|
||||||
pdfExportViewModelProvider.overrideWith(
|
pdfExportViewModelProvider.overrideWith(
|
||||||
(ref) => PdfExportViewModel(ref, savePathPicker: () async => 'out.pdf'),
|
(ref) => PdfExportViewModel(
|
||||||
|
ref,
|
||||||
|
exporter: fakeExport,
|
||||||
|
savePathPicker: () async => 'out.pdf',
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
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/ui/features/pdf/view_model/pdf_export_view_model.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';
|
||||||
|
|
@ -13,6 +14,33 @@ 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/data/repositories/document_repository.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:pdf_signature/l10n/app_localizations.dart';
|
import 'package:pdf_signature/l10n/app_localizations.dart';
|
||||||
|
import 'package:image/image.dart' as img;
|
||||||
|
import 'package:pdf_signature/domain/models/model.dart';
|
||||||
|
|
||||||
|
class RecordingExporter extends ExportService {
|
||||||
|
bool called = false;
|
||||||
|
@override
|
||||||
|
Future<Uint8List?> exportSignedPdfFromBytes({
|
||||||
|
required Uint8List srcBytes,
|
||||||
|
required Size uiPageSize,
|
||||||
|
required Uint8List? signatureImageBytes,
|
||||||
|
Map<int, List<SignaturePlacement>>? placementsByPage,
|
||||||
|
Map<String, img.Image>? libraryImages,
|
||||||
|
double targetDpi = 144.0,
|
||||||
|
}) async {
|
||||||
|
// Return tiny dummy PDF bytes
|
||||||
|
return Uint8List.fromList([0x25, 0x50, 0x44, 0x46]); // "%PDF" header start
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> saveBytesToFile({
|
||||||
|
required bytes,
|
||||||
|
required String outputPath,
|
||||||
|
}) async {
|
||||||
|
called = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
testWidgets('Save uses file selector (via provider) and injected exporter', (
|
testWidgets('Save uses file selector (via provider) and injected exporter', (
|
||||||
|
|
@ -20,6 +48,7 @@ void main() {
|
||||||
) async {
|
) async {
|
||||||
SharedPreferences.setMockInitialValues({});
|
SharedPreferences.setMockInitialValues({});
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final fake = RecordingExporter();
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
ProviderScope(
|
ProviderScope(
|
||||||
overrides: [
|
overrides: [
|
||||||
|
|
@ -37,6 +66,7 @@ void main() {
|
||||||
pdfExportViewModelProvider.overrideWith(
|
pdfExportViewModelProvider.overrideWith(
|
||||||
(ref) => PdfExportViewModel(
|
(ref) => PdfExportViewModel(
|
||||||
ref,
|
ref,
|
||||||
|
exporter: fake,
|
||||||
savePathPicker: () async => 'C:/tmp/output.pdf',
|
savePathPicker: () async => 'C:/tmp/output.pdf',
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -61,6 +91,6 @@ void main() {
|
||||||
|
|
||||||
// Expect success UI (localized)
|
// Expect success UI (localized)
|
||||||
expect(find.textContaining('Saved:'), findsOneWidget);
|
expect(find.textContaining('Saved:'), findsOneWidget);
|
||||||
// Basic assertion: a save flow completed and snackbar showed
|
expect(fake.called, isTrue);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue