refactor: unify signature placement handling with new model structure

This commit is contained in:
insleker 2025-09-08 17:07:09 +08:00
parent f74b724712
commit 4f149656bd
17 changed files with 179 additions and 167 deletions

View File

@ -1,12 +1,12 @@
# AGENTS # AGENTS
Always read `README.md` and `docs/meta-arch.md` when new chat created. Always read [`README.md`](README.md) and [`meta-arch.md`](docs/meta-arch.md) when new chat created.
Additionally read relevant files depends on task. Additionally read relevant files depends on task.
* If want to modify use cases (files at `test/features/*.feature`) * If want to modify use cases (files at `test/features/*.feature`)
* read `docs/FRs.md` * read [`FRs.md`](docs/FRs.md)
* If want to modify code (implement or test) of `View` of MVVM (UI widget) (files at `lib/ui/features/*/widgets/*`) * If want to modify code (implement or test) of `View` of MVVM (UI widget) (files at `lib/ui/features/*/widgets/*`)
* read `docs/wireframe.md`, `docs/NFRs.md`, `test/features/*.feature` * read [`wireframe.md`](docs/wireframe.md), [`NFRs.md`](docs/NFRs.md), `test/features/*.feature`
* If want to modify code (implement or test) of non-View e.g. `Model`, `View Model`, services... * If want to modify code (implement or test) of non-View e.g. `Model`, `View Model`, services...
* read `test/features/*.feature`, `docs/NFRs.md` * read `test/features/*.feature`, [`NFRs.md`](docs/NFRs.md)

View File

@ -126,7 +126,7 @@ void main() {
final pdf = container.read(pdfProvider); final pdf = container.read(pdfProvider);
container container
.read(pdfProvider.notifier) .read(pdfProvider.notifier)
.addPlacement(page: pdf.currentPage, rect: r, image: imageId); .addPlacement(page: pdf.currentPage, rect: r, imageId: imageId);
container.read(signatureProvider.notifier).clearActiveOverlay(); container.read(signatureProvider.notifier).clearActiveOverlay();
await tester.pumpAndSettle(); await tester.pumpAndSettle();

View File

@ -1,6 +1,35 @@
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
/// Represents a single signature placement on a page combining both the
/// geometric rectangle (UI coordinate space) and the identifier of the
/// image/signature asset assigned to that placement.
class SignaturePlacement {
final Rect rect;
/// Rotation in degrees to apply when rendering/exporting this placement.
final double rotationDeg;
/// Identifier of the image (e.g., filename / asset id) assigned to this placement.
/// Nullable to allow a placement reserved before an image is chosen.
final String? imageId;
const SignaturePlacement({
required this.rect,
this.imageId,
this.rotationDeg = 0.0,
});
SignaturePlacement copyWith({
Rect? rect,
String? imageId,
double? rotationDeg,
}) => SignaturePlacement(
rect: rect ?? this.rect,
imageId: imageId ?? this.imageId,
rotationDeg: rotationDeg ?? this.rotationDeg,
);
}
class PdfState { class PdfState {
final bool loaded; final bool loaded;
final int pageCount; final int pageCount;
@ -8,10 +37,8 @@ class PdfState {
final String? pickedPdfPath; final String? pickedPdfPath;
final Uint8List? pickedPdfBytes; final Uint8List? pickedPdfBytes;
final int? signedPage; final int? signedPage;
// Multiple signature placements per page, stored as UI-space rects (e.g., 400x560) // Multiple signature placements per page, each combines geometry and optional image id.
final Map<int, List<Rect>> placementsByPage; final Map<int, List<SignaturePlacement>> placementsByPage;
// For each placement, store the assigned image identifier (e.g., filename) in the same index order.
final Map<int, List<String>> placementImageByPage;
// UI state: selected placement index on the current page (if any) // UI state: selected placement index on the current page (if any)
final int? selectedPlacementIndex; final int? selectedPlacementIndex;
const PdfState({ const PdfState({
@ -22,7 +49,6 @@ class PdfState {
this.pickedPdfBytes, this.pickedPdfBytes,
this.signedPage, this.signedPage,
this.placementsByPage = const {}, this.placementsByPage = const {},
this.placementImageByPage = const {},
this.selectedPlacementIndex, this.selectedPlacementIndex,
}); });
factory PdfState.initial() => const PdfState( factory PdfState.initial() => const PdfState(
@ -32,7 +58,6 @@ class PdfState {
pickedPdfBytes: null, pickedPdfBytes: null,
signedPage: null, signedPage: null,
placementsByPage: {}, placementsByPage: {},
placementImageByPage: {},
selectedPlacementIndex: null, selectedPlacementIndex: null,
); );
PdfState copyWith({ PdfState copyWith({
@ -42,8 +67,7 @@ class PdfState {
String? pickedPdfPath, String? pickedPdfPath,
Uint8List? pickedPdfBytes, Uint8List? pickedPdfBytes,
int? signedPage, int? signedPage,
Map<int, List<Rect>>? placementsByPage, Map<int, List<SignaturePlacement>>? placementsByPage,
Map<int, List<String>>? placementImageByPage,
int? selectedPlacementIndex, int? selectedPlacementIndex,
}) => PdfState( }) => PdfState(
loaded: loaded ?? this.loaded, loaded: loaded ?? this.loaded,
@ -53,7 +77,6 @@ class PdfState {
pickedPdfBytes: pickedPdfBytes ?? this.pickedPdfBytes, pickedPdfBytes: pickedPdfBytes ?? this.pickedPdfBytes,
signedPage: signedPage ?? this.signedPage, signedPage: signedPage ?? this.signedPage,
placementsByPage: placementsByPage ?? this.placementsByPage, placementsByPage: placementsByPage ?? this.placementsByPage,
placementImageByPage: placementImageByPage ?? this.placementImageByPage,
selectedPlacementIndex: selectedPlacementIndex:
selectedPlacementIndex ?? this.selectedPlacementIndex, selectedPlacementIndex ?? this.selectedPlacementIndex,
); );

View File

@ -6,6 +6,7 @@ import 'package:pdf/widgets.dart' as pw;
import 'package:pdf/pdf.dart' as pdf; import 'package:pdf/pdf.dart' as pdf;
import 'package:printing/printing.dart' as printing; import 'package:printing/printing.dart' as printing;
import 'package:image/image.dart' as img; import 'package:image/image.dart' as img;
import '../model/model.dart';
// NOTE: // NOTE:
// - This exporter uses a raster snapshot of the UI (RepaintBoundary) and embeds it into a new PDF. // - This exporter uses a raster snapshot of the UI (RepaintBoundary) and embeds it into a new PDF.
@ -32,8 +33,7 @@ class ExportService {
required Rect? signatureRectUi, required Rect? signatureRectUi,
required Size uiPageSize, required Size uiPageSize,
required Uint8List? signatureImageBytes, required Uint8List? signatureImageBytes,
Map<int, List<Rect>>? placementsByPage, Map<int, List<SignaturePlacement>>? placementsByPage,
Map<int, List<String>>? placementImageByPage,
Map<String, Uint8List>? libraryBytes, Map<String, Uint8List>? libraryBytes,
double targetDpi = 144.0, double targetDpi = 144.0,
}) async { }) async {
@ -55,7 +55,6 @@ class ExportService {
uiPageSize: uiPageSize, uiPageSize: uiPageSize,
signatureImageBytes: signatureImageBytes, signatureImageBytes: signatureImageBytes,
placementsByPage: placementsByPage, placementsByPage: placementsByPage,
placementImageByPage: placementImageByPage,
libraryBytes: libraryBytes, libraryBytes: libraryBytes,
targetDpi: targetDpi, targetDpi: targetDpi,
); );
@ -76,8 +75,7 @@ class ExportService {
required Rect? signatureRectUi, required Rect? signatureRectUi,
required Size uiPageSize, required Size uiPageSize,
required Uint8List? signatureImageBytes, required Uint8List? signatureImageBytes,
Map<int, List<Rect>>? placementsByPage, Map<int, List<SignaturePlacement>>? placementsByPage,
Map<int, List<String>>? placementImageByPage,
Map<String, Uint8List>? libraryBytes, Map<String, Uint8List>? libraryBytes,
double targetDpi = 144.0, double targetDpi = 144.0,
}) async { }) async {
@ -104,12 +102,8 @@ class ExportService {
(placementsByPage != null && placementsByPage.isNotEmpty); (placementsByPage != null && placementsByPage.isNotEmpty);
final pagePlacements = final pagePlacements =
hasMulti hasMulti
? (placementsByPage[pageIndex] ?? const <Rect>[]) ? (placementsByPage[pageIndex] ?? const <SignaturePlacement>[])
: const <Rect>[]; : const <SignaturePlacement>[];
final pageImageIds =
hasMulti
? (placementImageByPage?[pageIndex] ?? const <String>[])
: const <String>[];
final shouldStampSingle = final shouldStampSingle =
!hasMulti && !hasMulti &&
signedPage != null && signedPage != null &&
@ -147,18 +141,18 @@ class ExportService {
// Multi-placement stamping: per-placement image from libraryBytes // Multi-placement stamping: per-placement image from libraryBytes
if (hasMulti && pagePlacements.isNotEmpty) { if (hasMulti && pagePlacements.isNotEmpty) {
for (var i = 0; i < pagePlacements.length; i++) { for (var i = 0; i < pagePlacements.length; i++) {
final r = pagePlacements[i]; final placement = pagePlacements[i];
final r = placement.rect;
final left = r.left / uiPageSize.width * widthPts; final left = r.left / uiPageSize.width * widthPts;
final top = r.top / uiPageSize.height * heightPts; final top = r.top / uiPageSize.height * heightPts;
final w = r.width / uiPageSize.width * widthPts; final w = r.width / uiPageSize.width * widthPts;
final h = r.height / uiPageSize.height * heightPts; final h = r.height / uiPageSize.height * heightPts;
Uint8List? bytes; Uint8List? bytes;
if (i < pageImageIds.length) { final id = placement.imageId;
final id = pageImageIds[i]; if (id != null) {
bytes = libraryBytes?[id]; bytes = libraryBytes?[id];
} }
bytes ??= bytes ??= signatureImageBytes; // fallback
signatureImageBytes; // fallback to single image if provided
if (bytes != null && bytes.isNotEmpty) { if (bytes != null && bytes.isNotEmpty) {
pw.MemoryImage? imgObj; pw.MemoryImage? imgObj;
try { try {
@ -176,7 +170,13 @@ class ExportService {
height: h, height: h,
child: pw.FittedBox( child: pw.FittedBox(
fit: pw.BoxFit.contain, fit: pw.BoxFit.contain,
child: pw.Image(imgObj), child: pw.Transform.rotate(
angle:
placement.rotationDeg *
3.1415926535 /
180.0,
child: pw.Image(imgObj),
),
), ),
), ),
), ),
@ -222,11 +222,9 @@ class ExportService {
final hasMulti = final hasMulti =
(placementsByPage != null && placementsByPage.isNotEmpty); (placementsByPage != null && placementsByPage.isNotEmpty);
final pagePlacements = final pagePlacements =
hasMulti ? (placementsByPage[1] ?? const <Rect>[]) : const <Rect>[];
final pageImageIds =
hasMulti hasMulti
? (placementImageByPage?[1] ?? const <String>[]) ? (placementsByPage[1] ?? const <SignaturePlacement>[])
: const <String>[]; : const <SignaturePlacement>[];
final shouldStampSingle = final shouldStampSingle =
!hasMulti && !hasMulti &&
signedPage != null && signedPage != null &&
@ -270,18 +268,18 @@ class ExportService {
// Multi-placement stamping on fallback page // Multi-placement stamping on fallback page
if (hasMulti && pagePlacements.isNotEmpty) { if (hasMulti && pagePlacements.isNotEmpty) {
for (var i = 0; i < pagePlacements.length; i++) { for (var i = 0; i < pagePlacements.length; i++) {
final r = pagePlacements[i]; final placement = pagePlacements[i];
final r = placement.rect;
final left = r.left / uiPageSize.width * widthPts; final left = r.left / uiPageSize.width * widthPts;
final top = r.top / uiPageSize.height * heightPts; final top = r.top / uiPageSize.height * heightPts;
final w = r.width / uiPageSize.width * widthPts; final w = r.width / uiPageSize.width * widthPts;
final h = r.height / uiPageSize.height * heightPts; final h = r.height / uiPageSize.height * heightPts;
Uint8List? bytes; Uint8List? bytes;
if (i < pageImageIds.length) { final id = placement.imageId;
final id = pageImageIds[i]; if (id != null) {
bytes = libraryBytes?[id]; bytes = libraryBytes?[id];
} }
bytes ??= bytes ??= signatureImageBytes; // fallback
signatureImageBytes; // fallback to single image if provided
if (bytes != null && bytes.isNotEmpty) { if (bytes != null && bytes.isNotEmpty) {
pw.MemoryImage? imgObj; pw.MemoryImage? imgObj;
try { try {
@ -313,7 +311,11 @@ class ExportService {
height: h, height: h,
child: pw.FittedBox( child: pw.FittedBox(
fit: pw.BoxFit.contain, fit: pw.BoxFit.contain,
child: pw.Image(imgObj), child: pw.Transform.rotate(
angle:
placement.rotationDeg * 3.1415926535 / 180.0,
child: pw.Image(imgObj),
),
), ),
), ),
), ),

View File

@ -17,7 +17,6 @@ class PdfController extends StateNotifier<PdfState> {
pickedPdfPath: null, pickedPdfPath: null,
signedPage: null, signedPage: null,
placementsByPage: {}, placementsByPage: {},
placementImageByPage: {},
selectedPlacementIndex: null, selectedPlacementIndex: null,
); );
} }
@ -35,7 +34,6 @@ class PdfController extends StateNotifier<PdfState> {
pickedPdfBytes: bytes, pickedPdfBytes: bytes,
signedPage: null, signedPage: null,
placementsByPage: {}, placementsByPage: {},
placementImageByPage: {},
selectedPlacementIndex: null, selectedPlacementIndex: null,
); );
} }
@ -67,49 +65,54 @@ class PdfController extends StateNotifier<PdfState> {
void addPlacement({ void addPlacement({
required int page, required int page,
required Rect rect, required Rect rect,
String image = 'default.png', String? imageId = 'default.png',
double rotationDeg = 0.0,
}) { }) {
if (!state.loaded) return; if (!state.loaded) return;
final p = page.clamp(1, state.pageCount); final p = page.clamp(1, state.pageCount);
final map = Map<int, List<Rect>>.from(state.placementsByPage); final map = Map<int, List<SignaturePlacement>>.from(state.placementsByPage);
final list = List<Rect>.from(map[p] ?? const []); final list = List<SignaturePlacement>.from(map[p] ?? const []);
list.add(rect); list.add(
map[p] = list; SignaturePlacement(
// Sync image mapping list rect: rect,
final imgMap = Map<int, List<String>>.from(state.placementImageByPage); imageId: imageId,
final imgList = List<String>.from(imgMap[p] ?? const []); rotationDeg: rotationDeg,
imgList.add(image); ),
imgMap[p] = imgList;
state = state.copyWith(
placementsByPage: map,
placementImageByPage: imgMap,
selectedPlacementIndex: null,
); );
map[p] = list;
state = state.copyWith(placementsByPage: map, selectedPlacementIndex: null);
}
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<int, List<SignaturePlacement>>.from(state.placementsByPage);
final list = List<SignaturePlacement>.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}) { void removePlacement({required int page, required int index}) {
if (!state.loaded) return; if (!state.loaded) return;
final p = page.clamp(1, state.pageCount); final p = page.clamp(1, state.pageCount);
final map = Map<int, List<Rect>>.from(state.placementsByPage); final map = Map<int, List<SignaturePlacement>>.from(state.placementsByPage);
final list = List<Rect>.from(map[p] ?? const []); final list = List<SignaturePlacement>.from(map[p] ?? const []);
if (index >= 0 && index < list.length) { if (index >= 0 && index < list.length) {
list.removeAt(index); list.removeAt(index);
// Sync image mapping
final imgMap = Map<int, List<String>>.from(state.placementImageByPage);
final imgList = List<String>.from(imgMap[p] ?? const []);
if (index >= 0 && index < imgList.length) {
imgList.removeAt(index);
}
if (list.isEmpty) { if (list.isEmpty) {
map.remove(p); map.remove(p);
imgMap.remove(p);
} else { } else {
map[p] = list; map[p] = list;
imgMap[p] = imgList;
} }
state = state.copyWith( state = state.copyWith(
placementsByPage: map, placementsByPage: map,
placementImageByPage: imgMap,
selectedPlacementIndex: null, selectedPlacementIndex: null,
); );
} }
@ -123,17 +126,20 @@ class PdfController extends StateNotifier<PdfState> {
}) { }) {
if (!state.loaded) return; if (!state.loaded) return;
final p = page.clamp(1, state.pageCount); final p = page.clamp(1, state.pageCount);
final map = Map<int, List<Rect>>.from(state.placementsByPage); final map = Map<int, List<SignaturePlacement>>.from(state.placementsByPage);
final list = List<Rect>.from(map[p] ?? const []); final list = List<SignaturePlacement>.from(map[p] ?? const []);
if (index >= 0 && index < list.length) { if (index >= 0 && index < list.length) {
list[index] = rect; final existing = list[index];
list[index] = existing.copyWith(rect: rect);
map[p] = list; map[p] = list;
state = state.copyWith(placementsByPage: map); state = state.copyWith(placementsByPage: map);
} }
} }
List<Rect> placementsOn(int page) { List<SignaturePlacement> placementsOn(int page) {
return List<Rect>.from(state.placementsByPage[page] ?? const []); return List<SignaturePlacement>.from(
state.placementsByPage[page] ?? const [],
);
} }
void selectPlacement(int? index) { void selectPlacement(int? index) {
@ -161,9 +167,9 @@ class PdfController extends StateNotifier<PdfState> {
// Convenience to get image name for a placement // Convenience to get image name for a placement
String? imageOfPlacement({required int page, required int index}) { String? imageOfPlacement({required int page, required int index}) {
final list = state.placementImageByPage[page] ?? const []; final list = state.placementsByPage[page] ?? const [];
if (index < 0 || index >= list.length) return null; if (index < 0 || index >= list.length) return null;
return list[index]; return list[index].imageId;
} }
} }

View File

@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../signature/view_model/signature_controller.dart'; import '../../signature/view_model/signature_controller.dart';
import '../../../../data/model/model.dart';
import '../view_model/pdf_controller.dart'; import '../view_model/pdf_controller.dart';
import 'signature_overlay.dart'; import 'signature_overlay.dart';
@ -30,12 +31,13 @@ class PdfPageOverlays extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final pdf = ref.watch(pdfProvider); final pdf = ref.watch(pdfProvider);
final sig = ref.watch(signatureProvider); final sig = ref.watch(signatureProvider);
final placed = pdf.placementsByPage[pageNumber] ?? const <Rect>[]; final placed =
pdf.placementsByPage[pageNumber] ?? const <SignaturePlacement>[];
final widgets = <Widget>[]; final widgets = <Widget>[];
for (int i = 0; i < placed.length; i++) { for (int i = 0; i < placed.length; i++) {
// Stored as UI-space rects (SignatureController.pageSize). // Stored as UI-space rects (SignatureController.pageSize).
final uiRect = placed[i]; final uiRect = placed[i].rect;
widgets.add( widgets.add(
SignatureOverlay( SignatureOverlay(
pageSize: pageSize, pageSize: pageSize,

View File

@ -174,7 +174,6 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
uiPageSize: SignatureController.pageSize, uiPageSize: SignatureController.pageSize,
signatureImageBytes: rotated, signatureImageBytes: rotated,
placementsByPage: pdf.placementsByPage, placementsByPage: pdf.placementsByPage,
placementImageByPage: pdf.placementImageByPage,
libraryBytes: { libraryBytes: {
for (final a in ref.read(signatureLibraryProvider)) a.id: a.bytes, for (final a in ref.read(signatureLibraryProvider)) a.id: a.bytes,
}, },
@ -211,7 +210,6 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
uiPageSize: SignatureController.pageSize, uiPageSize: SignatureController.pageSize,
signatureImageBytes: rotated, signatureImageBytes: rotated,
placementsByPage: pdf.placementsByPage, placementsByPage: pdf.placementsByPage,
placementImageByPage: pdf.placementImageByPage,
libraryBytes: { libraryBytes: {
for (final a in ref.read(signatureLibraryProvider)) a.id: a.bytes, for (final a in ref.read(signatureLibraryProvider)) a.id: a.bytes,
}, },
@ -242,7 +240,6 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
uiPageSize: SignatureController.pageSize, uiPageSize: SignatureController.pageSize,
signatureImageBytes: rotated, signatureImageBytes: rotated,
placementsByPage: pdf.placementsByPage, placementsByPage: pdf.placementsByPage,
placementImageByPage: pdf.placementImageByPage,
libraryBytes: { libraryBytes: {
for (final a in ref.read(signatureLibraryProvider)) for (final a in ref.read(signatureLibraryProvider))
a.id: a.bytes, a.id: a.bytes,

View File

@ -253,11 +253,12 @@ class _SignatureImage extends ConsumerWidget {
final processed = ref.watch(processedSignatureImageProvider); final processed = ref.watch(processedSignatureImageProvider);
bytes = processed ?? sig.imageBytes; bytes = processed ?? sig.imageBytes;
} else if (placedIndex != null) { } else if (placedIndex != null) {
// Use the image assigned to this placement final placementList = ref.read(pdfProvider).placementsByPage[pageNumber];
final imgId = ref final placement =
.read(pdfProvider) (placementList != null && placedIndex! < placementList.length)
.placementImageByPage[pageNumber] ? placementList[placedIndex!]
?.elementAt(placedIndex!); : null;
final imgId = placement?.imageId;
if (imgId != null) { if (imgId != null) {
final lib = ref.watch(signatureLibraryProvider); final lib = ref.watch(signatureLibraryProvider);
for (final a in lib) { for (final a in lib) {
@ -267,7 +268,6 @@ class _SignatureImage extends ConsumerWidget {
} }
} }
} }
// Fallback to current processed
bytes ??= ref.read(processedSignatureImageProvider) ?? sig.imageBytes; bytes ??= ref.read(processedSignatureImageProvider) ?? sig.imageBytes;
} }
@ -281,9 +281,19 @@ class _SignatureImage extends ConsumerWidget {
return Center(child: Text(label)); return Center(child: Text(label));
} }
// Use live rotation for interactive overlay; stored rotation for placed
double rotationDeg = 0.0;
if (interactive) {
rotationDeg = sig.rotation;
} else if (placedIndex != null) {
final placementList = ref.read(pdfProvider).placementsByPage[pageNumber];
if (placementList != null && placedIndex! < placementList.length) {
rotationDeg = placementList[placedIndex!].rotationDeg;
}
}
return RotatedSignatureImage( return RotatedSignatureImage(
bytes: bytes, bytes: bytes,
rotationDeg: interactive ? sig.rotation : 0.0, rotationDeg: rotationDeg,
enableAngleAwareScale: interactive, enableAngleAwareScale: interactive,
fit: BoxFit.contain, fit: BoxFit.contain,
wrapInRepaintBoundary: true, wrapInRepaintBoundary: true,

View File

@ -21,17 +21,13 @@ class SignatureController extends StateNotifier<SignatureState> {
@visibleForTesting @visibleForTesting
void placeDefaultRect() { void placeDefaultRect() {
final w = 120.0, h = 60.0; final w = 120.0, h = 60.0;
state = state.copyWith( final rand = Random();
rect: Rect.fromCenter( // Generate a center within 10%..90% of each axis to reduce off-screen risk
center: Offset( final cx = pageSize.width * (0.1 + rand.nextDouble() * 0.8);
(pageSize.width / 2) * (Random().nextDouble() * 1.5 + 1), final cy = pageSize.height * (0.1 + rand.nextDouble() * 0.8);
(pageSize.height / 2) * (Random().nextDouble() * 1.5 + 1), Rect r = Rect.fromCenter(center: Offset(cx, cy), width: w, height: h);
), r = _clampRectToPage(r);
width: w, state = state.copyWith(rect: r, editingEnabled: true);
height: h,
),
editingEnabled: true,
);
} }
void loadSample() { void loadSample() {
@ -181,37 +177,20 @@ class SignatureController extends StateNotifier<SignatureState> {
if (!pdf.loaded) return null; if (!pdf.loaded) return null;
// Bind the processed image at placement time (so placed preview matches adjustments). // Bind the processed image at placement time (so placed preview matches adjustments).
// If processed bytes exist, always create a new asset for this placement. // If processed bytes exist, always create a new asset for this placement.
String id = ''; // Prefer reusing an existing library asset id when the active overlay is
// Compose final bytes for placement: apply adjustments (processed) then rotation. // based on a library item. If there is no library asset, do NOT create
Uint8List? srcBytes = ref.read(processedSignatureImageProvider); // a new library card here keep the placement's image id empty so the
srcBytes ??= state.imageBytes; // UI and exporter will fall back to using the processed/current bytes.
// If still null, fall back to asset reference only. String id = state.assetId ?? '';
if (srcBytes != null && srcBytes.isNotEmpty) {
final rot = state.rotation % 360;
Uint8List finalBytes = srcBytes;
if (rot != 0) {
try {
final decoded = img.decodeImage(srcBytes);
if (decoded != null) {
var out = img.copyRotate(
decoded,
angle: rot,
interpolation: img.Interpolation.linear,
);
finalBytes = Uint8List.fromList(img.encodePng(out, level: 6));
}
} catch (_) {}
}
id = ref
.read(signatureLibraryProvider.notifier)
.add(finalBytes, name: 'image');
} else {
id = state.assetId ?? 'default.png';
}
// Store as UI-space rect (consistent with export and rendering paths) // Store as UI-space rect (consistent with export and rendering paths)
ref ref
.read(pdfProvider.notifier) .read(pdfProvider.notifier)
.addPlacement(page: pdf.currentPage, rect: r, image: id); .addPlacement(
page: pdf.currentPage,
rect: r,
imageId: id,
rotationDeg: state.rotation,
);
// Newly placed index is the last one on the page // Newly placed index is the last one on the page
final idx = final idx =
(ref.read(pdfProvider).placementsByPage[pdf.currentPage]?.length ?? 1) - (ref.read(pdfProvider).placementsByPage[pdf.currentPage]?.length ?? 1) -
@ -227,39 +206,23 @@ class SignatureController extends StateNotifier<SignatureState> {
// Test/helper variant: confirm using a ProviderContainer instead of WidgetRef. // Test/helper variant: confirm using a ProviderContainer instead of WidgetRef.
// Useful in widget tests where obtaining a WidgetRef is not straightforward. // Useful in widget tests where obtaining a WidgetRef is not straightforward.
@visibleForTesting
Rect? confirmCurrentSignatureWithContainer(ProviderContainer container) { Rect? confirmCurrentSignatureWithContainer(ProviderContainer container) {
final r = state.rect; final r = state.rect;
if (r == null) return null; if (r == null) return null;
final pdf = container.read(pdfProvider); final pdf = container.read(pdfProvider);
if (!pdf.loaded) return null; if (!pdf.loaded) return null;
String id = ''; // Reuse existing library id if present; otherwise leave empty so the
Uint8List? srcBytes = container.read(processedSignatureImageProvider); // placement will reference the current bytes via fallback paths.
srcBytes ??= state.imageBytes; String id = state.assetId ?? '';
if (srcBytes != null && srcBytes.isNotEmpty) {
final rot = state.rotation % 360;
Uint8List finalBytes = srcBytes;
if (rot != 0) {
try {
final decoded = img.decodeImage(srcBytes);
if (decoded != null) {
var out = img.copyRotate(
decoded,
angle: rot,
interpolation: img.Interpolation.linear,
);
finalBytes = Uint8List.fromList(img.encodePng(out, level: 6));
}
} catch (_) {}
}
id = container
.read(signatureLibraryProvider.notifier)
.add(finalBytes, name: 'image');
} else {
id = state.assetId ?? 'default.png';
}
container container
.read(pdfProvider.notifier) .read(pdfProvider.notifier)
.addPlacement(page: pdf.currentPage, rect: r, image: id); .addPlacement(
page: pdf.currentPage,
rect: r,
imageId: id,
rotationDeg: state.rotation,
);
final idx = final idx =
(container (container
.read(pdfProvider) .read(pdfProvider)

View File

@ -21,5 +21,5 @@ Future<void> aSignatureIsPlacedOnPage(WidgetTester tester, num page) async {
final Rect r = container.read(signatureProvider).rect!; final Rect r = container.read(signatureProvider).rect!;
container container
.read(pdfProvider.notifier) .read(pdfProvider.notifier)
.addPlacement(page: page.toInt(), rect: r, image: 'default.png'); .addPlacement(page: page.toInt(), rect: r, imageId: 'default.png');
} }

View File

@ -10,9 +10,11 @@ Future<void> adjustingOneInstanceDoesNotAffectTheOthers(
final container = TestWorld.container ?? ProviderContainer(); final container = TestWorld.container ?? ProviderContainer();
final before = container.read(pdfProvider.notifier).placementsOn(2); final before = container.read(pdfProvider.notifier).placementsOn(2);
expect(before.length, greaterThanOrEqualTo(2)); expect(before.length, greaterThanOrEqualTo(2));
final modified = before[0].translate(5, 0).inflate(3); final modified = before[0].rect.translate(5, 0).inflate(3);
container.read(pdfProvider.notifier).removePlacement(page: 2, index: 0); container.read(pdfProvider.notifier).removePlacement(page: 2, index: 0);
container.read(pdfProvider.notifier).addPlacement(page: 2, rect: modified); container
.read(pdfProvider.notifier)
.addPlacement(page: 2, rect: modified, imageId: before[0].imageId);
final after = container.read(pdfProvider.notifier).placementsOn(2); final after = container.read(pdfProvider.notifier).placementsOn(2);
expect(after.any((r) => r == before[1]), isTrue); expect(after.any((p) => p.rect == before[1].rect), isTrue);
} }

View File

@ -11,11 +11,18 @@ Future<void> draggingOrResizingOneDoesNotChangeTheOther(
final container = TestWorld.container ?? ProviderContainer(); final container = TestWorld.container ?? ProviderContainer();
final list = container.read(pdfProvider.notifier).placementsOn(1); final list = container.read(pdfProvider.notifier).placementsOn(1);
expect(list.length, greaterThanOrEqualTo(2)); expect(list.length, greaterThanOrEqualTo(2));
final before = List<Rect>.from(list.take(2)); final before = List<Rect>.from(list.take(2).map((p) => p.rect));
// Simulate changing the first only // Simulate changing the first only
final changed = before[0].inflate(5); final changed = before[0].inflate(5);
container.read(pdfProvider.notifier).removePlacement(page: 1, index: 0); container.read(pdfProvider.notifier).removePlacement(page: 1, index: 0);
container.read(pdfProvider.notifier).addPlacement(page: 1, rect: changed); container
.read(pdfProvider.notifier)
.addPlacement(
page: 1,
rect: changed,
imageId: list[1].imageId,
rotationDeg: list[1].rotationDeg,
);
final after = container.read(pdfProvider.notifier).placementsOn(1); final after = container.read(pdfProvider.notifier).placementsOn(1);
expect(after.any((r) => r == before[1]), isTrue); expect(after.any((p) => p.rect == before[1]), isTrue);
} }

View File

@ -11,9 +11,9 @@ Future<void> eachSignatureCanBeDraggedAndResizedIndependently(
final list = container.read(pdfProvider.notifier).placementsOn(1); final list = container.read(pdfProvider.notifier).placementsOn(1);
expect(list.length, greaterThanOrEqualTo(2)); expect(list.length, greaterThanOrEqualTo(2));
// Independence is modeled by distinct rects; ensure not equal and both within page // Independence is modeled by distinct rects; ensure not equal and both within page
expect(list[0], isNot(equals(list[1]))); expect(list[0].rect, isNot(equals(list[1].rect)));
for (final r in list.take(2)) { for (final p in list.take(2)) {
expect(r.left, greaterThanOrEqualTo(0)); expect(p.rect.left, greaterThanOrEqualTo(0));
expect(r.top, greaterThanOrEqualTo(0)); expect(p.rect.top, greaterThanOrEqualTo(0));
} }
} }

View File

@ -31,5 +31,5 @@ Future<void> theUserNavigatesToPageAndPlacesAnotherSignature(
final Rect r = container.read(signatureProvider).rect!; final Rect r = container.read(signatureProvider).rect!;
container container
.read(pdfProvider.notifier) .read(pdfProvider.notifier)
.addPlacement(page: page.toInt(), rect: r, image: 'default.png'); .addPlacement(page: page.toInt(), rect: r, imageId: 'default.png');
} }

View File

@ -54,5 +54,5 @@ Future<void> theUserPlacesASignatureFromPictureOnPage(
((TestWorld.placeFromPictureCallCount <= 1) ? 1 : 3); ((TestWorld.placeFromPictureCallCount <= 1) ? 1 : 3);
container container
.read(pdfProvider.notifier) .read(pdfProvider.notifier)
.addPlacement(page: page, rect: r, image: name); .addPlacement(page: page, rect: r, imageId: name);
} }

View File

@ -30,5 +30,5 @@ Future<void> theUserPlacesASignatureOnPage(
final Rect r = container.read(signatureProvider).rect!; final Rect r = container.read(signatureProvider).rect!;
container container
.read(pdfProvider.notifier) .read(pdfProvider.notifier)
.addPlacement(page: page.toInt(), rect: r, image: 'default.png'); .addPlacement(page: page.toInt(), rect: r, imageId: 'default.png');
} }

View File

@ -119,7 +119,7 @@ void main() {
final processed = container3.read(processedSignatureImageProvider); final processed = container3.read(processedSignatureImageProvider);
expect(processed, isNotNull); expect(processed, isNotNull);
final pdf = container3.read(pdfProvider); final pdf = container3.read(pdfProvider);
final imgId = pdf.placementImageByPage[pdf.currentPage]?.first; final imgId = pdf.placementsByPage[pdf.currentPage]?.first.imageId;
expect(imgId, isNotNull); expect(imgId, isNotNull);
final lib = container3.read(signatureLibraryProvider); final lib = container3.read(signatureLibraryProvider);
final match = lib.firstWhere((a) => a.id == imgId); final match = lib.firstWhere((a) => a.id == imgId);