eas_passport_ocr 0.2.8
eas_passport_ocr: ^0.2.8 copied to clipboard
On-device passport MRZ recognition for Flutter Android and iOS.
example/lib/main.dart
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter/services.dart';
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import 'package:eas_passport_ocr/eas_passport_ocr.dart';
void main() => runApp(const PassportDemo());
class PassportDemo extends StatelessWidget {
const PassportDemo({super.key});
@override
Widget build(BuildContext context) => MaterialApp(
debugShowCheckedModeBanner: false,
title: '护照识别',
theme: ThemeData(
colorSchemeSeed: const Color(0xff246b54),
scaffoldBackgroundColor: const Color(0xfff5f7f3),
useMaterial3: true,
),
home: const PassportPage(),
);
}
class PassportPage extends StatefulWidget {
const PassportPage({super.key});
@override
State<PassportPage> createState() => _PassportPageState();
}
class _PassportPageState extends State<PassportPage> {
final _service = EasPassportOcr();
bool _busy = false;
bool _showJson = false;
String _status = '完整拍入护照资料页,确保底部两行文字清晰。';
String? _imagePath;
Duration? _elapsed;
Map<String, dynamic>? _result;
Future<void> _scan(ImageSource source) async {
if (_busy) return;
setState(() => _busy = true);
try {
// Keep original pixels: the SDK handles size and orientation itself.
final file = await ImagePicker().pickImage(source: source);
if (file == null || !mounted) return;
setState(() {
_imagePath = file.path;
_result = null;
_elapsed = null;
_showJson = false;
_status = '正在识别,请稍候…';
});
final watch = Stopwatch()..start();
final result = await _service.recognizeResult(file.path);
watch.stop();
if (mounted) {
setState(() {
_result = result;
_elapsed = watch.elapsed;
_status = result.containsKey('code')
? result['msg'] as String
: '识别完成,请与原件核对。';
});
}
} on PlatformException catch (error) {
if (mounted) {
setState(
() => _status = error.code.toLowerCase().contains('access')
? '无法访问相机或相册,请在系统设置中允许访问后重试。'
: '无法打开照片,请重新选择或拍摄。',
);
}
} catch (_) {
if (mounted) {
setState(() => _status = '本次识别未完成,请重新选择照片后重试。');
}
} finally {
if (mounted) {
setState(() => _busy = false);
} else {
await _service.dispose();
}
}
}
Future<void> _copy() async {
final current = _result;
if (current == null) return;
try {
final result = current.containsKey('code')
? current
: parsePassportMrz((current['rawMrz'] as List).join('\n')).toJson();
await Clipboard.setData(
ClipboardData(text: const JsonEncoder.withIndent(' ').convert(result)),
);
if (mounted) {
setState(() => _result = result);
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('JSON 已复制')));
}
} catch (_) {
if (mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('复制失败,请重试')));
}
}
}
@override
void dispose() {
if (!_busy) unawaited(_service.dispose());
super.dispose();
}
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(
title: const Text('护照识别'),
backgroundColor: const Color(0xfff5f7f3),
),
body: SafeArea(
child: ListView(
padding: const EdgeInsets.fromLTRB(20, 12, 20, 28),
children: [
Text('拍一张,读取护照信息', style: Theme.of(context).textTheme.headlineSmall),
const SizedBox(height: 8),
const Text(
'Flutter SDK · 设备内识别',
style: TextStyle(color: Colors.black54),
),
const SizedBox(height: 24),
if (_imagePath != null)
ClipRRect(
borderRadius: BorderRadius.circular(16),
child: Image.file(
File(_imagePath!),
height: 220,
fit: BoxFit.contain,
cacheWidth: 1000,
errorBuilder: (_, _, _) => const SizedBox(
height: 80,
child: Center(child: Text('照片预览不可用')),
),
),
)
else
Container(
height: 160,
decoration: BoxDecoration(
color: const Color(0xffe5ede6),
borderRadius: BorderRadius.circular(16),
),
child: const Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.document_scanner_outlined,
size: 48,
color: Color(0xff246b54),
),
SizedBox(height: 12),
Text('选择完整的护照资料页'),
],
),
),
const SizedBox(height: 20),
Row(
children: [
Expanded(
child: FilledButton.icon(
onPressed: _busy ? null : () => _scan(ImageSource.camera),
icon: const Icon(Icons.camera_alt_outlined),
label: const Text('拍摄护照'),
),
),
const SizedBox(width: 12),
Expanded(
child: OutlinedButton.icon(
onPressed: _busy ? null : () => _scan(ImageSource.gallery),
icon: const Icon(Icons.photo_library_outlined),
label: const Text('从相册选择'),
),
),
],
),
const SizedBox(height: 16),
if (_busy) ...[
const LinearProgressIndicator(),
const SizedBox(height: 12),
],
Text(_status),
if (_elapsed != null) ...[
const SizedBox(height: 6),
Text(
'识别耗时 ${(_elapsed!.inMilliseconds / 1000).toStringAsFixed(2)} 秒',
style: const TextStyle(color: Colors.black54),
),
],
if (_result != null) ...[
const SizedBox(height: 20),
if (_result!.containsKey('code'))
Text(_result!['suggestion'] as String)
else ...[
SegmentedButton<bool>(
segments: const [
ButtonSegment(value: false, label: Text('字段')),
ButtonSegment(value: true, label: Text('JSON')),
],
selected: {_showJson},
onSelectionChanged: (value) =>
setState(() => _showJson = value.first),
),
const SizedBox(height: 12),
if (!_showJson) PassportFields(result: _result!),
],
if (_showJson || _result!.containsKey('code'))
SelectableText(
const JsonEncoder.withIndent(' ').convert(_result),
),
const SizedBox(height: 16),
OutlinedButton.icon(
onPressed: _busy ? null : _copy,
icon: const Icon(Icons.copy),
label: const Text('复制 JSON 结果'),
),
TextButton(
onPressed: _busy
? null
: () => setState(() {
_result = null;
_imagePath = null;
_elapsed = null;
_status = '完整拍入护照资料页,确保底部两行文字清晰。';
}),
child: const Text('清空结果'),
),
],
],
),
),
);
}
class PassportFields extends StatelessWidget {
const PassportFields({super.key, required this.result});
final Map<String, dynamic> result;
static const labels = {
'surname': '姓',
'givenNames': '名',
'passportNumber': '护照号',
'nationality': '国籍',
'birthDate': '出生日期',
'sex': '性别',
'expiryDate': '有效期至',
'issuingCountry': '签发国家',
'documentType': '证件类型',
};
@override
Widget build(BuildContext context) => Card(
margin: EdgeInsets.zero,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (final entry in labels.entries)
Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
entry.value,
style: const TextStyle(color: Colors.black54, fontSize: 12),
),
SelectableText(
'${result[entry.key] ?? '—'}',
style: const TextStyle(fontSize: 18),
),
],
),
),
Text(
result['isExpired'] == true
? '已过期'
: result['isExpired'] == false
? '有效期内'
: '请核对有效期',
),
],
),
),
);
}