feat: implement image processing and caching in signatureCard
repository
This commit is contained in:
parent
80cf115ab3
commit
26a0c93390
|
|
@ -1,16 +1,73 @@
|
||||||
|
import 'dart:typed_data';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import '../../domain/models/model.dart';
|
import '../../domain/models/model.dart';
|
||||||
|
import '../../data/services/signature_image_processing_service.dart';
|
||||||
|
|
||||||
class SignatureCardStateNotifier extends StateNotifier<List<SignatureCard>> {
|
/// CachedSignatureCard extends SignatureCard with an internal processed cache
|
||||||
SignatureCardStateNotifier() : super(const []);
|
class CachedSignatureCard extends SignatureCard {
|
||||||
|
Uint8List? _cachedProcessed;
|
||||||
|
|
||||||
|
CachedSignatureCard({
|
||||||
|
required super.asset,
|
||||||
|
required super.rotationDeg,
|
||||||
|
super.graphicAdjust,
|
||||||
|
Uint8List? initialProcessed,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Returns cached processed bytes for the current [graphicAdjust], computing
|
||||||
|
/// via [service] if not cached yet.
|
||||||
|
Uint8List getOrComputeProcessed(SignatureImageProcessingService service) {
|
||||||
|
final existing = _cachedProcessed;
|
||||||
|
if (existing != null) return existing;
|
||||||
|
final computed = service.processImage(asset.bytes, graphicAdjust);
|
||||||
|
_cachedProcessed = computed;
|
||||||
|
return computed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Invalidate the cached processed bytes, forcing recompute next time.
|
||||||
|
void invalidateCache() {
|
||||||
|
_cachedProcessed = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets/updates the processed bytes explicitly (used after adjustments update)
|
||||||
|
void setProcessed(Uint8List bytes) {
|
||||||
|
_cachedProcessed = bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
factory CachedSignatureCard.initial() => CachedSignatureCard(
|
||||||
|
asset: SignatureCard.initial().asset,
|
||||||
|
rotationDeg: SignatureCard.initial().rotationDeg,
|
||||||
|
graphicAdjust: SignatureCard.initial().graphicAdjust,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class SignatureCardStateNotifier
|
||||||
|
extends StateNotifier<List<CachedSignatureCard>> {
|
||||||
|
SignatureCardStateNotifier() : super(const []) {
|
||||||
|
state = const <CachedSignatureCard>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stateless image processing service used by this repository
|
||||||
|
final SignatureImageProcessingService _processingService =
|
||||||
|
SignatureImageProcessingService();
|
||||||
|
|
||||||
void add(SignatureCard card) {
|
void add(SignatureCard card) {
|
||||||
state = List.of(state)..add(card);
|
final wrapped =
|
||||||
|
card is CachedSignatureCard
|
||||||
|
? card
|
||||||
|
: CachedSignatureCard(
|
||||||
|
asset: card.asset,
|
||||||
|
rotationDeg: card.rotationDeg,
|
||||||
|
graphicAdjust: card.graphicAdjust,
|
||||||
|
);
|
||||||
|
final next = List<CachedSignatureCard>.of(state)..add(wrapped);
|
||||||
|
state = List<CachedSignatureCard>.unmodifiable(next);
|
||||||
}
|
}
|
||||||
|
|
||||||
void addWithAsset(SignatureAsset asset, double rotationDeg) {
|
void addWithAsset(SignatureAsset asset, double rotationDeg) {
|
||||||
state = List.of(state)
|
final next = List<CachedSignatureCard>.of(state)
|
||||||
..add(SignatureCard(asset: asset, rotationDeg: rotationDeg));
|
..add(CachedSignatureCard(asset: asset, rotationDeg: rotationDeg));
|
||||||
|
state = List<CachedSignatureCard>.unmodifiable(next);
|
||||||
}
|
}
|
||||||
|
|
||||||
void update(
|
void update(
|
||||||
|
|
@ -18,30 +75,78 @@ class SignatureCardStateNotifier extends StateNotifier<List<SignatureCard>> {
|
||||||
double? rotationDeg,
|
double? rotationDeg,
|
||||||
GraphicAdjust? graphicAdjust,
|
GraphicAdjust? graphicAdjust,
|
||||||
) {
|
) {
|
||||||
final list = List<SignatureCard>.of(state);
|
final list = List<CachedSignatureCard>.of(state);
|
||||||
for (var i = 0; i < list.length; i++) {
|
for (var i = 0; i < list.length; i++) {
|
||||||
final c = list[i];
|
final c = list[i];
|
||||||
if (c == card) {
|
if (c == card) {
|
||||||
list[i] = c.copyWith(
|
final updated = c.copyWith(
|
||||||
rotationDeg: rotationDeg ?? c.rotationDeg,
|
rotationDeg: rotationDeg ?? c.rotationDeg,
|
||||||
graphicAdjust: graphicAdjust ?? c.graphicAdjust,
|
graphicAdjust: graphicAdjust ?? c.graphicAdjust,
|
||||||
);
|
);
|
||||||
state = list;
|
// Compute and set the single processed bytes for the updated adjust
|
||||||
|
final processed = _processingService.processImage(
|
||||||
|
updated.asset.bytes,
|
||||||
|
updated.graphicAdjust,
|
||||||
|
);
|
||||||
|
final next = CachedSignatureCard(
|
||||||
|
asset: updated.asset,
|
||||||
|
rotationDeg: updated.rotationDeg,
|
||||||
|
graphicAdjust: updated.graphicAdjust,
|
||||||
|
);
|
||||||
|
next.setProcessed(processed);
|
||||||
|
list[i] = next;
|
||||||
|
state = List<CachedSignatureCard>.unmodifiable(list);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void remove(SignatureCard card) {
|
void remove(SignatureCard card) {
|
||||||
state = state.where((c) => c != card).toList(growable: false);
|
state = List<CachedSignatureCard>.unmodifiable(
|
||||||
|
state.where((c) => c != card).toList(growable: false),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void clearAll() {
|
void clearAll() {
|
||||||
state = const [];
|
state = const <CachedSignatureCard>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns processed image bytes for the given asset + adjustments.
|
||||||
|
/// Uses an internal cache to avoid re-processing.
|
||||||
|
Uint8List getProcessedBytes(SignatureAsset asset, GraphicAdjust adjust) {
|
||||||
|
// Try to find a matching card by asset
|
||||||
|
for (final c in state) {
|
||||||
|
if (c.asset == asset) {
|
||||||
|
// If requested adjust equals the card's current adjust, use per-card cache
|
||||||
|
if (c.graphicAdjust == adjust) {
|
||||||
|
return c.getOrComputeProcessed(_processingService);
|
||||||
|
}
|
||||||
|
// Previewing unsaved adjustments: compute without caching
|
||||||
|
return _processingService.processImage(asset.bytes, adjust);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Asset not found among cards (e.g., preview in dialog): compute on-the-fly
|
||||||
|
return _processingService.processImage(asset.bytes, adjust);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clears all cached processed images.
|
||||||
|
void clearProcessedCache() {
|
||||||
|
for (final c in state) {
|
||||||
|
c.invalidateCache();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clears cached processed images for a specific asset only.
|
||||||
|
void clearCacheForAsset(SignatureAsset asset) {
|
||||||
|
for (final c in state) {
|
||||||
|
if (c.asset == asset) {
|
||||||
|
c.invalidateCache();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final signatureCardRepositoryProvider =
|
final signatureCardRepositoryProvider = StateNotifierProvider<
|
||||||
StateNotifierProvider<SignatureCardStateNotifier, List<SignatureCard>>(
|
SignatureCardStateNotifier,
|
||||||
(ref) => SignatureCardStateNotifier(),
|
List<CachedSignatureCard>
|
||||||
);
|
>((ref) => SignatureCardStateNotifier());
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,126 @@
|
||||||
|
import 'dart:typed_data';
|
||||||
|
import 'package:image/image.dart' as img;
|
||||||
|
import '../../domain/models/model.dart' as domain;
|
||||||
|
|
||||||
|
/// Service for processing signature images with graphic adjustments
|
||||||
|
class SignatureImageProcessingService {
|
||||||
|
/// Decode image bytes once and reuse the decoded image for preview processing.
|
||||||
|
img.Image? decode(Uint8List bytes) {
|
||||||
|
try {
|
||||||
|
return img.decodeImage(bytes);
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Process image bytes with the given graphic adjustments
|
||||||
|
Uint8List processImage(Uint8List bytes, domain.GraphicAdjust adjust) {
|
||||||
|
if (adjust.contrast == 1.0 &&
|
||||||
|
adjust.brightness == 0.0 &&
|
||||||
|
!adjust.bgRemoval) {
|
||||||
|
return bytes; // No processing needed
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
final decoded = img.decodeImage(bytes);
|
||||||
|
if (decoded != null) {
|
||||||
|
img.Image processed = decoded;
|
||||||
|
|
||||||
|
// Apply contrast and brightness first
|
||||||
|
if (adjust.contrast != 1.0 || adjust.brightness != 0.0) {
|
||||||
|
processed = img.adjustColor(
|
||||||
|
processed,
|
||||||
|
contrast: adjust.contrast,
|
||||||
|
brightness: adjust.brightness,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply background removal after color adjustments
|
||||||
|
if (adjust.bgRemoval) {
|
||||||
|
processed = _removeBackground(processed);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encode back to PNG to preserve transparency
|
||||||
|
return Uint8List.fromList(img.encodePng(processed));
|
||||||
|
} else {
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// If processing fails, return original bytes
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fast preview processing:
|
||||||
|
/// - Reuses a decoded image
|
||||||
|
/// - Downscales to a small size for UI preview
|
||||||
|
/// - Uses low-compression PNG to reduce CPU cost
|
||||||
|
Uint8List processPreviewFromDecoded(
|
||||||
|
img.Image decoded,
|
||||||
|
domain.GraphicAdjust adjust, {
|
||||||
|
int maxDimension = 256,
|
||||||
|
}) {
|
||||||
|
try {
|
||||||
|
// Create a small working copy for quick adjustments
|
||||||
|
final int w = decoded.width;
|
||||||
|
final int h = decoded.height;
|
||||||
|
final double scale = (w > h ? maxDimension / w : maxDimension / h).clamp(
|
||||||
|
0.0,
|
||||||
|
1.0,
|
||||||
|
);
|
||||||
|
img.Image work =
|
||||||
|
(scale < 1.0)
|
||||||
|
? img.copyResize(decoded, width: (w * scale).round())
|
||||||
|
: img.Image.from(decoded);
|
||||||
|
|
||||||
|
// Apply contrast and brightness
|
||||||
|
if (adjust.contrast != 1.0 || adjust.brightness != 0.0) {
|
||||||
|
work = img.adjustColor(
|
||||||
|
work,
|
||||||
|
contrast: adjust.contrast,
|
||||||
|
brightness: adjust.brightness,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Background removal on downscaled image for speed
|
||||||
|
if (adjust.bgRemoval) {
|
||||||
|
work = _removeBackground(work);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encode with low compression (level 0) for speed
|
||||||
|
return Uint8List.fromList(img.encodePng(work, level: 0));
|
||||||
|
} catch (_) {
|
||||||
|
// Fall back to original size path if something goes wrong
|
||||||
|
return processImage(
|
||||||
|
Uint8List.fromList(img.encodePng(decoded, level: 0)),
|
||||||
|
adjust,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove near-white background using simple threshold approach for maximum speed
|
||||||
|
img.Image _removeBackground(img.Image image) {
|
||||||
|
final result =
|
||||||
|
image.hasAlpha ? img.Image.from(image) : image.convert(numChannels: 4);
|
||||||
|
|
||||||
|
// Simple and fast: single pass through all pixels
|
||||||
|
for (int y = 0; y < result.height; y++) {
|
||||||
|
for (int x = 0; x < result.width; x++) {
|
||||||
|
final pixel = result.getPixel(x, y);
|
||||||
|
final r = pixel.r;
|
||||||
|
final g = pixel.g;
|
||||||
|
final b = pixel.b;
|
||||||
|
|
||||||
|
// Simple threshold: if pixel is close to white, make it transparent
|
||||||
|
const int threshold = 240; // Very close to white
|
||||||
|
if (r >= threshold && g >= threshold && b >= threshold) {
|
||||||
|
result.setPixel(
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
img.ColorRgba8(r.toInt(), g.toInt(), b.toInt(), 0),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -18,4 +18,17 @@ class GraphicAdjust {
|
||||||
brightness: brightness ?? this.brightness,
|
brightness: brightness ?? this.brightness,
|
||||||
bgRemoval: bgRemoval ?? this.bgRemoval,
|
bgRemoval: bgRemoval ?? this.bgRemoval,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) =>
|
||||||
|
identical(this, other) ||
|
||||||
|
other is GraphicAdjust &&
|
||||||
|
runtimeType == other.runtimeType &&
|
||||||
|
contrast == other.contrast &&
|
||||||
|
brightness == other.brightness &&
|
||||||
|
bgRemoval == other.bgRemoval;
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode =>
|
||||||
|
contrast.hashCode ^ brightness.hashCode ^ bgRemoval.hashCode;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,4 +6,22 @@ class SignatureAsset {
|
||||||
// List<List<Offset>>? strokes;
|
// List<List<Offset>>? strokes;
|
||||||
final String? name; // optional display name (e.g., filename)
|
final String? name; // optional display name (e.g., filename)
|
||||||
const SignatureAsset({required this.bytes, this.name});
|
const SignatureAsset({required this.bytes, this.name});
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) =>
|
||||||
|
identical(this, other) ||
|
||||||
|
other is SignatureAsset &&
|
||||||
|
name == other.name &&
|
||||||
|
_bytesEqual(bytes, other.bytes);
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => name.hashCode ^ bytes.length.hashCode;
|
||||||
|
|
||||||
|
static bool _bytesEqual(Uint8List a, Uint8List b) {
|
||||||
|
if (a.length != b.length) return false;
|
||||||
|
for (int i = 0; i < a.length; i++) {
|
||||||
|
if (a[i] != b[i]) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ class DrawCanvas extends StatefulWidget {
|
||||||
this.control,
|
this.control,
|
||||||
this.onConfirm,
|
this.onConfirm,
|
||||||
this.debugBytesSink,
|
this.debugBytesSink,
|
||||||
|
this.closeOnConfirmImmediately = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
final hand.HandSignatureControl? control;
|
final hand.HandSignatureControl? control;
|
||||||
|
|
@ -17,6 +18,9 @@ class DrawCanvas extends StatefulWidget {
|
||||||
// For tests: allows observing exported bytes without relying on Navigator
|
// For tests: allows observing exported bytes without relying on Navigator
|
||||||
@visibleForTesting
|
@visibleForTesting
|
||||||
final ValueNotifier<Uint8List?>? debugBytesSink;
|
final ValueNotifier<Uint8List?>? debugBytesSink;
|
||||||
|
// When true (used by bottom sheet), the sheet will be closed immediately
|
||||||
|
// on confirm without waiting for export to finish.
|
||||||
|
final bool closeOnConfirmImmediately;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<DrawCanvas> createState() => _DrawCanvasState();
|
State<DrawCanvas> createState() => _DrawCanvasState();
|
||||||
|
|
@ -48,6 +52,12 @@ class _DrawCanvasState extends State<DrawCanvas> {
|
||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
key: const Key('btn_canvas_confirm'),
|
key: const Key('btn_canvas_confirm'),
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
|
// If requested, close the sheet immediately without waiting
|
||||||
|
// for the potentially heavy export.
|
||||||
|
if (widget.closeOnConfirmImmediately &&
|
||||||
|
Navigator.canPop(context)) {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
}
|
||||||
// Export signature to PNG bytes
|
// Export signature to PNG bytes
|
||||||
final byteData = await _control.toImage(
|
final byteData = await _control.toImage(
|
||||||
width: 1024,
|
width: 1024,
|
||||||
|
|
@ -60,7 +70,7 @@ class _DrawCanvasState extends State<DrawCanvas> {
|
||||||
widget.debugBytesSink?.value = bytes;
|
widget.debugBytesSink?.value = bytes;
|
||||||
if (widget.onConfirm != null) {
|
if (widget.onConfirm != null) {
|
||||||
widget.onConfirm!(bytes);
|
widget.onConfirm!(bytes);
|
||||||
} else {
|
} else if (!widget.closeOnConfirmImmediately) {
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
Navigator.of(context).pop(bytes);
|
Navigator.of(context).pop(bytes);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -124,10 +124,7 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
|
||||||
context: context,
|
context: context,
|
||||||
isScrollControlled: true,
|
isScrollControlled: true,
|
||||||
enableDrag: false,
|
enableDrag: false,
|
||||||
builder:
|
builder: (_) => const DrawCanvas(closeOnConfirmImmediately: true),
|
||||||
(_) => DrawCanvas(
|
|
||||||
onConfirm: (bytes) => Navigator.of(context).pop(bytes),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
if (result != null && result.isNotEmpty) {
|
if (result != null && result.isNotEmpty) {
|
||||||
// In simplified UI, adding to library isn't implemented
|
// In simplified UI, adding to library isn't implemented
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import '../../../../domain/models/model.dart';
|
import '../../../../domain/models/model.dart';
|
||||||
import '../../signature/widgets/rotated_signature_image.dart';
|
import '../../signature/widgets/rotated_signature_image.dart';
|
||||||
|
import '../../signature/view_model/signature_view_model.dart';
|
||||||
|
|
||||||
/// Minimal overlay widget for rendering a placed signature.
|
/// Minimal overlay widget for rendering a placed signature.
|
||||||
class SignatureOverlay extends StatelessWidget {
|
class SignatureOverlay extends ConsumerWidget {
|
||||||
const SignatureOverlay({
|
const SignatureOverlay({
|
||||||
super.key,
|
super.key,
|
||||||
required this.pageSize,
|
required this.pageSize,
|
||||||
|
|
@ -18,7 +20,10 @@ class SignatureOverlay extends StatelessWidget {
|
||||||
final int placedIndex;
|
final int placedIndex;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final processedBytes = ref
|
||||||
|
.watch(signatureViewModelProvider)
|
||||||
|
.getProcessedBytes(placement.asset, placement.graphicAdjust);
|
||||||
return LayoutBuilder(
|
return LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
final left = rect.left * constraints.maxWidth;
|
final left = rect.left * constraints.maxWidth;
|
||||||
|
|
@ -40,7 +45,7 @@ class SignatureOverlay extends StatelessWidget {
|
||||||
child: FittedBox(
|
child: FittedBox(
|
||||||
fit: BoxFit.contain,
|
fit: BoxFit.contain,
|
||||||
child: RotatedSignatureImage(
|
child: RotatedSignatureImage(
|
||||||
bytes: placement.asset.bytes,
|
bytes: processedBytes,
|
||||||
rotationDeg: placement.rotationDeg,
|
rotationDeg: placement.rotationDeg,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,26 @@
|
||||||
|
import 'dart:typed_data';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:pdf_signature/domain/models/model.dart' as domain;
|
||||||
|
import 'package:pdf_signature/data/repositories/signature_card_repository.dart'
|
||||||
|
as repo;
|
||||||
|
|
||||||
class SignatureViewModel {
|
class SignatureViewModel {
|
||||||
final Ref ref;
|
final Ref ref;
|
||||||
|
|
||||||
SignatureViewModel(this.ref);
|
SignatureViewModel(this.ref);
|
||||||
|
|
||||||
// Add methods as needed
|
Uint8List getProcessedBytes(
|
||||||
|
domain.SignatureAsset asset,
|
||||||
|
domain.GraphicAdjust adjust,
|
||||||
|
) {
|
||||||
|
final notifier = ref.read(repo.signatureCardRepositoryProvider.notifier);
|
||||||
|
return notifier.getProcessedBytes(asset, adjust);
|
||||||
|
}
|
||||||
|
|
||||||
|
void clearCache() {
|
||||||
|
final notifier = ref.read(repo.signatureCardRepositoryProvider.notifier);
|
||||||
|
notifier.clearProcessedCache();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final signatureViewModelProvider = Provider<SignatureViewModel>((ref) {
|
final signatureViewModelProvider = Provider<SignatureViewModel>((ref) {
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,13 @@
|
||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:image/image.dart' as img;
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:pdf_signature/l10n/app_localizations.dart';
|
import 'package:pdf_signature/l10n/app_localizations.dart';
|
||||||
import '../../pdf/widgets/adjustments_panel.dart';
|
import '../../pdf/widgets/adjustments_panel.dart';
|
||||||
import '../../../../domain/models/model.dart' as domain;
|
import '../../../../domain/models/model.dart' as domain;
|
||||||
|
import '../view_model/signature_view_model.dart';
|
||||||
import 'rotated_signature_image.dart';
|
import 'rotated_signature_image.dart';
|
||||||
|
import '../../../../data/services/signature_image_processing_service.dart';
|
||||||
|
import 'package:image/image.dart' as img;
|
||||||
|
|
||||||
class ImageEditorResult {
|
class ImageEditorResult {
|
||||||
final double rotation;
|
final double rotation;
|
||||||
|
|
@ -16,7 +19,7 @@ class ImageEditorResult {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
class ImageEditorDialog extends StatefulWidget {
|
class ImageEditorDialog extends ConsumerStatefulWidget {
|
||||||
const ImageEditorDialog({
|
const ImageEditorDialog({
|
||||||
super.key,
|
super.key,
|
||||||
required this.asset,
|
required this.asset,
|
||||||
|
|
@ -29,16 +32,20 @@ class ImageEditorDialog extends StatefulWidget {
|
||||||
final domain.GraphicAdjust initialGraphicAdjust;
|
final domain.GraphicAdjust initialGraphicAdjust;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<ImageEditorDialog> createState() => _ImageEditorDialogState();
|
ConsumerState<ImageEditorDialog> createState() => _ImageEditorDialogState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _ImageEditorDialogState extends State<ImageEditorDialog> {
|
class _ImageEditorDialogState extends ConsumerState<ImageEditorDialog> {
|
||||||
late bool _aspectLocked;
|
late bool _aspectLocked;
|
||||||
late bool _bgRemoval;
|
late bool _bgRemoval;
|
||||||
late double _contrast;
|
late double _contrast;
|
||||||
late double _brightness;
|
late double _brightness;
|
||||||
late double _rotation;
|
late double _rotation;
|
||||||
late Uint8List _processedBytes;
|
late Uint8List _processedBytes;
|
||||||
|
img.Image? _decodedSource; // Reused decoded source for fast previews
|
||||||
|
bool _previewScheduled = false;
|
||||||
|
bool _previewDirty = false;
|
||||||
|
late final SignatureImageProcessingService _svc;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
|
|
@ -48,62 +55,47 @@ class _ImageEditorDialogState extends State<ImageEditorDialog> {
|
||||||
_contrast = widget.initialGraphicAdjust.contrast;
|
_contrast = widget.initialGraphicAdjust.contrast;
|
||||||
_brightness = 1.0; // Changed from 0.0 to 1.0
|
_brightness = 1.0; // Changed from 0.0 to 1.0
|
||||||
_rotation = widget.initialRotation;
|
_rotation = widget.initialRotation;
|
||||||
_processedBytes = widget.asset.bytes; // Initialize with original bytes
|
_processedBytes = widget.asset.bytes; // initial preview
|
||||||
|
_svc = SignatureImageProcessingService();
|
||||||
|
// Decode once for preview reuse
|
||||||
|
// Note: package:image lives in service; expose decode via service
|
||||||
|
_decodedSource = _svc.decode(widget.asset.bytes);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Update processed image bytes when processing parameters change
|
@override
|
||||||
void _updateProcessedBytes() {
|
void dispose() {
|
||||||
try {
|
// Frame callbacks are tied to mounting; nothing to cancel explicitly
|
||||||
final decoded = img.decodeImage(widget.asset.bytes);
|
super.dispose();
|
||||||
if (decoded != null) {
|
}
|
||||||
img.Image processed = decoded;
|
|
||||||
|
|
||||||
// Apply contrast and brightness first
|
/// Update processed image bytes when processing parameters change.
|
||||||
if (_contrast != 1.0 || _brightness != 1.0) {
|
/// Coalesce rapid changes once per frame to keep UI responsive and tests stable.
|
||||||
processed = img.adjustColor(
|
void _updateProcessedBytes() {
|
||||||
processed,
|
_previewDirty = true;
|
||||||
|
if (_previewScheduled) return;
|
||||||
|
_previewScheduled = true;
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
_previewScheduled = false;
|
||||||
|
if (!mounted || !_previewDirty) return;
|
||||||
|
_previewDirty = false;
|
||||||
|
final adjust = domain.GraphicAdjust(
|
||||||
contrast: _contrast,
|
contrast: _contrast,
|
||||||
brightness: _brightness,
|
brightness: _brightness,
|
||||||
|
bgRemoval: _bgRemoval,
|
||||||
);
|
);
|
||||||
|
// Fast preview path: reuse decoded, downscale, low-compression encode
|
||||||
|
final decoded = _decodedSource;
|
||||||
|
if (decoded != null) {
|
||||||
|
final preview = _svc.processPreviewFromDecoded(decoded, adjust);
|
||||||
|
if (mounted) setState(() => _processedBytes = preview);
|
||||||
|
} else {
|
||||||
|
// Fallback to repository path if decode failed
|
||||||
|
final bytes = ref
|
||||||
|
.read(signatureViewModelProvider)
|
||||||
|
.getProcessedBytes(widget.asset, adjust);
|
||||||
|
if (mounted) setState(() => _processedBytes = bytes);
|
||||||
}
|
}
|
||||||
|
});
|
||||||
// Apply background removal after color adjustments
|
|
||||||
if (_bgRemoval) {
|
|
||||||
processed = _removeBackground(processed);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Encode back to PNG to preserve transparency
|
|
||||||
_processedBytes = Uint8List.fromList(img.encodePng(processed));
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// If processing fails, keep original bytes
|
|
||||||
_processedBytes = widget.asset.bytes;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Remove near-white background using simple threshold approach for maximum speed
|
|
||||||
/// TODO: remove double loops with SIMD matrix
|
|
||||||
img.Image _removeBackground(img.Image image) {
|
|
||||||
final result =
|
|
||||||
image.hasAlpha ? img.Image.from(image) : image.convert(numChannels: 4);
|
|
||||||
|
|
||||||
// Simple and fast: single pass through all pixels
|
|
||||||
for (int y = 0; y < result.height; y++) {
|
|
||||||
for (int x = 0; x < result.width; x++) {
|
|
||||||
final pixel = result.getPixel(x, y);
|
|
||||||
final r = pixel.r;
|
|
||||||
final g = pixel.g;
|
|
||||||
final b = pixel.b;
|
|
||||||
|
|
||||||
// Simple threshold: if pixel is close to white, make it transparent
|
|
||||||
const int threshold = 240; // Very close to white
|
|
||||||
if (r >= threshold && g >= threshold && b >= threshold) {
|
|
||||||
result.setPixelRgba(x, y, r, g, b, 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:pdf_signature/domain/models/model.dart' as domain;
|
import 'package:pdf_signature/domain/models/model.dart' as domain;
|
||||||
import 'signature_drag_data.dart';
|
import 'signature_drag_data.dart';
|
||||||
import 'rotated_signature_image.dart';
|
import 'rotated_signature_image.dart';
|
||||||
import 'package:pdf_signature/l10n/app_localizations.dart';
|
import 'package:pdf_signature/l10n/app_localizations.dart';
|
||||||
|
import '../view_model/signature_view_model.dart';
|
||||||
|
|
||||||
class SignatureCard extends StatelessWidget {
|
class SignatureCard extends ConsumerWidget {
|
||||||
const SignatureCard({
|
const SignatureCard({
|
||||||
super.key,
|
super.key,
|
||||||
required this.asset,
|
required this.asset,
|
||||||
|
|
@ -26,11 +28,14 @@ class SignatureCard extends StatelessWidget {
|
||||||
final domain.GraphicAdjust graphicAdjust;
|
final domain.GraphicAdjust graphicAdjust;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final processedBytes = ref
|
||||||
|
.watch(signatureViewModelProvider)
|
||||||
|
.getProcessedBytes(asset, graphicAdjust);
|
||||||
// Fit inside 96x64 with 6px padding using the shared rotated image widget
|
// Fit inside 96x64 with 6px padding using the shared rotated image widget
|
||||||
const boxW = 96.0, boxH = 64.0, pad = 6.0;
|
const boxW = 96.0, boxH = 64.0, pad = 6.0;
|
||||||
Widget img = RotatedSignatureImage(
|
Widget img = RotatedSignatureImage(
|
||||||
bytes: asset.bytes,
|
bytes: processedBytes,
|
||||||
rotationDeg: rotationDeg,
|
rotationDeg: rotationDeg,
|
||||||
);
|
);
|
||||||
Widget base = SizedBox(
|
Widget base = SizedBox(
|
||||||
|
|
@ -166,7 +171,7 @@ class SignatureCard extends StatelessWidget {
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(6.0),
|
padding: const EdgeInsets.all(6.0),
|
||||||
child: RotatedSignatureImage(
|
child: RotatedSignatureImage(
|
||||||
bytes: asset.bytes,
|
bytes: processedBytes,
|
||||||
rotationDeg: rotationDeg,
|
rotationDeg: rotationDeg,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,7 @@ class _SignatureDrawerState extends ConsumerState<SignatureDrawer> {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
final result = await showDialog<ImageEditorResult>(
|
final result = await showDialog<ImageEditorResult>(
|
||||||
context: context,
|
context: context,
|
||||||
|
barrierDismissible: false,
|
||||||
builder:
|
builder:
|
||||||
(_) => ImageEditorDialog(
|
(_) => ImageEditorDialog(
|
||||||
asset: card.asset,
|
asset: card.asset,
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ Future<void> aMultipageDocumentIsOpen(WidgetTester tester) async {
|
||||||
container.read(documentRepositoryProvider.notifier).state =
|
container.read(documentRepositoryProvider.notifier).state =
|
||||||
Document.initial();
|
Document.initial();
|
||||||
container.read(signatureCardRepositoryProvider.notifier).state = [
|
container.read(signatureCardRepositoryProvider.notifier).state = [
|
||||||
SignatureCard.initial(),
|
CachedSignatureCard.initial(),
|
||||||
];
|
];
|
||||||
container.read(documentRepositoryProvider.notifier).openPicked(pageCount: 5);
|
container.read(documentRepositoryProvider.notifier).openPicked(pageCount: 5);
|
||||||
// Reset page state providers
|
// Reset page state providers
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ Future<void> aSignatureAssetIsLoadedOrDrawn(WidgetTester tester) async {
|
||||||
container.read(documentRepositoryProvider.notifier).state =
|
container.read(documentRepositoryProvider.notifier).state =
|
||||||
Document.initial();
|
Document.initial();
|
||||||
container.read(signatureCardRepositoryProvider.notifier).state = [
|
container.read(signatureCardRepositoryProvider.notifier).state = [
|
||||||
SignatureCard.initial(),
|
CachedSignatureCard.initial(),
|
||||||
];
|
];
|
||||||
// Use a tiny valid PNG so any later image decoding succeeds.
|
// Use a tiny valid PNG so any later image decoding succeeds.
|
||||||
final bytes = Uint8List.fromList([
|
final bytes = Uint8List.fromList([
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ Future<void> aSignatureAssetLoadedOrDrawnIsWrappedInASignatureCard(
|
||||||
container.read(documentRepositoryProvider.notifier).state =
|
container.read(documentRepositoryProvider.notifier).state =
|
||||||
Document.initial();
|
Document.initial();
|
||||||
container.read(signatureCardRepositoryProvider.notifier).state = [
|
container.read(signatureCardRepositoryProvider.notifier).state = [
|
||||||
SignatureCard.initial(),
|
CachedSignatureCard.initial(),
|
||||||
];
|
];
|
||||||
final bytes = Uint8List.fromList([1, 2, 3, 4, 5]);
|
final bytes = Uint8List.fromList([1, 2, 3, 4, 5]);
|
||||||
container
|
container
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ Future<void> threeSignaturePlacementsArePlacedOnTheCurrentPage(
|
||||||
container.read(documentRepositoryProvider.notifier).state =
|
container.read(documentRepositoryProvider.notifier).state =
|
||||||
Document.initial();
|
Document.initial();
|
||||||
container.read(signatureCardRepositoryProvider.notifier).state = [
|
container.read(signatureCardRepositoryProvider.notifier).state = [
|
||||||
SignatureCard.initial(),
|
CachedSignatureCard.initial(),
|
||||||
];
|
];
|
||||||
container.read(documentRepositoryProvider.notifier).openPicked(pageCount: 5);
|
container.read(documentRepositoryProvider.notifier).openPicked(pageCount: 5);
|
||||||
final pdfN = container.read(documentRepositoryProvider.notifier);
|
final pdfN = container.read(documentRepositoryProvider.notifier);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue