pdf_signature/lib/ui/features/pdf/widgets/pdf_page_area.dart

349 lines
13 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:pdf_signature/l10n/app_localizations.dart';
import 'package:pdfrx/pdfrx.dart';
import '../../../../data/services/export_providers.dart';
import '../view_model/view_model.dart';
import '../../../../data/services/preferences_providers.dart';
import 'signature_drag_data.dart';
import 'pdf_mock_continuous_list.dart';
class PdfPageArea extends ConsumerStatefulWidget {
const PdfPageArea({
super.key,
required this.pageSize,
required this.onDragSignature,
required this.onResizeSignature,
required this.onConfirmSignature,
required this.onClearActiveOverlay,
required this.onSelectPlaced,
this.viewerController,
});
final Size pageSize;
final PdfViewerController? viewerController;
final ValueChanged<Offset> onDragSignature;
final ValueChanged<Offset> onResizeSignature;
final VoidCallback onConfirmSignature;
final VoidCallback onClearActiveOverlay;
final ValueChanged<int?> onSelectPlaced;
@override
ConsumerState<PdfPageArea> createState() => _PdfPageAreaState();
}
class _PdfPageAreaState extends ConsumerState<PdfPageArea> {
final Map<int, GlobalKey> _pageKeys = {};
late final PdfViewerController _viewerController =
widget.viewerController ?? PdfViewerController();
// Guards to avoid scroll feedback between provider and viewer
int? _programmaticTargetPage;
bool _suppressProviderListen = false;
int? _visiblePage; // last page reported by viewer
int? _pendingPage; // pending target for mock ensureVisible retry
int _scrollRetryCount = 0;
static const int _maxScrollRetries = 50;
@override
void initState() {
super.initState();
// If app starts in continuous mode with a loaded PDF, ensure the viewer
// is instructed to align to the provider's current page once ready.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
final mode = ref.read(pageViewModeProvider);
final pdf = ref.read(pdfProvider);
if (mode == 'continuous' && pdf.pickedPdfPath != null && pdf.loaded) {
_scrollToPage(pdf.currentPage);
}
});
}
// No dispose required for PdfViewerController (managed by owner if any)
GlobalKey _pageKey(int page) => _pageKeys.putIfAbsent(
page,
() => GlobalKey(debugLabel: 'cont_page_$page'),
);
void _scrollToPage(int page) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
final pdf = ref.read(pdfProvider);
final isContinuous = ref.read(pageViewModeProvider) == 'continuous';
// Real continuous: drive via PdfViewerController
if (pdf.pickedPdfPath != null && isContinuous) {
if (_viewerController.isReady) {
_programmaticTargetPage = page;
// print("[DEBUG] viewerController Scrolling to page $page");
_viewerController.goToPage(
pageNumber: page,
anchor: PdfPageAnchor.top,
);
// Fallback: if no onPageChanged arrives (e.g., same page), don't block future jumps
// Use post-frame callbacks to avoid scheduling timers in tests.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
if (_programmaticTargetPage == page) {
_programmaticTargetPage = null;
}
});
});
_pendingPage = null;
_scrollRetryCount = 0;
} else {
_pendingPage = page;
if (_scrollRetryCount < _maxScrollRetries) {
_scrollRetryCount += 1;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
final p = _pendingPage;
if (p == null) return;
_scrollToPage(p);
});
}
}
return;
}
// print("[DEBUG] Mock Scrolling to page $page");
// Mock continuous: try ensureVisible on the page container
final ctx = _pageKey(page).currentContext;
if (ctx != null) {
try {
final scrollable = Scrollable.of(ctx);
final position = scrollable.position;
final targetBox = ctx.findRenderObject() as RenderBox?;
final scrollBox = scrollable.context.findRenderObject() as RenderBox?;
if (targetBox != null && scrollBox != null) {
final offsetInViewport = targetBox.localToGlobal(
Offset.zero,
ancestor: scrollBox,
);
final desiredTop = scrollBox.size.height * 0.1;
final newPixels =
(position.pixels + offsetInViewport.dy - desiredTop)
.clamp(position.minScrollExtent, position.maxScrollExtent)
.toDouble();
position.jumpTo(newPixels);
return;
}
} catch (_) {
// Fallback to ensureVisible if any calculation fails
Scrollable.ensureVisible(
ctx,
alignment: 0.1,
duration: Duration.zero,
curve: Curves.linear,
);
return;
}
return;
}
_pendingPage = page;
if (_scrollRetryCount < _maxScrollRetries) {
_scrollRetryCount += 1;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
final p = _pendingPage;
if (p == null) return;
_scrollToPage(p);
});
}
});
}
@override
Widget build(BuildContext context) {
final pdf = ref.watch(pdfProvider);
final pageViewMode = ref.watch(pageViewModeProvider);
// React to provider currentPage changes (e.g., user tapped overview)
ref.listen(pdfProvider, (prev, next) {
final mode = ref.read(pageViewModeProvider);
if (_suppressProviderListen) return;
if (mode == 'continuous' && (prev?.currentPage != next.currentPage)) {
final target = next.currentPage;
// If we're already navigating to this target, ignore; otherwise allow new target.
if (_programmaticTargetPage != null &&
_programmaticTargetPage == target) {
return;
}
// Only navigate if target differs from what viewer shows
if (_visiblePage != target) {
_scrollToPage(target);
}
}
});
// When switching to continuous, bring current page into view
ref.listen<String>(pageViewModeProvider, (prev, next) {
if (next == 'continuous') {
// Skip initial auto-scroll in mock mode to avoid fighting with
// early provider-driven jumps during tests.
final isMock = ref.read(useMockViewerProvider);
if (isMock) return;
final p = ref.read(pdfProvider).currentPage;
if (_visiblePage != p) {
_scrollToPage(p);
}
}
});
if (!pdf.loaded) {
return Center(child: Text(AppLocalizations.of(context).noPdfLoaded));
}
final useMock = ref.watch(useMockViewerProvider);
final isContinuous = pageViewMode == 'continuous';
// Mock continuous: ListView with prebuilt children, no controller
if (useMock && isContinuous) {
final count = pdf.pageCount > 0 ? pdf.pageCount : 1;
return PdfMockContinuousList(
pageSize: widget.pageSize,
count: count,
pageKeyBuilder: _pageKey,
scrollToPage: _scrollToPage,
pendingPage: _pendingPage,
clearPending: () {
_pendingPage = null;
_scrollRetryCount = 0;
},
onDragSignature: (delta) => widget.onDragSignature(delta),
onResizeSignature: (delta) => widget.onResizeSignature(delta),
onConfirmSignature: widget.onConfirmSignature,
onClearActiveOverlay: widget.onClearActiveOverlay,
onSelectPlaced: widget.onSelectPlaced,
);
}
// Real continuous mode (pdfrx): copy example patterns
// https://github.com/espresso3389/pdfrx/blob/2cc32c1e2aa2a054602d20a5e7cf60bcc2d6a889/packages/pdfrx/example/viewer/lib/main.dart
if (pdf.pickedPdfPath != null && isContinuous) {
final viewer = PdfViewer.file(
pdf.pickedPdfPath!,
controller: _viewerController,
params: PdfViewerParams(
pageAnchor: PdfPageAnchor.top,
keyHandlerParams: PdfViewerKeyHandlerParams(autofocus: true),
maxScale: 8,
scrollByMouseWheel: 0.6,
// Add overlay scroll thumbs (vertical on right, horizontal on bottom)
viewerOverlayBuilder:
(context, size, handleLinkTap) => [
PdfViewerScrollThumb(
controller: _viewerController,
orientation: ScrollbarOrientation.right,
thumbSize: const Size(40, 24),
thumbBuilder:
(context, thumbSize, pageNumber, controller) => Container(
color: Colors.black.withValues(alpha: 0.7),
child: Center(
child: Text(
pageNumber.toString(),
style: const TextStyle(color: Colors.white),
),
),
),
),
PdfViewerScrollThumb(
controller: _viewerController,
orientation: ScrollbarOrientation.bottom,
thumbSize: const Size(40, 24),
thumbBuilder:
(context, thumbSize, pageNumber, controller) => Container(
color: Colors.black.withValues(alpha: 0.7),
child: Center(
child: Text(
pageNumber.toString(),
style: const TextStyle(color: Colors.white),
),
),
),
),
],
onViewerReady: (doc, controller) {
if (pdf.pageCount != doc.pages.length) {
ref.read(pdfProvider.notifier).setPageCount(doc.pages.length);
}
final target = _pendingPage ?? pdf.currentPage;
_pendingPage = null;
_scrollRetryCount = 0;
// Defer navigation to the next frame to ensure controller state is fully ready.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
_scrollToPage(target);
});
},
onPageChanged: (n) {
if (n == null) return;
_visiblePage = n;
// Programmatic navigation: wait until target reached
if (_programmaticTargetPage != null) {
if (n == _programmaticTargetPage) {
if (n != ref.read(pdfProvider).currentPage) {
_suppressProviderListen = true;
ref.read(pdfProvider.notifier).jumpTo(n);
WidgetsBinding.instance.addPostFrameCallback((_) {
_suppressProviderListen = false;
});
}
_programmaticTargetPage = null;
}
return;
}
// User scroll -> reflect page to provider without re-triggering scroll
if (n != ref.read(pdfProvider).currentPage) {
_suppressProviderListen = true;
ref.read(pdfProvider.notifier).jumpTo(n);
WidgetsBinding.instance.addPostFrameCallback((_) {
_suppressProviderListen = false;
});
}
},
),
);
// Accept drops of signature card over the viewer
final drop = DragTarget<Object>(
onWillAcceptWithDetails: (details) => details.data is SignatureDragData,
onAcceptWithDetails: (details) {
// Map the local position to UI page coordinates of the visible page
final box = context.findRenderObject() as RenderBox?;
if (box == null) return;
final local = box.globalToLocal(details.offset);
final size = box.size;
// Assume drop targets the current visible page; compute relative center
final cx = (local.dx / size.width) * widget.pageSize.width;
final cy = (local.dy / size.height) * widget.pageSize.height;
final data = details.data;
if (data is SignatureDragData && data.assetId != null) {
// Set current overlay to use this asset
ref
.read(signatureProvider.notifier)
.setImageFromLibrary(assetId: data.assetId!);
}
ref.read(signatureProvider.notifier).placeAtCenter(Offset(cx, cy));
ref
.read(pdfProvider.notifier)
.setSignedPage(ref.read(pdfProvider).currentPage);
},
builder:
(context, candidateData, rejected) => Stack(
fit: StackFit.expand,
children: [
viewer,
if (candidateData.isNotEmpty)
Container(color: Colors.blue.withValues(alpha: 0.08)),
],
),
);
return drop;
}
return const SizedBox.shrink();
}
}
// Zoom controls removed with single-page mode; continuous viewer manages zoom.