410 lines
13 KiB
Dart
410 lines
13 KiB
Dart
import 'dart:isolate';
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:image/image.dart' as img;
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:pdf_signature/data/services/export_service.dart';
|
|
import 'package:pdfrx/pdfrx.dart';
|
|
|
|
import '../../domain/models/model.dart';
|
|
|
|
class DocumentStateNotifier extends StateNotifier<Document> {
|
|
DocumentStateNotifier({ExportService? service})
|
|
: _service = service ?? ExportService(),
|
|
super(Document.initial());
|
|
|
|
final ExportService _service;
|
|
|
|
@visibleForTesting
|
|
void openSample() {
|
|
state = state.copyWith(
|
|
loaded: true,
|
|
pageCount: 5,
|
|
pickedPdfBytes: null,
|
|
placementsByPage: <int, List<SignaturePlacement>>{},
|
|
);
|
|
}
|
|
|
|
/// Unified open API replacing multiple legacy variants.
|
|
///
|
|
/// Usage patterns:
|
|
/// openDocument(bytes: data) -> derive page count asynchronously.
|
|
/// openDocument(bytes: data, pageCount: 203, knownPageCount: true) -> fast path.
|
|
/// openDocument(pageCount: 5) -> open empty placeholder document (tests).
|
|
void openDocument({
|
|
Uint8List? bytes,
|
|
int? pageCount,
|
|
bool knownPageCount = false,
|
|
}) {
|
|
debugPrint(
|
|
'[DocumentRepository] openDocument called (bytes=${bytes?.length} pageCount=$pageCount known=$knownPageCount)',
|
|
);
|
|
if (bytes == null) {
|
|
// No bytes: treat as synthetic document (tests) using provided pageCount or default 1
|
|
final pc = pageCount ?? 1;
|
|
state = state.copyWith(
|
|
loaded: true,
|
|
pageCount: pc,
|
|
pickedPdfBytes: null,
|
|
placementsByPage: <int, List<SignaturePlacement>>{},
|
|
);
|
|
return;
|
|
}
|
|
// Bytes provided
|
|
if ((knownPageCount || pageCount != null) && pageCount != null) {
|
|
// Fast path: caller already determined count
|
|
state = state.copyWith(
|
|
loaded: true,
|
|
pageCount: pageCount.clamp(1, 9999),
|
|
pickedPdfBytes: bytes,
|
|
placementsByPage: <int, List<SignaturePlacement>>{},
|
|
);
|
|
return;
|
|
}
|
|
// Derive asynchronously
|
|
_openPickedAsync(bytes);
|
|
}
|
|
|
|
// --- Deprecated wrappers for backward compatibility (can be removed later) ---
|
|
@Deprecated('Use openDocument(bytes: ...) instead')
|
|
void openPicked({Uint8List? bytes}) => openDocument(bytes: bytes);
|
|
|
|
@Deprecated(
|
|
'Use openDocument(bytes: ..., pageCount: x, knownPageCount: true) instead',
|
|
)
|
|
void openPickedKnown({required int pageCount, required Uint8List bytes}) =>
|
|
openDocument(bytes: bytes, pageCount: pageCount, knownPageCount: true);
|
|
|
|
Future<void> _openPickedAsync(Uint8List bytes) async {
|
|
int pageCount = 1; // default fallback
|
|
|
|
try {
|
|
// Determine actual page count from PDF bytes
|
|
final doc = await PdfDocument.openData(bytes);
|
|
pageCount = doc.pages.length;
|
|
debugPrint('[DocumentRepository] PDF has $pageCount pages');
|
|
} catch (e) {
|
|
debugPrint('[DocumentRepository] Failed to read PDF page count: $e');
|
|
// Keep default pageCount = 1 on error
|
|
}
|
|
|
|
state = state.copyWith(
|
|
loaded: true,
|
|
pageCount: pageCount,
|
|
pickedPdfBytes: bytes,
|
|
placementsByPage: <int, List<SignaturePlacement>>{},
|
|
);
|
|
}
|
|
|
|
// For tests that need to specify page count explicitly
|
|
@visibleForTesting
|
|
@Deprecated(
|
|
'Use openDocument(pageCount: x) for synthetic docs or with bytes+knownPageCount',
|
|
)
|
|
void openPickedWithPageCount({required int pageCount, Uint8List? bytes}) =>
|
|
openDocument(bytes: bytes, pageCount: pageCount, knownPageCount: true);
|
|
|
|
void close() {
|
|
state = Document.initial();
|
|
}
|
|
|
|
void setPageCount(int count) {
|
|
if (!state.loaded) return;
|
|
debugPrint(
|
|
'[DocumentRepository] setPageCount called: $count (current: ${state.pageCount})',
|
|
);
|
|
state = state.copyWith(pageCount: count.clamp(1, 9999));
|
|
}
|
|
|
|
void jumpTo(int page) {
|
|
// currentPage is now in view model, so jumpTo does nothing here
|
|
}
|
|
|
|
// Multiple-signature helpers (rects are stored in normalized fractions 0..1
|
|
// relative to the page size: left/top/width/height are all 0..1)
|
|
void addPlacement({
|
|
required int page,
|
|
required Rect rect,
|
|
SignatureAsset? asset,
|
|
double rotationDeg = 0.0,
|
|
GraphicAdjust? graphicAdjust,
|
|
}) {
|
|
if (!state.loaded) return;
|
|
final p = page.clamp(1, state.pageCount);
|
|
final map = Map<int, List<SignaturePlacement>>.from(state.placementsByPage);
|
|
final list = List<SignaturePlacement>.from(map[p] ?? const []);
|
|
list.add(
|
|
SignaturePlacement(
|
|
rect: rect,
|
|
asset: asset ?? SignatureAsset(sigImage: _singleTransparentPng),
|
|
rotationDeg: rotationDeg,
|
|
graphicAdjust: graphicAdjust ?? const GraphicAdjust(),
|
|
),
|
|
);
|
|
map[p] = list;
|
|
state = state.copyWith(placementsByPage: map);
|
|
}
|
|
|
|
// Tiny 1x1 transparent PNG to avoid decode crashes in tests when no real
|
|
// signature bytes were provided.
|
|
static final img.Image _singleTransparentPng = img.Image(width: 1, height: 1);
|
|
|
|
@Deprecated('Use modifyPlacement')
|
|
void updatePlacementRotation({
|
|
required int page,
|
|
required int index,
|
|
required double rotationDeg,
|
|
}) => modifyPlacement(page: page, index: index, rotationDeg: rotationDeg);
|
|
|
|
void removePlacement({required int page, required int index}) {
|
|
if (!state.loaded) return;
|
|
final p = page.clamp(1, state.pageCount);
|
|
final map = Map<int, List<SignaturePlacement>>.from(state.placementsByPage);
|
|
final list = List<SignaturePlacement>.from(map[p] ?? const []);
|
|
if (index >= 0 && index < list.length) {
|
|
list.removeAt(index);
|
|
if (list.isEmpty) {
|
|
map.remove(p);
|
|
} else {
|
|
map[p] = list;
|
|
}
|
|
state = state.copyWith(placementsByPage: map);
|
|
}
|
|
}
|
|
|
|
// Update the rect of an existing placement on a page.
|
|
@Deprecated('Use modifyPlacement')
|
|
void updatePlacementRect({
|
|
required int page,
|
|
required int index,
|
|
required Rect rect,
|
|
}) => modifyPlacement(page: page, index: index, rect: rect);
|
|
|
|
/// Generic partial update for a placement. Any non-null field is applied.
|
|
void modifyPlacement({
|
|
required int page,
|
|
required int index,
|
|
Rect? rect,
|
|
double? rotationDeg,
|
|
SignatureAsset? asset,
|
|
GraphicAdjust? graphicAdjust,
|
|
}) {
|
|
if (!state.loaded) return;
|
|
final p = page.clamp(1, state.pageCount);
|
|
final map = Map<int, List<SignaturePlacement>>.from(state.placementsByPage);
|
|
final list = List<SignaturePlacement>.from(map[p] ?? const []);
|
|
if (index < 0 || index >= list.length) return;
|
|
final current = list[index];
|
|
list[index] = current.copyWith(
|
|
rect: rect ?? current.rect,
|
|
rotationDeg: rotationDeg ?? current.rotationDeg,
|
|
asset: asset ?? current.asset,
|
|
graphicAdjust: graphicAdjust ?? current.graphicAdjust,
|
|
);
|
|
map[p] = list;
|
|
state = state.copyWith(placementsByPage: map);
|
|
}
|
|
|
|
List<SignaturePlacement> placementsOn(int page) {
|
|
return List<SignaturePlacement>.from(
|
|
state.placementsByPage[page] ?? const [],
|
|
);
|
|
}
|
|
|
|
// Convenience to get asset for a placement
|
|
SignatureAsset? assetOfPlacement({required int page, required int index}) {
|
|
final list = state.placementsByPage[page] ?? const [];
|
|
if (index < 0 || index >= list.length) return null;
|
|
return list[index].asset;
|
|
}
|
|
|
|
Future<bool> exportDocument({
|
|
required String outputPath,
|
|
required Size uiPageSize,
|
|
required Uint8List? signatureImageBytes,
|
|
double targetDpi = 144.0,
|
|
}) async {
|
|
final bytes = await exportDocumentToBytes(
|
|
uiPageSize: uiPageSize,
|
|
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!,
|
|
uiPageSize: uiPageSize,
|
|
signatureImageBytes: signatureImageBytes,
|
|
placementsByPage: state.placementsByPage,
|
|
targetDpi: targetDpi,
|
|
);
|
|
final result = await compute<_ExportIsolateArgs, Uint8List?>(
|
|
_exportInIsolate,
|
|
args,
|
|
);
|
|
if (result != null) return result;
|
|
} catch (_) {
|
|
debugPrint('Warning: export in isolate failed');
|
|
// 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,
|
|
);
|
|
}
|
|
}
|
|
|
|
final documentRepositoryProvider =
|
|
StateNotifierProvider<DocumentStateNotifier, Document>(
|
|
(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,
|
|
);
|
|
}
|