feat: support export functionality on web by download
This commit is contained in:
parent
41eea5f00c
commit
a08f93e8d4
|
|
@ -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);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue