Merge branch 'feat/util' into feat/multi_signature_picture
This commit is contained in:
commit
5ae266d008
|
@ -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
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
#!/bin/sh
|
||||
|
||||
export LD_LIBRARY_PATH="${APPDIR}/bundle/lib"
|
||||
exec $APPDIR/bundle/pdf_signature "$@"
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg version="1.1" id="_x32_" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
viewBox="0 0 512 512" xml:space="preserve">
|
||||
<style type="text/css">
|
||||
.st0{fill:#000000;}
|
||||
</style>
|
||||
<g>
|
||||
<path class="st0" d="M56.007,114.35c-5.535-5.539-14.51-5.539-20.045,0L4.148,146.159c-5.531,5.539-5.531,14.506,0,20.046
|
||||
l20.622,20.621l51.859-51.855L56.007,114.35z"/>
|
||||
<polygon class="st0" points="286.422,396.623 268.742,327.077 216.884,378.94 "/>
|
||||
<path class="st0" d="M258.136,316.475L86.058,144.397L34.2,196.26l172.073,172.077L258.136,316.475z M87.468,166.56
|
||||
l149.919,149.922l-11.784,11.78L75.684,178.348L87.468,166.56z"/>
|
||||
<rect x="195.662" y="132.491" class="st0" width="29.356" height="28.017"/>
|
||||
<rect x="195.662" y="200.693" class="st0" width="29.356" height="28.009"/>
|
||||
<rect x="256.69" y="132.491" class="st0" width="173.056" height="28.017"/>
|
||||
<rect x="256.69" y="200.693" class="st0" width="173.056" height="28.009"/>
|
||||
<rect x="288.598" y="268.894" class="st0" width="141.148" height="28.01"/>
|
||||
<path class="st0" d="M429.817,11.059H195.582c-45.32,0-82.182,36.858-82.182,82.179v32.726l30.427,30.435V93.238
|
||||
c0-28.586,23.178-51.752,51.755-51.752h234.235c28.594,0,51.756,23.166,51.756,51.752v254.042h-80.097
|
||||
c-23.822,0-43.124,19.318-43.124,43.132v80.101h-162.77c-28.578,0-51.755-23.166-51.755-51.752v-37.072l-6.234-1.587l6.234-6.235
|
||||
v-22.202L113.4,321.239v97.522c0,45.313,36.862,82.179,82.182,82.179h162.77h12.598l8.917-8.913l123.224-123.224l8.909-8.912
|
||||
v-12.61V93.238C512,47.917,475.138,11.059,429.817,11.059z"/>
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 1.7 KiB |
|
@ -0,0 +1,7 @@
|
|||
[Desktop Entry]
|
||||
Version=1.0
|
||||
Type=Application
|
||||
Name=pdf_signature
|
||||
Exec=AppRun %U
|
||||
Icon=pdf_signature-icon
|
||||
Categories=Utility
|
|
@ -33,8 +33,29 @@ class PdfPageArea extends ConsumerStatefulWidget {
|
|||
}
|
||||
|
||||
class _PdfPageAreaState extends ConsumerState<PdfPageArea> {
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
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(
|
||||
page,
|
||||
|
@ -43,88 +64,135 @@ class _PdfPageAreaState extends ConsumerState<PdfPageArea> {
|
|||
|
||||
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<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) {
|
||||
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<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
|
||||
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<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;
|
||||
_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<PdfPageArea> {
|
|||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 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<void>.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<PdfPageArea> {
|
|||
},
|
||||
);
|
||||
}
|
||||
|
||||
// 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<void>.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<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;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Widget _buildPageOverlays(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
|
|
|
@ -12,21 +12,30 @@ Future<void> theAppSupportsLanguages(
|
|||
// Accept either a DataTable from bdd_widget_test or a string like "{en, zh-TW, es}"
|
||||
final Set<String> 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
|
||||
|
|
|
@ -0,0 +1,23 @@
|
|||
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);
|
||||
}
|
|
@ -0,0 +1,23 @@
|
|||
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);
|
||||
}
|
|
@ -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<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 _) {}
|
|
@ -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<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