Machine Readable Zone (MRZ) scanning is a critical feature in identity verification, border control, hotel check-in, and know-your-customer (KYC) workflows. Passports and ICAO-compliant ID cards embed personal data in two or three lines of OCR-B printed text at the bottom of the data page — the MRZ. A mobile app that can decode this in real time cuts manual data-entry errors, speeds up workflows, and improves user experience.
This tutorial walks you through building a Flutter MRZ scanner for Android and iOS using the Dynamsoft MRZ Scanner SDK.
Demo Video: Flutter MRZ Scanner
Prerequisites
| Requirement | Version |
|---|---|
| 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 |
You also need a physical Android or iOS device — the MRZ scanner cannot run on an emulator because it requires a real camera.
Understanding MRZ formats
The International Civil Aviation Organization (ICAO) defines three Travel Document formats under doc 9303:
- TD1 — 3-line, 30 characters per line — used on national ID cards
- TD2 — 2-line, 36 characters per line — used on some national ID cards
- TD3 — 2-line, 44 characters per line — used on passports and travel documents
Each line encodes fields using a fixed positional schema with check digits. Manually implementing the parse logic is error-prone, which is why a dedicated SDK is the right choice.
Project setup
Create the Flutter project
flutter create --org com.dynamsoft.flutter mrz_scanner
cd mrz_scanner
Add the Dynamsoft dependency
Open pubspec.yaml and add the SDK under dependencies:
name: mrz_scanner
description: >-
A production-ready Flutter MRZ scanner that reads passport and ID card data.
version: 1.0.0+1
publish_to: 'none'
environment:
sdk: ^3.8.1
dependencies:
flutter:
sdk: flutter
cupertino_icons: ^1.0.8
dynamsoft_mrz_scanner_bundle_flutter: ^3.2.5000
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^5.0.0
flutter:
uses-material-design: true
Then fetch the packages:
flutter pub get
Project architecture
The following structure is used in this project:
lib/
├── main.dart ← App bootstrap and Material 3 theme
├── config/
│ └── app_config.dart ← License key and app-wide constants
├── models/
│ └── mrz_result_model.dart ← View-model that wraps raw MRZData
├── screens/
│ ├── home_screen.dart ← Landing page with instructions + scan CTA
│ └── result_screen.dart ← Full-screen display of parsed MRZ fields
└── widgets/
└── mrz_result_card.dart ← Reusable card that renders one MRZ field row
This structure keeps each file focused, makes unit-testing individual pieces straightforward, and scales well when you add more features (history, export, dark-mode toggle, etc.).
Step 1: App configuration
Centralize the license key and any app-wide constants in a single file so they are easy to find and replace:
// lib/config/app_config.dart
class AppConfig {
AppConfig._();
/// Replace with your own Dynamsoft license key.
/// https://www.dynamsoft.com/customer/license/trialLicense/?product=dcv&package=cross-platform
static const String licenseKey =
'LICENSE_KEY_HERE';
static const String appName = 'MRZ Scanner';
}
Step 2: The view-model
The raw MRZData object returned by the SDK contains all the data you need, but a view-model layer converts it into display-ready strings and a list of field records:
// lib/models/mrz_result_model.dart
import 'package:dynamsoft_mrz_scanner_bundle_flutter/dynamsoft_mrz_scanner_bundle_flutter.dart';
class MrzResultModel {
MrzResultModel({required MRZData data}) : _data = data;
final MRZData _data;
String get fullName => '${_data.firstName} ${_data.lastName}'.trim();
String get sex => _capitalize(_data.sex);
String get age => _data.age.toString();
String get documentType => _data.documentType;
String get documentNumber => _data.documentNumber;
String get issuingState => _data.issuingState;
String get nationality => _data.nationality;
String get dateOfBirth => _data.dateOfBirth;
String get dateOfExpiry => _data.dateOfExpire;
String _capitalize(String s) {
if (s.isEmpty) return s;
return s[0].toUpperCase() + s.substring(1).toLowerCase();
}
List<({String label, String value})> get fields => [
(label: 'Full Name', value: fullName),
(label: 'Sex', value: sex),
(label: 'Age', value: age),
(label: 'Document Type', value: documentType),
(label: 'Document Number', value: documentNumber),
(label: 'Issuing State', value: issuingState),
(label: 'Nationality', value: nationality),
(label: 'Date of Birth', value: dateOfBirth),
(label: 'Date of Expiry', value: dateOfExpiry),
];
}
The fields getter returns Dart 3 record types — a concise, type-safe approach that avoids defining a separate FieldEntry class for such simple data.
Step 3: Reusable widgets
Build a small widget library that the screens can compose:
// lib/widgets/mrz_result_card.dart
import 'package:flutter/material.dart';
import '../models/mrz_result_model.dart';
class MrzResultCard extends StatelessWidget {
const MrzResultCard({super.key, required this.result});
final MrzResultModel result;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(color: theme.colorScheme.outlineVariant),
),
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(children: [
Icon(Icons.badge_outlined, color: theme.colorScheme.primary),
const SizedBox(width: 8),
Text('Travel Document',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
color: theme.colorScheme.primary,
)),
]),
const Divider(height: 24),
...result.fields.map((f) => _FieldTile(label: f.label, value: f.value)),
],
),
),
);
}
}
class _FieldTile extends StatelessWidget {
const _FieldTile({required this.label, required this.value});
final String label;
final String value;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 140,
child: Text(label,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w500,
)),
),
Expanded(
child: Text(value.isNotEmpty ? value : '—',
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
)),
),
],
),
);
}
}
Step 4: The home screen
The home screen handles all scan logic. Key production concerns are addressed here:
-
Loading guard —
_isScanningprevents a second scanner from launching while one is already open. -
Switch on enum — Dart exhaustive
switchensures allEnumResultStatuscases are handled at compile time. -
Mounted guard —
if (!mounted) returnprevents callingsetStateorNavigatoron a disposed widget. - SnackBar feedback — Errors and unexpected states surface to the user without crashing.
// lib/screens/home_screen.dart (key excerpt)
Future<void> _startScan() async {
if (_isScanning) return;
setState(() => _isScanning = true);
try {
final config = MRZScannerConfig(license: AppConfig.licenseKey);
final result = await MRZScanner.launch(config);
if (!mounted) return;
switch (result.status) {
case EnumResultStatus.finished:
final model = MrzResultModel(data: result.mrzData!);
await Navigator.of(context).push(
MaterialPageRoute(builder: (_) => ResultScreen(result: model)),
);
case EnumResultStatus.canceled:
break; // user dismissed — no action needed
case EnumResultStatus.exception:
_showError('Scan failed: ${result.errorMessage} (${result.errorCode})');
}
} catch (e) {
if (mounted) _showError('Unexpected error: $e');
} finally {
if (mounted) setState(() => _isScanning = false);
}
}
The UI uses FilledButton (Material 3) and shows an inline CircularProgressIndicator while scanning is in progress — a much better UX than disabling the button silently.
Step 5: The result screen
// lib/screens/result_screen.dart
import 'package:flutter/material.dart';
import '../models/mrz_result_model.dart';
import '../widgets/mrz_result_card.dart';
class ResultScreen extends StatelessWidget {
const ResultScreen({super.key, required this.result});
final MrzResultModel result;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Scan Result'), centerTitle: true),
body: SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
MrzResultCard(result: result),
const SizedBox(height: 24),
FilledButton.icon(
onPressed: () => Navigator.of(context).pop(),
icon: const Icon(Icons.document_scanner_outlined),
label: const Text('Scan Another Document'),
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12)),
),
),
],
),
),
),
);
}
}
Step 6: App entry point with Material 3
// lib/main.dart
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'screens/home_screen.dart';
import 'config/app_config.dart';
void main() {
WidgetsFlutterBinding.ensureInitialized();
SystemChrome.setPreferredOrientations([
DeviceOrientation.portraitUp,
DeviceOrientation.portraitDown,
]);
runApp(const MrzScannerApp());
}
class MrzScannerApp extends StatelessWidget {
const MrzScannerApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: AppConfig.appName,
debugShowCheckedModeBanner: false,
theme: ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(
seedColor: const Color(0xFF1565C0),
brightness: Brightness.light,
),
),
darkTheme: ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(
seedColor: const Color(0xFF1565C0),
brightness: Brightness.dark,
),
),
themeMode: ThemeMode.system,
home: const HomeScreen(),
);
}
}
Notable production choices:
-
debugShowCheckedModeBanner: false— removes the debug banner from release screenshots. -
themeMode: ThemeMode.system— automatically follows the device dark/light preference. - Portrait lock via
SystemChrome.setPreferredOrientations— consistent scanning frame.
Step 7: Android configuration
android/app/build.gradle.kts
android {
namespace = "com.dynamsoft.flutter.mrz_scanner"
compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion
defaultConfig {
applicationId = "com.dynamsoft.flutter.mrz_scanner"
minSdk = 21 // Android 5.0 — SDK minimum requirement
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
}
buildTypes {
release {
// TODO: Add your own signing config for the release build.
signingConfig = signingConfigs.getByName("debug")
}
}
}
android/app/src/main/AndroidManifest.xml
Add the camera permission and the Android 13+ predictive back gesture opt-in:
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.CAMERA" />
<uses-feature android:name="android.hardware.camera" android:required="true" />
<application
android:label="MRZ Scanner"
android:enableOnBackInvokedCallback="true"
...>
Step 8: iOS configuration
ios/Runner/Info.plist
iOS requires an explicit NSCameraUsageDescription. An empty string will be rejected by App Store review:
<key>NSCameraUsageDescription</key>
<string>Camera access is required to scan the Machine Readable Zone (MRZ) on passports and ID cards.</string>
Also update the display name:
<key>CFBundleDisplayName</key>
<string>MRZ Scanner</string>
Install CocoaPods dependencies
cd ios/
pod install --repo-update
Running and testing
Android
flutter devices # list connected devices
flutter run -d <ID> # run debug build
flutter run --release # run optimised release build
iOS
Open ios/Runner.xcworkspace in Xcode, select your device, configure your Apple Developer team under Signing & Capabilities, then press Run.
Source Code
https://github.com/yushulx/flutter-barcode-mrz-document-scanner/tree/main/examples/dynamsoft_mrz

Top comments (0)