How to Build a Flutter Document Scanner App for Android and iOS

Mobile document scanning has become a baseline feature for productivity apps — think expense reports captured from receipts, signed contracts photographed in a meeting room, or ID documents registered at a service counter. Building such a feature from scratch, handling perspective correction, edge detection, and multi-format export, is a significant engineering effort.

In this tutorial we will walk through building DocScan, a Flutter document scanner that runs on both Android and iOS. The app is powered by the Dynamsoft Capture Vision SDK, which provides on-device, real-time document boundary detection, deskewing, and image-processing capabilities.

Demo Video: Flutter Document Scanner

Prerequisites

  • Flutter SDK ≥ 3.8
  • Dart SDK ≥ 3.8
  • Android Studio or VS Code Latest
  • Xcode (for iOS builds) Latest
  • A Dynamsoft license key Free 30-day trial

Project Structure

Before diving into code, it is helpful to understand the final file layout:

lib/
├── main.dart          # App entry point, Material 3 theme, home screen
├── scan_page.dart     # Live camera + real-time document detection
├── result_page.dart   # Preview, colour-mode switching, PNG export
├── edit_page.dart     # Manual quad-corner drag adjustment
├── constants.dart     # App-wide constants (license key, padding, etc.)
└── app_theme.dart     # Centralised Material 3 light/dark theme

Each Dart file has a single, clearly scoped responsibility. This separation makes unit testing and future feature additions straightforward.

Step 1: Create the Flutter Project and Add Dependencies

Start by creating a new Flutter project:

flutter create --org com.dynamsoft.flutter scan_document
cd scan_document

Open pubspec.yaml and add the two required packages:

dependencies:
  flutter:
    sdk: flutter
  dynamsoft_capture_vision_flutter: ^3.2.5000
  path_provider: ^2.1.5

Run flutter pub get to fetch them.

Step 2: Configure Platform Permissions

Android

Open android/app/src/main/AndroidManifest.xml and add permissions before the <application> tag:

<uses-feature android:name="android.hardware.camera" android:required="true" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
    android:maxSdkVersion="28" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
    android:maxSdkVersion="32" />

Because the Dynamsoft Capture Vision SDK requires API 21 or higher, update android/app/build.gradle.kts:

defaultConfig {
    applicationId = "com.dynamsoft.flutter.scan_document"
    minSdk = 21
    targetSdk = flutter.targetSdkVersion
    versionCode = flutter.versionCode
    versionName = flutter.versionName
}

iOS

Add camera and photo-library usage descriptions to ios/Runner/Info.plist:

<key>NSCameraUsageDescription</key>
<string>DocScan needs camera access to scan documents.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>DocScan saves scanned documents to your photo library.</string>

Step 3: Centralize Configuration with constants.dart

Hardcoding the license key directly into the scanner widget makes it difficult to rotate keys or run CI builds with a different key. Create lib/constants.dart:

class AppConstants {
  AppConstants._();

  /// Replace with your own license from https://www.dynamsoft.com/customer/license/trialLicense/?product=dcv&package=cross-platform
  static const String licenseKey = 'YOUR_LICENSE_KEY_HERE';

  static const String appName = 'DocScan';
  static const double defaultPadding = 16.0;
  static const double fabSize = 64.0;
  static const Duration snackBarDuration = Duration(seconds: 4);
  static const String exportFilePrefix = 'docscan_';
  static const String exportFileExtension = '.png';
}

Step 4: Define the App Theme

Material 3 provides a robust theming system. Create lib/app_theme.dart that returns both a light and a dark ThemeData:

import 'package:flutter/material.dart';
import 'constants.dart';

class AppTheme {
  AppTheme._();

  static const Color primaryColor = Color(0xFFFF6D00);

  static ThemeData get light {
    final base = ColorScheme.fromSeed(
      seedColor: primaryColor,
      brightness: Brightness.light,
    );
    return ThemeData(
      useMaterial3: true,
      colorScheme: base,
      appBarTheme: AppBarTheme(
        backgroundColor: base.primary,
        foregroundColor: base.onPrimary,
      ),
      elevatedButtonTheme: ElevatedButtonThemeData(
        style: ElevatedButton.styleFrom(
          backgroundColor: primaryColor,
          foregroundColor: Colors.white,
          padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 14),
          shape: RoundedRectangleBorder(
              borderRadius: BorderRadius.circular(8)),
        ),
      ),
    );
  }

  static ThemeData get dark {
    final base = ColorScheme.fromSeed(
      seedColor: primaryColor,
      brightness: Brightness.dark,
    );
    return ThemeData(useMaterial3: true, colorScheme: base);
  }
}

Step 5: Build the Home Screen (main.dart)

The entry point locks the app to portrait mode and renders a feature-list home screen:

void main() {
  WidgetsFlutterBinding.ensureInitialized();
  SystemChrome.setPreferredOrientations([
    DeviceOrientation.portraitUp,
    DeviceOrientation.portraitDown,
  ]);
  runApp(const DocScanApp());
}

class DocScanApp extends StatelessWidget {
  const DocScanApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: AppConstants.appName,
      debugShowCheckedModeBanner: false,
      theme: AppTheme.light,
      darkTheme: AppTheme.dark,
      themeMode: ThemeMode.system,
      navigatorObservers: [routeObserver],
      home: const HomeScreen(),
    );
  }
}

The RouteObserver is a global singleton that the ScannerPage subscribes to. This enables the camera to pause automatically when another screen is pushed on top of it and resume when that screen is popped — a common pitfall that causes battery drain and camera resource conflicts if not handled.

Step 6: Implement the Scanner Page (scan_page.dart)

The scanner page is the heart of the application. It wires together three SDK objects:

  • CameraEnhancer — manages the device camera stream
  • CaptureVisionRouter — runs the document-detection algorithm
  • CapturedResultReceiver — receives detection results asynchronously

Initialization

Future<void> _initSdk() async {
  _cvr = CaptureVisionRouter.instance
    ..addResultFilter(
      MultiFrameResultCrossFilter()
        ..enableResultCrossVerification(
          EnumCapturedResultItemType.deskewedImage.value,
          true,
        ),
    );

  _receiver = CapturedResultReceiver()
    ..onProcessedDocumentResultReceived = _onResult;

  final (isSuccess, message) =
      await LicenseManager.initLicense(AppConstants.licenseKey);
  if (!isSuccess) { /* show error */ return; }

  await _cvr.setInput(_camera);
  _cvr.addResultReceiver(_receiver);
  await _camera.open();
  await _cvr.startCapturing(EnumPresetTemplate.detectAndNormalizeDocument);
}

The MultiFrameResultCrossFilter is critical for a good user experience. Without it, the receiver fires on every frame, including unstable or blurry frames. Enabling enableResultCrossVerification causes the SDK to wait until the same document boundary is confirmed across multiple consecutive frames before firing the callback.

Handling Results

Future<void> _onResult(ProcessedDocumentResult result) async {
  if (_isProcessing) return;
  final item = result.deskewedImageResultItems?.firstOrNull;
  if (item == null) return;

  final bool autoVerified =
      item.crossVerificationStatus == EnumCrossVerificationStatus.passed;
  if (!autoVerified && !_isCapturing) return;

  _isCapturing = false;
  _isProcessing = true;

  // Fetch intermediate image BEFORE stopping capture
  final originalImage = await _cvr
      .getIntermediateResultManager()
      .getOriginalImage(result.originalImageHashId);

  await _cvr.stopCapturing();
  _camera.close();

  if (mounted && item.imageData != null && originalImage != null) {
    await Navigator.push(
      context,
      MaterialPageRoute(
        builder: (_) => ResultPage(
          deskewedImage: item.imageData!,
          originalImage: originalImage,
          sourceDeskewQuad: item.sourceDeskewQuad,
        ),
      ),
    );
  }
  _isProcessing = false;
}

Important: getOriginalImage() must be called before stopCapturing(). Once capturing stops the intermediate result buffer is cleared.

Camera Lifecycle

Subscribe to RouteAware callbacks to pause and resume the camera automatically:

@override
void didPushNext() {
  _cvr.stopCapturing();
  _camera.close();
}

@override
void didPopNext() {
  _isProcessing = false;
  _camera.open();
  _cvr.startCapturing(EnumPresetTemplate.detectAndNormalizeDocument);
}

@override
void dispose() {
  routeObserver.unsubscribe(this);
  _cvr.stopCapturing();
  _camera.close();
  _cvr.removeResultReceiver(_receiver);
  _cvr.removeAllResultFilters();
  super.dispose();
}

UI

The camera view fills the screen. A gradient overlay at the bottom contains the capture button:

Stack(
  fit: StackFit.expand,
  children: [
    CameraView(cameraEnhancer: _camera),
    Positioned(
      left: 0, right: 0, bottom: 0,
      child: /* gradient + capture button */,
    ),
  ],
)

Step 7: Build the Result Page (result_page.dart)

document scanner result page

Once a document is captured, ResultPage shows a preview and lets the user:

  1. Switch between color, grayscale, and binary (B&W) modes
  2. Go back to EditPage to re-adjust the crop
  3. Export the final image as a PNG

Color Mode Conversion

Future<void> _changeColourMode(_ColourMode mode) async {
  setState(() => _isConverting = true);
  try {
    ImageData? converted;
    switch (mode) {
      case _ColourMode.colour:
        converted = _deskewedColorfulImage;
      case _ColourMode.grayscale:
        converted = await ImageProcessor().convertToGray(_deskewedColorfulImage);
      case _ColourMode.binary:
        converted = await ImageProcessor()
            .convertToBinaryLocal(_deskewedColorfulImage, compensation: 15);
    }
    if (mounted && converted != null) {
      setState(() { _showingImage = converted!; _currentMode = mode; });
    }
  } finally {
    if (mounted) setState(() => _isConverting = false);
  }
}

The compensation parameter in convertToBinaryLocal controls the threshold offset. A value of 15 works well for most printed documents scanned under ambient lighting.

Export

Future<void> _exportImage() async {
  final directory = Platform.isAndroid
      ? (await getExternalStorageDirectory())!
      : await getApplicationDocumentsDirectory();

  final filePath =
      '${directory.path}/${AppConstants.exportFilePrefix}'
      '${DateTime.now().millisecondsSinceEpoch}'
      '${AppConstants.exportFileExtension}';

  await ImageIO().saveToFile(_showingImage, filePath, true);
}

Step 8: Build the Edit Page (edit_page.dart)

ImageEditorView from the SDK renders the original camera frame and overlays a draggable quadrilateral that the user can reposition:

ImageEditorView(
  imageData: widget.originalImageData,
  drawingQuadsByLayer: {
    EnumDrawingLayerId.ddn: [widget.quad],
  },
  onPlatformViewCreated: (controller) {
    _controller = controller;
  },
),

When the user confirms, getSelectedQuad() returns the adjusted corners, and cropAndDeskewImage() re-processes the original image with the new quadrilateral:

final selectedQuad = await _controller!.getSelectedQuad();
final croppedImageData = await ImageProcessor()
    .cropAndDeskewImage(widget.originalImageData, selectedQuad);
Navigator.pop(context, {
  'croppedImageData': croppedImageData,
  'updatedQuad': selectedQuad,
});

Step 9: Build and Run

Debug build on a connected Android device

flutter devices          # find your device ID
flutter run -d <id>

Release APK

flutter build apk --release

The compiled APK is at build/app/outputs/flutter-apk/app-release.apk.

Source Code

https://github.com/yushulx/flutter-barcode-mrz-document-scanner/tree/main/examples/dynamsoft_document