Compare commits

..

No commits in common. "9e0ae1dcfe8cf55d9d5e0666706626cabcf0aec7" and "b2bf489af09e367ee52ca0dbc34fa87a50c4c7e9" have entirely different histories.

23 changed files with 104 additions and 1460 deletions

File diff suppressed because it is too large Load Diff

View File

@ -78,23 +78,6 @@ Illustration:
---
### Mobile PDF screen (phone)
Purpose: compact PDF viewing and signature placement on small screens.
Route: root --> opened (mobile)
Design notes:
- Top app bar with: menu (page thumbnails), title with current page and total pages (prev/next).
- Center viewport supports scroll and pinch-zoom.
- Bottom sheet button to show Signatures drawers.
- bottom drawer to add/drag a signature onto the page.
Illustration:
![](wireframe.assets/mobile_pdf_phone.excalidraw)
---
## How to view and export
We keep links in this file pointing to `.excalidraw`. To preview the SVGs and generate `docs/.wireframe.md` with `.svg` links, run from repo root:

View File

@ -182,7 +182,6 @@ class DocumentStateNotifier extends StateNotifier<Document> {
);
if (result != null) return result;
} catch (_) {
debugPrint('Warning: export in isolate failed');
// Fall back to main-isolate export if isolate fails (e.g., engine limitations).
}

View File

@ -6,7 +6,6 @@ import 'package:image/image.dart' as img;
import 'package:pdf/widgets.dart' as pw;
import 'package:pdf/pdf.dart' as pdf;
import 'package:pdfrx_engine/pdfrx_engine.dart' as engine;
import 'package:pdfrx/pdfrx.dart' show pdfrxFlutterInitialize;
import '../../domain/models/model.dart';
import '../../utils/rotation_utils.dart' as rot;
import '../../utils/background_removal.dart' as br;
@ -105,14 +104,15 @@ class ExportService {
}
// Initialize engine (safe to call multiple times)
pdfrxFlutterInitialize();
try {
await engine.pdfrxInitialize();
} catch (_) {}
// Open source document from memory; if not supported, write temp file
engine.PdfDocument? doc;
try {
doc = await engine.PdfDocument.openData(srcBytes);
} catch (_) {
debugPrint('Warning: pdfrx openData failed');
final tmp = File(
'${Directory.systemTemp.path}/pdfrx_src_${DateTime.now().millisecondsSinceEpoch}.pdf',
);
@ -120,9 +120,7 @@ class ExportService {
doc = await engine.PdfDocument.openFile(tmp.path);
try {
tmp.deleteSync();
} catch (_) {
debugPrint('Warning: temp file delete failed');
}
} catch (_) {}
}
// doc is guaranteed to be assigned by either openData or openFile above
@ -223,7 +221,6 @@ class ExportService {
final bytes = await out.save();
doc.dispose();
debugPrint('exportSignedPdfFromBytes succeeded');
return bytes;
}
@ -236,7 +233,6 @@ class ExportService {
await file.writeAsBytes(bytes, flush: true);
return true;
} catch (_) {
debugPrint('Error: saveBytesToFile failed');
return false;
}
}

View File

@ -325,9 +325,7 @@ class PdfSessionViewModel extends ChangeNotifier {
ref
.read(documentRepositoryProvider.notifier)
.openPicked(pageCount: pageCount, bytes: bytes);
// Keep existing signature cards when opening a new document.
// The feature "Open a different document will reset signature placements but keep signature cards"
// relies on this behavior. Placements are reset by openPicked() above.
ref.read(signatureCardRepositoryProvider.notifier).clearAll();
router.go('/pdf');
notifyListeners();
}

View File

@ -221,8 +221,6 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
if (out != null) {
ok = await downloadBytes(out, filename: suggested);
savedPath = suggested;
} else {
debugPrint('_saveSignedPdf: export to bytes failed');
}
}
if (!kIsWeb) {
@ -237,6 +235,7 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
),
),
);
// ignore: avoid_print
debugPrint('_saveSignedPdf: SnackBar shown ok=' + ok.toString());
} else {
messenger.showSnackBar(
@ -313,17 +312,9 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
max: _pagesMax,
builder:
(context, area) => Offstage(
offstage: () {
try {
return !(ResponsiveBreakpoints.of(
context,
).largerThan(MOBILE) &&
_showPagesSidebar);
} catch (_) {
// In test environments without ResponsiveBreakpoints, default to showing
return !_showPagesSidebar;
}
}(),
offstage:
!(ResponsiveBreakpoints.of(context).largerThan(MOBILE) &&
_showPagesSidebar),
child: Consumer(
builder: (context, ref, child) {
final pdfViewModel = ref.watch(pdfViewModelProvider);
@ -473,13 +464,6 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
Widget _buildScaffold(BuildContext context) {
final isExporting = ref.watch(pdfExportViewModelProvider).exporting;
final l = AppLocalizations.of(context);
// Defensive flag for tests not wrapped in ResponsiveBreakpoints
bool largerThanMobile;
try {
largerThanMobile = ResponsiveBreakpoints.of(context).largerThan(MOBILE);
} catch (_) {
largerThanMobile = true;
}
return Scaffold(
body: Padding(
padding: const EdgeInsets.all(12),
@ -540,28 +524,6 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
_applySidebarVisibility();
}),
),
// Optional quick toggle for pages sidebar on larger screens
if (largerThanMobile)
Align(
alignment: Alignment.centerLeft,
child: SizedBox(
height: 0,
width: 0,
child: Offstage(
offstage: true,
child: IconButton(
key: const Key('btn_toggle_pages_sidebar_hidden'),
onPressed: () {
setState(() {
_showPagesSidebar = !_showPagesSidebar;
_applySidebarVisibility();
});
},
icon: const Icon(Icons.view_sidebar),
),
),
),
),
// Expose a compact signature drawer trigger area for tests when sidebar hidden
if (!_showSignaturesSidebar)
Align(

View File

@ -68,14 +68,9 @@ class _PdfToolbarState extends ConsumerState<PdfToolbar> {
builder: (context, constraints) {
final bool compact = constraints.maxWidth < 260;
final double gotoWidth = 50;
// Be defensive in tests that don't provide ResponsiveBreakpoints
final bool isLargerThanMobile = () {
try {
return ResponsiveBreakpoints.of(context).largerThan(MOBILE);
} catch (_) {
return true; // default to full toolbar on tests/minimal hosts
}
}();
final bool isLargerThanMobile = ResponsiveBreakpoints.of(
context,
).largerThan(MOBILE);
final String fileDisplay = () {
final path = widget.filePath;
if (path == null || path.isEmpty) return 'No file selected';
@ -147,7 +142,7 @@ class _PdfToolbarState extends ConsumerState<PdfToolbar> {
),
],
),
if (isLargerThanMobile)
if (ResponsiveBreakpoints.of(context).largerThan(MOBILE))
Wrap(
spacing: 6,
runSpacing: 4,
@ -181,7 +176,7 @@ class _PdfToolbarState extends ConsumerState<PdfToolbar> {
],
),
if (isLargerThanMobile) ...[
if (ResponsiveBreakpoints.of(context).largerThan(MOBILE)) ...[
const SizedBox(width: 8),
Wrap(
crossAxisAlignment: WrapCrossAlignment.center,
@ -217,7 +212,7 @@ class _PdfToolbarState extends ConsumerState<PdfToolbar> {
return Row(
children: [
if (isLargerThanMobile) ...[
if (ResponsiveBreakpoints.of(context).largerThan(MOBILE)) ...[
IconButton(
key: const Key('btn_toggle_pages_sidebar'),
tooltip: 'Toggle pages overview',

View File

@ -1,17 +1,11 @@
import 'package:flutter/foundation.dart';
import 'dart:typed_data';
// On modern Flutter Web (Wasm GC, e.g., Chromium), dart:html is not available.
// Use js_interop capability to select the web implementation that relies on
// package:web instead of dart:html.
import 'download_stub.dart'
if (dart.library.js_interop) 'download_web.dart'
as impl;
import 'download_stub.dart' if (dart.library.html) 'download_web.dart' as impl;
/// Initiates a platform-appropriate download/save operation.
///
/// On Web: triggers a browser download with the provided filename.
/// On non-Web: returns false (no-op). Use your existing IO save flow instead.
Future<bool> downloadBytes(Uint8List bytes, {required String filename}) {
debugPrint('downloadBytes: initiating download');
return impl.downloadBytes(bytes, filename: filename);
}

View File

@ -1,9 +1,6 @@
import 'dart:typed_data';
import 'package:flutter/widgets.dart';
Future<bool> downloadBytes(Uint8List bytes, {required String filename}) async {
// Not supported on non-web. Return false so caller can fallback to file save.
debugPrint('downloadBytes: not supported on this platform');
return false;
}

View File

@ -1,28 +1,23 @@
// Implementation for Web using package:web to support Wasm GC (Chromium)
// without importing dart:html directly.
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:web/web.dart' as web;
// ignore_for_file: deprecated_member_use
// ignore: avoid_web_libraries_in_flutter
import 'dart:html' as html;
import 'dart:typed_data';
Future<bool> downloadBytes(Uint8List bytes, {required String filename}) async {
try {
// Use a data URL to avoid Blob/typed array interop issues under Wasm GC.
final url = 'data:application/pdf;base64,${base64Encode(bytes)}';
// Create an anchor element and trigger a click to download
final blob = html.Blob([bytes], 'application/pdf');
final url = html.Url.createObjectUrlFromBlob(blob);
final anchor =
web.HTMLAnchorElement()
html.document.createElement('a') as html.AnchorElement
..href = url
..download = filename
..style.display = 'none';
web.document.body?.append(anchor);
html.document.body?.children.add(anchor);
anchor.click();
anchor.remove();
html.Url.revokeObjectUrl(url);
return true;
} catch (e, st) {
debugPrint('Error: downloadBytes failed: $e\n$st');
} catch (_) {
return false;
}
}

View File

@ -62,7 +62,6 @@ dependencies:
responsive_framework: ^1.5.1
# disable_web_context_menu: ^1.1.0
# ml_linalg: ^13.12.6
web: ^1.1.1
dev_dependencies:
flutter_test:

View File

@ -9,6 +9,13 @@ Feature: document browser
And the user can move to the next or previous page
And the page label shows "Page {1} of {5}"
Scenario: Jump to a specific page by typing Enter
Given the document is open
When the user types {3} into the Go to input and presses Enter
Then page {3} is displayed
And the page label shows "Page {3} of {5}"
And the left pages overview highlights page {3}
Scenario: Jump to a specific page using the Apply button
Given the document is open
When the user types {4} into the Go to input
@ -22,6 +29,15 @@ Feature: document browser
Then page {2} is displayed
And the page label shows "Page {2} of {5}"
Scenario: Continuous mode scrolls target page into view on jump
Given the document is open
And the Page view mode is set to Continuous
When the user jumps to page {5}
Then page {5} becomes visible in the scroll area
And the left pages overview highlights page {5}
Scenario: Go to clamps out-of-range inputs to valid bounds
Given the document is open
When the user enters {0} into the Go to input and applies it
@ -34,14 +50,3 @@ Feature: document browser
Scenario: Go to is disabled when no document is loaded
Given no document is open
Then the Go to input cannot be used
Scenario: Open a different document will reset signature placements but keep signature cards
Given the document is open
When the user opens a different document with {3} pages
And {1} signature placements exist on page {1}
And {1} signature placements exist on page {2}
And {2} signature cards exist
Then the first page of the new document is displayed
And the page label shows "Page {1} of {3}"
And number of signature placements is {0}
And {2} signature cards exist

View File

@ -36,9 +36,6 @@ class TestWorld {
// Generic flags/values
static int? selectedPage;
static int? pendingGoTo; // for simulating typed Go To value across steps
static int?
nextDocPageCount; // for BDD: desired page count for the next opened document
static Map<int, int>? prevPlacementsCount; // snapshot before an action
// Preferences & settings
static Map<String, String> prefs = {};
@ -64,8 +61,6 @@ class TestWorld {
nothingToSaveAttempt = false;
selectedPage = null;
pendingGoTo = null;
nextDocPageCount = null;
prevPlacementsCount = null;
// Preferences
prefs = {};

View File

@ -1,33 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:pdf_signature/data/repositories/document_repository.dart';
import 'package:pdf_signature/data/repositories/signature_card_repository.dart';
import '_world.dart';
/// Usage: number of signature placements is {0}
Future<void> numberOfSignaturePlacementsIs(
WidgetTester tester,
num param1,
) async {
final expected = param1.toInt();
final c = TestWorld.container ?? ProviderContainer();
final doc = c.read(documentRepositoryProvider);
final total = doc.placementsByPage.values.fold<int>(
0,
(sum, list) => sum + list.length,
);
expect(total, expected);
// If we had previous placements recorded, ensure they were non-zero to
// validate that a reset actually happened when opening a different doc.
if (TestWorld.prevPlacementsCount != null &&
TestWorld.prevPlacementsCount!.isNotEmpty) {
final prevTotal = TestWorld.prevPlacementsCount!.values.fold<int>(
0,
(sum, v) => sum + v,
);
expect(prevTotal, greaterThan(0));
}
// Also verify that signature cards still exist (persistence across open).
final cards = c.read(signatureCardRepositoryProvider);
expect(cards.length, greaterThanOrEqualTo(1));
}

View File

@ -0,0 +1,14 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:pdf_signature/ui/features/pdf/view_model/pdf_view_model.dart';
import '_world.dart';
/// Usage: page {5} becomes visible in the scroll area
Future<void> pageBecomesVisibleInTheScrollArea(
WidgetTester tester,
num param1,
) async {
final page = param1.toInt();
final c = TestWorld.container ?? ProviderContainer();
expect(c.read(pdfViewModelProvider).currentPage, page);
}

View File

@ -1,12 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:pdf_signature/data/repositories/signature_card_repository.dart';
import '_world.dart';
/// Usage: {2} signature cards exist
Future<void> signatureCardsExist(WidgetTester tester, num param1) async {
final expected = param1.toInt();
final c = TestWorld.container ?? ProviderContainer();
final cards = c.read(signatureCardRepositoryProvider);
expect(cards.length, expected);
}

View File

@ -1,17 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import '_world.dart';
/// Usage: {1} signature placements exist on page {2}
Future<void> signaturePlacementsExistOnPage(
WidgetTester tester,
num param1,
num param2,
) async {
final expected = param1.toInt();
final page = param2.toInt();
// Record the expectation as part of scenario context instead of asserting
// against current state (the scenario describes placements in the previous
// document before opening a new one).
TestWorld.prevPlacementsCount ??= {};
TestWorld.prevPlacementsCount![page] = expected;
}

View File

@ -3,10 +3,12 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:pdf_signature/ui/features/pdf/view_model/pdf_view_model.dart';
import '_world.dart';
/// Usage: the first page of the new document is displayed
Future<void> theFirstPageOfTheNewDocumentIsDisplayed(
/// Usage: the left pages overview highlights page {5}
Future<void> theLeftPagesOverviewHighlightsPage(
WidgetTester tester,
num param1,
) async {
final n = param1.toInt();
final c = TestWorld.container ?? ProviderContainer();
expect(c.read(pdfViewModelProvider).currentPage, 1);
expect(c.read(pdfViewModelProvider).currentPage, n);
}

View File

@ -0,0 +1,8 @@
import 'package:flutter_test/flutter_test.dart';
import '_world.dart';
/// Usage: the Page view mode is set to Continuous
Future<void> thePageViewModeIsSetToContinuous(WidgetTester tester) async {
// Logic-level test: no widget tree; just mark a flag if needed
TestWorld.prefs['page_view'] = 'continuous';
}

View File

@ -0,0 +1,15 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:pdf_signature/ui/features/pdf/view_model/pdf_view_model.dart';
import '_world.dart';
/// Usage: the user jumps to page {2}
Future<void> theUserJumpsToPage(WidgetTester tester, num param1) async {
final page = param1.toInt();
final c = TestWorld.container ?? ProviderContainer();
try {
c.read(pdfViewModelProvider).jumpToPage(page);
} catch (_) {}
await tester.pump();
}

View File

@ -1,43 +0,0 @@
import 'package:image/image.dart' as img;
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:pdf_signature/data/repositories/document_repository.dart';
import 'package:pdf_signature/data/repositories/signature_card_repository.dart';
import 'package:pdf_signature/domain/models/model.dart';
import 'package:pdf_signature/ui/features/pdf/view_model/pdf_view_model.dart';
import '_world.dart';
/// Usage: the user opens a different document with {3} pages
Future<void> theUserOpensADifferentDocumentWithPages(
WidgetTester tester,
num param1,
) async {
final pageCount = param1.toInt();
final container = TestWorld.container ?? ProviderContainer();
TestWorld.container = container;
// Simulate "open a different document": reset placements and set page count.
container
.read(documentRepositoryProvider.notifier)
.openPicked(pageCount: pageCount);
// Ensure there are 2 signature cards available as per scenario.
final cards = container.read(signatureCardRepositoryProvider);
if (cards.length < 2) {
final notifier = container.read(signatureCardRepositoryProvider.notifier);
while (container.read(signatureCardRepositoryProvider).length < 2) {
notifier.add(
SignatureCard(
asset: SignatureAsset(
sigImage: img.Image(width: 1, height: 1),
name: 'sig.png',
),
rotationDeg: 0,
graphicAdjust: const GraphicAdjust(),
),
);
}
}
// Moving to a new document should show page 1.
container.read(pdfViewModelProvider).currentPage = 1;
await tester.pumpAndSettle();
}

View File

@ -0,0 +1,19 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:pdf_signature/ui/features/pdf/view_model/pdf_view_model.dart';
import '_world.dart';
/// Usage: the user types {3} into the Go to input and presses Enter
Future<void> theUserTypesIntoTheGoToInputAndPressesEnter(
WidgetTester tester,
num param1,
) async {
final target = param1.toInt();
final c = TestWorld.container ?? ProviderContainer();
TestWorld.container = c;
try {
c.read(pdfViewModelProvider.notifier).jumpToPage(target);
} catch (_) {}
await tester.pump();
}