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 { 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: >{}, ); } void openPicked({Uint8List? bytes}) { debugPrint( '[DocumentRepository] openPicked called (bytes length: ${bytes?.length})', ); // For real usage, determine page count from PDF bytes asynchronously if (bytes != null) { _openPickedAsync(bytes); } else { // Handle null bytes case state = state.copyWith( loaded: true, pageCount: 1, pickedPdfBytes: bytes, placementsByPage: >{}, ); } } Future _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: >{}, ); // Schedule delayed check to ensure our page count wasn't overridden by UI callbacks Future.delayed(const Duration(milliseconds: 100), () { if (state.loaded && state.pickedPdfBytes == bytes && state.pageCount != pageCount) { state = state.copyWith(pageCount: pageCount); } }); } // For tests that need to specify page count explicitly @visibleForTesting void openPickedWithPageCount({required int pageCount, Uint8List? bytes}) { debugPrint( '[DocumentRepository] openPickedWithPageCount called (pageCount=$pageCount)', ); state = state.copyWith( loaded: true, pageCount: pageCount, pickedPdfBytes: bytes, placementsByPage: >{}, ); } 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>.from(state.placementsByPage); final list = List.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); void updatePlacementRotation({ required int page, required int index, required double rotationDeg, }) { if (!state.loaded) return; final p = page.clamp(1, state.pageCount); final map = Map>.from(state.placementsByPage); final list = List.from(map[p] ?? const []); if (index >= 0 && index < list.length) { list[index] = list[index].copyWith(rotationDeg: rotationDeg); map[p] = list; state = state.copyWith(placementsByPage: map); } } void removePlacement({required int page, required int index}) { if (!state.loaded) return; final p = page.clamp(1, state.pageCount); final map = Map>.from(state.placementsByPage); final list = List.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. void updatePlacementRect({ required int page, required int index, required Rect rect, }) { if (!state.loaded) return; final p = page.clamp(1, state.pageCount); final map = Map>.from(state.placementsByPage); final list = List.from(map[p] ?? const []); if (index >= 0 && index < list.length) { final existing = list[index]; list[index] = existing.copyWith(rect: rect); map[p] = list; state = state.copyWith(placementsByPage: map); } } List placementsOn(int page) { return List.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 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 _ = Future.delayed(Duration.zero); if (bytes == null) return false; final ok = await _service.saveBytesToFile( bytes: bytes, outputPath: outputPath, ); return ok; } Future 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( (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> 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 _exportInIsolate(_ExportIsolateArgs args) async { // Rebuild placements final placementsByPage = >{}; for (final page in args.pages) { final list = []; 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, ); }