diff --git a/.gitignore b/.gitignore
index 7938982..62d3614 100644
--- a/.gitignore
+++ b/.gitignore
@@ -129,3 +129,7 @@ docs/wireframe.assets/*.excalidraw.svg
docs/wireframe.assets/*.svg
docs/wireframe.assets/*.png
node_modules/
+AppDir/.DirIcon
+AppDir/bundle/
+appimage-build/
+/*.AppImage
diff --git a/AppDir/AppRun b/AppDir/AppRun
new file mode 100755
index 0000000..36caa4f
--- /dev/null
+++ b/AppDir/AppRun
@@ -0,0 +1,5 @@
+#!/bin/sh
+
+export LD_LIBRARY_PATH="${APPDIR}/bundle/lib"
+exec $APPDIR/bundle/pdf_signature "$@"
+
diff --git a/AppDir/pdf_signature-icon.svg b/AppDir/pdf_signature-icon.svg
new file mode 100755
index 0000000..eb7f890
--- /dev/null
+++ b/AppDir/pdf_signature-icon.svg
@@ -0,0 +1,27 @@
+
+
+
+
+
diff --git a/AppDir/pdf_signature.desktop b/AppDir/pdf_signature.desktop
new file mode 100755
index 0000000..a2ea43d
--- /dev/null
+++ b/AppDir/pdf_signature.desktop
@@ -0,0 +1,7 @@
+[Desktop Entry]
+Version=1.0
+Type=Application
+Name=pdf_signature
+Exec=AppRun %U
+Icon=pdf_signature-icon
+Categories=Utility
diff --git a/lib/ui/features/pdf/widgets/pdf_page_area.dart b/lib/ui/features/pdf/widgets/pdf_page_area.dart
index 894e6e6..1db2c09 100644
--- a/lib/ui/features/pdf/widgets/pdf_page_area.dart
+++ b/lib/ui/features/pdf/widgets/pdf_page_area.dart
@@ -33,8 +33,29 @@ class PdfPageArea extends ConsumerStatefulWidget {
}
class _PdfPageAreaState extends ConsumerState {
- final ScrollController _scrollController = ScrollController();
final Map _pageKeys = {};
+ final PdfViewerController _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);
+ }
+ });
+ }
GlobalKey _pageKey(int page) => _pageKeys.putIfAbsent(
page,
@@ -43,88 +64,135 @@ class _PdfPageAreaState extends ConsumerState {
void _scrollToPage(int page) {
WidgetsBinding.instance.addPostFrameCallback((_) {
- final key = _pageKey(page);
- final ctx = key.currentContext;
+ 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
+ WidgetsBinding.instance.addPostFrameCallback((_) {
+ if (!mounted) return;
+ Future.delayed(const Duration(milliseconds: 120), () {
+ 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) {
- Scrollable.ensureVisible(
- ctx,
- duration: const Duration(milliseconds: 250),
- curve: Curves.easeInOut,
- alignment: 0.1,
- );
+ 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: const Duration(milliseconds: 1),
+ 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
- void initState() {
- super.initState();
- }
-
- @override
- void dispose() {
- _scrollController.dispose();
- super.dispose();
- }
-
- Future _showContextMenuForPlaced({
- required BuildContext context,
- required WidgetRef ref,
- required Offset globalPos,
- required int index,
- required int page,
- }) async {
- widget.onSelectPlaced(index);
- final choice = await showMenu(
- context: context,
- position: RelativeRect.fromLTRB(
- globalPos.dx,
- globalPos.dy,
- globalPos.dx,
- globalPos.dy,
- ),
- items: [
- PopupMenuItem(
- key: Key('ctx_delete_signature'),
- value: 'delete',
- child: Text(AppLocalizations.of(context).delete),
- ),
- ],
- );
- if (choice == 'delete') {
- ref.read(pdfProvider.notifier).removePlacement(page: page, index: index);
- }
- }
-
@override
Widget build(BuildContext context) {
final pdf = ref.watch(pdfProvider);
final pageViewMode = ref.watch(pageViewModeProvider);
- // Subscribe to provider changes during build (allowed by Riverpod) to trigger side-effects.
+
+ // 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)) {
- _scrollToPage(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(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;
- _scrollToPage(p);
+ 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';
- if (isContinuous) {
- // Make sure the current page is visible after first build of continuous list.
- WidgetsBinding.instance.addPostFrameCallback((_) {
- if (!mounted) return;
- _scrollToPage(pdf.currentPage);
- });
- }
+
+ // Mock single-page
if (useMock && !isContinuous) {
return Center(
child: AspectRatio(
@@ -168,53 +236,86 @@ class _PdfPageAreaState extends ConsumerState {
),
);
}
+
+ // Mock continuous: ListView with prebuilt children, no controller
if (useMock && isContinuous) {
final count = pdf.pageCount > 0 ? pdf.pageCount : 1;
- return ListView.builder(
- key: const Key('pdf_continuous_mock_list'),
- controller: _scrollController,
- padding: const EdgeInsets.symmetric(vertical: 8),
- itemCount: count,
- itemBuilder: (context, 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,
+ return Builder(
+ builder: (ctx) {
+ // Defer processing of any pending jump until after the tree is mounted.
+ if (_pendingPage != null) {
+ WidgetsBinding.instance.addPostFrameCallback((_) {
+ if (!mounted) return;
+ final p = _pendingPage;
+ if (p != null) {
+ _pendingPage = null;
+ _scrollRetryCount = 0;
+ Future.delayed(const Duration(milliseconds: 1), () {
+ if (!mounted) return;
+ _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: _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();
+ },
+ ),
+ ],
),
),
- Consumer(
- builder: (context, ref, _) {
- final sig = ref.watch(signatureProvider);
- final visible = ref.watch(signatureVisibilityProvider);
- return visible
- ? _buildPageOverlays(context, ref, sig, pageNum)
- : const SizedBox.shrink();
- },
- ),
- ],
- ),
- ),
+ ),
+ );
+ }),
),
);
},
);
}
+
+ // Real single-page mode
if (pdf.pickedPdfPath != null && !isContinuous) {
return PdfDocumentViewBuilder.file(
pdf.pickedPdfPath!,
@@ -266,67 +367,112 @@ class _PdfPageAreaState extends ConsumerState {
},
);
}
+
+ // Real continuous mode (pdfrx): copy example patterns
if (pdf.pickedPdfPath != null && isContinuous) {
- return PdfDocumentViewBuilder.file(
+ return PdfViewer.file(
pdf.pickedPdfPath!,
- builder: (context, document) {
- if (document == null) {
- return const Center(child: CircularProgressIndicator());
- }
- final pages = document.pages;
- if (pdf.pageCount != pages.length) {
+ controller: _viewerController,
+ params: PdfViewerParams(
+ pageAnchor: PdfPageAnchor.top,
+ 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;
+ _programmaticTargetPage = target;
+ controller.goToPage(pageNumber: target, anchor: PdfPageAnchor.top);
+ // Fallback: if the viewer doesn't emit onPageChanged (e.g., already at target),
+ // ensure we don't keep blocking provider-driven jumps.
WidgetsBinding.instance.addPostFrameCallback((_) {
- ref.read(pdfProvider.notifier).setPageCount(pages.length);
+ if (!mounted) return;
+ Future.delayed(const Duration(milliseconds: 120), () {
+ if (!mounted) return;
+ if (_programmaticTargetPage == target) {
+ _programmaticTargetPage = null;
+ }
+ });
});
- }
- return ListView.builder(
- key: const Key('pdf_continuous_list'),
- controller: _scrollController,
- padding: const EdgeInsets.symmetric(vertical: 8),
- itemCount: pages.length,
- itemBuilder: (context, idx) {
- final pageNum = idx + 1;
- final page = pages[idx];
- final aspect = page.width / page.height;
- return Center(
- child: Padding(
- key: _pageKey(pageNum),
- padding: const EdgeInsets.symmetric(vertical: 8),
- child: AspectRatio(
- aspectRatio: aspect,
- child: Stack(
- key: ValueKey('page_stack_$pageNum'),
- children: [
- PdfPageView(
- key: ValueKey('pdf_page_view_$pageNum'),
- document: document,
- pageNumber: pageNum,
- alignment: Alignment.center,
- ),
- Consumer(
- builder: (context, ref, _) {
- final sig = ref.watch(signatureProvider);
- final visible = ref.watch(
- signatureVisibilityProvider,
- );
- return visible
- ? _buildPageOverlays(context, ref, sig, pageNum)
- : const SizedBox.shrink();
- },
- ),
- ],
- ),
- ),
- ),
- );
- },
- );
- },
+ // Also ensure a scroll attempt is queued in case current state suppressed earlier.
+ WidgetsBinding.instance.addPostFrameCallback((_) {
+ if (!mounted) return;
+ if (_visiblePage != ref.read(pdfProvider).currentPage) {
+ _scrollToPage(ref.read(pdfProvider).currentPage);
+ }
+ });
+ },
+ 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;
+ });
+ }
+ },
+ ),
);
}
+
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(
+ context: context,
+ position: RelativeRect.fromLTRB(
+ globalPos.dx,
+ globalPos.dy,
+ globalPos.dx,
+ globalPos.dy,
+ ),
+ items: [
+ PopupMenuItem(
+ key: const Key('ctx_placed_delete'),
+ value: 'delete',
+ child: Text(l.delete),
+ ),
+ ],
+ ).then((choice) {
+ switch (choice) {
+ case 'delete':
+ ref
+ .read(pdfProvider.notifier)
+ .removePlacement(page: page, index: index);
+ break;
+ default:
+ break;
+ }
+ });
+ }
+
Widget _buildPageOverlays(
BuildContext context,
WidgetRef ref,
diff --git a/test/features/step/the_app_supports_languages.dart b/test/features/step/the_app_supports_languages.dart
index 3bfd9fa..822efc8 100644
--- a/test/features/step/the_app_supports_languages.dart
+++ b/test/features/step/the_app_supports_languages.dart
@@ -12,21 +12,30 @@ Future theAppSupportsLanguages(
// Accept either a DataTable from bdd_widget_test or a string like "{en, zh-TW, es}"
final Set expected;
if (languages is bdd.DataTable) {
- final lists = languages.asLists();
- // Flatten ignoring header rows if any
- final items = lists
- .skipWhile((row) => row.any((e) => e.toString().contains('artist') || e.toString().contains('name')))
- .expand((row) => row)
- .map((e) => e.toString().replaceAll("'", '').trim())
- .where((e) => e.isNotEmpty)
- .toSet();
- expected = items;
+ final lists = languages.asLists();
+ // Flatten ignoring header rows if any
+ final items =
+ lists
+ .skipWhile(
+ (row) => row.any(
+ (e) =>
+ e.toString().contains('artist') ||
+ e.toString().contains('name'),
+ ),
+ )
+ .expand((row) => row)
+ .map((e) => e.toString().replaceAll("'", '').trim())
+ .where((e) => e.isNotEmpty)
+ .toSet();
+ expected = items;
} else {
final raw = languages.toString().trim();
- final inner = raw.startsWith('{') && raw.endsWith('}')
- ? raw.substring(1, raw.length - 1)
- : raw;
- expected = inner.split(',').map((s) => s.trim().replaceAll("'", '')).toSet();
+ final inner =
+ raw.startsWith('{') && raw.endsWith('}')
+ ? raw.substring(1, raw.length - 1)
+ : raw;
+ expected =
+ inner.split(',').map((s) => s.trim().replaceAll("'", '')).toSet();
}
// Keep this in sync with the app's supported locales
diff --git a/test/features/step/the_preference_language_is_saved_as.dart b/test/features/step/the_preference_language_is_saved_as.dart
new file mode 100644
index 0000000..0cfdd6d
--- /dev/null
+++ b/test/features/step/the_preference_language_is_saved_as.dart
@@ -0,0 +1,23 @@
+import 'package:flutter_test/flutter_test.dart';
+import '_world.dart';
+
+/// Usage: the preference 'language' is saved as {""}
+Future thePreferenceLanguageIsSavedAs(
+ WidgetTester tester, [
+ dynamic valueWrapped,
+]) async {
+ String unwrap(String s) {
+ var out = s.trim();
+ if (out.startsWith('{') && out.endsWith('}')) {
+ out = out.substring(1, out.length - 1);
+ }
+ if ((out.startsWith("'") && out.endsWith("'")) ||
+ (out.startsWith('"') && out.endsWith('"'))) {
+ out = out.substring(1, out.length - 1);
+ }
+ return out;
+ }
+
+ final expected = unwrap((valueWrapped ?? '').toString());
+ expect(TestWorld.prefs['language'], expected);
+}
diff --git a/test/features/step/the_preference_theme_is_saved_as.dart b/test/features/step/the_preference_theme_is_saved_as.dart
new file mode 100644
index 0000000..3f98047
--- /dev/null
+++ b/test/features/step/the_preference_theme_is_saved_as.dart
@@ -0,0 +1,23 @@
+import 'package:flutter_test/flutter_test.dart';
+import '_world.dart';
+
+/// Usage: the preference 'theme' is saved as {""}
+Future thePreferenceThemeIsSavedAs(
+ WidgetTester tester, [
+ dynamic valueWrapped,
+]) async {
+ String unwrap(String s) {
+ var out = s.trim();
+ if (out.startsWith('{') && out.endsWith('}')) {
+ out = out.substring(1, out.length - 1);
+ }
+ if ((out.startsWith("'") && out.endsWith("'")) ||
+ (out.startsWith('"') && out.endsWith('"'))) {
+ out = out.substring(1, out.length - 1);
+ }
+ return out;
+ }
+
+ final expected = unwrap((valueWrapped ?? '').toString());
+ expect(TestWorld.prefs['theme'], expected);
+}
diff --git a/test/widget/pdf_page_area_early_jump_test.dart b/test/widget/pdf_page_area_early_jump_test.dart
new file mode 100644
index 0000000..2f93983
--- /dev/null
+++ b/test/widget/pdf_page_area_early_jump_test.dart
@@ -0,0 +1,87 @@
+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/providers.dart';
+import 'package:pdf_signature/l10n/app_localizations.dart';
+import 'package:pdf_signature/data/model/model.dart';
+import 'package:pdf_signature/ui/features/preferences/providers.dart';
+
+class _TestPdfController extends PdfController {
+ _TestPdfController() : super() {
+ state = PdfState.initial().copyWith(
+ loaded: true,
+ pageCount: 6,
+ currentPage: 1,
+ );
+ }
+}
+
+void main() {
+ testWidgets('PdfPageArea: early jump queues and scrolls once list builds', (
+ tester,
+ ) async {
+ final ctrl = _TestPdfController();
+
+ // Build the widget tree
+ await tester.pumpWidget(
+ ProviderScope(
+ overrides: [
+ useMockViewerProvider.overrideWithValue(true),
+ pageViewModeProvider.overrideWithValue('continuous'),
+ pdfProvider.overrideWith((ref) => ctrl),
+ ],
+ child: MaterialApp(
+ localizationsDelegates: AppLocalizations.localizationsDelegates,
+ supportedLocales: AppLocalizations.supportedLocales,
+ locale: const Locale('en'),
+ home: const Scaffold(
+ body: Center(
+ child: SizedBox(
+ width: 800,
+ height: 520,
+ child: PdfPageArea(
+ pageSize: Size(676, 400),
+ onDragSignature: _noopOffset,
+ onResizeSignature: _noopOffset,
+ onConfirmSignature: _noop,
+ onClearActiveOverlay: _noop,
+ onSelectPlaced: _noopInt,
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ );
+
+ // Trigger an early jump immediately after first pump, before settle.
+ ctrl.jumpTo(5);
+
+ // Now allow frames to build and settle
+ await tester.pump();
+ await tester.pumpAndSettle(const Duration(milliseconds: 800));
+
+ // Validate that page 5 is in view and scroll offset moved.
+ final listFinder = find.byKey(const Key('pdf_continuous_mock_list'));
+ expect(listFinder, findsOneWidget);
+ final scrollableFinder = find.descendant(
+ of: listFinder,
+ matching: find.byType(Scrollable),
+ );
+ final pos = tester.state(scrollableFinder).position;
+ expect(pos.pixels, greaterThan(0));
+
+ final pageStack = find.byKey(const ValueKey('page_stack_5'));
+ expect(pageStack, findsOneWidget);
+ final viewport = tester.getRect(listFinder);
+ final pageRect = tester.getRect(pageStack);
+ expect(viewport.overlaps(pageRect), isTrue);
+ });
+}
+
+void _noop() {}
+void _noopInt(int? _) {}
+void _noopOffset(Offset _) {}
diff --git a/test/widget/pdf_page_area_jump_test.dart b/test/widget/pdf_page_area_jump_test.dart
new file mode 100644
index 0000000..dfc2245
--- /dev/null
+++ b/test/widget/pdf_page_area_jump_test.dart
@@ -0,0 +1,106 @@
+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/providers.dart';
+import 'package:pdf_signature/l10n/app_localizations.dart';
+import 'package:pdf_signature/data/model/model.dart';
+import 'package:pdf_signature/ui/features/preferences/providers.dart';
+
+class _TestPdfController extends PdfController {
+ _TestPdfController() : super() {
+ state = PdfState.initial().copyWith(
+ loaded: true,
+ pageCount: 6,
+ currentPage: 2,
+ );
+ }
+}
+
+void main() {
+ testWidgets(
+ 'PdfPageArea: continuous mode scrolls target page into view on jump',
+ (tester) async {
+ final ctrl = _TestPdfController();
+
+ await tester.pumpWidget(
+ ProviderScope(
+ overrides: [
+ useMockViewerProvider.overrideWithValue(true),
+ // Force continuous mode without SharedPreferences
+ pageViewModeProvider.overrideWithValue('continuous'),
+ pdfProvider.overrideWith((ref) => ctrl),
+ ],
+ child: MaterialApp(
+ localizationsDelegates: AppLocalizations.localizationsDelegates,
+ supportedLocales: AppLocalizations.supportedLocales,
+ locale: const Locale('en'),
+ home: const Scaffold(
+ body: Center(
+ child: SizedBox(
+ width: 800,
+ height: 520,
+ child: PdfPageArea(
+ pageSize: Size(676, 400),
+ onDragSignature: _noopOffset,
+ onResizeSignature: _noopOffset,
+ onConfirmSignature: _noop,
+ onClearActiveOverlay: _noop,
+ onSelectPlaced: _noopInt,
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ );
+
+ // Get initial scroll position (may already have auto-scrolled to current page)
+ final listFinder = find.byKey(const Key('pdf_continuous_mock_list'));
+ expect(listFinder, findsOneWidget);
+ final scrollableFinder = find.descendant(
+ of: listFinder,
+ matching: find.byType(Scrollable),
+ );
+ double lastPixels =
+ tester.state(scrollableFinder).position.pixels;
+
+ Future jumpAndVerify(int targetPage) async {
+ final before = lastPixels;
+ ctrl.jumpTo(targetPage);
+ await tester.pump();
+ await tester.pumpAndSettle(const Duration(milliseconds: 600));
+
+ // Verify with viewport geometry
+ final pageStack = find.byKey(ValueKey('page_stack_$targetPage'));
+ expect(pageStack, findsOneWidget);
+
+ final viewport = tester.getRect(listFinder);
+ final pageRect = tester.getRect(pageStack);
+ expect(
+ viewport.overlaps(pageRect),
+ isTrue,
+ reason: 'Page $targetPage should overlap viewport after jump',
+ );
+
+ final currentPixels =
+ tester.state(scrollableFinder).position.pixels;
+ // Ensure scroll position changed (direction not enforced)
+ expect(currentPixels, isNot(equals(before)));
+ lastPixels = currentPixels;
+ }
+
+ // Jump to 4 different pages and verify each
+ await jumpAndVerify(5);
+ await jumpAndVerify(1);
+ await jumpAndVerify(6);
+ await jumpAndVerify(3);
+ },
+ );
+}
+
+void _noop() {}
+void _noopInt(int? _) {}
+void _noopOffset(Offset _) {}