feat: implement image processing and caching in signatureCard

repository
This commit is contained in:
insleker 2025-09-17 08:15:35 +08:00
parent 80cf115ab3
commit 26a0c93390
15 changed files with 373 additions and 86 deletions

View File

@ -1,16 +1,73 @@
import 'dart:typed_data';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../domain/models/model.dart';
import '../../data/services/signature_image_processing_service.dart';
class SignatureCardStateNotifier extends StateNotifier<List<SignatureCard>> {
SignatureCardStateNotifier() : super(const []);
/// CachedSignatureCard extends SignatureCard with an internal processed cache
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) {
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) {
state = List.of(state)
..add(SignatureCard(asset: asset, rotationDeg: rotationDeg));
final next = List<CachedSignatureCard>.of(state)
..add(CachedSignatureCard(asset: asset, rotationDeg: rotationDeg));
state = List<CachedSignatureCard>.unmodifiable(next);
}
void update(
@ -18,30 +75,78 @@ class SignatureCardStateNotifier extends StateNotifier<List<SignatureCard>> {
double? rotationDeg,
GraphicAdjust? graphicAdjust,
) {
final list = List<SignatureCard>.of(state);
final list = List<CachedSignatureCard>.of(state);
for (var i = 0; i < list.length; i++) {
final c = list[i];
if (c == card) {
list[i] = c.copyWith(
final updated = c.copyWith(
rotationDeg: rotationDeg ?? c.rotationDeg,
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;
}
}
}
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() {
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 =
StateNotifierProvider<SignatureCardStateNotifier, List<SignatureCard>>(
(ref) => SignatureCardStateNotifier(),
);
final signatureCardRepositoryProvider = StateNotifierProvider<
SignatureCardStateNotifier,
List<CachedSignatureCard>
>((ref) => SignatureCardStateNotifier());

View File

@ -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;
}
}

View File

@ -18,4 +18,17 @@ class GraphicAdjust {
brightness: brightness ?? this.brightness,
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;
}

View File

@ -6,4 +6,22 @@ class SignatureAsset {
// List<List<Offset>>? strokes;
final String? name; // optional display name (e.g., filename)
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;
}
}

View File

@ -10,6 +10,7 @@ class DrawCanvas extends StatefulWidget {
this.control,
this.onConfirm,
this.debugBytesSink,
this.closeOnConfirmImmediately = false,
});
final hand.HandSignatureControl? control;
@ -17,6 +18,9 @@ class DrawCanvas extends StatefulWidget {
// For tests: allows observing exported bytes without relying on Navigator
@visibleForTesting
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
State<DrawCanvas> createState() => _DrawCanvasState();
@ -48,6 +52,12 @@ class _DrawCanvasState extends State<DrawCanvas> {
ElevatedButton(
key: const Key('btn_canvas_confirm'),
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
final byteData = await _control.toImage(
width: 1024,
@ -60,7 +70,7 @@ class _DrawCanvasState extends State<DrawCanvas> {
widget.debugBytesSink?.value = bytes;
if (widget.onConfirm != null) {
widget.onConfirm!(bytes);
} else {
} else if (!widget.closeOnConfirmImmediately) {
if (context.mounted) {
Navigator.of(context).pop(bytes);
}

View File

@ -124,10 +124,7 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
context: context,
isScrollControlled: true,
enableDrag: false,
builder:
(_) => DrawCanvas(
onConfirm: (bytes) => Navigator.of(context).pop(bytes),
),
builder: (_) => const DrawCanvas(closeOnConfirmImmediately: true),
);
if (result != null && result.isNotEmpty) {
// In simplified UI, adding to library isn't implemented

View File

@ -1,9 +1,11 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../domain/models/model.dart';
import '../../signature/widgets/rotated_signature_image.dart';
import '../../signature/view_model/signature_view_model.dart';
/// Minimal overlay widget for rendering a placed signature.
class SignatureOverlay extends StatelessWidget {
class SignatureOverlay extends ConsumerWidget {
const SignatureOverlay({
super.key,
required this.pageSize,
@ -18,7 +20,10 @@ class SignatureOverlay extends StatelessWidget {
final int placedIndex;
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
final processedBytes = ref
.watch(signatureViewModelProvider)
.getProcessedBytes(placement.asset, placement.graphicAdjust);
return LayoutBuilder(
builder: (context, constraints) {
final left = rect.left * constraints.maxWidth;
@ -40,7 +45,7 @@ class SignatureOverlay extends StatelessWidget {
child: FittedBox(
fit: BoxFit.contain,
child: RotatedSignatureImage(
bytes: placement.asset.bytes,
bytes: processedBytes,
rotationDeg: placement.rotationDeg,
),
),

View File

@ -1,11 +1,26 @@
import 'dart:typed_data';
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 {
final Ref 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) {

View File

@ -1,10 +1,13 @@
import 'dart:typed_data';
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 '../../pdf/widgets/adjustments_panel.dart';
import '../../../../domain/models/model.dart' as domain;
import '../view_model/signature_view_model.dart';
import 'rotated_signature_image.dart';
import '../../../../data/services/signature_image_processing_service.dart';
import 'package:image/image.dart' as img;
class ImageEditorResult {
final double rotation;
@ -16,7 +19,7 @@ class ImageEditorResult {
});
}
class ImageEditorDialog extends StatefulWidget {
class ImageEditorDialog extends ConsumerStatefulWidget {
const ImageEditorDialog({
super.key,
required this.asset,
@ -29,16 +32,20 @@ class ImageEditorDialog extends StatefulWidget {
final domain.GraphicAdjust initialGraphicAdjust;
@override
State<ImageEditorDialog> createState() => _ImageEditorDialogState();
ConsumerState<ImageEditorDialog> createState() => _ImageEditorDialogState();
}
class _ImageEditorDialogState extends State<ImageEditorDialog> {
class _ImageEditorDialogState extends ConsumerState<ImageEditorDialog> {
late bool _aspectLocked;
late bool _bgRemoval;
late double _contrast;
late double _brightness;
late double _rotation;
late Uint8List _processedBytes;
img.Image? _decodedSource; // Reused decoded source for fast previews
bool _previewScheduled = false;
bool _previewDirty = false;
late final SignatureImageProcessingService _svc;
@override
void initState() {
@ -48,62 +55,47 @@ class _ImageEditorDialogState extends State<ImageEditorDialog> {
_contrast = widget.initialGraphicAdjust.contrast;
_brightness = 1.0; // Changed from 0.0 to 1.0
_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 dispose() {
// Frame callbacks are tied to mounting; nothing to cancel explicitly
super.dispose();
}
/// Update processed image bytes when processing parameters change.
/// Coalesce rapid changes once per frame to keep UI responsive and tests stable.
void _updateProcessedBytes() {
try {
final decoded = img.decodeImage(widget.asset.bytes);
_previewDirty = true;
if (_previewScheduled) return;
_previewScheduled = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
_previewScheduled = false;
if (!mounted || !_previewDirty) return;
_previewDirty = false;
final adjust = domain.GraphicAdjust(
contrast: _contrast,
brightness: _brightness,
bgRemoval: _bgRemoval,
);
// Fast preview path: reuse decoded, downscale, low-compression encode
final decoded = _decodedSource;
if (decoded != null) {
img.Image processed = decoded;
// Apply contrast and brightness first
if (_contrast != 1.0 || _brightness != 1.0) {
processed = img.adjustColor(
processed,
contrast: _contrast,
brightness: _brightness,
);
}
// Apply background removal after color adjustments
if (_bgRemoval) {
processed = _removeBackground(processed);
}
// Encode back to PNG to preserve transparency
_processedBytes = Uint8List.fromList(img.encodePng(processed));
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);
}
} 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

View File

@ -1,10 +1,12 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:pdf_signature/domain/models/model.dart' as domain;
import 'signature_drag_data.dart';
import 'rotated_signature_image.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({
super.key,
required this.asset,
@ -26,11 +28,14 @@ class SignatureCard extends StatelessWidget {
final domain.GraphicAdjust graphicAdjust;
@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
const boxW = 96.0, boxH = 64.0, pad = 6.0;
Widget img = RotatedSignatureImage(
bytes: asset.bytes,
bytes: processedBytes,
rotationDeg: rotationDeg,
);
Widget base = SizedBox(
@ -166,7 +171,7 @@ class SignatureCard extends StatelessWidget {
child: Padding(
padding: const EdgeInsets.all(6.0),
child: RotatedSignatureImage(
bytes: asset.bytes,
bytes: processedBytes,
rotationDeg: rotationDeg,
),
),

View File

@ -62,6 +62,7 @@ class _SignatureDrawerState extends ConsumerState<SignatureDrawer> {
if (!mounted) return;
final result = await showDialog<ImageEditorResult>(
context: context,
barrierDismissible: false,
builder:
(_) => ImageEditorDialog(
asset: card.asset,

View File

@ -16,7 +16,7 @@ Future<void> aMultipageDocumentIsOpen(WidgetTester tester) async {
container.read(documentRepositoryProvider.notifier).state =
Document.initial();
container.read(signatureCardRepositoryProvider.notifier).state = [
SignatureCard.initial(),
CachedSignatureCard.initial(),
];
container.read(documentRepositoryProvider.notifier).openPicked(pageCount: 5);
// Reset page state providers

View File

@ -15,7 +15,7 @@ Future<void> aSignatureAssetIsLoadedOrDrawn(WidgetTester tester) async {
container.read(documentRepositoryProvider.notifier).state =
Document.initial();
container.read(signatureCardRepositoryProvider.notifier).state = [
SignatureCard.initial(),
CachedSignatureCard.initial(),
];
// Use a tiny valid PNG so any later image decoding succeeds.
final bytes = Uint8List.fromList([

View File

@ -17,7 +17,7 @@ Future<void> aSignatureAssetLoadedOrDrawnIsWrappedInASignatureCard(
container.read(documentRepositoryProvider.notifier).state =
Document.initial();
container.read(signatureCardRepositoryProvider.notifier).state = [
SignatureCard.initial(),
CachedSignatureCard.initial(),
];
final bytes = Uint8List.fromList([1, 2, 3, 4, 5]);
container

View File

@ -20,7 +20,7 @@ Future<void> threeSignaturePlacementsArePlacedOnTheCurrentPage(
container.read(documentRepositoryProvider.notifier).state =
Document.initial();
container.read(signatureCardRepositoryProvider.notifier).state = [
SignatureCard.initial(),
CachedSignatureCard.initial(),
];
container.read(documentRepositoryProvider.notifier).openPicked(pageCount: 5);
final pdfN = container.read(documentRepositoryProvider.notifier);