Compare commits

...

5 Commits

69 changed files with 1109 additions and 1062 deletions

12
AGENTS.md Normal file
View File

@ -0,0 +1,12 @@
# AGENTS
Always read `README.md` and `docs/meta-arch.md` when new chat created.
Additionally read relevant files depends on task.
* If want to modify use cases (files at `test/features/*.feature`)
* read `docs/FRs.md`
* 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`
* If want to modify code (implement or test) of non-View e.g. `Model`, `View Model`, services...
* read `test/features/*.feature`, `docs/NFRs.md`

View File

@ -12,16 +12,22 @@ checkout [`docs/FRs.md`](docs/FRs.md)
# flutter clean
# arb_translate
flutter pub get
# generate gherkin test
# > to generate gherkin test
flutter pub run build_runner build --delete-conflicting-outputs
# > to remove unused step definitions
# dart run tool/prune_unused_steps.dart --delete
# > to static analyze the code
flutter analyze
# > run unit tests and widget tests
flutter test
# > run integration tests
flutter test integration_test/ -d linux
# dart run tool/gen_view_wireframe_md.dart
# flutter pub run dead_code_analyzer
# run the app
flutter run
# run unit tests and widget tests
flutter test
```
### build

View File

@ -12,6 +12,8 @@ include: package:flutter_lints/flutter.yaml
analyzer:
plugins:
- custom_lint
exclude:
- 'test/features/*_test.dart'
linter:
# The lint rules applied to this project can be customized in the
@ -27,6 +29,12 @@ linter:
rules:
# avoid_print: false # Uncomment to disable the `avoid_print` rule
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
unintended_html_in_doc_comment:
exclude:
- 'test/features/step/*.dart'
unnecessary_import:
exclude:
- 'test/features/step/*.dart'
# Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options

View File

@ -2,4 +2,4 @@
* support multiple platforms (windows, linux, android, web)
* only FOSS libs can use
* recommend no more than 300 lines of code per file
* should not exceed 350 lines of code per file

View File

@ -10,3 +10,15 @@ The repo structure follows official [Package structure](https://docs.flutter.dev
* `test/features/` contains BDD unit tests for each feature. It focuses on pure logic, therefore will not access `View` but `ViewModel` and `Model`.
* `test/widget/` contains UI widget(component) tests which focus on `View` from MVVM of each component.
* `integration_test/` for integration tests. They should be volatile to follow UI layout changes.
## key dependencies
* [pdfrx](https://pub.dev/packages/pdfrx)
* [packages/pdfrx/example/viewer/lib/main.dart](https://github.com/espresso3389/pdfrx/blob/master/packages/pdfrx/example/viewer/lib/main.dart)
* When using pdfrx, developers should control view function e.g. zoom, scroll... by component of pdfrx e.g. `PdfViewer`, rather than introduce additional view.
* [PdfViewer could not be scrollable when nested inside SingleChildScrollView #27](https://github.com/espresso3389/pdfrx/issues/27)
* [How to zoom in PdfPageView #244](https://github.com/espresso3389/pdfrx/issues/244)
* So does overlay some widgets, they should be placed using the provided overlay builder.
* [Viewer Customization using Widget Overlay](https://pub.dev/documentation/pdfrx/latest/pdfrx/PdfViewerParams/viewerOverlayBuilder.html)
* [Per-page Customization using Widget Overlay](https://pub.dev/documentation/pdfrx/latest/pdfrx/PdfViewerParams/pageOverlaysBuilder.html)
* `pageOverlaysBuilder`

View File

@ -0,0 +1,11 @@
import 'package:flutter_test/flutter_test.dart';
// This file is intentionally skipped. The integrated E2E test lives in
// integration_test/export_flow_test.dart to avoid multiple app launches.
void main() {
testWidgets('skipped duplicate E2E (see export_flow_test.dart)', (
tester,
) async {
expect(true, isTrue);
}, skip: true);
}

View File

@ -1,7 +1,9 @@
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:image/image.dart' as img;
import 'package:pdf_signature/data/services/export_service.dart';
import 'package:pdf_signature/data/services/export_providers.dart';
@ -18,8 +20,6 @@ class RecordingExporter extends ExportService {
}
}
class BasicExporter extends ExportService {}
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
@ -58,4 +58,92 @@ void main() {
// Expect success UI
expect(find.textContaining('Saved:'), findsOneWidget);
});
// Helper to build a simple in-memory PNG as a signature image
Uint8List _makeSig() {
final canvas = img.Image(width: 80, height: 40);
img.fill(canvas, color: img.ColorUint8.rgb(255, 255, 255));
img.drawLine(
canvas,
x1: 6,
y1: 20,
x2: 74,
y2: 20,
color: img.ColorUint8.rgb(0, 0, 0),
);
return Uint8List.fromList(img.encodePng(canvas));
}
testWidgets('E2E (integration): place and confirm keeps size', (
tester,
) async {
final sigBytes = _makeSig();
await tester.pumpWidget(
ProviderScope(
overrides: [
pdfProvider.overrideWith(
(ref) => PdfController()..openPicked(path: 'test.pdf'),
),
signatureLibraryProvider.overrideWith((ref) {
final c = SignatureLibraryController();
c.add(sigBytes, name: 'image');
return c;
}),
// Keep mock viewer for determinism on CI/desktop devices
useMockViewerProvider.overrideWithValue(true),
],
child: const MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: PdfSignatureHomePage(),
),
),
);
await tester.pumpAndSettle();
final card = find.byKey(const Key('gd_signature_card_area')).first;
await tester.tap(card);
await tester.pump();
final active = find.byKey(const Key('signature_overlay'));
expect(active, findsOneWidget);
final sizeBefore = tester.getSize(active);
await tester.ensureVisible(active);
await tester.pumpAndSettle();
// Programmatically simulate confirm: add placement with current rect and bound image, then clear active overlay.
final ctx = tester.element(find.byType(PdfSignatureHomePage));
final container = ProviderScope.containerOf(ctx);
final sigState = container.read(signatureProvider);
final r = sigState.rect!;
final Size pageSize = SignatureController.pageSize;
final normalized = Rect.fromLTWH(
(r.left / pageSize.width).clamp(0.0, 1.0),
(r.top / pageSize.height).clamp(0.0, 1.0),
(r.width / pageSize.width).clamp(0.0, 1.0),
(r.height / pageSize.height).clamp(0.0, 1.0),
);
final lib = container.read(signatureLibraryProvider);
final imageId = lib.isNotEmpty ? lib.first.id : 'default.png';
final pdf = container.read(pdfProvider);
container
.read(pdfProvider.notifier)
.addPlacement(page: pdf.currentPage, rect: normalized, image: imageId);
container.read(signatureProvider.notifier).clearActiveOverlay();
await tester.pumpAndSettle();
final placed = find.byKey(const Key('placed_signature_0'));
expect(placed, findsOneWidget);
final sizeAfter = tester.getSize(placed);
expect(
(sizeAfter.width - sizeBefore.width).abs() < sizeBefore.width * 0.15,
isTrue,
);
expect(
(sizeAfter.height - sizeBefore.height).abs() < sizeBefore.height * 0.15,
isTrue,
);
});
}

View File

@ -173,15 +173,7 @@ final preferencesProvider =
return PreferencesNotifier(prefs);
});
/// Safe accessor for page view mode that falls back to 'continuous' until
/// SharedPreferences is available (useful for lightweight widget tests).
final pageViewModeProvider = Provider<String>((ref) {
final sp = ref.watch(sharedPreferencesProvider);
return sp.maybeWhen(
data: (_) => ref.watch(preferencesProvider).pageView,
orElse: () => 'continuous',
);
});
// pageViewModeProvider removed; the app always runs in continuous mode.
/// Derive the active ThemeMode based on preference and platform brightness
final themeModeProvider = Provider<ThemeMode>((ref) {

View File

@ -32,7 +32,6 @@
"pageInfo": "Seite {current}/{total}",
"pageView": "Seitenansicht",
"pageViewContinuous": "Kontinuierlich",
"pageViewSingle": "Einzelne Seite",
"prev": "Vorherige",
"resetToDefaults": "Auf Standardwerte zurücksetzen",
"save": "Speichern",

View File

@ -83,8 +83,6 @@
"@pageView": {},
"pageViewContinuous": "Continuous",
"@pageViewContinuous": {},
"pageViewSingle": "Single page",
"@pageViewSingle": {},
"prev": "Prev",
"@prev": {},
"resetToDefaults": "Reset to defaults",

View File

@ -32,7 +32,6 @@
"pageInfo": "Página {current}/{total}",
"pageView": "Vista de página",
"pageViewContinuous": "Continuo",
"pageViewSingle": "Página única",
"prev": "Anterior",
"resetToDefaults": "Restablecer valores predeterminados",
"save": "Guardar",

View File

@ -32,7 +32,6 @@
"pageInfo": "Page {current}/{total}",
"pageView": "Affichage de la page",
"pageViewContinuous": "Continu",
"pageViewSingle": "Page unique",
"prev": "Précédent",
"resetToDefaults": "Rétablir les valeurs par défaut",
"save": "Enregistrer",

View File

@ -32,7 +32,6 @@
"pageInfo": "ページ {current}/{total}",
"pageView": "ページ表示",
"pageViewContinuous": "連続",
"pageViewSingle": "シングルページ",
"prev": "前へ",
"resetToDefaults": "デフォルトに戻す",
"save": "保存",

View File

@ -32,7 +32,6 @@
"pageInfo": "{current}/{total} 페이지",
"pageView": "페이지 보기",
"pageViewContinuous": "연속",
"pageViewSingle": "단일 페이지",
"prev": "이전",
"resetToDefaults": "기본값으로 재설정",
"save": "저장",

View File

@ -32,7 +32,6 @@
"pageInfo": "Сторінка {current}/{total}",
"pageView": "Перегляд сторінки",
"pageViewContinuous": "Безперервний",
"pageViewSingle": "Одна сторінка",
"prev": "Попередня",
"resetToDefaults": "Скинути до значень за замовчуванням",
"save": "Зберегти",

View File

@ -33,7 +33,6 @@
"pageInfo": "第 {current}/{total} 頁",
"pageView": "頁面檢視",
"pageViewContinuous": "連續",
"pageViewSingle": "單頁",
"prev": "上一頁",
"resetToDefaults": "重設為預設值",
"save": "儲存",

View File

@ -32,7 +32,6 @@
"pageInfo": "第 {current} 页 / 共 {total} 页",
"pageView": "分页浏览",
"pageViewContinuous": "连续",
"pageViewSingle": "单页",
"prev": "上一页",
"resetToDefaults": "恢复默认值",
"save": "保存",

View File

@ -33,7 +33,6 @@
"pageInfo": "第 {current}/{total} 頁",
"pageView": "頁面檢視",
"pageViewContinuous": "連續",
"pageViewSingle": "單頁",
"prev": "上一頁",
"resetToDefaults": "重設為預設值",
"save": "儲存",

View File

@ -0,0 +1,14 @@
import 'package:flutter/widgets.dart';
import 'package:pdf_signature/l10n/app_localizations.dart';
/// Centralized accessors for context menu labels to avoid duplication.
class MenuLabels {
static String confirm(BuildContext context) =>
AppLocalizations.of(context).confirm;
static String delete(BuildContext context) =>
AppLocalizations.of(context).delete;
// Not yet localized in l10n; keep here for single source of truth.
static String adjustGraphic(BuildContext context) => 'Adjust graphic';
}

View File

@ -1,4 +1,5 @@
import 'dart:math' as math;
import 'dart:math';
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
@ -10,6 +11,7 @@ class PdfController extends StateNotifier<PdfState> {
PdfController() : super(PdfState.initial());
static const int samplePageCount = 5;
@visibleForTesting
void openSample() {
state = state.copyWith(
loaded: true,
@ -63,7 +65,8 @@ class PdfController extends StateNotifier<PdfState> {
state = state.copyWith(pageCount: count.clamp(1, 9999));
}
// Multiple-signature helpers
// Multiple-signature helpers (rects are stored in normalized fractions 0..1
// relative to the page size: left/top/width/height are all 0..1)
void addPlacement({
required int page,
required Rect rect,
@ -115,6 +118,23 @@ class PdfController extends StateNotifier<PdfState> {
}
}
// Update the rect of an existing placement on a page.
void updatePlacementRect({
required int page,
required int index,
required Rect rect,
}) {
if (!state.loaded) return;
final p = page.clamp(1, state.pageCount);
final map = Map<int, List<Rect>>.from(state.placementsByPage);
final list = List<Rect>.from(map[p] ?? const []);
if (index >= 0 && index < list.length) {
list[index] = rect;
map[p] = list;
state = state.copyWith(placementsByPage: map);
}
}
List<Rect> placementsOn(int page) {
return List<Rect>.from(state.placementsByPage[page] ?? const []);
}
@ -140,22 +160,7 @@ class PdfController extends StateNotifier<PdfState> {
removePlacement(page: state.currentPage, index: idx);
}
// Assign a different image name to a placement on a page.
void assignImageToPlacement({
required int page,
required int index,
required String image,
}) {
if (!state.loaded) return;
final p = page.clamp(1, state.pageCount);
final imgMap = Map<int, List<String>>.from(state.placementImageByPage);
final list = List<String>.from(imgMap[p] ?? const []);
if (index >= 0 && index < list.length) {
list[index] = image;
imgMap[p] = list;
state = state.copyWith(placementImageByPage: imgMap);
}
}
// NOTE: Programmatic reassignment of images has been removed.
// Convenience to get image name for a placement
String? imageOfPlacement({required int page, required int index}) {
@ -215,11 +220,15 @@ class SignatureController extends StateNotifier<SignatureState> {
state = SignatureState.initial();
}
@visibleForTesting
void placeDefaultRect() {
final w = 120.0, h = 60.0;
state = state.copyWith(
rect: Rect.fromCenter(
center: Offset(pageSize.width / 2, pageSize.height * 0.75),
center: Offset(
(pageSize.width / 2) * (Random().nextDouble() * 1.5 + 1),
(pageSize.height / 2) * (Random().nextDouble() * 1.5 + 1),
),
width: w,
height: h,
),
@ -364,25 +373,37 @@ class SignatureController extends StateNotifier<SignatureState> {
// Place onto the current page
final pdf = ref.read(pdfProvider);
if (!pdf.loaded) return null;
ref.read(pdfProvider.notifier).addPlacement(page: pdf.currentPage, rect: r);
// Assign image id to this placement (last index)
final idx =
(ref.read(pdfProvider).placementsByPage[pdf.currentPage]?.length ?? 1) -
1;
String? id = state.assetId;
if (id == null) {
// Convert UI-space rect (400x560) to normalized rect
final Size pageSize = SignatureController.pageSize;
final normalized = Rect.fromLTWH(
(r.left / pageSize.width).clamp(0.0, 1.0),
(r.top / pageSize.height).clamp(0.0, 1.0),
(r.width / pageSize.width).clamp(0.0, 1.0),
(r.height / pageSize.height).clamp(0.0, 1.0),
);
// Determine the image id to bind at placement time
String id = state.assetId ?? '';
if (id.isEmpty) {
final bytes =
ref.read(processedSignatureImageProvider) ?? state.imageBytes;
if (bytes != null) {
if (bytes != null && bytes.isNotEmpty) {
id = ref
.read(signatureLibraryProvider.notifier)
.add(bytes, name: 'image');
} else {
id = 'default.png';
}
}
if (id != null && id.isNotEmpty && idx >= 0) {
ref
.read(pdfProvider.notifier)
.assignImageToPlacement(page: pdf.currentPage, index: idx, image: id);
ref
.read(pdfProvider.notifier)
.addPlacement(page: pdf.currentPage, rect: normalized, image: id);
// Newly placed index is the last one on the page
final idx =
(ref.read(pdfProvider).placementsByPage[pdf.currentPage]?.length ?? 1) -
1;
// Auto-select the newly placed item so the red box appears
if (idx >= 0) {
ref.read(pdfProvider.notifier).selectPlacement(idx);
}
// Freeze editing: keep rect for preview but disable interaction
state = state.copyWith(editingEnabled: false);

View File

@ -0,0 +1,115 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:pdf_signature/l10n/app_localizations.dart';
import '../../../../data/services/export_providers.dart';
import 'pdf_page_overlays.dart';
/// Mocked continuous viewer for tests or platforms without real viewer.
class PdfMockContinuousList extends ConsumerWidget {
const PdfMockContinuousList({
super.key,
required this.pageSize,
required this.count,
required this.pageKeyBuilder,
required this.scrollToPage,
this.onDragSignature,
this.onResizeSignature,
this.onConfirmSignature,
this.onClearActiveOverlay,
this.onSelectPlaced,
this.pendingPage,
this.clearPending,
});
final Size pageSize;
final int count;
final GlobalKey Function(int page) pageKeyBuilder;
final void Function(int page) scrollToPage;
final int? pendingPage;
final VoidCallback? clearPending;
final ValueChanged<Offset>? onDragSignature;
final ValueChanged<Offset>? onResizeSignature;
final VoidCallback? onConfirmSignature;
final VoidCallback? onClearActiveOverlay;
final ValueChanged<int?>? onSelectPlaced;
@override
Widget build(BuildContext context, WidgetRef ref) {
if (pendingPage != null) {
WidgetsBinding.instance.addPostFrameCallback((_) {
final p = pendingPage;
if (p != null) {
clearPending?.call();
scheduleMicrotask(() => scrollToPage(p));
}
});
}
return SingleChildScrollView(
key: const Key('pdf_continuous_mock_list'),
padding: const EdgeInsets.symmetric(vertical: 8),
child: Column(
children: List.generate(count, (idx) {
final pageNum = idx + 1;
return Center(
child: Padding(
key: pageKeyBuilder(pageNum),
padding: const EdgeInsets.symmetric(vertical: 8),
child: AspectRatio(
aspectRatio: pageSize.width / pageSize.height,
child: Stack(
key: ValueKey('page_stack_$pageNum'),
children: [
Container(
color: Colors.grey.shade200,
child: Center(
child: Builder(
builder: (ctx) {
String label;
try {
label = AppLocalizations.of(
ctx,
).pageInfo(pageNum, count);
} catch (_) {
label = 'Page $pageNum of $count';
}
return Text(
label,
style: const TextStyle(
fontSize: 24,
color: Colors.black54,
),
);
},
),
),
),
Consumer(
builder: (context, ref, _) {
final visible = ref.watch(signatureVisibilityProvider);
return visible
? PdfPageOverlays(
pageSize: pageSize,
pageNumber: pageNum,
onDragSignature: onDragSignature,
onResizeSignature: onResizeSignature,
onConfirmSignature: onConfirmSignature,
onClearActiveOverlay: onClearActiveOverlay,
onSelectPlaced: onSelectPlaced,
)
: const SizedBox.shrink();
},
),
],
),
),
),
);
}),
),
);
}
}

View File

@ -1,17 +1,13 @@
import 'dart:math' as math;
import 'dart:async';
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:pdf_signature/l10n/app_localizations.dart';
import 'package:pdfrx/pdfrx.dart';
import '../../../../data/services/export_providers.dart';
import '../../../../data/model/model.dart';
import '../view_model/view_model.dart';
import '../../../../data/services/preferences_providers.dart';
import 'signature_drag_data.dart';
import 'image_editor_dialog.dart';
import 'pdf_mock_continuous_list.dart';
import 'pdf_page_overlays.dart';
class PdfPageArea extends ConsumerStatefulWidget {
const PdfPageArea({
@ -54,9 +50,8 @@ class _PdfPageAreaState extends ConsumerState<PdfPageArea> {
// is instructed to align to the provider's current page once ready.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
final mode = ref.read(pageViewModeProvider);
final pdf = ref.read(pdfProvider);
if (mode == 'continuous' && pdf.pickedPdfPath != null && pdf.loaded) {
if (pdf.pickedPdfPath != null && pdf.loaded) {
_scrollToPage(pdf.currentPage);
}
});
@ -73,7 +68,7 @@ class _PdfPageAreaState extends ConsumerState<PdfPageArea> {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
final pdf = ref.read(pdfProvider);
final isContinuous = ref.read(pageViewModeProvider) == 'continuous';
const isContinuous = true;
// Real continuous: drive via PdfViewerController
if (pdf.pickedPdfPath != null && isContinuous) {
@ -161,13 +156,12 @@ class _PdfPageAreaState extends ConsumerState<PdfPageArea> {
@override
Widget build(BuildContext context) {
final pdf = ref.watch(pdfProvider);
final pageViewMode = ref.watch(pageViewModeProvider);
const pageViewMode = 'continuous';
// React to provider currentPage changes (e.g., user tapped overview)
ref.listen(pdfProvider, (prev, next) {
final mode = ref.read(pageViewModeProvider);
if (_suppressProviderListen) return;
if (mode == 'continuous' && (prev?.currentPage != next.currentPage)) {
if ((prev?.currentPage != next.currentPage)) {
final target = next.currentPage;
// If we're already navigating to this target, ignore; otherwise allow new target.
if (_programmaticTargetPage != null &&
@ -180,22 +174,17 @@ class _PdfPageAreaState extends ConsumerState<PdfPageArea> {
}
}
});
// When switching to continuous, bring current page into view
ref.listen<String>(pageViewModeProvider, (prev, next) {
if (next == 'continuous') {
// Skip initial auto-scroll in mock mode to avoid fighting with
// early provider-driven jumps during tests.
final isMock = ref.read(useMockViewerProvider);
if (isMock) return;
final p = ref.read(pdfProvider).currentPage;
if (_visiblePage != p) {
_scrollToPage(p);
}
}
});
// No page view mode switching; always continuous.
if (!pdf.loaded) {
return Center(child: Text(AppLocalizations.of(context).noPdfLoaded));
// In tests, AppLocalizations delegate may not be injected; fallback.
String text;
try {
text = AppLocalizations.of(context).noPdfLoaded;
} catch (_) {
text = 'No PDF loaded';
}
return Center(child: Text(text));
}
final useMock = ref.watch(useMockViewerProvider);
@ -204,80 +193,21 @@ class _PdfPageAreaState extends ConsumerState<PdfPageArea> {
// Mock continuous: ListView with prebuilt children, no controller
if (useMock && isContinuous) {
final count = pdf.pageCount > 0 ? pdf.pageCount : 1;
return Builder(
builder: (ctx) {
// Defer processing of any pending jump until after the tree is mounted.
if (_pendingPage != null) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
final p = _pendingPage;
if (p != null) {
_pendingPage = null;
_scrollRetryCount = 0;
// Schedule via microtask to avoid test timers remaining pending
scheduleMicrotask(() {
if (!mounted) return;
_scrollToPage(p);
});
}
});
}
final content = SingleChildScrollView(
key: const Key('pdf_continuous_mock_list'),
padding: const EdgeInsets.symmetric(vertical: 8),
child: Column(
children: List.generate(count, (idx) {
final pageNum = idx + 1;
return Center(
child: Padding(
key: _pageKey(pageNum),
padding: const EdgeInsets.symmetric(vertical: 8),
child: AspectRatio(
aspectRatio:
widget.pageSize.width / widget.pageSize.height,
child: Stack(
key: ValueKey('page_stack_$pageNum'),
children: [
Container(
color: Colors.grey.shade200,
child: Center(
child: Text(
AppLocalizations.of(
context,
).pageInfo(pageNum, count),
style: const TextStyle(
fontSize: 24,
color: Colors.black54,
),
),
),
),
Consumer(
builder: (context, ref, _) {
final sig = ref.watch(signatureProvider);
final visible = ref.watch(
signatureVisibilityProvider,
);
return visible
? _buildPageOverlays(
context,
ref,
sig,
pageNum,
)
: const SizedBox.shrink();
},
),
],
),
),
),
);
}),
),
);
return content;
return PdfMockContinuousList(
pageSize: widget.pageSize,
count: count,
pageKeyBuilder: _pageKey,
scrollToPage: _scrollToPage,
pendingPage: _pendingPage,
clearPending: () {
_pendingPage = null;
_scrollRetryCount = 0;
},
onDragSignature: (delta) => widget.onDragSignature(delta),
onResizeSignature: (delta) => widget.onResizeSignature(delta),
onConfirmSignature: widget.onConfirmSignature,
onClearActiveOverlay: widget.onClearActiveOverlay,
onSelectPlaced: widget.onSelectPlaced,
);
}
@ -292,6 +222,35 @@ class _PdfPageAreaState extends ConsumerState<PdfPageArea> {
keyHandlerParams: PdfViewerKeyHandlerParams(autofocus: true),
maxScale: 8,
scrollByMouseWheel: 0.6,
// Render signature overlays on each page via pdfrx pageOverlaysBuilder
pageOverlaysBuilder: (context, pageRect, page) {
return [
Consumer(
builder: (context, ref, _) {
final visible = ref.watch(signatureVisibilityProvider);
if (!visible) return const SizedBox.shrink();
return Align(
alignment: Alignment.topLeft,
child: SizedBox(
width: pageRect.width,
height: pageRect.height,
child: PdfPageOverlays(
pageSize: widget.pageSize,
pageNumber: page.pageNumber,
onDragSignature:
(delta) => widget.onDragSignature(delta),
onResizeSignature:
(delta) => widget.onResizeSignature(delta),
onConfirmSignature: widget.onConfirmSignature,
onClearActiveOverlay: widget.onClearActiveOverlay,
onSelectPlaced: widget.onSelectPlaced,
),
),
);
},
),
];
},
// Add overlay scroll thumbs (vertical on right, horizontal on bottom)
viewerOverlayBuilder:
(context, size, handleLinkTap) => [
@ -406,347 +365,6 @@ class _PdfPageAreaState extends ConsumerState<PdfPageArea> {
return const SizedBox.shrink();
}
// Context menu for already placed signatures
void _showContextMenuForPlaced({
required BuildContext context,
required WidgetRef ref,
required Offset globalPos,
required int index,
required int page,
}) {
final l = AppLocalizations.of(context);
showMenu<String>(
context: context,
position: RelativeRect.fromLTRB(
globalPos.dx,
globalPos.dy,
globalPos.dx,
globalPos.dy,
),
items: [
PopupMenuItem<String>(
key: const Key('ctx_placed_delete'),
value: 'delete',
child: Text(l.delete),
),
const PopupMenuItem<String>(
key: Key('ctx_placed_adjust'),
value: 'adjust',
child: Text('Adjust graphic'),
),
],
).then((choice) {
switch (choice) {
case 'delete':
ref
.read(pdfProvider.notifier)
.removePlacement(page: page, index: index);
break;
case 'adjust':
showDialog(
context: context,
builder: (ctx) => const ImageEditorDialog(),
);
break;
default:
break;
}
});
}
Widget _buildPageOverlays(
BuildContext context,
WidgetRef ref,
SignatureState sig,
int pageNumber,
) {
final pdf = ref.watch(pdfProvider);
final placed = pdf.placementsByPage[pageNumber] ?? const <Rect>[];
final widgets = <Widget>[];
for (int i = 0; i < placed.length; i++) {
final r = placed[i];
widgets.add(
_buildSignatureOverlay(
context,
ref,
sig,
r,
interactive: false,
placedIndex: i,
pageNumber: pageNumber,
),
);
}
// Only show the active (interactive) signature overlay on the current page
// in continuous mode, so tests can reliably find a single overlay.
if (sig.rect != null &&
sig.editingEnabled &&
(pdf.signedPage == null || pdf.signedPage == pageNumber) &&
pdf.currentPage == pageNumber) {
widgets.add(
_buildSignatureOverlay(
context,
ref,
sig,
sig.rect!,
interactive: true,
pageNumber: pageNumber,
),
);
}
return Stack(children: widgets);
}
Widget _buildSignatureOverlay(
BuildContext context,
WidgetRef ref,
SignatureState sig,
Rect r, {
bool interactive = true,
int? placedIndex,
required int pageNumber,
}) {
return LayoutBuilder(
builder: (context, constraints) {
final scaleX = constraints.maxWidth / widget.pageSize.width;
final scaleY = constraints.maxHeight / widget.pageSize.height;
final left = r.left * scaleX;
final top = r.top * scaleY;
final width = r.width * scaleX;
final height = r.height * scaleY;
return Stack(
children: [
Positioned(
left: left,
top: top,
width: width,
height: height,
child: Builder(
builder: (context) {
final selectedIdx =
ref.read(pdfProvider).selectedPlacementIndex;
final bool isPlaced = placedIndex != null;
final bool isSelected =
isPlaced && selectedIdx == placedIndex;
final Color borderColor =
isPlaced ? Colors.red : Colors.indigo;
final double borderWidth =
isPlaced ? (isSelected ? 3.0 : 2.0) : 2.0;
Widget content = DecoratedBox(
decoration: BoxDecoration(
color: Color.fromRGBO(
0,
0,
0,
0.05 + math.min(0.25, (sig.contrast - 1.0).abs()),
),
border: Border.all(
color: borderColor,
width: borderWidth,
),
),
child: Stack(
children: [
Consumer(
builder: (context, ref, _) {
Uint8List? bytes;
if (interactive) {
final processed = ref.watch(
processedSignatureImageProvider,
);
bytes = processed ?? sig.imageBytes;
} else if (placedIndex != null) {
// Use the image assigned to this placement
final imgId = ref
.read(pdfProvider)
.placementImageByPage[pageNumber]
?.elementAt(placedIndex);
if (imgId != null) {
final lib = ref.watch(signatureLibraryProvider);
for (final a in lib) {
if (a.id == imgId) {
bytes = a.bytes;
break;
}
}
}
// Fallback to current processed
bytes ??=
ref.read(processedSignatureImageProvider) ??
sig.imageBytes;
}
if (bytes == null) {
return Center(
child: Text(
AppLocalizations.of(context).signature,
),
);
}
Widget im = Image.memory(
bytes,
fit: BoxFit.contain,
);
if (sig.rotation % 360 != 0) {
im = Transform.rotate(
angle: sig.rotation * math.pi / 180.0,
child: im,
);
}
return im;
},
),
if (interactive)
Positioned(
right: 0,
bottom: 0,
child: GestureDetector(
key: const Key('signature_handle'),
behavior: HitTestBehavior.opaque,
onPanUpdate:
(d) => widget.onResizeSignature(
Offset(
d.delta.dx / scaleX,
d.delta.dy / scaleY,
),
),
child: const Icon(Icons.open_in_full, size: 20),
),
),
],
),
);
if (interactive && sig.editingEnabled) {
content = GestureDetector(
key: const Key('signature_overlay'),
behavior: HitTestBehavior.opaque,
onPanStart: (_) {},
onPanUpdate:
(d) => widget.onDragSignature(
Offset(d.delta.dx / scaleX, d.delta.dy / scaleY),
),
onSecondaryTapDown: (d) {
final pos = d.globalPosition;
showMenu<String>(
context: context,
position: RelativeRect.fromLTRB(
pos.dx,
pos.dy,
pos.dx,
pos.dy,
),
items: [
PopupMenuItem<String>(
key: Key('ctx_active_confirm'),
value: 'confirm',
child: Text(AppLocalizations.of(context).confirm),
),
PopupMenuItem<String>(
key: Key('ctx_active_delete'),
value: 'delete',
child: Text(AppLocalizations.of(context).delete),
),
const PopupMenuItem<String>(
key: Key('ctx_active_adjust'),
value: 'adjust',
child: Text('Adjust graphic'),
),
],
).then((choice) {
if (choice == 'confirm') {
widget.onConfirmSignature();
} else if (choice == 'delete') {
widget.onClearActiveOverlay();
} else if (choice == 'adjust') {
showDialog(
context: context,
builder: (_) => const ImageEditorDialog(),
);
}
});
},
onLongPressStart: (d) {
final pos = d.globalPosition;
showMenu<String>(
context: context,
position: RelativeRect.fromLTRB(
pos.dx,
pos.dy,
pos.dx,
pos.dy,
),
items: [
PopupMenuItem<String>(
key: Key('ctx_active_confirm_lp'),
value: 'confirm',
child: Text(AppLocalizations.of(context).confirm),
),
PopupMenuItem<String>(
key: Key('ctx_active_delete_lp'),
value: 'delete',
child: Text(AppLocalizations.of(context).delete),
),
const PopupMenuItem<String>(
key: Key('ctx_active_adjust_lp'),
value: 'adjust',
child: Text('Adjust graphic'),
),
],
).then((choice) {
if (choice == 'confirm') {
widget.onConfirmSignature();
} else if (choice == 'delete') {
widget.onClearActiveOverlay();
} else if (choice == 'adjust') {
showDialog(
context: context,
builder: (_) => const ImageEditorDialog(),
);
}
});
},
child: content,
);
} else {
content = GestureDetector(
key: Key('placed_signature_${placedIndex ?? 'x'}'),
behavior: HitTestBehavior.opaque,
onTap: () => widget.onSelectPlaced(placedIndex),
onSecondaryTapDown: (d) {
if (placedIndex != null) {
_showContextMenuForPlaced(
context: context,
ref: ref,
globalPos: d.globalPosition,
index: placedIndex,
page: pageNumber,
);
}
},
onLongPressStart: (d) {
if (placedIndex != null) {
_showContextMenuForPlaced(
context: context,
ref: ref,
globalPos: d.globalPosition,
index: placedIndex,
page: pageNumber,
);
}
},
child: content,
);
}
return content;
},
),
),
],
);
},
);
}
}
// Zoom controls removed with single-page mode; continuous viewer manages zoom.

View File

@ -0,0 +1,80 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../view_model/view_model.dart';
import 'signature_overlay.dart';
/// Builds all overlays for a given page: placed signatures and the active one.
class PdfPageOverlays extends ConsumerWidget {
const PdfPageOverlays({
super.key,
required this.pageSize,
required this.pageNumber,
this.onDragSignature,
this.onResizeSignature,
this.onConfirmSignature,
this.onClearActiveOverlay,
this.onSelectPlaced,
});
final Size pageSize;
final int pageNumber;
final ValueChanged<Offset>? onDragSignature;
final ValueChanged<Offset>? onResizeSignature;
final VoidCallback? onConfirmSignature;
final VoidCallback? onClearActiveOverlay;
final ValueChanged<int?>? onSelectPlaced;
@override
Widget build(BuildContext context, WidgetRef ref) {
final pdf = ref.watch(pdfProvider);
final sig = ref.watch(signatureProvider);
final placed = pdf.placementsByPage[pageNumber] ?? const <Rect>[];
final widgets = <Widget>[];
for (int i = 0; i < placed.length; i++) {
final r = placed[i]; // stored as normalized 0..1 of page size
final uiRect = Rect.fromLTWH(
r.left * pageSize.width,
r.top * pageSize.height,
r.width * pageSize.width,
r.height * pageSize.height,
);
widgets.add(
SignatureOverlay(
pageSize: pageSize,
rect: uiRect,
sig: sig,
pageNumber: pageNumber,
interactive: false,
placedIndex: i,
onSelectPlaced: onSelectPlaced,
),
);
}
final showActive =
sig.rect != null &&
sig.editingEnabled &&
(pdf.signedPage == null || pdf.signedPage == pageNumber) &&
pdf.currentPage == pageNumber;
if (showActive) {
widgets.add(
SignatureOverlay(
pageSize: pageSize,
rect: sig.rect!,
sig: sig,
pageNumber: pageNumber,
interactive: true,
onDragSignature: onDragSignature,
onResizeSignature: onResizeSignature,
onConfirmSignature: onConfirmSignature,
onClearActiveOverlay: onClearActiveOverlay,
),
);
}
return Stack(children: widgets);
}
}

View File

@ -20,7 +20,7 @@ class PdfPagesOverview extends ConsumerWidget {
return ListView.separated(
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 8),
itemCount: pageCount,
separatorBuilder: (_, __) => const SizedBox(height: 8),
separatorBuilder: (_, _) => const SizedBox(height: 8),
itemBuilder: (context, index) {
final pageNumber = index + 1;
final isSelected = pdf.currentPage == pageNumber;

View File

@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import '../view_model/view_model.dart';
import 'signature_drag_data.dart';
import '../../../common/menu_labels.dart';
class SignatureCard extends StatelessWidget {
const SignatureCard({
@ -69,16 +70,16 @@ class SignatureCard extends StatelessWidget {
details.globalPosition.dx,
details.globalPosition.dy,
),
items: const [
items: [
PopupMenuItem(
key: Key('mi_signature_adjust'),
key: const Key('mi_signature_adjust'),
value: 'adjust',
child: Text('Adjust graphic'),
child: Text(MenuLabels.adjustGraphic(context)),
),
PopupMenuItem(
key: Key('mi_signature_delete'),
key: const Key('mi_signature_delete'),
value: 'delete',
child: Text('Delete'),
child: Text(MenuLabels.delete(context)),
),
],
);
@ -100,16 +101,16 @@ class SignatureCard extends StatelessWidget {
details.globalPosition.dx,
details.globalPosition.dy,
),
items: const [
items: [
PopupMenuItem(
key: Key('mi_signature_adjust'),
key: const Key('mi_signature_adjust'),
value: 'adjust',
child: Text('Adjust graphic'),
child: Text(MenuLabels.adjustGraphic(context)),
),
PopupMenuItem(
key: Key('mi_signature_delete'),
key: const Key('mi_signature_delete'),
value: 'delete',
child: Text('Delete'),
child: Text(MenuLabels.delete(context)),
),
],
);

View File

@ -67,21 +67,10 @@ class _SignatureDrawerState extends ConsumerState<SignatureDrawer> {
);
},
onTap: () {
final sel = ref.read(pdfProvider).selectedPlacementIndex;
final page = ref.read(pdfProvider).currentPage;
if (sel != null) {
ref
.read(pdfProvider.notifier)
.assignImageToPlacement(
page: page,
index: sel,
image: a.id,
);
} else {
ref
.read(signatureProvider.notifier)
.setImageFromLibrary(assetId: a.id);
}
// Never reassign placed signatures via tap; only set active overlay source
ref
.read(signatureProvider.notifier)
.setImageFromLibrary(assetId: a.id);
},
),
),
@ -146,9 +135,12 @@ class _SignatureDrawerState extends ConsumerState<SignatureDrawer> {
ref.read(processedSignatureImageProvider) ??
ref.read(signatureProvider).imageBytes;
if (b != null) {
ref
final id = ref
.read(signatureLibraryProvider.notifier)
.add(b, name: 'image');
ref
.read(signatureProvider.notifier)
.setImageFromLibrary(assetId: id);
}
},
icon: const Icon(Icons.image_outlined),
@ -166,9 +158,12 @@ class _SignatureDrawerState extends ConsumerState<SignatureDrawer> {
ref.read(processedSignatureImageProvider) ??
ref.read(signatureProvider).imageBytes;
if (b != null) {
ref
final id = ref
.read(signatureLibraryProvider.notifier)
.add(b, name: 'drawing');
ref
.read(signatureProvider.notifier)
.setImageFromLibrary(assetId: id);
}
},
icon: const Icon(Icons.gesture),

View File

@ -0,0 +1,286 @@
import 'dart:math' as math;
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:pdf_signature/l10n/app_localizations.dart';
import '../../../../data/model/model.dart';
import '../view_model/view_model.dart';
import 'image_editor_dialog.dart';
import '../../../common/menu_labels.dart';
/// Renders a single signature overlay (either interactive or placed) on a page.
class SignatureOverlay extends ConsumerWidget {
const SignatureOverlay({
super.key,
required this.pageSize,
required this.rect,
required this.sig,
required this.pageNumber,
this.interactive = true,
this.placedIndex,
this.onDragSignature,
this.onResizeSignature,
this.onConfirmSignature,
this.onClearActiveOverlay,
this.onSelectPlaced,
});
final Size pageSize;
final Rect rect;
final SignatureState sig;
final int pageNumber;
final bool interactive;
final int? placedIndex;
// Callbacks used by interactive overlay
final ValueChanged<Offset>? onDragSignature;
final ValueChanged<Offset>? onResizeSignature;
final VoidCallback? onConfirmSignature;
final VoidCallback? onClearActiveOverlay;
// Callback for selecting a placed overlay
final ValueChanged<int?>? onSelectPlaced;
@override
Widget build(BuildContext context, WidgetRef ref) {
return LayoutBuilder(
builder: (context, constraints) {
final scaleX = constraints.maxWidth / pageSize.width;
final scaleY = constraints.maxHeight / pageSize.height;
final left = rect.left * scaleX;
final top = rect.top * scaleY;
final width = rect.width * scaleX;
final height = rect.height * scaleY;
return Stack(
children: [
Positioned(
left: left,
top: top,
width: width,
height: height,
child: _buildContent(context, ref, scaleX, scaleY),
),
],
);
},
);
}
Widget _buildContent(
BuildContext context,
WidgetRef ref,
double scaleX,
double scaleY,
) {
final selectedIdx = ref.read(pdfProvider).selectedPlacementIndex;
final bool isPlaced = placedIndex != null;
final bool isSelected = isPlaced && selectedIdx == placedIndex;
final Color borderColor = isPlaced ? Colors.red : Colors.indigo;
final double borderWidth = isPlaced ? (isSelected ? 3.0 : 2.0) : 2.0;
Widget content = DecoratedBox(
decoration: BoxDecoration(
color: Color.fromRGBO(
0,
0,
0,
0.05 + math.min(0.25, (sig.contrast - 1.0).abs()),
),
border: Border.all(color: borderColor, width: borderWidth),
),
child: Stack(
children: [
_SignatureImage(
interactive: interactive,
placedIndex: placedIndex,
pageNumber: pageNumber,
sig: sig,
),
if (interactive)
Positioned(
right: 0,
bottom: 0,
child: GestureDetector(
key: const Key('signature_handle'),
behavior: HitTestBehavior.opaque,
onPanUpdate:
(d) => onResizeSignature?.call(
Offset(d.delta.dx / scaleX, d.delta.dy / scaleY),
),
child: const Icon(Icons.open_in_full, size: 20),
),
),
],
),
);
if (interactive && sig.editingEnabled) {
content = GestureDetector(
key: const Key('signature_overlay'),
behavior: HitTestBehavior.opaque,
onPanStart: (_) {},
onPanUpdate:
(d) => onDragSignature?.call(
Offset(d.delta.dx / scaleX, d.delta.dy / scaleY),
),
onSecondaryTapDown: (d) => _showActiveMenu(context, d.globalPosition),
onLongPressStart: (d) => _showActiveMenu(context, d.globalPosition),
child: content,
);
} else {
content = GestureDetector(
key: Key('placed_signature_${placedIndex ?? 'x'}'),
behavior: HitTestBehavior.opaque,
onTap: () => onSelectPlaced?.call(placedIndex),
onSecondaryTapDown: (d) {
if (placedIndex != null) {
_showPlacedMenu(context, ref, d.globalPosition);
}
},
onLongPressStart: (d) {
if (placedIndex != null) {
_showPlacedMenu(context, ref, d.globalPosition);
}
},
child: content,
);
}
return content;
}
void _showActiveMenu(BuildContext context, Offset globalPos) {
showMenu<String>(
context: context,
position: RelativeRect.fromLTRB(
globalPos.dx,
globalPos.dy,
globalPos.dx,
globalPos.dy,
),
items: [
PopupMenuItem<String>(
key: const Key('ctx_active_confirm'),
value: 'confirm',
child: Text(MenuLabels.confirm(context)),
),
PopupMenuItem<String>(
key: const Key('ctx_active_delete'),
value: 'delete',
child: Text(MenuLabels.delete(context)),
),
PopupMenuItem<String>(
key: const Key('ctx_active_adjust'),
value: 'adjust',
child: Text(MenuLabels.adjustGraphic(context)),
),
],
).then((choice) {
if (choice == 'confirm') {
onConfirmSignature?.call();
} else if (choice == 'delete') {
onClearActiveOverlay?.call();
} else if (choice == 'adjust') {
showDialog(context: context, builder: (_) => const ImageEditorDialog());
}
});
}
void _showPlacedMenu(BuildContext context, WidgetRef ref, Offset globalPos) {
showMenu<String>(
context: context,
position: RelativeRect.fromLTRB(
globalPos.dx,
globalPos.dy,
globalPos.dx,
globalPos.dy,
),
items: [
PopupMenuItem<String>(
key: const Key('ctx_placed_delete'),
value: 'delete',
child: Text(MenuLabels.delete(context)),
),
PopupMenuItem<String>(
key: const Key('ctx_placed_adjust'),
value: 'adjust',
child: Text(MenuLabels.adjustGraphic(context)),
),
],
).then((choice) {
switch (choice) {
case 'delete':
if (placedIndex != null) {
ref
.read(pdfProvider.notifier)
.removePlacement(page: pageNumber, index: placedIndex!);
}
break;
case 'adjust':
showDialog(
context: context,
builder: (ctx) => const ImageEditorDialog(),
);
break;
default:
break;
}
});
}
}
class _SignatureImage extends ConsumerWidget {
const _SignatureImage({
required this.interactive,
required this.placedIndex,
required this.pageNumber,
required this.sig,
});
final bool interactive;
final int? placedIndex;
final int pageNumber;
final SignatureState sig;
@override
Widget build(BuildContext context, WidgetRef ref) {
Uint8List? bytes;
if (interactive) {
final processed = ref.watch(processedSignatureImageProvider);
bytes = processed ?? sig.imageBytes;
} else if (placedIndex != null) {
// Use the image assigned to this placement
final imgId = ref
.read(pdfProvider)
.placementImageByPage[pageNumber]
?.elementAt(placedIndex!);
if (imgId != null) {
final lib = ref.watch(signatureLibraryProvider);
for (final a in lib) {
if (a.id == imgId) {
bytes = a.bytes;
break;
}
}
}
// Fallback to current processed
bytes ??= ref.read(processedSignatureImageProvider) ?? sig.imageBytes;
}
if (bytes == null) {
String label;
try {
label = AppLocalizations.of(context).signature;
} catch (_) {
label = 'Signature';
}
return Center(child: Text(label));
}
Widget im = Image.memory(bytes, fit: BoxFit.contain);
if (sig.rotation % 360 != 0) {
im = Transform.rotate(angle: sig.rotation * math.pi / 180.0, child: im);
}
return im;
}
}

View File

@ -70,7 +70,7 @@ class _SettingsDialogState extends ConsumerState<SettingsDialog> {
child: CircularProgressIndicator(),
),
),
error: (_, __) {
error: (_, _) {
final items =
AppLocalizations.supportedLocales
.map((loc) => toLanguageTag(loc))

View File

@ -72,6 +72,7 @@ dev_dependencies:
flutter_lints: ^6.0.0
msix: ^3.16.12
json_serializable: ^6.11.0
dead_code_analyzer: ^1.1.0
# For information on the generic Dart part of this file, see the
# following page: https://dart.dev/tools/pub/pubspec

View File

@ -1,29 +0,0 @@
Feature: PDF state logic
Scenario: openPicked loads document and initializes state
Given a new provider container
When I openPicked with path {'test.pdf'} and pageCount {7}
Then pdf state is loaded {true}
And pdf picked path is {'test.pdf'}
And pdf page count is {7}
And pdf current page is {1}
And pdf marked for signing is {false}
Scenario: jumpTo clamps within page boundaries
Given a new provider container
And a pdf is open with path {'test.pdf'} and pageCount {5}
When I jumpTo {10}
Then pdf current page is {5}
When I jumpTo {0}
Then pdf current page is {1}
When I jumpTo {3}
Then pdf current page is {3}
Scenario: setPageCount updates count without toggling other flags
Given a new provider container
And a pdf is open with path {'test.pdf'} and pageCount {2}
When I toggle mark
And I set page count {9}
Then pdf page count is {9}
And pdf state is loaded {true}
And pdf marked for signing is {true}

View File

@ -1,35 +0,0 @@
Feature: Signature state logic
Scenario: placeDefaultRect centers a reasonable default rect
Given a new provider container
Then signature rect is null
When I place default signature rect
Then signature rect left >= {0}
And signature rect top >= {0}
And signature rect right <= {400}
And signature rect bottom <= {560}
And signature rect width > {50}
And signature rect height > {20}
Scenario: drag clamps to canvas bounds
Given a new provider container
And a default signature rect is placed
When I drag signature by {Offset(10000, -10000)}
Then signature rect left >= {0}
And signature rect top >= {0}
And signature rect right <= {400}
And signature rect bottom <= {560}
And signature rect moved from center
Scenario: resize respects aspect lock and clamps
Given a new provider container
And a default signature rect is placed
And aspect lock is {true}
When I resize signature by {Offset(1000, 1000)}
Then signature aspect ratio is preserved within {0.05}
And signature rect left >= {0}
And signature rect top >= {0}
And signature rect right <= {400}
And signature rect bottom <= {560}

View File

@ -1,64 +1,6 @@
import 'dart:typed_data';
import 'dart:ui' show Rect, Size;
import 'dart:io';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '_world.dart';
// A lightweight fake exporter to avoid platform rasterization in tests.
class FakeExportService {
Future<bool> exportSignedPdfFromFile({
required String inputPath,
required String outputPath,
required int? signedPage,
required Rect? signatureRectUi,
required Size uiPageSize,
required Uint8List? signatureImageBytes,
double targetDpi = 144.0,
}) async {
final bytes = await exportSignedPdfFromBytes(
srcBytes: Uint8List.fromList([0x25, 0x50, 0x44, 0x46]),
signedPage: signedPage,
signatureRectUi: signatureRectUi,
uiPageSize: uiPageSize,
signatureImageBytes: signatureImageBytes,
targetDpi: targetDpi,
);
if (bytes == null) return false;
try {
final file = File(outputPath);
await file.writeAsBytes(bytes, flush: true);
return true;
} catch (_) {
return false;
}
}
Future<Uint8List?> exportSignedPdfFromBytes({
required Uint8List srcBytes,
required int? signedPage,
required Rect? signatureRectUi,
required Size uiPageSize,
required Uint8List? signatureImageBytes,
double targetDpi = 144.0,
}) async {
// Return a deterministic tiny PDF-like byte array
final header = <int>[0x25, 0x50, 0x44, 0x46, 0x2D]; // %PDF-
final payload = <int>[...srcBytes.take(4)];
final sigFlag =
(signatureRectUi != null &&
signatureImageBytes != null &&
signatureImageBytes.isNotEmpty)
? 1
: 0;
final meta = <int>[
sigFlag,
uiPageSize.width.toInt() & 0xFF,
uiPageSize.height.toInt() & 0xFF,
];
return Uint8List.fromList([...header, ...payload, ...meta]);
}
}
ProviderContainer getOrCreateContainer() {
if (TestWorld.container != null) return TestWorld.container!;
final container = ProviderContainer();

View File

@ -1,11 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart';
import '_world.dart';
/// Usage: a default signature rect is placed
Future<void> aDefaultSignatureRectIsPlaced(WidgetTester tester) async {
final c = TestWorld.container!;
c.read(signatureProvider.notifier).placeDefaultRect();
// remember center for movement checks
TestWorld.prevCenter = c.read(signatureProvider).rect!.center;
}

View File

@ -1,15 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '_world.dart';
/// Usage: a new provider container
Future<void> aNewProviderContainer(WidgetTester tester) async {
// Ensure a fresh world per scenario
TestWorld.container?.dispose();
TestWorld.reset();
TestWorld.container = ProviderContainer();
addTearDown(() {
TestWorld.container?.dispose();
TestWorld.container = null;
});
}

View File

@ -1,13 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart';
import '_world.dart';
/// Usage: a pdf is open with path {'test.pdf'} and pageCount {5}
Future<void> aPdfIsOpenWithPathAndPagecount(
WidgetTester tester,
String path,
int pageCount,
) async {
final c = TestWorld.container!;
c.read(pdfProvider.notifier).openPicked(path: path, pageCount: pageCount);
}

View File

@ -1,26 +0,0 @@
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart';
import '_world.dart';
/// Usage: an image {"bob.png"} is loaded
Future<void> anImageIsLoaded(WidgetTester tester, String param1) async {
final container = TestWorld.container ?? ProviderContainer();
TestWorld.container = container;
// Remember current image name
TestWorld.currentImageName = param1;
// Map name to deterministic bytes for testing
Uint8List bytes;
switch (param1) {
case 'alice.png':
bytes = Uint8List.fromList([1, 2, 3]);
break;
case 'bob.png':
bytes = Uint8List.fromList([4, 5, 6]);
break;
default:
bytes = Uint8List.fromList(param1.codeUnits.take(10).toList());
}
container.read(signatureProvider.notifier).setImageBytes(bytes);
}

View File

@ -1,14 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart';
import '_world.dart';
/// Usage: aspect lock is {true}
Future<void> aspectLockIs(WidgetTester tester, bool value) async {
final c = TestWorld.container!;
// snapshot current aspect for later validation
final r = c.read(signatureProvider).rect;
if (r != null) {
TestWorld.prevAspect = r.width / r.height;
}
c.read(signatureProvider.notifier).toggleAspect(value);
}

View File

@ -1,9 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart';
import '_world.dart';
/// Usage: I drag signature by {Offset(10000, -10000)}
Future<void> iDragSignatureBy(WidgetTester tester, Offset delta) async {
final c = TestWorld.container!;
c.read(signatureProvider.notifier).drag(delta);
}

View File

@ -1,9 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart';
import '_world.dart';
/// Usage: I jumpTo {10}
Future<void> iJumpto(WidgetTester tester, int page) async {
final c = TestWorld.container!;
c.read(pdfProvider.notifier).jumpTo(page);
}

View File

@ -1,13 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart';
import '_world.dart';
/// Usage: I openPicked with path {'test.pdf'} and pageCount {7}
Future<void> iOpenpickedWithPathAndPagecount(
WidgetTester tester,
String path,
int pageCount,
) async {
final c = TestWorld.container!;
c.read(pdfProvider.notifier).openPicked(path: path, pageCount: pageCount);
}

View File

@ -1,9 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart';
import '_world.dart';
/// Usage: I place default signature rect
Future<void> iPlaceDefaultSignatureRect(WidgetTester tester) async {
final c = TestWorld.container!;
c.read(signatureProvider.notifier).placeDefaultRect();
}

View File

@ -1,9 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart';
import '_world.dart';
/// Usage: I resize signature by {Offset(1000, 1000)}
Future<void> iResizeSignatureBy(WidgetTester tester, Offset delta) async {
final c = TestWorld.container!;
c.read(signatureProvider.notifier).resize(delta);
}

View File

@ -1,9 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart';
import '_world.dart';
/// Usage: I set page count {9}
Future<void> iSetPageCount(WidgetTester tester, int count) async {
final c = TestWorld.container!;
c.read(pdfProvider.notifier).setPageCount(count);
}

View File

@ -1,17 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart';
import '_world.dart';
/// Usage: I toggle mark
Future<void> iToggleMark(WidgetTester tester) async {
final container = TestWorld.container ?? ProviderContainer();
TestWorld.container = container;
final state = container.read(pdfProvider);
final notifier = container.read(pdfProvider.notifier);
if (state.signedPage == null) {
notifier.setSignedPage(state.currentPage);
} else {
notifier.setSignedPage(null);
}
}

View File

@ -1,9 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart';
import '_world.dart';
/// Usage: pdf current page is {1}
Future<void> pdfCurrentPageIs(WidgetTester tester, int expected) async {
final c = TestWorld.container!;
expect(c.read(pdfProvider).currentPage, expected);
}

View File

@ -1,11 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart';
import '_world.dart';
/// Usage: pdf marked for signing is {false}
Future<void> pdfMarkedForSigningIs(WidgetTester tester, bool expected) async {
final container = TestWorld.container ?? ProviderContainer();
final signed = container.read(pdfProvider).signedPage != null;
expect(signed, expected);
}

View File

@ -1,9 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart';
import '_world.dart';
/// Usage: pdf page count is {7}
Future<void> pdfPageCountIs(WidgetTester tester, int expected) async {
final c = TestWorld.container!;
expect(c.read(pdfProvider).pageCount, expected);
}

View File

@ -1,10 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart';
import '_world.dart';
/// Usage: pdf picked path is {'test.pdf'}
Future<void> pdfPickedPathIs(WidgetTester tester, String expected) async {
final c = TestWorld.container!;
final s = c.read(pdfProvider);
expect(s.pickedPdfPath, expected);
}

View File

@ -1,9 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart';
import '_world.dart';
/// Usage: pdf state is loaded {true}
Future<void> pdfStateIsLoaded(WidgetTester tester, bool expected) async {
final c = TestWorld.container!;
expect(c.read(pdfProvider).loaded, expected);
}

View File

@ -1,20 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart';
import '_world.dart';
/// Usage: signature aspect ratio is preserved within {0.05}
Future<void> signatureAspectRatioIsPreservedWithin(
WidgetTester tester,
num tolerance,
) async {
final c = TestWorld.container!;
final r = c.read(signatureProvider).rect!;
final before = TestWorld.prevAspect;
if (before == null) {
// save and pass
TestWorld.prevAspect = r.width / r.height;
return;
}
final after = r.width / r.height;
expect((after - before).abs(), lessThanOrEqualTo(tolerance.toDouble()));
}

View File

@ -1,10 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart';
import '_world.dart';
/// Usage: signature rect bottom <= {560}
Future<void> signatureRectBottom(WidgetTester tester, num maxBottom) async {
final c = TestWorld.container!;
final r = c.read(signatureProvider).rect!;
expect(r.bottom, lessThanOrEqualTo(maxBottom.toDouble()));
}

View File

@ -1,10 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart';
import '_world.dart';
/// Usage: signature rect height > {20}
Future<void> signatureRectHeight(WidgetTester tester, num minHeight) async {
final c = TestWorld.container!;
final r = c.read(signatureProvider).rect!;
expect(r.height, greaterThan(minHeight.toDouble()));
}

View File

@ -1,9 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart';
import '_world.dart';
/// Usage: signature rect is null
Future<void> signatureRectIsNull(WidgetTester tester) async {
final c = TestWorld.container!;
expect(c.read(signatureProvider).rect, isNull);
}

View File

@ -1,10 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart';
import '_world.dart';
/// Usage: signature rect left >= {0}
Future<void> signatureRectLeft(WidgetTester tester, num minLeft) async {
final c = TestWorld.container!;
final r = c.read(signatureProvider).rect!;
expect(r.left, greaterThanOrEqualTo(minLeft.toDouble()));
}

View File

@ -1,12 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart';
import '_world.dart';
/// Usage: signature rect moved from center
Future<void> signatureRectMovedFromCenter(WidgetTester tester) async {
final c = TestWorld.container!;
final prev = TestWorld.prevCenter;
final now = c.read(signatureProvider).rect!.center;
expect(prev, isNotNull);
expect(now, isNot(equals(prev)));
}

View File

@ -1,10 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart';
import '_world.dart';
/// Usage: signature rect right <= {400}
Future<void> signatureRectRight(WidgetTester tester, num maxRight) async {
final c = TestWorld.container!;
final r = c.read(signatureProvider).rect!;
expect(r.right, lessThanOrEqualTo(maxRight.toDouble()));
}

View File

@ -1,10 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart';
import '_world.dart';
/// Usage: signature rect top >= {0}
Future<void> signatureRectTop(WidgetTester tester, num minTop) async {
final c = TestWorld.container!;
final r = c.read(signatureProvider).rect!;
expect(r.top, greaterThanOrEqualTo(minTop.toDouble()));
}

View File

@ -1,10 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart';
import '_world.dart';
/// Usage: signature rect width > {50}
Future<void> signatureRectWidth(WidgetTester tester, num minWidth) async {
final c = TestWorld.container!;
final r = c.read(signatureProvider).rect!;
expect(r.width, greaterThan(minWidth.toDouble()));
}

View File

@ -1,22 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart';
import '_world.dart';
/// Usage: the selected signature is shown with image {"bob.png"}
Future<void> theSelectedSignatureIsShownWithImage(
WidgetTester tester,
String expected,
) async {
final container = TestWorld.container ?? ProviderContainer();
TestWorld.container = container;
final pdf = container.read(pdfProvider);
final page = pdf.currentPage;
final idx =
pdf.selectedPlacementIndex ??
((pdf.placementsByPage[page]?.length ?? 1) - 1);
final name = container
.read(pdfProvider.notifier)
.imageOfPlacement(page: page, index: idx);
expect(name, expected);
}

View File

@ -1,30 +0,0 @@
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart';
import '_world.dart';
/// Usage: the user assigns {"bob.png"} to the selected signature
Future<void> theUserAssignsToTheSelectedSignature(
WidgetTester tester,
String newImageName,
) async {
final container = TestWorld.container ?? ProviderContainer();
TestWorld.container = container;
// Load the new image into signature state (simulating pick)
Uint8List bytes =
newImageName == 'bob.png'
? Uint8List.fromList([4, 5, 6])
: Uint8List.fromList([1, 2, 3]);
container.read(signatureProvider.notifier).setImageBytes(bytes);
TestWorld.currentImageName = newImageName;
// Assign to currently selected placement
final pdf = container.read(pdfProvider);
final page = pdf.currentPage;
final idx =
pdf.selectedPlacementIndex ??
((pdf.placementsByPage[page]?.length ?? 1) - 1);
container
.read(pdfProvider.notifier)
.assignImageToPlacement(page: page, index: idx, image: newImageName);
}

View File

@ -1,40 +0,0 @@
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter/material.dart';
import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart';
import '_world.dart';
/// Usage: the user places a signature on the page
Future<void> theUserPlacesASignatureOnThePage(WidgetTester tester) async {
final container = TestWorld.container ?? ProviderContainer();
TestWorld.container = container;
final pdf = container.read(pdfProvider);
if (!pdf.loaded) {
container
.read(pdfProvider.notifier)
.openPicked(path: 'mock.pdf', pageCount: 1);
container.read(pdfProvider.notifier).setSignedPage(1);
}
// Ensure image bytes
if (container.read(signatureProvider).imageBytes == null) {
final name = TestWorld.currentImageName ?? 'alice.png';
Uint8List bytes =
name == 'bob.png'
? Uint8List.fromList([4, 5, 6])
: Uint8List.fromList([1, 2, 3]);
container.read(signatureProvider.notifier).setImageBytes(bytes);
}
container.read(signatureProvider.notifier).placeDefaultRect();
final Rect r = container.read(signatureProvider).rect!;
final int page = container.read(pdfProvider).signedPage ?? 1;
final imgName = TestWorld.currentImageName ?? 'alice.png';
container
.read(pdfProvider.notifier)
.addPlacement(page: page, rect: r, image: imgName);
// Select the just placed signature (last index)
final list = container.read(pdfProvider).placementsByPage[page] ?? const [];
container
.read(pdfProvider.notifier)
.selectPlacement(list.isEmpty ? null : (list.length - 1));
}

View File

@ -22,14 +22,6 @@ Feature: support multiple signature pictures
Then identical signature instances appear in each location
And adjusting one instance does not affect the others
Scenario: Reassign a different image to an existing signature
Given a PDF page is selected for signing
And an image {"alice.png"} is loaded
And the user places a signature on the page
When an image {"bob.png"} is loaded
And the user assigns {"bob.png"} to the selected signature
Then the selected signature is shown with image {"bob.png"}
Scenario: Save/export uses the assigned image for each signature
Given a PDF is open and contains multiple placed signatures across pages
When the user saves/exports the document

View File

@ -0,0 +1,136 @@
import 'dart:ui' as ui;
import 'package:flutter/gestures.dart' show kSecondaryMouseButton;
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:image/image.dart' as img;
import 'package:pdf_signature/ui/features/pdf/widgets/pdf_screen.dart';
import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart';
import 'package:pdf_signature/data/services/export_providers.dart';
import 'package:pdf_signature/l10n/app_localizations.dart';
void main() {
// Open the active overlay context menu robustly (mouse right-click, fallback to long-press)
Future<void> _openActiveMenuAndConfirm(WidgetTester tester) async {
final overlay = find.byKey(const Key('signature_overlay'));
expect(overlay, findsOneWidget);
// Ensure visible before interacting
await tester.ensureVisible(overlay);
await tester.pumpAndSettle();
// Try right-click first
final center = tester.getCenter(overlay);
final TestGesture mouse = await tester.createGesture(
kind: ui.PointerDeviceKind.mouse,
buttons: kSecondaryMouseButton,
);
await mouse.addPointer(location: center);
addTearDown(mouse.removePointer);
await tester.pump();
await mouse.down(center);
await tester.pump(const Duration(milliseconds: 30));
await mouse.up();
await tester.pumpAndSettle();
// If menu didn't appear, try long-press
if (find.byKey(const Key('ctx_active_confirm')).evaluate().isEmpty) {
await tester.longPress(overlay, warnIfMissed: false);
await tester.pumpAndSettle();
}
await tester.tap(find.byKey(const Key('ctx_active_confirm')));
await tester.pumpAndSettle();
}
// Build a simple in-memory PNG as a signature image
Uint8List _makeSig() {
final canvas = img.Image(width: 80, height: 40);
img.fill(canvas, color: img.ColorUint8.rgb(255, 255, 255));
img.drawLine(
canvas,
x1: 6,
y1: 20,
x2: 74,
y2: 20,
color: img.ColorUint8.rgb(0, 0, 0),
);
return Uint8List.fromList(img.encodePng(canvas));
}
testWidgets('E2E: select, place default, and confirm signature', (
tester,
) async {
final sigBytes = _makeSig();
await tester.pumpWidget(
ProviderScope(
overrides: [
// Open a PDF
pdfProvider.overrideWith(
(ref) => PdfController()..openPicked(path: 'test.pdf'),
),
// Provide one signature asset in the library
signatureLibraryProvider.overrideWith((ref) {
final c = SignatureLibraryController();
c.add(sigBytes, name: 'image');
return c;
}),
// Use mock continuous viewer for deterministic layout in widget tests
useMockViewerProvider.overrideWithValue(true),
],
child: MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: const PdfSignatureHomePage(),
),
),
);
await tester.pumpAndSettle();
// Tap the signature card to set it as active overlay
final card = find.byKey(const Key('gd_signature_card_area')).first;
expect(card, findsOneWidget);
await tester.tap(card);
await tester.pump();
// Active overlay should appear
final active = find.byKey(const Key('signature_overlay'));
expect(active, findsOneWidget);
final sizeBefore = tester.getSize(active);
// Bring the overlay into the viewport (it's near the bottom of the page by default)
final listFinder = find.byKey(const Key('pdf_continuous_mock_list'));
if (listFinder.evaluate().isNotEmpty) {
// Ensure the active overlay is fully visible within the scrollable viewport
await tester.ensureVisible(active);
await tester.pumpAndSettle();
}
// Open context menu and confirm using a robust flow
await _openActiveMenuAndConfirm(tester);
// Verify active overlay gone and placed overlay shown
expect(find.byKey(const Key('signature_overlay')), findsNothing);
final placed = find.byKey(const Key('placed_signature_0'));
expect(placed, findsOneWidget);
final sizeAfter = tester.getSize(placed);
// Compare sizes: should be roughly equal (allowing small layout variance)
expect(
(sizeAfter.width - sizeBefore.width).abs() < sizeBefore.width * 0.15,
isTrue,
);
expect(
(sizeAfter.height - sizeBefore.height).abs() < sizeBefore.height * 0.15,
isTrue,
);
// Verify provider state reflects one placement on current page
final ctx = tester.element(find.byType(PdfSignatureHomePage));
final container = ProviderScope.containerOf(ctx);
final pdf = container.read(pdfProvider);
final list = pdf.placementsByPage[pdf.currentPage] ?? const [];
expect(list.length, 1);
});
}

View File

@ -17,8 +17,6 @@ class RecordingExporter extends ExportService {
}
}
class BasicExporter extends ExportService {}
void main() {
testWidgets('Save uses file selector (via provider) and injected exporter', (
tester,

View File

@ -8,7 +8,7 @@ import 'package:pdf_signature/ui/features/pdf/widgets/pdf_screen.dart';
import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart';
import 'package:pdf_signature/data/services/export_providers.dart';
import 'package:pdf_signature/l10n/app_localizations.dart';
import 'package:pdf_signature/data/services/preferences_providers.dart';
// preferences_providers.dart no longer exports pageViewModeProvider
Future<void> pumpWithOpenPdf(WidgetTester tester) async {
await tester.pumpWidget(
@ -18,8 +18,7 @@ Future<void> pumpWithOpenPdf(WidgetTester tester) async {
(ref) => PdfController()..openPicked(path: 'test.pdf'),
),
useMockViewerProvider.overrideWith((ref) => true),
// Force continuous mode regardless of prefs
pageViewModeProvider.overrideWithValue('continuous'),
// Continuous mode is always-on; no page view override needed
],
child: MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
@ -59,7 +58,7 @@ Future<void> pumpWithOpenPdfAndSig(WidgetTester tester) async {
..placeDefaultRect(),
),
useMockViewerProvider.overrideWith((ref) => true),
pageViewModeProvider.overrideWithValue('continuous'),
// Continuous mode is always-on; no page view override needed
],
child: MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,

View File

@ -7,7 +7,6 @@ import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart';
import 'package:pdf_signature/data/services/export_providers.dart';
import 'package:pdf_signature/l10n/app_localizations.dart';
import 'package:pdf_signature/data/model/model.dart';
import 'package:pdf_signature/data/services/preferences_providers.dart';
class _TestPdfController extends PdfController {
_TestPdfController() : super() {
@ -30,7 +29,7 @@ void main() {
ProviderScope(
overrides: [
useMockViewerProvider.overrideWithValue(true),
pageViewModeProvider.overrideWithValue('continuous'),
// Continuous mode is always-on; no page view override needed
pdfProvider.overrideWith((ref) => ctrl),
],
child: MaterialApp(

View File

@ -7,7 +7,6 @@ import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart';
import 'package:pdf_signature/data/services/export_providers.dart';
import 'package:pdf_signature/l10n/app_localizations.dart';
import 'package:pdf_signature/data/model/model.dart';
import 'package:pdf_signature/data/services/preferences_providers.dart';
class _TestPdfController extends PdfController {
_TestPdfController() : super() {
@ -29,8 +28,7 @@ void main() {
ProviderScope(
overrides: [
useMockViewerProvider.overrideWithValue(true),
// Force continuous mode without SharedPreferences
pageViewModeProvider.overrideWithValue('continuous'),
// Continuous mode is always-on; no page view override needed
pdfProvider.overrideWith((ref) => ctrl),
],
child: MaterialApp(

View File

@ -0,0 +1,104 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:pdf_signature/ui/features/pdf/widgets/pdf_page_area.dart';
import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart';
import 'package:pdf_signature/data/services/export_providers.dart';
void main() {
testWidgets('placed signature stays attached on zoom (mock continuous)', (
tester,
) async {
const Size uiPageSize = Size(400, 560);
// Test harness that exposes the ProviderContainer to mutate state
late ProviderContainer container;
Widget buildHarness({required double width}) {
return ProviderScope(
overrides: [
// Force mock viewer for predictable layout; pageViewModeProvider already falls back to 'continuous'
useMockViewerProvider.overrideWithValue(true),
],
child: Builder(
builder: (context) {
container = ProviderScope.containerOf(context);
return Directionality(
textDirection: TextDirection.ltr,
child: MaterialApp(
home: Scaffold(
body: Center(
child: SizedBox(
width: width,
// Keep aspect ratio consistent with uiPageSize
child: PdfPageArea(
pageSize: uiPageSize,
onDragSignature: (_) {},
onResizeSignature: (_) {},
onConfirmSignature: () {},
onClearActiveOverlay: () {},
onSelectPlaced: (_) {},
),
),
),
),
),
);
},
),
);
}
// Initial pump at base width
await tester.pumpWidget(buildHarness(width: 480));
// Open sample and add a normalized placement to page 1
container.read(pdfProvider.notifier).openSample();
// One placement at (25% x, 50% y), size 10% x 10%
container
.read(pdfProvider.notifier)
.addPlacement(
page: 1,
rect: const Rect.fromLTWH(0.25, 0.50, 0.10, 0.10),
);
await tester.pumpAndSettle();
// Find the first page stack and the placed signature widget
final pageStackFinder = find.byKey(const ValueKey('page_stack_1'));
expect(pageStackFinder, findsOneWidget);
final placedFinder = find.byKey(const Key('placed_signature_0'));
expect(placedFinder, findsOneWidget);
final pageBox = tester.getRect(pageStackFinder);
final placedBox1 = tester.getRect(placedFinder);
// Compute normalized position within the page container
final relX1 = (placedBox1.left - pageBox.left) / pageBox.width;
final relY1 = (placedBox1.top - pageBox.top) / pageBox.height;
// Simulate zoom by doubling the available width
await tester.pumpWidget(buildHarness(width: 960));
// Maintain state across rebuild
await tester.pumpAndSettle();
final pageBox2 = tester.getRect(pageStackFinder);
final placedBox2 = tester.getRect(placedFinder);
final relX2 = (placedBox2.left - pageBox2.left) / pageBox2.width;
final relY2 = (placedBox2.top - pageBox2.top) / pageBox2.height;
// The relative position should stay approximately the same
expect(
(relX2 - relX1).abs() < 0.01,
isTrue,
reason: 'X should remain attached',
);
expect(
(relY2 - relY1).abs() < 0.01,
isTrue,
reason: 'Y should remain attached',
);
});
}

View File

@ -0,0 +1,89 @@
import 'dart:ui' as ui;
import 'package:flutter/gestures.dart' show kSecondaryMouseButton;
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'helpers.dart';
void main() {
Future<void> _confirmActiveOverlay(WidgetTester tester) async {
final overlay = find.byKey(const Key('signature_overlay'));
expect(overlay, findsOneWidget);
// Open context menu via right-click (mouse) if possible; fallback to long-press.
final center = tester.getCenter(overlay);
final TestGesture mouse = await tester.createGesture(
kind: ui.PointerDeviceKind.mouse,
buttons: kSecondaryMouseButton,
);
await mouse.addPointer(location: center);
addTearDown(mouse.removePointer);
await tester.pump();
await mouse.down(center);
await tester.pump(const Duration(milliseconds: 30));
await mouse.up();
await tester.pumpAndSettle();
// If menu didn't appear, try long-press
if (find.byKey(const Key('ctx_active_confirm')).evaluate().isEmpty) {
await tester.longPress(overlay);
await tester.pumpAndSettle();
}
await tester.tap(find.byKey(const Key('ctx_active_confirm')));
await tester.pumpAndSettle();
}
testWidgets('Confirming causes placed signature to shrink to upper-left', (
tester,
) async {
await pumpWithOpenPdfAndSig(tester);
final overlay = find.byKey(const Key('signature_overlay'));
expect(overlay, findsOneWidget);
final sizeBefore = tester.getSize(overlay);
final topLeftBefore = tester.getTopLeft(overlay);
await _confirmActiveOverlay(tester);
final placed = find.byKey(const Key('placed_signature_0'));
expect(placed, findsOneWidget);
final sizeAfter = tester.getSize(placed);
final topLeftAfter = tester.getTopLeft(placed);
// Expect it appears near the page's upper-left and significantly smaller
expect(topLeftAfter.dx <= topLeftBefore.dx + 10, isTrue);
expect(topLeftAfter.dy <= topLeftBefore.dy + 10, isTrue);
expect(sizeAfter.width < sizeBefore.width * 0.5, isTrue);
expect(sizeAfter.height < sizeBefore.height * 0.5, isTrue);
});
testWidgets('Placing a new signature makes the previous one disappear', (
tester,
) async {
await pumpWithOpenPdfAndSig(tester);
// Place first
await _confirmActiveOverlay(tester);
expect(find.byKey(const Key('placed_signature_0')), findsOneWidget);
// Activate a new overlay by tapping the first signature card in the sidebar
final cardTapTarget = find.byKey(const Key('gd_signature_card_area')).first;
expect(cardTapTarget, findsOneWidget);
await tester.tap(cardTapTarget);
await tester.pumpAndSettle();
// Optionally move a bit to avoid exact overlap
final active = find.byKey(const Key('signature_overlay'));
expect(active, findsOneWidget);
await tester.drag(active, const Offset(20, 10));
await tester.pump();
// Confirm again
await _confirmActiveOverlay(tester);
await tester.pumpAndSettle();
// Expect only one placed signature remains visible (old one disappeared)
final placedAll = find.byWidgetPredicate(
(w) => w.key?.toString().contains('placed_signature_') == true,
);
expect(placedAll.evaluate().length, 1);
});
}