refactor: split pdf_page_area.dart to multi smaller files

This commit is contained in:
insleker 2025-09-03 14:08:35 +08:00
parent 0969ec2931
commit 8e2599c0f8
28 changed files with 657 additions and 507 deletions

View File

@ -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

View File

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

View File

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

View File

@ -10,3 +10,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)

View File

@ -18,8 +18,6 @@ class RecordingExporter extends ExportService {
} }
} }
class BasicExporter extends ExportService {}
void main() { void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized(); IntegrationTestWidgetsFlutterBinding.ensureInitialized();

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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;

View File

@ -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();
},
),
],
),
),
),
);
}),
),
);
}
}

View File

@ -1,17 +1,13 @@
import 'dart:math' as math;
import 'dart:async';
import 'dart:typed_data';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:pdf_signature/l10n/app_localizations.dart'; import 'package:pdf_signature/l10n/app_localizations.dart';
import 'package:pdfrx/pdfrx.dart'; import 'package:pdfrx/pdfrx.dart';
import '../../../../data/services/export_providers.dart'; import '../../../../data/services/export_providers.dart';
import '../../../../data/model/model.dart';
import '../view_model/view_model.dart'; import '../view_model/view_model.dart';
import '../../../../data/services/preferences_providers.dart'; import '../../../../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.

View File

@ -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);
}
}

View File

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

View File

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

View File

@ -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;
}
}

View File

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

View File

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

View File

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

View File

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

View File

@ -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',
);
});
}