Compare commits

..

5 Commits

69 changed files with 1109 additions and 1062 deletions

12
AGENTS.md Normal file
View File

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

View File

@ -12,16 +12,22 @@ checkout [`docs/FRs.md`](docs/FRs.md)
# flutter clean # flutter clean
# arb_translate # arb_translate
flutter pub get flutter pub get
# generate gherkin test # > to generate gherkin test
flutter pub run build_runner build --delete-conflicting-outputs flutter pub run build_runner build --delete-conflicting-outputs
# > to remove unused step definitions
# dart run tool/prune_unused_steps.dart --delete # 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 # dart run tool/gen_view_wireframe_md.dart
# flutter pub run dead_code_analyzer
# run the app # run the app
flutter run flutter run
# run unit tests and widget tests
flutter test
``` ```
### build ### build

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -173,15 +173,7 @@ final preferencesProvider =
return PreferencesNotifier(prefs); return PreferencesNotifier(prefs);
}); });
/// Safe accessor for page view mode that falls back to 'continuous' until // pageViewModeProvider removed; the app always runs in continuous mode.
/// 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',
);
});
/// Derive the active ThemeMode based on preference and platform brightness /// Derive the active ThemeMode based on preference and platform brightness
final themeModeProvider = Provider<ThemeMode>((ref) { final themeModeProvider = Provider<ThemeMode>((ref) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,4 +1,5 @@
import 'dart:math' as math; import 'dart:math' as math;
import 'dart:math';
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
@ -10,6 +11,7 @@ class PdfController extends StateNotifier<PdfState> {
PdfController() : super(PdfState.initial()); PdfController() : super(PdfState.initial());
static const int samplePageCount = 5; static const int samplePageCount = 5;
@visibleForTesting
void openSample() { void openSample() {
state = state.copyWith( state = state.copyWith(
loaded: true, loaded: true,
@ -63,7 +65,8 @@ class PdfController extends StateNotifier<PdfState> {
state = state.copyWith(pageCount: count.clamp(1, 9999)); 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({ void addPlacement({
required int page, required int page,
required Rect rect, 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) { List<Rect> placementsOn(int page) {
return List<Rect>.from(state.placementsByPage[page] ?? const []); return List<Rect>.from(state.placementsByPage[page] ?? const []);
} }
@ -140,22 +160,7 @@ class PdfController extends StateNotifier<PdfState> {
removePlacement(page: state.currentPage, index: idx); removePlacement(page: state.currentPage, index: idx);
} }
// Assign a different image name to a placement on a page. // NOTE: Programmatic reassignment of images has been removed.
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);
}
}
// Convenience to get image name for a placement // Convenience to get image name for a placement
String? imageOfPlacement({required int page, required int index}) { String? imageOfPlacement({required int page, required int index}) {
@ -215,11 +220,15 @@ class SignatureController extends StateNotifier<SignatureState> {
state = SignatureState.initial(); state = SignatureState.initial();
} }
@visibleForTesting
void placeDefaultRect() { void placeDefaultRect() {
final w = 120.0, h = 60.0; final w = 120.0, h = 60.0;
state = state.copyWith( state = state.copyWith(
rect: Rect.fromCenter( 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, width: w,
height: h, height: h,
), ),
@ -364,25 +373,37 @@ class SignatureController extends StateNotifier<SignatureState> {
// Place onto the current page // Place onto the current page
final pdf = ref.read(pdfProvider); final pdf = ref.read(pdfProvider);
if (!pdf.loaded) return null; if (!pdf.loaded) return null;
ref.read(pdfProvider.notifier).addPlacement(page: pdf.currentPage, rect: r); // Convert UI-space rect (400x560) to normalized rect
// Assign image id to this placement (last index) final Size pageSize = SignatureController.pageSize;
final idx = final normalized = Rect.fromLTWH(
(ref.read(pdfProvider).placementsByPage[pdf.currentPage]?.length ?? 1) - (r.left / pageSize.width).clamp(0.0, 1.0),
1; (r.top / pageSize.height).clamp(0.0, 1.0),
String? id = state.assetId; (r.width / pageSize.width).clamp(0.0, 1.0),
if (id == null) { (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 = final bytes =
ref.read(processedSignatureImageProvider) ?? state.imageBytes; ref.read(processedSignatureImageProvider) ?? state.imageBytes;
if (bytes != null) { if (bytes != null && bytes.isNotEmpty) {
id = ref id = ref
.read(signatureLibraryProvider.notifier) .read(signatureLibraryProvider.notifier)
.add(bytes, name: 'image'); .add(bytes, name: 'image');
} else {
id = 'default.png';
} }
} }
if (id != null && id.isNotEmpty && idx >= 0) { ref
ref .read(pdfProvider.notifier)
.read(pdfProvider.notifier) .addPlacement(page: pdf.currentPage, rect: normalized, image: id);
.assignImageToPlacement(page: pdf.currentPage, index: idx, 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 // Freeze editing: keep rect for preview but disable interaction
state = state.copyWith(editingEnabled: false); state = state.copyWith(editingEnabled: false);

View File

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

View File

@ -1,17 +1,13 @@
import 'dart:math' as math;
import 'dart:async';
import 'dart:typed_data';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:pdf_signature/l10n/app_localizations.dart'; import 'package:pdf_signature/l10n/app_localizations.dart';
import 'package:pdfrx/pdfrx.dart'; import 'package:pdfrx/pdfrx.dart';
import '../../../../data/services/export_providers.dart'; import '../../../../data/services/export_providers.dart';
import '../../../../data/model/model.dart';
import '../view_model/view_model.dart'; import '../view_model/view_model.dart';
import '../../../../data/services/preferences_providers.dart';
import 'signature_drag_data.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 { class PdfPageArea extends ConsumerStatefulWidget {
const PdfPageArea({ const PdfPageArea({
@ -54,9 +50,8 @@ class _PdfPageAreaState extends ConsumerState<PdfPageArea> {
// is instructed to align to the provider's current page once ready. // is instructed to align to the provider's current page once ready.
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return; if (!mounted) return;
final mode = ref.read(pageViewModeProvider);
final pdf = ref.read(pdfProvider); final pdf = ref.read(pdfProvider);
if (mode == 'continuous' && pdf.pickedPdfPath != null && pdf.loaded) { if (pdf.pickedPdfPath != null && pdf.loaded) {
_scrollToPage(pdf.currentPage); _scrollToPage(pdf.currentPage);
} }
}); });
@ -73,7 +68,7 @@ class _PdfPageAreaState extends ConsumerState<PdfPageArea> {
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return; if (!mounted) return;
final pdf = ref.read(pdfProvider); final pdf = ref.read(pdfProvider);
final isContinuous = ref.read(pageViewModeProvider) == 'continuous'; const isContinuous = true;
// Real continuous: drive via PdfViewerController // Real continuous: drive via PdfViewerController
if (pdf.pickedPdfPath != null && isContinuous) { if (pdf.pickedPdfPath != null && isContinuous) {
@ -161,13 +156,12 @@ class _PdfPageAreaState extends ConsumerState<PdfPageArea> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final pdf = ref.watch(pdfProvider); final pdf = ref.watch(pdfProvider);
final pageViewMode = ref.watch(pageViewModeProvider); const pageViewMode = 'continuous';
// React to provider currentPage changes (e.g., user tapped overview) // React to provider currentPage changes (e.g., user tapped overview)
ref.listen(pdfProvider, (prev, next) { ref.listen(pdfProvider, (prev, next) {
final mode = ref.read(pageViewModeProvider);
if (_suppressProviderListen) return; if (_suppressProviderListen) return;
if (mode == 'continuous' && (prev?.currentPage != next.currentPage)) { if ((prev?.currentPage != next.currentPage)) {
final target = next.currentPage; final target = next.currentPage;
// If we're already navigating to this target, ignore; otherwise allow new target. // If we're already navigating to this target, ignore; otherwise allow new target.
if (_programmaticTargetPage != null && if (_programmaticTargetPage != null &&
@ -180,22 +174,17 @@ class _PdfPageAreaState extends ConsumerState<PdfPageArea> {
} }
} }
}); });
// When switching to continuous, bring current page into view // No page view mode switching; always continuous.
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);
}
}
});
if (!pdf.loaded) { 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); final useMock = ref.watch(useMockViewerProvider);
@ -204,80 +193,21 @@ class _PdfPageAreaState extends ConsumerState<PdfPageArea> {
// Mock continuous: ListView with prebuilt children, no controller // Mock continuous: ListView with prebuilt children, no controller
if (useMock && isContinuous) { if (useMock && isContinuous) {
final count = pdf.pageCount > 0 ? pdf.pageCount : 1; final count = pdf.pageCount > 0 ? pdf.pageCount : 1;
return Builder( return PdfMockContinuousList(
builder: (ctx) { pageSize: widget.pageSize,
// Defer processing of any pending jump until after the tree is mounted. count: count,
if (_pendingPage != null) { pageKeyBuilder: _pageKey,
WidgetsBinding.instance.addPostFrameCallback((_) { scrollToPage: _scrollToPage,
if (!mounted) return; pendingPage: _pendingPage,
final p = _pendingPage; clearPending: () {
if (p != null) { _pendingPage = null;
_pendingPage = null; _scrollRetryCount = 0;
_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;
}, },
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), keyHandlerParams: PdfViewerKeyHandlerParams(autofocus: true),
maxScale: 8, maxScale: 8,
scrollByMouseWheel: 0.6, 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) // Add overlay scroll thumbs (vertical on right, horizontal on bottom)
viewerOverlayBuilder: viewerOverlayBuilder:
(context, size, handleLinkTap) => [ (context, size, handleLinkTap) => [
@ -406,347 +365,6 @@ class _PdfPageAreaState extends ConsumerState<PdfPageArea> {
return const SizedBox.shrink(); 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. // Zoom controls removed with single-page mode; continuous viewer manages zoom.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,64 +1,6 @@
import 'dart:typed_data';
import 'dart:ui' show Rect, Size;
import 'dart:io';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '_world.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() { ProviderContainer getOrCreateContainer() {
if (TestWorld.container != null) return TestWorld.container!; if (TestWorld.container != null) return TestWorld.container!;
final container = ProviderContainer(); final container = ProviderContainer();

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -22,14 +22,6 @@ Feature: support multiple signature pictures
Then identical signature instances appear in each location Then identical signature instances appear in each location
And adjusting one instance does not affect the others 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 Scenario: Save/export uses the assigned image for each signature
Given a PDF is open and contains multiple placed signatures across pages Given a PDF is open and contains multiple placed signatures across pages
When the user saves/exports the document When the user saves/exports the document

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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