How to Build an Android/iOS App to Scan, Edit, and Save Documents with Flutter and HTML5

A mobile document scanner app typically includes features such as document capture, editing, and saving as PDF files. Since we’ve already created a web application with similar functionality using HTML5, JavaScript, and the Dynamsoft Document Viewer SDK,we can integrate that web app into an Android/iOS WebView to quickly develop mobile document scanner apps. In this tutorial, we’ll walk you through the process of creating such a hybrid mobile app using Flutter and HTML5.

Demo: Scan Document and Save PDF with Flutter Mobile App

Prerequisites

  • 30-day Trial License: Get a license key to activate the Dynamsoft Document Viewer.
  • Dynamsoft Document Viewer: Download the sample code to learn how to scan, capture, edit, and save documents as PDFs. The project will be loaded by the Flutter WebView.

Installing Flutter Dependencies

Add the following dependencies to your pubspec.yaml file:

dependencies:
  ...

  webview_flutter: ^4.13.0
  webview_flutter_android: ^4.7.0
  webview_flutter_wkwebview: ^3.22.0
  image_picker: ^1.1.2
  permission_handler: ^12.0.0+1
  shelf: ^1.4.2
  path: ^1.9.1
  shelf_static: ^1.1.3
  path_provider: ^2.1.5
  fluttertoast: ^8.2.12
  share_plus: ^11.0.0
  open_file: ^3.5.10

Explanation

  • webview_flutter: Displays web content inside the Flutter app.
  • webview_flutter_android: Android-specific WebView implementation for webview_flutter.
  • webview_flutter_wkwebview: iOS WebKit-based WebView implementation for webview_flutter.
  • image_picker: Selects images from the gallery or camera.
  • permission_handler: Manages runtime permissions.
  • shelf: Serves as a web server for Dart applications.
  • path: Provides utilities for handling file paths.
  • shelf_static: Serves static assets using Shelf.
  • path_provider: Locates commonly used storage paths.
  • fluttertoast: Shows native toast messages.
  • share_plus: Shares files and text using the platform’s share interface.
  • open_file: Opens files with the system’s default app (e.g., PDF viewer).

Loading Local HTML, JavaScript, and CSS Files into Flutter WebView

  1. Create an assets/web directory at the root of your project and place your HTML, JavaScript, and CSS files there.
  2. In pubspec.yaml, register the assets:

     assets:
     - assets/web/
    
  3. Load the HTML file in your Flutter code using the loadFlutterAsset method:

     WebViewController _controller =
           WebViewController()
             ..setJavaScriptMode(JavaScriptMode.unrestricted)
             ..enableZoom(true)
             ..setBackgroundColor(Colors.transparent)
             ..setNavigationDelegate(NavigationDelegate())
             ..platform.setOnPlatformPermissionRequest((request) {
               debugPrint(
                 'Permission requested by web content: ${request.types}',
               );
               request.grant();
             })
             ..loadFlutterAsset('assets/web/index.html');
    

    Flutter mobile document viewer

Interoperation Between Dart and JavaScript

To save a PDF file from JavaScript memory to the local file system, add a JavaScript channel named SaveFile to the WebViewController, which listens for messages:

_controller =
          WebViewController()
            ...
            ..addJavaScriptChannel(
              'SaveFile',
              onMessageReceived: (JavaScriptMessage message) async {
                List<dynamic> byteList = jsonDecode(message.message);
                Uint8List pdfBytes = Uint8List.fromList(byteList.cast<int>());

                String filename = await saveFile(pdfBytes);
                Fluttertoast.showToast(
                  msg: "File saved as: $filename",
                  toastLength: Toast.LENGTH_LONG,
                );
              },
            )
            ...

In main.js, convert the blob to a byte array and send it to Dart using SaveFile.postMessage():

savePDFButton.addEventListener('click', async () => {
    const fileName = document.getElementById('fileName').value;
    const password = document.getElementById('password').value;
    const annotationType = document.getElementById('annotationType').value;

    try {
        const pdfSettings = {
            password: password,
            saveAnnotation: annotationType,
        };

        let blob = await editViewer.currentDocument.saveToPdf(pdfSettings);

        sendBlobToDart(blob, fileName + `.pdf`);
    } catch (error) {
        console.log(error);
    }

    document.getElementById("save-pdf").style.display = "none";
});

function sendBlobToDart(blob) {
    const reader = new FileReader();

    reader.onload = function () {
        const arrayBuffer = reader.result;
        const byteArray = new Uint8Array(arrayBuffer);
        SaveFile.postMessage(JSON.stringify(Array.from(byteArray)));
    };

    reader.onerror = function (error) {
        console.error("Error reading blob:", error);
    };

    reader.readAsArrayBuffer(blob);
}

When a message is received on the Dart side:

  1. Decode the JSON message into a list of dynamic values.
  2. Convert the list to a Uint8List representing PDF bytes.
  3. Call saveFile() to save the bytes and return the filename.

     Future<String> saveFile(Uint8List pdfBytes) async {
       final directory = await getApplicationDocumentsDirectory();
        
       String filename = getFileName();
       final filePath = '${directory.path}/$filename';
        
       await File(filePath).writeAsBytes(pdfBytes);
        
       return filePath;
     }
    
     String getFileName() {
       DateTime now = DateTime.now();
        
       String timestamp =
           '${now.year}${now.month}${now.day}_${now.hour}${now.minute}${now.second}';
        
       String name = '$timestamp.pdf';
        
       return name;
     }
    
  4. Display a toast message showing the saved filename using Fluttertoast.

Handling File Selection on Android

By default, <input type="file"> does not work in Android WebView. Use the following code to handle file selection:

if (_controller.platform is AndroidWebViewController) {
      AndroidWebViewController.enableDebugging(true);
      (_controller.platform as AndroidWebViewController)
          .setMediaPlaybackRequiresUserGesture(false);
      (_controller.platform as AndroidWebViewController).setOnShowFileSelector((
        params,
      ) async {
        final XFile? image = await _picker.pickImage(
          source: ImageSource.gallery,
        );
        if (image != null) {
          return ['file://${image.path}'];
        }
        return [""];
      });
    }

Explanation

  • setOnShowFileSelector is a method that sets a callback function to handle file selection requests from the WebView.
  • Inside the callback, it uses the ImagePicker instance _picker to open the device’s image gallery and let the user select an image.
  • If the user selects an image successfully, it returns an array containing the file URI of the selected image.
  • If the user cancels the selection, it returns an array containing an empty string.

Granting Camera Access Permission

To enable camera access:

  1. Add permissions in platform-specific files:

    android/app/src/main/AndroidManifest.xml

     <uses-permission android:name="android.permission.CAMERA"/>
    

    ios/Runner/Info.plist

     <key>NSCameraUsageDescription</key>
     <string>This app needs camera access to allow the webpage to use your camera for video input.</string>
    
  2. Request the camera permission at runtime in Flutter:

     Future<void> requestCameraPermission() async {
       final status = await Permission.camera.request();
       if (status == PermissionStatus.granted) {
       } else if (status == PermissionStatus.denied) {
       } else if (status == PermissionStatus.permanentlyDenied) {
       }
     }
    
     @override
     void initState() {
         super.initState();
         ...
         requestCameraPermission();
     }
    

On Android, navigator.mediaDevices.enumerateDevices() returns only the front camera due to WebView security restrictions. This is because loading with file:// lacks a secure context.

Workaround for Android: Use HTTP instead of file://

To enable full camera access:

  1. Serve assets via http://localhost.

     void main() async {
       WidgetsFlutterBinding.ensureInitialized();
        
       if (Platform.isAndroid) {
        
         final dir = await getTemporaryDirectory();
         final path = p.join(dir.path, 'web');
         final webDir = Directory(path)..createSync(recursive: true);
        
         final files = ['index.html', 'full.json', 'main.css', 'main.js'];
        
         for (var filename in files) {
           final ByteData data = await rootBundle.load('assets/web/$filename');
           final file = File(p.join(webDir.path, filename));
           await file.writeAsBytes(data.buffer.asUint8List());
         }
        
         final handler = createStaticHandler(
           webDir.path,
           defaultDocument: 'index.html',
           serveFilesOutsidePath: true,
         );
        
         try {
           final server = await shelf_io.serve(handler, 'localhost', 8080);
           print('Serving at http://${server.address.host}:${server.port}');
         } catch (e) {
           print('Failed to start server: $e');
         }
       }
        
       runApp(const MyApp());
     }
        
    

    Explanation

    1. Get the temporary directory and creates a web subdirectory.
    2. Copy web files (HTML, JSON, CSS, JS) from assets to this directory.
    3. Create a static file handler to serve these files.
    4. Start a local HTTP server on port 8080 to serve the content.
  2. Load the web page using ..loadRequest(Uri.parse('http://localhost:8080/index.html')):

     if (Platform.isAndroid) {
           _controller =
               WebViewController()
                 ..setJavaScriptMode(JavaScriptMode.unrestricted)
                 ..enableZoom(true)
                 ..setBackgroundColor(Colors.transparent)
                 ..setNavigationDelegate(NavigationDelegate())
                 ..addJavaScriptChannel(
                   'SaveFile',
                   onMessageReceived: (JavaScriptMessage message) async {
                     List<dynamic> byteList = jsonDecode(message.message);
                     Uint8List pdfBytes = Uint8List.fromList(byteList.cast<int>());
        
                     String filename = await saveFile(pdfBytes);
                     Fluttertoast.showToast(
                       msg: "File saved as: $filename",
                       toastLength: Toast.LENGTH_LONG,
                     );
                   },
                 )
                 ..platform.setOnPlatformPermissionRequest((request) {
                   debugPrint(
                     'Permission requested by web content: ${request.types}',
                   );
                   request.grant(); 
                 })
                 ..loadRequest(Uri.parse('http://localhost:8080/index.html'));
         } 
    

    camera access in Flutter Android WebView

Creating a History Page for Saved PDF Files

To view, share, and delete saved PDFs, add a second tab:

  1. In main.dart, construct the UI with a TabBarView that has two tabs: Home and History.

     @override
     Widget build(BuildContext context) {
         return Scaffold(
         body: TabBarView(
             controller: _tabController,
             physics: const NeverScrollableScrollPhysics(),
             children: [
             HomeView(title: 'Document Viewer', controller: _controller),
             const HistoryView(title: 'History'),
             ],
         ),
         bottomNavigationBar: TabBar(
             labelColor: Colors.blue,
             controller: _tabController,
             tabs: const [
             Tab(icon: Icon(Icons.home), text: 'Home'),
             Tab(icon: Icon(Icons.history_sharp), text: 'History'),
             ],
         ),
         );
     }
    
  2. Create home_view.dart to show the WebViewWidget:

     import 'package:flutter/material.dart';
    
     import 'package:webview_flutter/webview_flutter.dart';
        
     class HomeView extends StatefulWidget {
       final WebViewController controller;
       final String title;
        
       const HomeView({super.key, required this.title, required this.controller});
        
       @override
       State<HomeView> createState() => _HomeViewState();
     }
        
     class _HomeViewState extends State<HomeView>
         with SingleTickerProviderStateMixin {
       @override
       void initState() {
         super.initState();
       }
        
       @override
       Widget build(BuildContext context) {
         return Scaffold(
           appBar: AppBar(title: Text(widget.title)),
           body: WebViewWidget(controller: widget.controller),
         );
       }
     }
        
    
  3. Create history_view.dart to display the saved PDF documents:

     import 'package:flutter/material.dart';
     import 'package:open_file/open_file.dart';
     import 'package:share_plus/share_plus.dart';
        
     import 'utils.dart';
        
     class HistoryView extends StatefulWidget {
       const HistoryView({super.key, required this.title});
        
       final String title;
        
       @override
       State<HistoryView> createState() => _HistoryViewState();
     }
        
     class _HistoryViewState extends State<HistoryView> {
       List<String> _results = [];
       int selectedValue = -1;
        
       @override
       void initState() {
         super.initState();
        
         getFiles().then((value) {
           setState(() {
             _results = value;
           });
         });
       }
        
       Widget createListView(BuildContext context, List<String> results) {
         return ListView.builder(
           itemCount: results.length,
           itemBuilder: (context, index) {
             return RadioListTile<int>(
               value: index,
               groupValue: selectedValue,
               title: Text(results[index]),
               onChanged: (int? value) {
                 setState(() {
                   selectedValue = value!;
                 });
               },
             );
           },
         );
       }
        
       @override
       Widget build(BuildContext context) {
         return Scaffold(
           appBar: AppBar(
             title: Text(widget.title),
             actions: [
               IconButton(
                 icon: const Icon(Icons.info),
                 onPressed: () async {
                   if (selectedValue == -1) {
                     return;
                   }
                   await OpenFile.open(_results[selectedValue]);
                 },
               ),
               IconButton(
                 icon: const Icon(Icons.share),
                 onPressed: () async {
                   if (selectedValue == -1) {
                     return;
                   }
                   await SharePlus.instance.share(
                     ShareParams(
                       text: 'Check out this image!',
                       files: [XFile(_results[selectedValue])],
                     ),
                   );
                 },
               ),
               IconButton(
                 icon: const Icon(Icons.delete),
                 onPressed: () async {
                   if (selectedValue == -1) {
                     return;
                   }
                   bool isDeleted = await deleteFile(_results[selectedValue]);
                   if (isDeleted) {
                     setState(() {
                       _results.removeAt(selectedValue);
                       selectedValue = -1;
                     });
                   }
                 },
               ),
             ],
           ),
           body: Center(
             child: Stack(
               children: [
                 SizedBox(height: MediaQuery.of(context).size.height),
                 if (_results.isNotEmpty)
                   SizedBox(
                     width: MediaQuery.of(context).size.width,
                     height:
                         MediaQuery.of(context).size.height -
                         200 -
                         MediaQuery.of(context).padding.top,
                     child: createListView(context, _results),
                   ),
               ],
             ),
           ),
         );
       }
     }
        
    

    Flutter PDF document history

Source Code

https://github.com/yushulx/web-twain-document-scan-management/tree/main/examples/flutter_document_viewer