zp_cccd_scanner 1.7.0
zp_cccd_scanner: ^1.7.0 copied to clipboard
Flutter plugin quét và nhận diện thẻ CCCD / Căn cước Việt Nam (đủ 3 mẫu thẻ, 2 mặt) bằng camera native trên iOS (AVFoundation + Vision) và Android (CameraX + ML Kit).
example/lib/main.dart
import 'dart:async';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:zp_cccd_scanner/zp_cccd_scanner.dart';
import 'scan_log.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]);
await ScanLog.instance.init();
runApp(
MaterialApp(
title: 'CCCD Scanner',
debugShowCheckedModeBanner: false,
theme: ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFF0068FF)),
),
home: const HomePage(),
),
);
}
String typeLabel(CccdCardType t) {
switch (t) {
case CccdCardType.cccdBarcode:
return 'CCCD mã vạch (2016)';
case CccdCardType.cccdChip:
return 'CCCD gắn chip (2021)';
case CccdCardType.canCuoc:
return 'Thẻ Căn cước (2024)';
}
}
String sideLabel(CccdSide s) => s == CccdSide.front ? 'Mặt trước' : 'Mặt sau';
String pairLabel(CccdPairStatus s) {
switch (s) {
case CccdPairStatus.match:
return 'Hai mặt cùng một thẻ';
case CccdPairStatus.sameTypeOnly:
return 'Cùng mẫu thẻ (mặt sau không in số để đối chiếu)';
case CccdPairStatus.invalidSides:
return 'Sai thứ tự mặt thẻ';
case CccdPairStatus.mismatchType:
return 'Hai mặt khác mẫu thẻ';
case CccdPairStatus.mismatchId:
return 'Hai mặt không cùng một thẻ';
}
}
/// Kết quả một lần quét kèm thời gian từ lúc mở camera đến khi nhận diện xong
class ScanOutcome {
const ScanOutcome.auto(CccdScanResult this.result, this.elapsed) : manual = null;
const ScanOutcome.manual(CccdManualCaptureResult this.manual, this.elapsed) : result = null;
final CccdScanResult? result;
final CccdManualCaptureResult? manual;
final Duration elapsed;
bool get isManual => manual != null;
CccdSide get side => result?.side ?? manual!.side;
}
class HomePage extends StatefulWidget {
const HomePage({super.key});
@override
State<HomePage> createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> {
bool _debug = false;
bool _centerHint = false;
ZpCccdAiModel? _aiModel;
Future<ScanOutcome?> _scan(CccdSide side) {
ZpCccdScanner.aiModel = _aiModel;
return Navigator.of(context).push<ScanOutcome>(
MaterialPageRoute(
builder:
(_) => ScanPage(
side: side,
debug: _debug,
centerHint: _centerHint,
),
),
);
}
Future<void> _scanEkyc() async {
final front = await _scan(CccdSide.front);
if (front == null || !mounted) return;
final back = await _scan(CccdSide.back);
if (back == null || !mounted) return;
final pairStatus =
front.isManual || back.isManual
? null
: CccdPairing.check(front.result!, back.result!);
await Navigator.of(context).push(
MaterialPageRoute(
builder:
(_) => ResultPage(
outcomes: [front, back],
pairStatus: pairStatus,
),
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('CCCD Scanner')),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
_ActionTile(
icon: Icons.verified_user_outlined,
title: 'Luồng eKYC',
subtitle: 'Mặt trước → mặt sau → kiểm tra cùng một thẻ',
onTap: _scanEkyc,
),
const SizedBox(height: 8),
SwitchListTile(
value: _aiModel == ZpCccdAiModel.fp32,
onChanged:
(v) => setState(() => _aiModel = v ? ZpCccdAiModel.fp32 : null),
title: const Text('Dùng AI dò thẻ (fp32)'),
subtitle: const Text('Độ chính xác đầy đủ, ~10.6MB'),
),
SwitchListTile(
value: _aiModel == ZpCccdAiModel.int8,
onChanged:
(v) => setState(() => _aiModel = v ? ZpCccdAiModel.int8 : null),
title: const Text('Dùng AI dò thẻ (int8, nhẹ hơn)'),
subtitle: const Text(
'Nhẹ hơn, chậm hơn ~36%, lọt che khuất nhiều hơn một chút',
),
),
const SizedBox(height: 8),
SwitchListTile(
value: _debug,
onChanged: (v) => setState(() => _debug = v),
title: const Text('Hiển thị vùng nhận diện (debug)'),
subtitle: const Text('Vẽ khung các dòng chữ / mã native đọc được'),
),
SwitchListTile(
value: _centerHint,
onChanged: (v) => setState(() => _centerHint = v),
title: const Text('Cảnh báo giữa khung (kiểu TikTok)'),
subtitle: const Text('Hiển thị hướng dẫn ở giữa khung thay vì ở đáy'),
),
const _LogSection(),
],
),
);
}
}
/// Xuất / ghi chú / xoá log kiểm thử
class _LogSection extends StatelessWidget {
const _LogSection();
Future<void> _export(BuildContext context) async {
try {
await ScanLog.instance.export();
} on PlatformException catch (e) {
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Không xuất được log: ${e.message}')),
);
}
}
Future<void> _note(BuildContext context) async {
final controller = TextEditingController();
final text = await showDialog<String>(
context: context,
builder:
(context) => AlertDialog(
title: const Text('Ghi chú vào log'),
content: TextField(
controller: controller,
autofocus: true,
decoration: const InputDecoration(
hintText: 'VD: phòng tối, thẻ chip, đưa xa gần liên tục',
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Huỷ'),
),
FilledButton(
onPressed: () => Navigator.pop(context, controller.text),
child: const Text('Ghi'),
),
],
),
);
controller.dispose();
if (text == null || text.trim().isEmpty) return;
ScanLog.instance.note(text.trim());
}
Future<void> _clear(BuildContext context) async {
final ok = await showDialog<bool>(
context: context,
builder:
(context) => AlertDialog(
title: const Text('Xoá toàn bộ log?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Huỷ'),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Xoá'),
),
],
),
);
if (ok == true) await ScanLog.instance.clear();
}
@override
Widget build(BuildContext context) {
return Card(
child: ValueListenableBuilder<int>(
valueListenable: ScanLog.instance.lines,
builder:
(context, lines, _) => Column(
children: [
ListTile(
leading: const Icon(Icons.ios_share),
title: const Text('Xuất file log'),
subtitle: Text(
'$lines dòng · gửi file này để phân tích\n'
'${ScanLog.instance.path ?? ''}',
),
isThreeLine: true,
enabled: lines > 0,
onTap: () => _export(context),
),
ListTile(
leading: const Icon(Icons.edit_note),
title: const Text('Thêm ghi chú vào log'),
subtitle: const Text('Ghi tên kịch bản trước mỗi lượt test'),
onTap: () => _note(context),
),
ListTile(
leading: const Icon(Icons.delete_outline),
title: const Text('Xoá log'),
enabled: lines > 0,
onTap: () => _clear(context),
),
],
),
),
);
}
}
class _ActionTile extends StatelessWidget {
const _ActionTile({
required this.icon,
required this.title,
required this.subtitle,
required this.onTap,
});
final IconData icon;
final String title;
final String subtitle;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Card(
child: ListTile(
leading: Icon(icon, size: 32),
title: Text(title, style: const TextStyle(fontWeight: FontWeight.w600)),
subtitle: Text(subtitle),
trailing: const Icon(Icons.chevron_right),
onTap: onTap,
),
);
}
}
class ScanPage extends StatefulWidget {
const ScanPage({
super.key,
required this.side,
this.debug = false,
this.centerHint = false,
});
final CccdSide side;
final bool debug;
/// `true`: ẩn hint mặc định ở đáy khung, vẽ text hướng dẫn ở giữa khung
/// (kiểu TikTok) do demo tự vẽ. `false` (mặc định): giữ hint mặc định của
/// thư viện ở đáy khung. Chỉ ảnh hưởng vị trí hiển thị text, không liên
/// quan đến `freezeOnVerify`/`tipWarningColor` (luôn bật trong demo).
final bool centerHint;
@override
State<ScanPage> createState() => _ScanPageState();
}
class _ScanPageState extends State<ScanPage> {
// Alignment mặc định của khung quét trong CccdScannerView (lệch lên trên,
// không phải giữa màn hình). Khai báo tường minh ở đây và truyền cho cả
// CccdScannerView lẫn Align của text giữa khung để 2 bên không bao giờ
// lệch nhau dù sau này đổi giá trị.
static const _guideAlignment = Alignment(0, -0.25);
final _watch = Stopwatch()..start();
final _controller = CccdScannerController();
Timer? _manualCaptureTimer;
bool _manualCaptureAvailable = false;
bool _capturing = false;
CccdScanState _scanState = CccdScanState.searching;
CccdScanTip? _scanTip;
bool _noCandidate = false;
@override
void initState() {
super.initState();
ScanLog.instance.add({
'event': 'session',
'side': widget.side.name,
'debugOverlay': widget.debug,
});
_manualCaptureTimer = Timer(const Duration(seconds: 15), () {
if (!mounted) return;
setState(() => _manualCaptureAvailable = true);
});
}
@override
void dispose() {
_manualCaptureTimer?.cancel();
ScanLog.instance.add({
'event': 'sessionEnd',
'elapsedMs': _watch.elapsedMilliseconds,
});
super.dispose();
}
void _toggleTorch() {
final on = !ZpCccdScanner.torch.value;
ScanLog.instance.add({'event': 'torch', 'on': on});
ZpCccdScanner.setTorch(on);
}
Future<void> _onScanned(CccdScanResult r) async {
final image = r.image;
ScanLog.instance.add({
'event': 'scanned',
'elapsedMs': _watch.elapsedMilliseconds,
'type': r.type.name,
'side': r.side.name,
'highResolution': r.highResolution,
'imageSize': image == null ? null : await _imageSize(image),
});
if (!mounted) return;
Navigator.of(context).pop(ScanOutcome.auto(r, _watch.elapsed));
}
/// Kích thước ảnh JPEG mà không cần giải mã toàn bộ ảnh
static Future<List<int>> _imageSize(Uint8List bytes) async {
final buffer = await ui.ImmutableBuffer.fromUint8List(bytes);
final descriptor = await ui.ImageDescriptor.encoded(buffer);
final size = [descriptor.width, descriptor.height];
descriptor.dispose();
buffer.dispose();
return size;
}
void _onStateChanged(CccdScanState s) => setState(() => _scanState = s);
void _onTipChanged(CccdScanTip? t) => setState(() => _scanTip = t);
void _onNoCandidateChanged(bool v) => setState(() => _noCandidate = v);
bool get _showManualCaptureButton =>
_manualCaptureAvailable &&
_scanState != CccdScanState.verifying &&
_scanState != CccdScanState.done &&
_scanState != CccdScanState.rejected;
Future<void> _captureManually() async {
setState(() => _capturing = true);
try {
final result = await _controller.captureManual();
if (result == null) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Chụp thất bại, thử lại')),
);
return;
}
ScanLog.instance.add({
'event': 'manualCaptured',
'elapsedMs': _watch.elapsedMilliseconds,
'highResolution': result.highResolution,
});
if (!mounted) return;
Navigator.of(context).pop(ScanOutcome.manual(result, _watch.elapsed));
} finally {
if (mounted) setState(() => _capturing = false);
}
}
@override
Widget build(BuildContext context) {
return AnnotatedRegion<SystemUiOverlayStyle>(
value: SystemUiOverlayStyle.light,
child: Scaffold(
backgroundColor: Colors.black,
body: CccdScannerView(
side: widget.side,
onScanned: _onScanned,
showDebugOverlay: widget.debug,
onDiagnostic: ScanLog.instance.add,
controller: _controller,
onStateChanged: _onStateChanged,
onTipChanged: _onTipChanged,
onNoCandidateChanged: _onNoCandidateChanged,
// Hành vi "kiểu TikTok" (Goal 3/4 của spec): không đóng băng hình khi
// đang xác thực (hiện mesh thay vì scan-line), cảnh báo bằng màu đỏ ở
// 4 góc khung — luôn bật trong demo, không phụ thuộc centerHint (tuỳ
// chọn đó chỉ quyết định VỊ TRÍ hiển thị text hướng dẫn).
freezeOnVerify: false,
tipWarningColor: Colors.red,
guideAlignment: _guideAlignment,
hintBuilder:
widget.centerHint ? (_, __) => const SizedBox.shrink() : null,
// overlay phủ kín màn quét: đặt hàng nút lên trên cùng
overlay: Stack(
children: [
Align(
alignment: Alignment.topCenter,
child: SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
child: Row(
children: [
_CircleButton(
icon: Icons.arrow_back_ios_new_rounded,
onTap: () => Navigator.of(context).pop(),
),
Expanded(
child: Text(
'Chụp ${sideLabel(widget.side).toLowerCase()} CCCD',
textAlign: TextAlign.center,
style: const TextStyle(
color: Colors.white,
fontSize: 17,
fontWeight: FontWeight.w600,
),
),
),
ValueListenableBuilder<bool>(
valueListenable: ZpCccdScanner.torch,
builder:
(_, torch, __) => _CircleButton(
icon:
torch
? Icons.flash_on_rounded
: Icons.flash_off_rounded,
onTap: _toggleTorch,
),
),
],
),
),
),
),
if (widget.centerHint)
Align(
alignment: _guideAlignment,
child: IgnorePointer(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Text(
cccdHintText(
state: _scanState,
side: widget.side,
tip: _scanTip,
noCandidate: _noCandidate,
freezeOnVerify: false,
),
textAlign: TextAlign.center,
style: const TextStyle(
color: Colors.white,
fontSize: 15,
fontWeight: FontWeight.w600,
shadows: [Shadow(blurRadius: 4, color: Colors.black54)],
),
),
),
),
),
if (_showManualCaptureButton)
Align(
alignment: Alignment.bottomCenter,
child: SafeArea(
child: Padding(
padding: const EdgeInsets.only(bottom: 24),
child: FilledButton.icon(
onPressed: _capturing ? null : _captureManually,
icon: const Icon(Icons.camera_alt_outlined),
label: const Text('Chụp thủ công'),
),
),
),
),
],
),
),
),
);
}
}
class _CircleButton extends StatelessWidget {
const _CircleButton({required this.icon, required this.onTap});
final IconData icon;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
customBorder: const CircleBorder(),
child: Container(
width: 40,
height: 40,
decoration: const BoxDecoration(
color: Color(0x73000000),
shape: BoxShape.circle,
),
child: Icon(icon, color: Colors.white, size: 20),
),
);
}
}
class ResultPage extends StatelessWidget {
const ResultPage({super.key, required this.outcomes, this.pairStatus});
final List<ScanOutcome> outcomes;
final CccdPairStatus? pairStatus;
@override
Widget build(BuildContext context) {
final status = pairStatus;
return Scaffold(
appBar: AppBar(title: const Text('Kết quả')),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
if (status != null)
Card(
color:
status == CccdPairStatus.match ||
status == CccdPairStatus.sameTypeOnly
? Colors.green.shade50
: Colors.red.shade50,
child: ListTile(
leading: Icon(
status == CccdPairStatus.match ||
status == CccdPairStatus.sameTypeOnly
? Icons.check_circle
: Icons.error,
),
title: Text(pairLabel(status)),
),
),
for (final o in outcomes) _OutcomeCard(outcome: o),
],
),
);
}
}
class _OutcomeCard extends StatelessWidget {
const _OutcomeCard({required this.outcome});
final ScanOutcome outcome;
@override
Widget build(BuildContext context) {
final auto = outcome.result;
final manual = outcome.manual;
final image = auto?.image ?? manual?.image;
final title =
auto != null
? '${sideLabel(outcome.side)} · ${typeLabel(auto.type)}'
: '${sideLabel(outcome.side)} · Chụp thủ công — chờ BE xử lý';
return Card(
clipBehavior: Clip.antiAlias,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (image != null) Image.memory(image, fit: BoxFit.cover),
ListTile(
title: Text(title),
subtitle: Text(
'Thời gian: ${(outcome.elapsed.inMilliseconds / 1000).toStringAsFixed(1)} s',
),
),
],
),
);
}
}