Compare commits

..

No commits in common. "fc6e56c9ee012bf3d32f8da387219ff938dd5d64" and "ad37861303b5d34b84f0cdb1057a2e85eae16def" have entirely different histories.

12 changed files with 162 additions and 592 deletions

View File

@ -1,5 +1,4 @@
# Non-Functional Requirements # Non-Functional Requirements
* support multiple platforms (windows, linux, android, web) * Package structure
* only FOSS libs can use * plz follow official [Package structure](https://docs.flutter.dev/app-architecture/case-study#package-structure) with a slight modification, put each `<FEATURE NAME>/`s in `features/` sub-directory under `ui/`.
* recommend no more than 300 lines of code per file

View File

@ -2,10 +2,3 @@
* [MVVM](https://docs.flutter.dev/app-architecture/guide) * [MVVM](https://docs.flutter.dev/app-architecture/guide)
## Package structure
The repo structure follows official [Package structure](https://docs.flutter.dev/app-architecture/case-study#package-structure) with slight modifications.
* put each `<FEATURE NAME>/`s in `features/` sub-directory under `ui/`.
* `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` of MVVM only.

View File

@ -33,29 +33,8 @@ class PdfPageArea extends ConsumerStatefulWidget {
} }
class _PdfPageAreaState extends ConsumerState<PdfPageArea> { class _PdfPageAreaState extends ConsumerState<PdfPageArea> {
final ScrollController _scrollController = ScrollController();
final Map<int, GlobalKey> _pageKeys = {}; final Map<int, GlobalKey> _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( GlobalKey _pageKey(int page) => _pageKeys.putIfAbsent(
page, page,
@ -64,135 +43,88 @@ class _PdfPageAreaState extends ConsumerState<PdfPageArea> {
void _scrollToPage(int page) { void _scrollToPage(int page) {
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
final pdf = ref.read(pdfProvider); final key = _pageKey(page);
final isContinuous = ref.read(pageViewModeProvider) == 'continuous'; final ctx = key.currentContext;
// 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<void>.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) { 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( Scrollable.ensureVisible(
ctx, ctx,
duration: const Duration(milliseconds: 250),
curve: Curves.easeInOut,
alignment: 0.1, 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<void> _showContextMenuForPlaced({
required BuildContext context,
required WidgetRef ref,
required Offset globalPos,
required int index,
required int page,
}) async {
widget.onSelectPlaced(index);
final choice = await showMenu<String>(
context: context,
position: RelativeRect.fromLTRB(
globalPos.dx,
globalPos.dy,
globalPos.dx,
globalPos.dy,
),
items: [
PopupMenuItem<String>(
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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final pdf = ref.watch(pdfProvider); final pdf = ref.watch(pdfProvider);
final pageViewMode = ref.watch(pageViewModeProvider); 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) { ref.listen(pdfProvider, (prev, next) {
final mode = ref.read(pageViewModeProvider); final mode = ref.read(pageViewModeProvider);
if (_suppressProviderListen) return;
if (mode == 'continuous' && (prev?.currentPage != next.currentPage)) { if (mode == 'continuous' && (prev?.currentPage != next.currentPage)) {
final target = next.currentPage; _scrollToPage(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) { ref.listen<String>(pageViewModeProvider, (prev, next) {
if (next == 'continuous') { 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; final p = ref.read(pdfProvider).currentPage;
if (_visiblePage != p) {
_scrollToPage(p); _scrollToPage(p);
} }
}
}); });
if (!pdf.loaded) { if (!pdf.loaded) {
return Center(child: Text(AppLocalizations.of(context).noPdfLoaded)); return Center(child: Text(AppLocalizations.of(context).noPdfLoaded));
} }
final useMock = ref.watch(useMockViewerProvider); final useMock = ref.watch(useMockViewerProvider);
final isContinuous = pageViewMode == 'continuous'; final isContinuous = pageViewMode == 'continuous';
if (isContinuous) {
// Mock single-page // Make sure the current page is visible after first build of continuous list.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
_scrollToPage(pdf.currentPage);
});
}
if (useMock && !isContinuous) { if (useMock && !isContinuous) {
return Center( return Center(
child: AspectRatio( child: AspectRatio(
@ -236,40 +168,21 @@ class _PdfPageAreaState extends ConsumerState<PdfPageArea> {
), ),
); );
} }
// 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 ListView.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<void>.delayed(const Duration(milliseconds: 1), () {
if (!mounted) return;
_scrollToPage(p);
});
}
});
}
return SingleChildScrollView(
key: const Key('pdf_continuous_mock_list'), key: const Key('pdf_continuous_mock_list'),
controller: _scrollController,
padding: const EdgeInsets.symmetric(vertical: 8), padding: const EdgeInsets.symmetric(vertical: 8),
child: Column( itemCount: count,
children: List.generate(count, (idx) { itemBuilder: (context, idx) {
final pageNum = idx + 1; final pageNum = idx + 1;
return Center( return Center(
child: Padding( child: Padding(
key: _pageKey(pageNum), key: _pageKey(pageNum),
padding: const EdgeInsets.symmetric(vertical: 8), padding: const EdgeInsets.symmetric(vertical: 8),
child: AspectRatio( child: AspectRatio(
aspectRatio: aspectRatio: widget.pageSize.width / widget.pageSize.height,
widget.pageSize.width / widget.pageSize.height,
child: Stack( child: Stack(
key: ValueKey('page_stack_$pageNum'), key: ValueKey('page_stack_$pageNum'),
children: [ children: [
@ -277,9 +190,7 @@ class _PdfPageAreaState extends ConsumerState<PdfPageArea> {
color: Colors.grey.shade200, color: Colors.grey.shade200,
child: Center( child: Center(
child: Text( child: Text(
AppLocalizations.of( AppLocalizations.of(context).pageInfo(pageNum, count),
context,
).pageInfo(pageNum, count),
style: const TextStyle( style: const TextStyle(
fontSize: 24, fontSize: 24,
color: Colors.black54, color: Colors.black54,
@ -290,16 +201,9 @@ class _PdfPageAreaState extends ConsumerState<PdfPageArea> {
Consumer( Consumer(
builder: (context, ref, _) { builder: (context, ref, _) {
final sig = ref.watch(signatureProvider); final sig = ref.watch(signatureProvider);
final visible = ref.watch( final visible = ref.watch(signatureVisibilityProvider);
signatureVisibilityProvider,
);
return visible return visible
? _buildPageOverlays( ? _buildPageOverlays(context, ref, sig, pageNum)
context,
ref,
sig,
pageNum,
)
: const SizedBox.shrink(); : const SizedBox.shrink();
}, },
), ),
@ -308,14 +212,9 @@ class _PdfPageAreaState extends ConsumerState<PdfPageArea> {
), ),
), ),
); );
}),
),
);
}, },
); );
} }
// Real single-page mode
if (pdf.pickedPdfPath != null && !isContinuous) { if (pdf.pickedPdfPath != null && !isContinuous) {
return PdfDocumentViewBuilder.file( return PdfDocumentViewBuilder.file(
pdf.pickedPdfPath!, pdf.pickedPdfPath!,
@ -367,110 +266,65 @@ class _PdfPageAreaState extends ConsumerState<PdfPageArea> {
}, },
); );
} }
// Real continuous mode (pdfrx): copy example patterns
if (pdf.pickedPdfPath != null && isContinuous) { if (pdf.pickedPdfPath != null && isContinuous) {
return PdfViewer.file( return PdfDocumentViewBuilder.file(
pdf.pickedPdfPath!, pdf.pickedPdfPath!,
controller: _viewerController, builder: (context, document) {
params: PdfViewerParams( if (document == null) {
pageAnchor: PdfPageAnchor.top, return const Center(child: CircularProgressIndicator());
onViewerReady: (doc, controller) {
if (pdf.pageCount != doc.pages.length) {
ref.read(pdfProvider.notifier).setPageCount(doc.pages.length);
} }
final target = _pendingPage ?? pdf.currentPage; final pages = document.pages;
_pendingPage = null; if (pdf.pageCount != pages.length) {
_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((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return; ref.read(pdfProvider.notifier).setPageCount(pages.length);
Future<void>.delayed(const Duration(milliseconds: 120), () {
if (!mounted) return;
if (_programmaticTargetPage == target) {
_programmaticTargetPage = null;
}
});
});
// 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 ListView.builder(
} key: const Key('pdf_continuous_list'),
return; controller: _scrollController,
} padding: const EdgeInsets.symmetric(vertical: 8),
// User scroll -> reflect page to provider without re-triggering scroll itemCount: pages.length,
if (n != ref.read(pdfProvider).currentPage) { itemBuilder: (context, idx) {
_suppressProviderListen = true; final pageNum = idx + 1;
ref.read(pdfProvider.notifier).jumpTo(n); final page = pages[idx];
WidgetsBinding.instance.addPostFrameCallback((_) { final aspect = page.width / page.height;
_suppressProviderListen = false; 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)
return const SizedBox.shrink(); : 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),
), ),
], ],
).then((choice) { ),
switch (choice) { ),
case 'delete': ),
ref );
.read(pdfProvider.notifier) },
.removePlacement(page: page, index: index); );
break; },
default: );
break;
} }
}); return const SizedBox.shrink();
} }
Widget _buildPageOverlays( Widget _buildPageOverlays(

View File

@ -4,7 +4,7 @@ Feature: App preferences
Given the settings screen is open Given the settings screen is open
When the user selects the "<theme>" theme When the user selects the "<theme>" theme
Then the app UI updates to use the "<theme>" theme Then the app UI updates to use the "<theme>" theme
And the preference {'theme'} is saved as <theme> And the preference {theme} is saved as {"<theme>"}
Examples: Examples:
| theme | | theme |
@ -16,7 +16,7 @@ Feature: App preferences
Given the settings screen is open Given the settings screen is open
When the user selects a supported language "<language>" When the user selects a supported language "<language>"
Then all visible texts are displayed in "<language>" Then all visible texts are displayed in "<language>"
And the preference {'language'} is saved as <language> And the preference {language} is saved as {"<language>"}
Examples: Examples:
| language | | language |

View File

@ -10,7 +10,4 @@ Feature: internationalizing
Then the language falls back to the device locale Then the language falls back to the device locale
Scenario: Supported languages are available Scenario: Supported languages are available
Then the app supports languages Then the app supports languages {en, zh-TW, es}
| 'en' |
| 'zh-TW' |
| 'es' |

View File

@ -17,11 +17,4 @@ Future<void> aSignatureImageIsSelected(WidgetTester tester) async {
.setImageBytes(Uint8List.fromList([1, 2, 3])); .setImageBytes(Uint8List.fromList([1, 2, 3]));
// Allow provider scheduler to process queued updates fully // Allow provider scheduler to process queued updates fully
await tester.pumpAndSettle(); await tester.pumpAndSettle();
// Extra pump with a non-zero duration to flush zero-delay timers
await tester.pump(const Duration(milliseconds: 1));
// Teardown to avoid pending timers from Riverpod's scheduler
addTearDown(() {
TestWorld.container?.dispose();
TestWorld.container = null;
});
} }

View File

@ -1,42 +1,17 @@
import 'package:bdd_widget_test/data_table.dart' as bdd;
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
/// Usage: the app supports languages /// Usage: the app supports languages {en, zh-TW, es}
/// | 'en' |
/// | 'zh-TW' |
/// | 'es' |
Future<void> theAppSupportsLanguages( Future<void> theAppSupportsLanguages(
WidgetTester tester, WidgetTester tester,
dynamic languages, String languages,
) async { ) async {
// Accept either a DataTable from bdd_widget_test or a string like "{en, zh-TW, es}" // Normalize the example token string "{en, zh-TW, es}" into a set
final Set<String> expected; final raw = languages.trim();
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;
} else {
final raw = languages.toString().trim();
final inner = final inner =
raw.startsWith('{') && raw.endsWith('}') raw.startsWith('{') && raw.endsWith('}')
? raw.substring(1, raw.length - 1) ? raw.substring(1, raw.length - 1)
: raw; : raw;
expected = final expected = inner.split(',').map((s) => s.trim()).toSet();
inner.split(',').map((s) => s.trim().replaceAll("'", '')).toSet();
}
// Keep this in sync with the app's supported locales // Keep this in sync with the app's supported locales
const actual = {'en', 'zh-TW', 'es'}; const actual = {'en', 'zh-TW', 'es'};

View File

@ -1,7 +1,5 @@
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import '_world.dart'; import '_world.dart';
// Re-export tokens so tests that import this step have access to token symbols
export '_tokens.dart';
/// Usage: the preference {language} is saved as {"<language>"} /// Usage: the preference {language} is saved as {"<language>"}
Future<void> thePreferenceIsSavedAs( Future<void> thePreferenceIsSavedAs(

View File

@ -1,23 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import '_world.dart';
/// Usage: the preference 'language' is saved as {"<language>"}
Future<void> 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);
}

View File

@ -1,23 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import '_world.dart';
/// Usage: the preference 'theme' is saved as {"<theme>"}
Future<void> 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);
}

View File

@ -1,87 +0,0 @@
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<ScrollableState>(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 _) {}

View File

@ -1,106 +0,0 @@
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<ScrollableState>(scrollableFinder).position.pixels;
Future<void> 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<ScrollableState>(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 _) {}