feat: support export functionality on web by download

This commit is contained in:
insleker 2025-09-18 18:04:43 +08:00
parent 41eea5f00c
commit a08f93e8d4
6 changed files with 156 additions and 9 deletions

View File

@ -30,6 +30,30 @@ class RecordingExporter extends ExportService {
} }
} }
// Lightweight fake exporter to avoid invoking heavy rasterization during tests
class LightweightExporter extends ExportService {
@override
Future<Uint8List?> exportSignedPdfFromBytes({
required Uint8List srcBytes,
required Size uiPageSize,
required Uint8List? signatureImageBytes,
Map<int, List<SignaturePlacement>>? placementsByPage,
Map<String, Uint8List>? libraryBytes,
double targetDpi = 144.0,
}) async {
// Return minimal non-empty bytes; content isn't used further in tests
return Uint8List.fromList([1, 2, 3]);
}
@override
Future<bool> saveBytesToFile({
required Uint8List bytes,
required String outputPath,
}) async {
return true;
}
}
void main() { void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized(); IntegrationTestWidgetsFlutterBinding.ensureInitialized();
@ -225,13 +249,13 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
final ctx = tester.element(find.byType(PdfSignatureHomePage)); final ctx = tester.element(find.byType(PdfSignatureHomePage));
final container = ProviderScope.containerOf(ctx); final container = ProviderScope.containerOf(ctx);
expect(container.read(pdfViewModelProvider), 1); expect(container.read(pdfViewModelProvider).currentPage, 1);
container.read(pdfViewModelProvider.notifier).jumpToPage(2); container.read(pdfViewModelProvider.notifier).jumpToPage(2);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(container.read(pdfViewModelProvider), 2); expect(container.read(pdfViewModelProvider).currentPage, 2);
container.read(pdfViewModelProvider.notifier).jumpToPage(3); container.read(pdfViewModelProvider.notifier).jumpToPage(3);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(container.read(pdfViewModelProvider), 3); expect(container.read(pdfViewModelProvider).currentPage, 3);
}); });
testWidgets('PDF View: zoom in/out', (tester) async { testWidgets('PDF View: zoom in/out', (tester) async {
@ -319,7 +343,7 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
final ctx = tester.element(find.byType(PdfSignatureHomePage)); final ctx = tester.element(find.byType(PdfSignatureHomePage));
final container = ProviderScope.containerOf(ctx); final container = ProviderScope.containerOf(ctx);
expect(container.read(pdfViewModelProvider), 1); expect(container.read(pdfViewModelProvider).currentPage, 1);
final pagesSidebar = find.byType(PagesSidebar); final pagesSidebar = find.byType(PagesSidebar);
expect(pagesSidebar, findsOneWidget); expect(pagesSidebar, findsOneWidget);
@ -332,7 +356,7 @@ void main() {
expect(page3Thumb, findsOneWidget); expect(page3Thumb, findsOneWidget);
await tester.tap(page3Thumb); await tester.tap(page3Thumb);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(container.read(pdfViewModelProvider), 3); expect(container.read(pdfViewModelProvider).currentPage, 3);
}); });
testWidgets('PDF View: thumbnails scroll and select', (tester) async { testWidgets('PDF View: thumbnails scroll and select', (tester) async {
@ -371,15 +395,70 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
final ctx = tester.element(find.byType(PdfSignatureHomePage)); final ctx = tester.element(find.byType(PdfSignatureHomePage));
final container = ProviderScope.containerOf(ctx); final container = ProviderScope.containerOf(ctx);
expect(container.read(pdfViewModelProvider), 1); expect(container.read(pdfViewModelProvider).currentPage, 1);
final sidebar = find.byType(PagesSidebar); final sidebar = find.byType(PagesSidebar);
expect(sidebar, findsOneWidget); expect(sidebar, findsOneWidget);
await tester.drag(sidebar, const Offset(0, -200)); await tester.drag(sidebar, const Offset(0, -200));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('1'), findsOneWidget); expect(find.text('1'), findsOneWidget);
expect(container.read(pdfViewModelProvider), 1); expect(container.read(pdfViewModelProvider).currentPage, 1);
await tester.tap(find.text('2')); await tester.tap(find.text('2'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(container.read(pdfViewModelProvider), 2); expect(container.read(pdfViewModelProvider).currentPage, 2);
});
testWidgets('PDF View: tap viewer after export does not crash', (
tester,
) async {
final pdfBytes =
await File('integration_test/data/sample-local-pdf.pdf').readAsBytes();
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
await tester.pumpWidget(
ProviderScope(
overrides: [
preferencesRepositoryProvider.overrideWith(
(ref) => PreferencesStateNotifier(prefs),
),
documentRepositoryProvider.overrideWith(
(ref) =>
DocumentStateNotifier()
..openPicked(pageCount: 3, bytes: pdfBytes),
),
pdfViewModelProvider.overrideWith(
(ref) => PdfViewModel(ref, useMockViewer: false),
),
exportServiceProvider.overrideWith((ref) => LightweightExporter()),
savePathPickerProvider.overrideWith(
(_) => () async => 'C:/tmp/output-after-export.pdf',
),
],
child: MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
locale: const Locale('en'),
home: PdfSignatureHomePage(
onPickPdf: () async {},
onClosePdf: () {},
currentFile: fs.XFile('test.pdf'),
),
),
),
);
await tester.pumpAndSettle();
// Trigger export
await tester.tap(find.byKey(const Key('btn_save_pdf')));
await tester.pumpAndSettle();
// Tap on the page area; should not crash
final pageArea = find.byKey(const ValueKey('pdf_page_area'));
expect(pageArea, findsOneWidget);
await tester.tap(pageArea);
await tester.pumpAndSettle();
// Still present and responsive
expect(pageArea, findsOneWidget);
}); });
} }

View File

@ -34,7 +34,7 @@ class PdfViewModel extends ChangeNotifier {
PdfViewModel(this.ref, {bool? useMockViewer}) PdfViewModel(this.ref, {bool? useMockViewer})
: _useMockViewer = : _useMockViewer =
useMockViewer ?? useMockViewer ??
bool.fromEnvironment('FLUTTER_TEST', defaultValue: false); const bool.fromEnvironment('FLUTTER_TEST', defaultValue: false);
bool get useMockViewer => _useMockViewer; bool get useMockViewer => _useMockViewer;

View File

@ -13,6 +13,7 @@ import 'pdf_page_area.dart';
import 'pages_sidebar.dart'; import 'pages_sidebar.dart';
import 'signatures_sidebar.dart'; import 'signatures_sidebar.dart';
import 'ui_services.dart'; import 'ui_services.dart';
import 'package:pdf_signature/utils/download.dart';
import '../view_model/pdf_view_model.dart'; import '../view_model/pdf_view_model.dart';
class PdfSignatureHomePage extends ConsumerStatefulWidget { class PdfSignatureHomePage extends ConsumerStatefulWidget {
@ -168,6 +169,21 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
if (out != null) { if (out != null) {
ok = await exporter.saveBytesToFile(bytes: out, outputPath: fullPath); ok = await exporter.saveBytesToFile(bytes: out, outputPath: fullPath);
} }
} else {
// Web: export and trigger browser download
final src = pdf.pickedPdfBytes ?? Uint8List(0);
final out = await exporter.exportSignedPdfFromBytes(
srcBytes: src,
uiPageSize: _pageSize,
signatureImageBytes: null,
placementsByPage: pdf.placementsByPage,
targetDpi: targetDpi,
);
if (out != null) {
// Use a sensible default filename (cannot prompt path on web)
ok = await downloadBytes(out, filename: 'signed.pdf');
savedPath = 'signed.pdf';
}
} }
if (!kIsWeb) { if (!kIsWeb) {
if (ok) { if (ok) {
@ -185,6 +201,19 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
), ),
); );
} }
} else {
// Web: show a toast-like confirmation
messenger.showSnackBar(
SnackBar(
content: Text(
ok
? AppLocalizations.of(
context,
).savedWithPath(savedPath ?? 'signed.pdf')
: AppLocalizations.of(context).failedToSavePdf,
),
),
);
} }
} finally { } finally {
ref.read(exportingProvider.notifier).state = false; ref.read(exportingProvider.notifier).state = false;

11
lib/utils/download.dart Normal file
View File

@ -0,0 +1,11 @@
import 'dart:typed_data';
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}) {
return impl.downloadBytes(bytes, filename: filename);
}

View File

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

View File

@ -0,0 +1,22 @@
// 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 {
final blob = html.Blob([bytes], 'application/pdf');
final url = html.Url.createObjectUrlFromBlob(blob);
final anchor =
html.document.createElement('a') as html.AnchorElement
..href = url
..download = filename
..style.display = 'none';
html.document.body?.children.add(anchor);
anchor.click();
anchor.remove();
html.Url.revokeObjectUrl(url);
return true;
} catch (_) {
return false;
}
}