refactor: split pdf_page_area.dart to multi smaller files
This commit is contained in:
parent
0969ec2931
commit
8e2599c0f8
|
@ -16,6 +16,7 @@ flutter pub get
|
|||
flutter pub run build_runner build --delete-conflicting-outputs
|
||||
# dart run tool/prune_unused_steps.dart --delete
|
||||
# dart run tool/gen_view_wireframe_md.dart
|
||||
# flutter pub run dead_code_analyzer
|
||||
|
||||
# run the app
|
||||
flutter run
|
||||
|
|
|
@ -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,13 @@ 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)
|
||||
|
|
|
@ -18,8 +18,6 @@ class RecordingExporter extends ExportService {
|
|||
}
|
||||
}
|
||||
|
||||
class BasicExporter extends ExportService {}
|
||||
|
||||
void main() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
|
|
|
@ -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';
|
||||
}
|
|
@ -63,7 +63,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 +116,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 []);
|
||||
}
|
||||
|
@ -364,7 +382,17 @@ 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);
|
||||
// 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),
|
||||
);
|
||||
ref
|
||||
.read(pdfProvider.notifier)
|
||||
.addPlacement(page: pdf.currentPage, rect: normalized);
|
||||
// Assign image id to this placement (last index)
|
||||
final idx =
|
||||
(ref.read(pdfProvider).placementsByPage[pdf.currentPage]?.length ?? 1) -
|
||||
|
@ -384,6 +412,10 @@ class SignatureController extends StateNotifier<SignatureState> {
|
|||
.read(pdfProvider.notifier)
|
||||
.assignImageToPlacement(page: pdf.currentPage, index: idx, image: id);
|
||||
}
|
||||
// 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);
|
||||
return r;
|
||||
|
|
|
@ -0,0 +1,103 @@
|
|||
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: Text(
|
||||
AppLocalizations.of(context).pageInfo(pageNum, count),
|
||||
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';
|
||||
|
||||
class PdfPageArea extends ConsumerStatefulWidget {
|
||||
const PdfPageArea({
|
||||
|
@ -204,80 +200,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,
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -406,347 +343,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,73 @@
|
|||
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++) {
|
||||
widgets.add(
|
||||
SignatureOverlay(
|
||||
pageSize: pageSize,
|
||||
rect: placed[i],
|
||||
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)),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
|
|
@ -0,0 +1,280 @@
|
|||
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) {
|
||||
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;
|
||||
}
|
||||
}
|
|
@ -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,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();
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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',
|
||||
);
|
||||
});
|
||||
}
|
Loading…
Reference in New Issue