Compare commits
5 Commits
0969ec2931
...
fdf0d1f7a9
Author | SHA1 | Date |
---|---|---|
|
fdf0d1f7a9 | |
|
0a21045761 | |
|
8507dcf6f5 | |
|
f4bd486ad9 | |
|
8e2599c0f8 |
|
@ -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`
|
14
README.md
14
README.md
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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`
|
||||
|
|
|
@ -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);
|
||||
}
|
|
@ -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,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -32,7 +32,6 @@
|
|||
"pageInfo": "Seite {current}/{total}",
|
||||
"pageView": "Seitenansicht",
|
||||
"pageViewContinuous": "Kontinuierlich",
|
||||
"pageViewSingle": "Einzelne Seite",
|
||||
"prev": "Vorherige",
|
||||
"resetToDefaults": "Auf Standardwerte zurücksetzen",
|
||||
"save": "Speichern",
|
||||
|
|
|
@ -83,8 +83,6 @@
|
|||
"@pageView": {},
|
||||
"pageViewContinuous": "Continuous",
|
||||
"@pageViewContinuous": {},
|
||||
"pageViewSingle": "Single page",
|
||||
"@pageViewSingle": {},
|
||||
"prev": "Prev",
|
||||
"@prev": {},
|
||||
"resetToDefaults": "Reset to defaults",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -32,7 +32,6 @@
|
|||
"pageInfo": "ページ {current}/{total}",
|
||||
"pageView": "ページ表示",
|
||||
"pageViewContinuous": "連続",
|
||||
"pageViewSingle": "シングルページ",
|
||||
"prev": "前へ",
|
||||
"resetToDefaults": "デフォルトに戻す",
|
||||
"save": "保存",
|
||||
|
|
|
@ -32,7 +32,6 @@
|
|||
"pageInfo": "{current}/{total} 페이지",
|
||||
"pageView": "페이지 보기",
|
||||
"pageViewContinuous": "연속",
|
||||
"pageViewSingle": "단일 페이지",
|
||||
"prev": "이전",
|
||||
"resetToDefaults": "기본값으로 재설정",
|
||||
"save": "저장",
|
||||
|
|
|
@ -32,7 +32,6 @@
|
|||
"pageInfo": "Сторінка {current}/{total}",
|
||||
"pageView": "Перегляд сторінки",
|
||||
"pageViewContinuous": "Безперервний",
|
||||
"pageViewSingle": "Одна сторінка",
|
||||
"prev": "Попередня",
|
||||
"resetToDefaults": "Скинути до значень за замовчуванням",
|
||||
"save": "Зберегти",
|
||||
|
|
|
@ -33,7 +33,6 @@
|
|||
"pageInfo": "第 {current}/{total} 頁",
|
||||
"pageView": "頁面檢視",
|
||||
"pageViewContinuous": "連續",
|
||||
"pageViewSingle": "單頁",
|
||||
"prev": "上一頁",
|
||||
"resetToDefaults": "重設為預設值",
|
||||
"save": "儲存",
|
||||
|
|
|
@ -32,7 +32,6 @@
|
|||
"pageInfo": "第 {current} 页 / 共 {total} 页",
|
||||
"pageView": "分页浏览",
|
||||
"pageViewContinuous": "连续",
|
||||
"pageViewSingle": "单页",
|
||||
"prev": "上一页",
|
||||
"resetToDefaults": "恢复默认值",
|
||||
"save": "保存",
|
||||
|
|
|
@ -33,7 +33,6 @@
|
|||
"pageInfo": "第 {current}/{total} 頁",
|
||||
"pageView": "頁面檢視",
|
||||
"pageViewContinuous": "連續",
|
||||
"pageViewSingle": "單頁",
|
||||
"prev": "上一頁",
|
||||
"resetToDefaults": "重設為預設值",
|
||||
"save": "儲存",
|
||||
|
|
|
@ -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';
|
||||
}
|
|
@ -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);
|
||||
|
|
|
@ -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();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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.
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -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)),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -70,7 +70,7 @@ class _SettingsDialogState extends ConsumerState<SettingsDialog> {
|
|||
child: CircularProgressIndicator(),
|
||||
),
|
||||
),
|
||||
error: (_, __) {
|
||||
error: (_, _) {
|
||||
final items =
|
||||
AppLocalizations.supportedLocales
|
||||
.map((loc) => toLanguageTag(loc))
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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}
|
|
@ -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}
|
||||
|
||||
|
|
@ -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();
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
});
|
||||
}
|
|
@ -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);
|
||||
}
|
|
@ -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);
|
||||
}
|
|
@ -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);
|
||||
}
|
|
@ -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);
|
||||
}
|
|
@ -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);
|
||||
}
|
|
@ -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);
|
||||
}
|
|
@ -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();
|
||||
}
|
|
@ -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);
|
||||
}
|
|
@ -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);
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
|
@ -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);
|
||||
}
|
|
@ -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);
|
||||
}
|
|
@ -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);
|
||||
}
|
|
@ -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);
|
||||
}
|
|
@ -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()));
|
||||
}
|
|
@ -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()));
|
||||
}
|
|
@ -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()));
|
||||
}
|
|
@ -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);
|
||||
}
|
|
@ -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()));
|
||||
}
|
|
@ -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)));
|
||||
}
|
|
@ -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()));
|
||||
}
|
|
@ -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()));
|
||||
}
|
|
@ -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()));
|
||||
}
|
|
@ -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);
|
||||
}
|
|
@ -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);
|
||||
}
|
|
@ -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));
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
}
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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',
|
||||
);
|
||||
});
|
||||
}
|
|
@ -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);
|
||||
});
|
||||
}
|
Loading…
Reference in New Issue