Compare commits
No commits in common. "fc6e56c9ee012bf3d32f8da387219ff938dd5d64" and "ad37861303b5d34b84f0cdb1057a2e85eae16def" have entirely different histories.
fc6e56c9ee
...
ad37861303
|
@ -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
|
|
||||||
|
|
|
@ -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.
|
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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 |
|
||||||
|
|
|
@ -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' |
|
|
||||||
|
|
|
@ -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;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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'};
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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);
|
|
||||||
}
|
|
|
@ -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);
|
|
||||||
}
|
|
|
@ -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 _) {}
|
|
|
@ -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 _) {}
|
|
Loading…
Reference in New Issue