import 'dart:math' as math; import 'dart:math'; import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:image/image.dart' as img; import '../../../../data/model/model.dart'; class PdfController extends StateNotifier { PdfController() : super(PdfState.initial()); static const int samplePageCount = 5; @visibleForTesting void openSample() { state = state.copyWith( loaded: true, pageCount: samplePageCount, currentPage: 1, pickedPdfPath: null, signedPage: null, placementsByPage: {}, placementImageByPage: {}, selectedPlacementIndex: null, ); } void openPicked({ required String path, int pageCount = samplePageCount, Uint8List? bytes, }) { state = state.copyWith( loaded: true, pageCount: pageCount, currentPage: 1, pickedPdfPath: path, pickedPdfBytes: bytes, signedPage: null, placementsByPage: {}, placementImageByPage: {}, selectedPlacementIndex: null, ); } void jumpTo(int page) { if (!state.loaded) return; final clamped = page.clamp(1, state.pageCount); state = state.copyWith(currentPage: clamped, selectedPlacementIndex: null); } // Set or clear the page that will receive the signature overlay. void setSignedPage(int? page) { if (!state.loaded) return; if (page == null) { state = state.copyWith(signedPage: null, selectedPlacementIndex: null); } else { final clamped = page.clamp(1, state.pageCount); state = state.copyWith(signedPage: clamped, selectedPlacementIndex: null); } } void setPageCount(int count) { if (!state.loaded) return; state = state.copyWith(pageCount: count.clamp(1, 9999)); } // 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, String image = 'default.png', }) { 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(rect); map[p] = list; // Sync image mapping list final imgMap = Map>.from(state.placementImageByPage); final imgList = List.from(imgMap[p] ?? const []); imgList.add(image); imgMap[p] = imgList; state = state.copyWith( placementsByPage: map, placementImageByPage: imgMap, selectedPlacementIndex: null, ); } 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); // Sync image mapping final imgMap = Map>.from(state.placementImageByPage); final imgList = List.from(imgMap[p] ?? const []); if (index >= 0 && index < imgList.length) { imgList.removeAt(index); } if (list.isEmpty) { map.remove(p); imgMap.remove(p); } else { map[p] = list; imgMap[p] = imgList; } state = state.copyWith( placementsByPage: map, placementImageByPage: imgMap, selectedPlacementIndex: null, ); } } // 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) { list[index] = rect; map[p] = list; state = state.copyWith(placementsByPage: map); } } List placementsOn(int page) { return List.from(state.placementsByPage[page] ?? const []); } void selectPlacement(int? index) { if (!state.loaded) return; // Only allow valid index on current page; otherwise clear if (index == null) { state = state.copyWith(selectedPlacementIndex: null); return; } final list = state.placementsByPage[state.currentPage] ?? const []; if (index >= 0 && index < list.length) { state = state.copyWith(selectedPlacementIndex: index); } else { state = state.copyWith(selectedPlacementIndex: null); } } void deleteSelectedPlacement() { final idx = state.selectedPlacementIndex; if (idx == null) return; removePlacement(page: state.currentPage, index: idx); } // NOTE: Programmatic reassignment of images has been removed. // Convenience to get image name for a placement String? imageOfPlacement({required int page, required int index}) { final list = state.placementImageByPage[page] ?? const []; if (index < 0 || index >= list.length) return null; return list[index]; } } final pdfProvider = StateNotifierProvider( (ref) => PdfController(), ); /// A simple library of signature images available to the user in the sidebar. class SignatureAsset { final String id; // unique id final Uint8List bytes; final String? name; // optional display name (e.g., filename) const SignatureAsset({required this.id, required this.bytes, this.name}); } class SignatureLibraryController extends StateNotifier> { SignatureLibraryController() : super(const []); String add(Uint8List bytes, {String? name}) { // Always add a new asset (allow duplicates). This lets users create multiple cards // even when loading the same image repeatedly for different adjustments/usages. if (bytes.isEmpty) return ''; final id = DateTime.now().microsecondsSinceEpoch.toString(); state = List.of(state) ..add(SignatureAsset(id: id, bytes: bytes, name: name)); return id; } void remove(String id) { state = state.where((a) => a.id != id).toList(growable: false); } SignatureAsset? byId(String id) { for (final a in state) { if (a.id == id) return a; } return null; } } final signatureLibraryProvider = StateNotifierProvider>( (ref) => SignatureLibraryController(), ); class SignatureController extends StateNotifier { SignatureController() : super(SignatureState.initial()); static const Size pageSize = Size(400, 560); void resetForNewPage() { state = SignatureState.initial(); } @visibleForTesting void placeDefaultRect() { final w = 120.0, h = 60.0; state = state.copyWith( rect: Rect.fromCenter( center: Offset( (pageSize.width / 2) * (Random().nextDouble() * 1.5 + 1), (pageSize.height / 2) * (Random().nextDouble() * 1.5 + 1), ), width: w, height: h, ), editingEnabled: true, ); } void loadSample() { final w = 120.0, h = 60.0; state = state.copyWith( rect: Rect.fromCenter( center: Offset(pageSize.width / 2, pageSize.height * 0.75), width: w, height: h, ), editingEnabled: true, ); } void setInvalidSelected(BuildContext context) { // Fallback message without localization to keep core logic testable ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Invalid or unsupported file')), ); } void drag(Offset delta) { if (state.rect == null || !state.editingEnabled) return; final moved = state.rect!.shift(delta); state = state.copyWith(rect: _clampRectToPage(moved)); } void resize(Offset delta) { if (state.rect == null || !state.editingEnabled) return; final r = state.rect!; double newW = r.width + delta.dx; double newH = r.height + delta.dy; if (state.aspectLocked) { final aspect = r.width / r.height; // Keep ratio based on the dominant proportional delta final dxRel = (delta.dx / r.width).abs(); final dyRel = (delta.dy / r.height).abs(); if (dxRel >= dyRel) { newW = newW.clamp(20.0, double.infinity); newH = newW / aspect; } else { newH = newH.clamp(20.0, double.infinity); newW = newH * aspect; } // Scale down to fit within page bounds while preserving ratio final scaleW = pageSize.width / newW; final scaleH = pageSize.height / newH; final scale = math.min(1.0, math.min(scaleW, scaleH)); newW *= scale; newH *= scale; // Ensure minimum size of 20x20, scaling up proportionally if needed final minScale = math.max(1.0, math.max(20.0 / newW, 20.0 / newH)); newW *= minScale; newH *= minScale; Rect resized = Rect.fromLTWH(r.left, r.top, newW, newH); resized = _clampRectPositionToPage(resized); state = state.copyWith(rect: resized); return; } // Unlocked aspect: clamp each dimension independently newW = newW.clamp(20.0, pageSize.width); newH = newH.clamp(20.0, pageSize.height); Rect resized = Rect.fromLTWH(r.left, r.top, newW, newH); resized = _clampRectToPage(resized); state = state.copyWith(rect: resized); } Rect _clampRectToPage(Rect r) { // Ensure size never exceeds page bounds first, to avoid invalid clamp ranges final double w = r.width.clamp(20.0, pageSize.width); final double h = r.height.clamp(20.0, pageSize.height); final double left = r.left.clamp(0.0, pageSize.width - w); final double top = r.top.clamp(0.0, pageSize.height - h); return Rect.fromLTWH(left, top, w, h); } Rect _clampRectPositionToPage(Rect r) { final double left = r.left.clamp(0.0, pageSize.width - r.width); final double top = r.top.clamp(0.0, pageSize.height - r.height); return Rect.fromLTWH(left, top, r.width, r.height); } void toggleAspect(bool v) => state = state.copyWith(aspectLocked: v); void setBgRemoval(bool v) => state = state.copyWith(bgRemoval: v); void setContrast(double v) => state = state.copyWith(contrast: v); void setBrightness(double v) => state = state.copyWith(brightness: v); void setRotation(double deg) => state = state.copyWith(rotation: deg); void setStrokes(List> strokes) => state = state.copyWith(strokes: strokes); void ensureRectForStrokes() { state = state.copyWith( rect: state.rect ?? Rect.fromCenter( center: Offset(pageSize.width / 2, pageSize.height * 0.75), width: 140, height: 70, ), editingEnabled: true, ); } void setImageBytes(Uint8List bytes) { state = state.copyWith(imageBytes: bytes, assetId: null); if (state.rect == null) { placeDefaultRect(); } // Mark as draft/editable when user just loaded image state = state.copyWith(editingEnabled: true); } // Select image from the shared signature library void setImageFromLibrary({required String assetId}) { state = state.copyWith(assetId: assetId); if (state.rect == null) { placeDefaultRect(); } state = state.copyWith(editingEnabled: true); } void clearImage() { state = state.copyWith(imageBytes: null, rect: null, editingEnabled: false); } void placeAtCenter(Offset center, {double width = 120, double height = 60}) { Rect r = Rect.fromCenter(center: center, width: width, height: height); r = _clampRectToPage(r); state = state.copyWith(rect: r, editingEnabled: true); } // Confirm current signature: freeze editing and place it on the PDF as an immutable overlay. // Stores the placement rect in UI-space (SignatureController.pageSize units). // Returns the Rect placed, or null if no rect to confirm. Rect? confirmCurrentSignature(WidgetRef ref) { final r = state.rect; if (r == null) return null; // Place onto the current page final pdf = ref.read(pdfProvider); if (!pdf.loaded) return null; // Bind the processed image at placement time (so placed preview matches adjustments). // If processed bytes exist, always create a new asset for this placement. String id = ''; final processed = ref.read(processedSignatureImageProvider); if (processed != null && processed.isNotEmpty) { id = ref .read(signatureLibraryProvider.notifier) .add(processed, name: 'image'); } else { // Fallback to current image source final bytes = state.imageBytes; if (bytes != null && bytes.isNotEmpty) { id = ref .read(signatureLibraryProvider.notifier) .add(bytes, name: 'image'); } else { id = state.assetId ?? 'default.png'; } } // Store as UI-space rect (consistent with export and rendering paths) ref .read(pdfProvider.notifier) .addPlacement(page: pdf.currentPage, rect: r, image: id); // Newly placed index is the last one on the page final idx = (ref.read(pdfProvider).placementsByPage[pdf.currentPage]?.length ?? 1) - 1; // Auto-select the newly placed item so the red box appears if (idx >= 0) { ref.read(pdfProvider.notifier).selectPlacement(idx); } // Freeze editing: keep rect for preview but disable interaction state = state.copyWith(editingEnabled: false); return r; } // Test/helper variant: confirm using a ProviderContainer instead of WidgetRef. // Useful in widget tests where obtaining a WidgetRef is not straightforward. Rect? confirmCurrentSignatureWithContainer(ProviderContainer container) { final r = state.rect; if (r == null) return null; final pdf = container.read(pdfProvider); if (!pdf.loaded) return null; String id = ''; final processed = container.read(processedSignatureImageProvider); if (processed != null && processed.isNotEmpty) { id = container .read(signatureLibraryProvider.notifier) .add(processed, name: 'image'); } else { final bytes = state.imageBytes; if (bytes != null && bytes.isNotEmpty) { id = container .read(signatureLibraryProvider.notifier) .add(bytes, name: 'image'); } else { id = state.assetId ?? 'default.png'; } } container .read(pdfProvider.notifier) .addPlacement(page: pdf.currentPage, rect: r, image: id); final idx = (container .read(pdfProvider) .placementsByPage[pdf.currentPage] ?.length ?? 1) - 1; if (idx >= 0) { container.read(pdfProvider.notifier).selectPlacement(idx); } state = state.copyWith(editingEnabled: false); return r; } // Remove the active overlay (draft or confirmed preview) but keep image settings intact void clearActiveOverlay() { state = state.copyWith(rect: null, editingEnabled: false); } } final signatureProvider = StateNotifierProvider( (ref) => SignatureController(), ); /// Derived provider that returns processed signature image bytes according to /// current adjustment settings (contrast/brightness) and background removal. /// Returns null if no image is loaded. The output is a PNG to preserve alpha. final processedSignatureImageProvider = Provider((ref) { final s = ref.watch(signatureProvider); // If active overlay is based on a library asset, pull its bytes Uint8List? bytes; if (s.assetId != null) { final lib = ref.watch(signatureLibraryProvider); for (final a in lib) { if (a.id == s.assetId) { bytes = a.bytes; break; } } } else { bytes = s.imageBytes; } if (bytes == null || bytes.isEmpty) return null; // Decode (supports PNG/JPEG, etc.) final decoded = img.decodeImage(bytes); if (decoded == null) return bytes; // Work on a copy and ensure an alpha channel is present (RGBA) var out = decoded.clone(); if (out.hasPalette || !out.hasAlpha) { // Force truecolor RGBA image so per-pixel alpha writes take effect out = out.convert(numChannels: 4); } // Parameters final double contrast = s.contrast; // [0..2], 1 = neutral final double brightness = s.brightness; // [-1..1], 0 = neutral final double rotationDeg = s.rotation; // degrees const int thrLow = 220; // begin soft transparency from this avg luminance const int thrHigh = 245; // fully transparent from this avg luminance // Helper to clamp int int clamp255(num v) => v.clamp(0, 255).toInt(); // Iterate pixels for (int y = 0; y < out.height; y++) { for (int x = 0; x < out.width; x++) { final p = out.getPixel(x, y); int a = clamp255(p.aNormalized * 255.0); int r = clamp255(p.rNormalized * 255.0); int g = clamp255(p.gNormalized * 255.0); int b = clamp255(p.bNormalized * 255.0); // Apply contrast/brightness in sRGB space // new = (old-128)*contrast + 128 + brightness*255 final double brOffset = brightness * 255.0; r = clamp255((r - 128) * contrast + 128 + brOffset); g = clamp255((g - 128) * contrast + 128 + brOffset); b = clamp255((b - 128) * contrast + 128 + brOffset); // Near-white background removal (compute average luminance) final int avg = ((r + g + b) / 3).round(); int remAlpha = 255; // 255 = fully opaque, 0 = transparent if (s.bgRemoval) { if (avg >= thrHigh) { remAlpha = 0; } else if (avg >= thrLow) { // Soft fade between thrLow..thrHigh final double t = (avg - thrLow) / (thrHigh - thrLow); remAlpha = clamp255(255 * (1.0 - t)); } else { remAlpha = 255; } } // Combine with existing alpha (preserve existing transparency) final newA = math.min(a, remAlpha); out.setPixelRgba(x, y, r, g, b, newA); } } // Apply rotation if any (around center) using bilinear interpolation and keep size if (rotationDeg % 360 != 0) { // The image package rotates counter-clockwise; positive degrees rotate CCW out = img.copyRotate( out, angle: rotationDeg, interpolation: img.Interpolation.linear, ); } // Encode as PNG to preserve transparency final png = img.encodePng(out, level: 6); return Uint8List.fromList(png); });