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
|
flutter pub run build_runner build --delete-conflicting-outputs
|
||||||
# dart run tool/prune_unused_steps.dart --delete
|
# dart run tool/prune_unused_steps.dart --delete
|
||||||
# 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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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/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)
|
||||||
|
|
|
@ -18,8 +18,6 @@ class RecordingExporter extends ExportService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class BasicExporter extends ExportService {}
|
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -32,7 +32,6 @@
|
||||||
"pageInfo": "ページ {current}/{total}",
|
"pageInfo": "ページ {current}/{total}",
|
||||||
"pageView": "ページ表示",
|
"pageView": "ページ表示",
|
||||||
"pageViewContinuous": "連続",
|
"pageViewContinuous": "連続",
|
||||||
"pageViewSingle": "シングルページ",
|
|
||||||
"prev": "前へ",
|
"prev": "前へ",
|
||||||
"resetToDefaults": "デフォルトに戻す",
|
"resetToDefaults": "デフォルトに戻す",
|
||||||
"save": "保存",
|
"save": "保存",
|
||||||
|
|
|
@ -32,7 +32,6 @@
|
||||||
"pageInfo": "{current}/{total} 페이지",
|
"pageInfo": "{current}/{total} 페이지",
|
||||||
"pageView": "페이지 보기",
|
"pageView": "페이지 보기",
|
||||||
"pageViewContinuous": "연속",
|
"pageViewContinuous": "연속",
|
||||||
"pageViewSingle": "단일 페이지",
|
|
||||||
"prev": "이전",
|
"prev": "이전",
|
||||||
"resetToDefaults": "기본값으로 재설정",
|
"resetToDefaults": "기본값으로 재설정",
|
||||||
"save": "저장",
|
"save": "저장",
|
||||||
|
|
|
@ -32,7 +32,6 @@
|
||||||
"pageInfo": "Сторінка {current}/{total}",
|
"pageInfo": "Сторінка {current}/{total}",
|
||||||
"pageView": "Перегляд сторінки",
|
"pageView": "Перегляд сторінки",
|
||||||
"pageViewContinuous": "Безперервний",
|
"pageViewContinuous": "Безперервний",
|
||||||
"pageViewSingle": "Одна сторінка",
|
|
||||||
"prev": "Попередня",
|
"prev": "Попередня",
|
||||||
"resetToDefaults": "Скинути до значень за замовчуванням",
|
"resetToDefaults": "Скинути до значень за замовчуванням",
|
||||||
"save": "Зберегти",
|
"save": "Зберегти",
|
||||||
|
|
|
@ -33,7 +33,6 @@
|
||||||
"pageInfo": "第 {current}/{total} 頁",
|
"pageInfo": "第 {current}/{total} 頁",
|
||||||
"pageView": "頁面檢視",
|
"pageView": "頁面檢視",
|
||||||
"pageViewContinuous": "連續",
|
"pageViewContinuous": "連續",
|
||||||
"pageViewSingle": "單頁",
|
|
||||||
"prev": "上一頁",
|
"prev": "上一頁",
|
||||||
"resetToDefaults": "重設為預設值",
|
"resetToDefaults": "重設為預設值",
|
||||||
"save": "儲存",
|
"save": "儲存",
|
||||||
|
|
|
@ -32,7 +32,6 @@
|
||||||
"pageInfo": "第 {current} 页 / 共 {total} 页",
|
"pageInfo": "第 {current} 页 / 共 {total} 页",
|
||||||
"pageView": "分页浏览",
|
"pageView": "分页浏览",
|
||||||
"pageViewContinuous": "连续",
|
"pageViewContinuous": "连续",
|
||||||
"pageViewSingle": "单页",
|
|
||||||
"prev": "上一页",
|
"prev": "上一页",
|
||||||
"resetToDefaults": "恢复默认值",
|
"resetToDefaults": "恢复默认值",
|
||||||
"save": "保存",
|
"save": "保存",
|
||||||
|
|
|
@ -33,7 +33,6 @@
|
||||||
"pageInfo": "第 {current}/{total} 頁",
|
"pageInfo": "第 {current}/{total} 頁",
|
||||||
"pageView": "頁面檢視",
|
"pageView": "頁面檢視",
|
||||||
"pageViewContinuous": "連續",
|
"pageViewContinuous": "連續",
|
||||||
"pageViewSingle": "單頁",
|
|
||||||
"prev": "上一頁",
|
"prev": "上一頁",
|
||||||
"resetToDefaults": "重設為預設值",
|
"resetToDefaults": "重設為預設值",
|
||||||
"save": "儲存",
|
"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));
|
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 +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) {
|
List<Rect> placementsOn(int page) {
|
||||||
return List<Rect>.from(state.placementsByPage[page] ?? const []);
|
return List<Rect>.from(state.placementsByPage[page] ?? const []);
|
||||||
}
|
}
|
||||||
|
@ -364,7 +382,17 @@ 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
|
||||||
|
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)
|
// Assign image id to this placement (last index)
|
||||||
final idx =
|
final idx =
|
||||||
(ref.read(pdfProvider).placementsByPage[pdf.currentPage]?.length ?? 1) -
|
(ref.read(pdfProvider).placementsByPage[pdf.currentPage]?.length ?? 1) -
|
||||||
|
@ -384,6 +412,10 @@ class SignatureController extends StateNotifier<SignatureState> {
|
||||||
.read(pdfProvider.notifier)
|
.read(pdfProvider.notifier)
|
||||||
.assignImageToPlacement(page: pdf.currentPage, index: idx, image: id);
|
.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
|
// Freeze editing: keep rect for preview but disable interaction
|
||||||
state = state.copyWith(editingEnabled: false);
|
state = state.copyWith(editingEnabled: false);
|
||||||
return r;
|
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/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 '../../../../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';
|
||||||
|
|
||||||
class PdfPageArea extends ConsumerStatefulWidget {
|
class PdfPageArea extends ConsumerStatefulWidget {
|
||||||
const PdfPageArea({
|
const PdfPageArea({
|
||||||
|
@ -204,80 +200,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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -406,347 +343,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.
|
||||||
|
|
|
@ -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(
|
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;
|
||||||
|
|
|
@ -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)),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
|
@ -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(),
|
child: CircularProgressIndicator(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
error: (_, __) {
|
error: (_, _) {
|
||||||
final items =
|
final items =
|
||||||
AppLocalizations.supportedLocales
|
AppLocalizations.supportedLocales
|
||||||
.map((loc) => toLanguageTag(loc))
|
.map((loc) => toLanguageTag(loc))
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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();
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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