import 'dart:math' as 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; void openSample() { state = state.copyWith( loaded: true, pageCount: samplePageCount, currentPage: 1, markedForSigning: false, pickedPdfPath: null, signedPage: null, ); } void openPicked({ required String path, int pageCount = samplePageCount, Uint8List? bytes, }) { state = state.copyWith( loaded: true, pageCount: pageCount, currentPage: 1, markedForSigning: false, pickedPdfPath: path, pickedPdfBytes: bytes, signedPage: null, ); } void jumpTo(int page) { if (!state.loaded) return; final clamped = page.clamp(1, state.pageCount); state = state.copyWith(currentPage: clamped); } void toggleMark() { if (!state.loaded) return; if (state.signedPage != null) { state = state.copyWith(markedForSigning: false, signedPage: null); } else { state = state.copyWith( markedForSigning: true, signedPage: state.currentPage, ); } } void setPageCount(int count) { if (!state.loaded) return; state = state.copyWith(pageCount: count.clamp(1, 9999)); } } final pdfProvider = StateNotifierProvider( (ref) => PdfController(), ); class SignatureController extends StateNotifier { SignatureController() : super(SignatureState.initial()); static const Size pageSize = Size(400, 560); void resetForNewPage() { state = SignatureState.initial(); } void placeDefaultRect() { 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, ), ); } 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, ), ); } void setInvalidSelected(BuildContext context) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Invalid or unsupported file')), ); } void drag(Offset delta) { if (state.rect == null) return; final moved = state.rect!.shift(delta); state = state.copyWith(rect: _clampRectToPage(moved)); } void resize(Offset delta) { if (state.rect == null) 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 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, ), ); } void setImageBytes(Uint8List bytes) { state = state.copyWith(imageBytes: bytes); if (state.rect == null) { placeDefaultRect(); } } } 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); final 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 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); } } // Encode as PNG to preserve transparency final png = img.encodePng(out, level: 6); return Uint8List.fromList(png); });