Skip to main content

zp_cccd_scanner 1.5.0 copy "zp_cccd_scanner: ^1.5.0" to clipboard
zp_cccd_scanner: ^1.5.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;

  Future<ScanOutcome?> _scan(CccdSide side) =>
      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: _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',
            ),
          ),
        ],
      ),
    );
  }
}
0
likes
0
points
792
downloads

Publisher

unverified uploader

Weekly Downloads

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).

Repository (GitHub)
View/report issues

Topics

#cccd #id-card #ekyc #scanner #camera

License

unknown (license)

Dependencies

flutter

More

Packages that depend on zp_cccd_scanner

Packages that implement zp_cccd_scanner