Building a Custom PDF Export Workflow in Flutter with printing

By Liam O'Brien · 7 August 20262,575 views
Building a Custom PDF Export Workflow in Flutter with printing

Introduction: The Offline Reality of Clinical Reporting

In the context of healthtech, documentation is the heartbeat of clinical workflows. Whether a nurse is documenting vitals in a remote community center or a doctor is exporting a patient’s historical summary while moving between hospital wards with spotty Wi-Fi, the ability to generate and export reports offline is not a 'nice-to-have'—it is a functional requirement. As a Flutter developer, my team and I have spent considerable time refining our document generation pipelines to ensure that if the app is offline, the PDF engine remains fully functional.

Building a PDF export workflow in Flutter requires more than just calling a print function. It requires an understanding of the rendering lifecycle, memory management for large documents, and, critically, how to integrate these workflows into a navigation stack that doesn't lose state if the app process is terminated during a sync. In this guide, we will leverage the printing and pdf packages, exploring how to build a robust, local-first reporting engine that handles complex clinical data sets.

Understanding the Rendering Engine

Before we dive into the code, we need to address the choice of tools. The pdf package is the standard for generating documents from scratch in Dart. It’s a pure-Dart implementation, which is essential because it avoids reliance on platform-specific C++ bindings that might break during compilation on edge devices. However, the printing package provides the necessary glue to interact with native print dialogs and share sheets on iOS, Android, and desktop.

When working in an offline-first architecture, the generation logic must reside in the data layer, distinct from the UI. We treat the PDF as a volatile document; if the user generates a report, we store it in a local sandbox directory. If the app closes, the go_router configuration should be able to reconstruct the 'print preview' state from a deep link or persistent state. This modularity ensures that our PDF generation isn't tightly coupled to a specific Screen widget, making it testable and resilient to the frequent interruptions inherent in a clinical environment.

Numbered Steps: Implementing the PDF Pipeline

Follow these steps to integrate a robust PDF generation workflow that respects local-first data constraints.

  1. Define the Data Contract: Create a Document Service class that is injected via dependency injection (we use get_it). This service should be responsible for fetching the latest local state of the clinical records and converting them into a data model suitable for the PDF generator.
  2. Isolate PDF Generation Logic: Never perform heavy PDF calculations on the main UI thread. Even with the pdf package’s efficiency, large patient records containing high-resolution images or complex tables will trigger jank. Use a compute function or an isolate to offload the rendering.
  3. Handle Local Persistence: Before presenting the preview, write the generated bytes to a temporary file using path_provider. This ensures the PDF is ready for export even if the user experiences a network drop or app transition.
  4. Configure the Print Interface: Utilize the Printing.layoutPdf method to show the native interface. This method allows the OS to handle the print job, including background queues, which is safer than implementing a custom queue.
  5. State Recovery via go_router: Ensure that your print preview screen is reachable via a unique path in your go_router configuration. If the app is killed by the OS while waiting for a Bluetooth printer connection, you must be able to restore the preview screen by passing the file path as a parameter.
// Example of an isolated generation task
Future<Uint8List> generateReport(PatientData data) async {
  final pdf = pw.Document();
  pdf.addPage(
    pw.Page(
      build: (pw.Context context) => pw.Column(
        children: [
          pw.Header(text: 'Clinical Summary: ${data.id}'),
          pw.Text('Diagnosis: ${data.diagnosis}'),
        ],
      ),
    ),
  );
  return pdf.save();
}

Handling Complex Clinical Data and Conflict Resolution

Clinical PDFs are rarely simple text files. They contain charts, medication lists, and historical trend data. When generating these locally, you must account for the fact that the data might be in a 'pending sync' state. We label our generated PDFs with metadata: a timestamp, a 'dirty' flag (indicating the data has not yet reached the central server), and the source ID.

We utilize a local SQLite database (via drift) to manage the state of these documents. When a user requests an export, the application checks the local DB. If the record is currently syncing, the document is generated with a watermark indicating 'Preliminary: Pending Server Sync'. This pattern manages clinical risk by ensuring the clinician knows exactly what version of the truth they are looking at. Handling these edge cases—where a document is generated, the user goes offline, and the data changes—requires a rigid immutable data architecture within your Flutter app.

Integration with Navigation and Deep Linking

One of the most persistent issues I’ve encountered while working with go_router is handling the navigation stack during a print job. Often, when a user triggers the Printing package, the UI pushes a new route or enters a modal state. If the app is put in the background, the OS might reclaim resources.

I’ve found that by keeping the print preview as a top-level route in my go_router map, I can handle these interruptions gracefully. The route takes a documentId as a parameter. When the app resumes, the route builder fetches the generated PDF bytes from the local storage cache rather than regenerating them from scratch. This avoids unnecessary computation and keeps the UI responsive.

// go_router route configuration for the print preview
GoRoute(
  path: '/print-preview/:docId',
  builder: (context, state) => PrintPreviewScreen(
    docId: state.pathParameters['docId']!,
  ),
),

Pro Tips for Performance and Usability

  • Caching Assets: Do not load your hospital logo or standard watermarks from the network every time. Bundle these in your assets/ folder and read them into the pdf package as pw.MemoryImage to ensure the generator works offline with zero latency.
  • Chunking Large Reports: For patient histories spanning years, use the pdf package’s multi-page support. Do not attempt to force a massive JSON object into a single memory buffer. Process the data in logical chunks.
  • Error Boundaries: Wrap your PDF export button in an error boundary. If the PDF generation fails (e.g., due to an out-of-memory error on a low-end device), the user should receive a graceful prompt to try a 'reduced quality' version or to sync their device first.
  • Printer Discovery: If you need to support direct Bluetooth or Wi-Fi printing, keep the discovery logic separate from the PDF generation. The printing package’s PrintDialog does a great job, but on Android, you may need to handle PlatformException if the user has disabled location services (required for BLE printer discovery).

Conclusion

Building for healthcare means building for uncertainty. A PDF export workflow is a microcosm of a larger offline-first strategy: it requires local data access, isolated computation, and robust state management that survives the transition between application states. By separating the generation logic, utilizing go_router to manage the lifecycle of the preview, and keeping your document model decoupled from the network, you build an application that clinicians can trust regardless of their connectivity status.

As we continue to iterate on our clinical tools, the lesson remains the same: treat the user’s device as the primary source of truth, handle data discrepancies with transparency, and keep your navigation stack clear. The printing package provides the functionality, but your architecture provides the reliability. Don't be afraid to read the underlying source code of these packages; understanding exactly how pdf handles font loading or how printing communicates with the native PrintManager is what separates a standard app from a clinical-grade medical utility.

Comments

No comments yet. Be the first!

Sign in to leave a comment.