zp_cccd_scanner 1.0.0
zp_cccd_scanner: ^1.0.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 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:zp_cccd_scanner/zp_cccd_scanner.dart';
void main() {
WidgetsFlutterBinding.ensureInitialized();
SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]);
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(this.result, this.elapsed);
final CccdScanResult result;
final Duration elapsed;
}
class HomePage extends StatefulWidget {
const HomePage({super.key});
@override
State<HomePage> createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> {
bool _debug = false;
Future<ScanOutcome?> _scan(CccdSide side) =>
Navigator.of(context).push<ScanOutcome>(
MaterialPageRoute(builder: (_) => ScanPage(side: side, debug: _debug)),
);
Future<void> _scanSingle(CccdSide side) async {
final outcome = await _scan(side);
if (outcome == null || !mounted) return;
await Navigator.of(
context,
).push(MaterialPageRoute(builder: (_) => ResultPage(outcomes: [outcome])));
}
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;
await Navigator.of(context).push(
MaterialPageRoute(
builder:
(_) => ResultPage(
outcomes: [front, back],
pairStatus: CccdPairing.check(front.result, back.result),
),
),
);
}
@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.badge_outlined,
title: 'Quét mặt trước',
subtitle: 'Chỉ nhận mặt trước, đưa mặt sau sẽ báo lật thẻ',
onTap: () => _scanSingle(CccdSide.front),
),
_ActionTile(
icon: Icons.fingerprint,
title: 'Quét mặt sau',
subtitle: 'Chỉ nhận mặt sau, đưa mặt trước sẽ báo lật thẻ',
onTap: () => _scanSingle(CccdSide.back),
),
_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: _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'),
),
],
),
);
}
}
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});
final CccdSide side;
final bool debug;
@override
State<ScanPage> createState() => _ScanPageState();
}
class _ScanPageState extends State<ScanPage> {
final _watch = Stopwatch()..start();
bool _torch = false;
void _toggleTorch() {
setState(() => _torch = !_torch);
ZpCccdScanner.setTorch(_torch);
}
Future<void> _onScanned(CccdScanResult r) async {
Navigator.of(context).pop(ScanOutcome(r, _watch.elapsed));
}
@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,
overlay: 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,
),
),
),
_CircleButton(
icon:
_torch
? Icons.flash_on_rounded
: Icons.flash_off_rounded,
onTap: _toggleTorch,
),
],
),
),
),
),
),
);
}
}
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 r = outcome.result;
final image = r.image;
return Card(
clipBehavior: Clip.antiAlias,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (image != null) Image.memory(image, fit: BoxFit.cover),
ListTile(
title: Text('${sideLabel(r.side)} · ${typeLabel(r.type)}'),
subtitle: Text(
'Thời gian nhận diện: '
'${(outcome.elapsed.inMilliseconds / 1000).toStringAsFixed(1)} s',
),
),
],
),
);
}
}