How to Build a Cross-platform Document Scanner App with Flutter

A document scanner app is a software application that uses your device’s camera to capture images of physical documents and convert them into digital formats. These apps typically support scanning documents, photos, receipts, business cards, and more. Document scanner apps are useful across various industries, including education, business, finance and healthcare. You may already be familiar with popular apps like Adobe Scan, CamScanner, and Microsoft Office Lens. The goal of this article is to guide you through building your own cross-platform document scanner app using Flutter and Dynamsoft Capture Vision. With a single codebase, you’ll be able to create an app that runs on Windows, Linux, Android, iOS and Web.

Demo Video

Comparing Flutter MRZ Scanners, Barcode Scanners, and Document Scanners

In previous projects, we developed both a Flutter MRZ scanner app and a Flutter barcode scanner app. All three apps share similar Flutter plugins and structure, with the main difference being the SDK used for specific tasks:

  • flutter_ocr_sdk for MRZ detection
  • flutter_barcode_sdk for barcode scanning
  • flutter_document_scan_sdk for document edge detection and perspective correction

Most of the UI code is reused across the apps. They all feature a tab bar for navigation between the home, and history pages. The camera control logic is consistent as well.

The document scanner app, however, adds a dedicated editing page for adjusting the perspective of scanned documents. Once adjusted, the document is cropped and rectified on a saving page, which also includes enhancement filters such as grayscale, black & white, and color.

Required Flutter Plugins

Getting Started with the App

  1. Create a new Flutter project:

     flutter create documentscanner
    
  2. Add dependencies in pubspec.yaml:

     dependencies:
       flutter_document_scan_sdk: ^1.0.2
       image_picker: ^1.0.0
       shared_preferences: ^2.1.1
       camera: 
         git:
           url: https://github.com/yushulx/flutter_camera.git
       flutter_lite_camera: ^0.0.1
       share_plus: ^7.0.2
       url_launcher: ^6.1.11
       flutter_exif_rotation: ^0.5.1
    
  3. Create a global.dart file for global variables:

     import 'package:flutter_document_scan_sdk/flutter_document_scan_sdk.dart';
    
     FlutterDocumentScanSdk docScanner = FlutterDocumentScanSdk();
     bool isLicenseValid = false;
    
     Future<int> initDocumentSDK() async {
       int? ret = await docScanner.init(
           'LICENSE-KEY');
       if (ret == 0) isLicenseValid = true;
       return ret ?? -1;
     }
    
  4. Replace the contents of lib/main.dart with the following code:

     import 'package:flutter/material.dart';
     import 'tab_page.dart';
     import 'dart:async';
     import 'global.dart';
    
     Future<void> main() async {
       runApp(const MyApp());
     }
    
     class MyApp extends StatelessWidget {
       const MyApp({super.key});
    
       Future<int> loadData() async {
         return await initDocumentSDK();
       }
    
       @override
       Widget build(BuildContext context) {
         return MaterialApp(
           title: 'Dynamsoft Barcode Detection',
           theme: ThemeData(
             scaffoldBackgroundColor: colorMainTheme,
           ),
           home: FutureBuilder<int>(
             future: loadData(),
             builder: (BuildContext context, AsyncSnapshot<int> snapshot) {
               if (!snapshot.hasData) {
                 return const CircularProgressIndicator(); 
               }
               Future.microtask(() {
                 Navigator.pushReplacement(context,
                     MaterialPageRoute(builder: (context) => const TabPage()));
               });
               return Container();
             },
           ),
         );
       }
     }
    

Building Document Scanner App Features

Refer to the UI design mockup:

document scanner UI design

In the following sections, we’ll explore the core features: edge detection, perspective correction, edge editing, and saving/exporting the rectified document.

Document Edge Detection & Perspective Correction

The app supports two scanning methods: live detection via camera or static image processing. You can use detectBuffer() for a video stream, or detectFile() and decodeImageFromList() for a selected image file. Both methods return a Future<List<DocumentResult>?> object.

// Image file
XFile? photo = await picker.pickImage(source: ImageSource.gallery);

// detectFile()
var results = await docScanner.detectFile(photo.path);

// detectBuffer()
Uint8List fileBytes = await photo.readAsBytes();
ui.Image image = await decodeImageFromList(fileBytes);

ByteData? byteData =
    await image.toByteData(format: ui.ImageByteFormat.rawRgba);
if (byteData != null) {
  List<DocumentResult>? results = await docScanner.detectBuffer(
          byteData.buffer.asUint8List(),
          image.width,
          image.height,
          byteData.lengthInBytes ~/ image.height,
          ImagePixelFormat.IPF_ARGB_8888.index,
          ImageRotation.rotation0.value);
}

// Video streaming buffer
Future<void> processDocument(List<Uint8List> bytes, int width, int height,
      List<int> strides, int format, List<int> pixelStrides) async {
    int rotation = 0;
    var results = docScanner
        .detectBuffer(bytes[0], width, height, strides[0], format, rotation);
    ...
}

document edge detection

The DocumentResult object contains the following properties:

  • List<Offset> points: The coordinates of the quadrilateral.
  • int confidence: The confidence of the result.

Once edges are detected, use normalizeFile() or normalizeBuffer() to crop and correct the document’s perspective.

// File
var normalizedImage = await docScanner.normalizeFile(file, points, ColorMode.COLOR);

// Buffer
Future<void> handleDocument(Uint8List bytes, int width, int height, int stride,
      int format, dynamic points) async {
    var normalizedImage = docScanner
        .normalizeBuffer(bytes, width, height, stride, format, points,
            ImageRotation.rotation90.value, ColorMode.COLOR);
  }

Both methods return a NormalizedImage object that represents the rectified image:

class NormalizedImage {
  /// Image data.
  final Uint8List data;

  /// Image width.
  final int width;

  /// Image height.
  final int height;

  NormalizedImage(this.data, this.width, this.height);
}

To display the image, decode Uint8List data to a ui.Image object with the decodeImageFromPixels() method:

decodeImageFromPixels(normalizedImage.data, normalizedImage.width,
            normalizedImage.height, pixelFormat, (ui.Image img) {
        });

Edge Editing Page

Since auto-detection may not be perfect, an editing interface allows users to manually adjust the document’s quadrilateral. The UI uses CustomPaint, GestureDetector, and Stack to handle rendering and user interaction.

class OverlayPainter extends CustomPainter {
  ui.Image? image;
  List<DocumentResult>? results;

  OverlayPainter(this.image, this.results);

  @override
  void paint(Canvas canvas, Size size) {
    final paint = Paint()
      ..color = colorOrange
      ..strokeWidth = 10
      ..style = PaintingStyle.stroke;

    if (image != null) {
      canvas.drawImage(image!, Offset.zero, paint);
    }

    Paint circlePaint = Paint()
      ..color = colorOrange
      ..strokeWidth = 20
      ..style = PaintingStyle.fill;

    if (results == null) return;

    for (var result in results!) {
      canvas.drawLine(result.points[0], result.points[1], paint);
      canvas.drawLine(result.points[1], result.points[2], paint);
      canvas.drawLine(result.points[2], result.points[3], paint);
      canvas.drawLine(result.points[3], result.points[0], paint);

      if (image != null) {
        double radius = 20;
        canvas.drawCircle(result.points[0], radius, circlePaint);
        canvas.drawCircle(result.points[1], radius, circlePaint);
        canvas.drawCircle(result.points[2], radius, circlePaint);
        canvas.drawCircle(result.points[3], radius, circlePaint);
      }
    }
  }

  @override
  bool shouldRepaint(OverlayPainter oldDelegate) => true;
}

Widget createCustomImage() {
    var image = widget.documentData.image;
    var detectionResults = widget.documentData.documentResults;
    return FittedBox(
        fit: BoxFit.contain,
        child: SizedBox(
            width: image!.width.toDouble(),
            height: image.height.toDouble(),
            child: GestureDetector(
              onPanUpdate: (details) {
                if (details.localPosition.dx < 0 ||
                    details.localPosition.dy < 0 ||
                    details.localPosition.dx > image.width ||
                    details.localPosition.dy > image.height) {
                  return;
                }

                for (int i = 0; i < detectionResults.length; i++) {
                  for (int j = 0; j < detectionResults[i].points.length; j++) {
                    if ((detectionResults[i].points[j] - details.localPosition)
                            .distance <
                        100) {
                      bool isCollided = false;
                      for (int index = 1; index < 4; index++) {
                        int otherIndex = (j + 1) % 4;
                        if ((detectionResults[i].points[otherIndex] -
                                    details.localPosition)
                                .distance <
                            20) {
                          isCollided = true;
                          return;
                        }
                      }

                      setState(() {
                        if (!isCollided) {
                          detectionResults[i].points[j] = details.localPosition;
                        }
                      });
                    }
                  }
                }
              },
              child: CustomPaint(
                painter: OverlayPainter(image, detectionResults!),
              ),
            )));
  }

body: Stack(
  children: <Widget>[
    Positioned.fill(
      child: createCustomImage(),
    ),
    const Positioned(
      left: 122,
      right: 122,
      bottom: 28,
      child: Text('Powered by Dynamsoft',
          textAlign: TextAlign.center,
          style: TextStyle(
            fontSize: 12,
            color: Colors.white,
          )),
    ),
  ],
),

quadrilateral adjustment

Document Rectification & Saving Page

This page lets users view the final rectified image, apply filters (e.g., grayscale, binary, color), and save the output. Users can select the filter via a radio group and save the image as a base64 string using shared_preferences.

Widget createCustomImage(BuildContext context, ui.Image image,
      List<DocumentResult> detectionResults) {
    return FittedBox(
        fit: BoxFit.contain,
        child: SizedBox(
            width: image.width.toDouble(),
            height: image.height.toDouble(),
            child: CustomPaint(
              painter: OverlayPainter(image, detectionResults),
            )));
  }

<Widget>[
  Theme(
    data: Theme.of(context).copyWith(
      unselectedWidgetColor:
          Colors.white, // Color when unselected
    ),
    child: Radio(
      activeColor: colorOrange,
      value: 'binary',
      groupValue: _pixelFormat,
      onChanged: (String? value) async {
        setState(() {
          _pixelFormat = value!;
        });

        await docScanner.setParameters(Template.binary);

        if (widget.documentData.documentResults!.isNotEmpty) {
          await normalizeBuffer(widget.documentData.image!,
              widget.documentData.documentResults![0].points);
        }
      },
    ),
  ),
  const Text('Binary', style: TextStyle(color: Colors.white)),
  Theme(
      data: Theme.of(context).copyWith(
        unselectedWidgetColor:
            Colors.white, // Color when unselected
      ),
      child: Radio(
        activeColor: colorOrange,
        value: 'grayscale',
        groupValue: _pixelFormat,
        onChanged: (String? value) async {
          setState(() {
            _pixelFormat = value!;
          });

          await docScanner.setParameters(Template.grayscale);

          if (widget.documentData.documentResults!.isNotEmpty) {
            await normalizeBuffer(widget.documentData.image!,
                widget.documentData.documentResults![0].points);
          }
        },
      )),
  const Text('Gray', style: TextStyle(color: Colors.white)),
  Theme(
      data: Theme.of(context).copyWith(
        unselectedWidgetColor:
            Colors.white, // Color when unselected
      ),
      child: Radio(
        activeColor: colorOrange,
        value: 'color',
        groupValue: _pixelFormat,
        onChanged: (String? value) async {
          setState(() {
            _pixelFormat = value!;
          });

          await docScanner.setParameters(Template.color);

          if (widget.documentData.documentResults!.isNotEmpty) {
            await normalizeBuffer(widget.documentData.image!,
                widget.documentData.documentResults![0].points);
          }
        },
      )),
  const Text('Color', style: TextStyle(color: Colors.white)),
]

ElevatedButton(
  onPressed: () async {
    String imageString =
        await convertImagetoPngBase64(normalizedUiImage!);

    final SharedPreferences prefs =
        await SharedPreferences.getInstance();
    var results = prefs.getStringList('document_data');
    List<String> imageList = <String>[];
    imageList.add(imageString);
    if (results == null) {
      prefs.setStringList('document_data', imageList);
    } else {
      results.addAll(imageList);
      prefs.setStringList('document_data', results);
    }

    close();
  },
  style: ButtonStyle(
      backgroundColor: MaterialStateProperty.all(colorMainTheme)),
  child: Text('Save',
      style: TextStyle(color: colorOrange, fontSize: 22)),
)

document rectification and enhancement

Known Issues on Flutter Web

⚠️ Web Local Storage Size Limitation

When saving images using shared_preferences, data is stored in the browser’s local storage, which is typically limited to 5MB. Attempting to store large or many base64-encoded images may result in app crashes or unexpected behavior.

web local storage size limitation

Source Code

https://github.com/yushulx/flutter_document_scan_sdk/tree/main/example