flutter_cccd_vn 2.0.2 copy "flutter_cccd_vn: ^2.0.2" to clipboard
flutter_cccd_vn: ^2.0.2 copied to clipboard

ICAO Doc 9303 MRTD (biometric passport) Dart library with a Vietnamese CCCD (căn cước công dân) NFC reading adapter — BAC/PACE, OCR-driven credentials, and a typed CCCD data/verification API.

example/lib/main.dart

// Created by Crt Vavros, copyright © 2022 ZeroPass. All rights reserved.
// ignore_for_file: prefer_adjacent_string_concatenation, prefer_interpolation_to_compose_strings
// ignore_for_file: implementation_imports

import 'package:expandable/expandable.dart';
import 'package:flutter/material.dart';
import 'package:flutter/cupertino.dart';
import 'dart:async';

import 'package:flutter_cccd_vn/dmrtd.dart';
import 'package:flutter_cccd_vn/extensions.dart';
import 'package:flutter/services.dart';
import 'package:flutter_platform_widgets/flutter_platform_widgets.dart';
import 'package:logging/logging.dart';
import 'package:flutter_cccd_vn/src/proto/can_key.dart';
import 'package:intl/intl.dart';

import 'cccd_verification_flow.dart';

class MrtdData {
  EfCardAccess? cardAccess;
  EfCardSecurity? cardSecurity;
  EfCOM? com;
  EfSOD? sod;
  EfDG1? dg1;
  EfDG2? dg2;
  EfDG3? dg3;
  EfDG4? dg4;
  EfDG5? dg5;
  EfDG6? dg6;
  EfDG7? dg7;
  EfDG8? dg8;
  EfDG9? dg9;
  EfDG10? dg10;
  EfDG11? dg11;
  EfDG12? dg12;
  EfDG13? dg13;
  EfDG14? dg14;
  EfDG15? dg15;
  EfDG16? dg16;
  Uint8List? aaSig;
  bool? isPACE;
  bool? isDBA;
}

final Map<DgTag, String> dgTagToString = {
  EfDG1.TAG: 'EF.DG1',
  EfDG2.TAG: 'EF.DG2',
  EfDG3.TAG: 'EF.DG3',
  EfDG4.TAG: 'EF.DG4',
  EfDG5.TAG: 'EF.DG5',
  EfDG6.TAG: 'EF.DG6',
  EfDG7.TAG: 'EF.DG7',
  EfDG8.TAG: 'EF.DG8',
  EfDG9.TAG: 'EF.DG9',
  EfDG10.TAG: 'EF.DG10',
  EfDG11.TAG: 'EF.DG11',
  EfDG12.TAG: 'EF.DG12',
  EfDG13.TAG: 'EF.DG13',
  EfDG14.TAG: 'EF.DG14',
  EfDG15.TAG: 'EF.DG15',
  EfDG16.TAG: 'EF.DG16',
};

Widget _makeMrtdAccessDataWidget({
  required String header,
  required String collapsedText,
  required bool isPACE,
  required bool isDBA,
}) {
  return ExpandablePanel(
    theme: const ExpandableThemeData(
      headerAlignment: ExpandablePanelHeaderAlignment.center,
      tapBodyToCollapse: true,
      hasIcon: true,
      iconColor: Colors.red,
    ),
    header: Text(header),
    collapsed: Text(
      collapsedText,
      softWrap: true,
      maxLines: 2,
      overflow: TextOverflow.ellipsis,
    ),
    expanded: Container(
      padding: const EdgeInsets.all(18),
      color: Color.fromARGB(255, 239, 239, 239),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.stretch,
        children: [
          Text(
            'Access protocol: ${isPACE ? "PACE" : "BAC"}',
            //style: TextStyle(fontSize: 16.0),
          ),
          SizedBox(height: 8.0),
          Text(
            'Access key type: ${isDBA ? "DBA" : "CAN"}',
            //style: TextStyle(fontSize: 16.0),
          ),
        ],
      ),
    ),
  );
}

String formatEfCom(final EfCOM efCom) {
  var str =
      "version: ${efCom.version}\n"
      "unicode version: ${efCom.unicodeVersion}\n"
      "DG tags:";

  for (final t in efCom.dgTags) {
    try {
      str += " ${dgTagToString[t]!}";
    } catch (e) {
      str += " 0x${t.value.toRadixString(16)}";
    }
  }
  return str;
}

String formatMRZ(final MRZ mrz) {
  return "MRZ\n"
          "  version: ${mrz.version}\n" +
      "  doc code: ${mrz.documentCode}\n" +
      "  doc No.: ${mrz.documentNumber}\n" +
      "  country: ${mrz.country}\n" +
      "  nationality: ${mrz.nationality}\n" +
      "  name: ${mrz.firstName}\n" +
      "  surname: ${mrz.lastName}\n" +
      "  gender: ${mrz.gender}\n" +
      "  date of birth: ${DateFormat.yMd().format(mrz.dateOfBirth)}\n" +
      "  date of expiry: ${DateFormat.yMd().format(mrz.dateOfExpiry)}\n" +
      "  add. data: ${mrz.optionalData}\n" +
      "  add. data: ${mrz.optionalData2}";
}

String formatDG15(final EfDG15 dg15) {
  var str =
      "EF.DG15:\n"
      "  AAPublicKey\n"
      "    type: ";

  final rawSubPubKey = dg15.aaPublicKey.rawSubjectPublicKey();
  if (dg15.aaPublicKey.type == AAPublicKeyType.RSA) {
    final tvSubPubKey = TLV.fromBytes(rawSubPubKey);
    var rawSeq = tvSubPubKey.value;
    if (rawSeq[0] == 0x00) {
      rawSeq = rawSeq.sublist(1);
    }

    final tvKeySeq = TLV.fromBytes(rawSeq);
    final tvModule = TLV.decode(tvKeySeq.value);
    final tvExp = TLV.decode(tvKeySeq.value.sublist(tvModule.encodedLen));

    str +=
        "RSA\n"
        "    exponent: ${tvExp.value.hex()}\n"
        "    modulus: ${tvModule.value.hex()}";
  } else {
    str += "EC\n    SubjectPublicKey: ${rawSubPubKey.hex()}";
  }
  return str;
}

String formatProgressMsg(String message, int percentProgress) {
  final p = (percentProgress / 20).round();
  final full = "🟢 " * p;
  final empty = "⚪️ " * (5 - p);
  return message + "\n\n" + full + empty;
}

void main() {
  Logger.root.level = Level.ALL;
  Logger.root.logSensitiveData = false;
  Logger.root.onRecord.listen((record) {
    print(
      '${record.loggerName} ${record.level.name}: ${record.time}: ${record.message}',
    );
    if (record.error != null) {
      print('  error: ${record.error}');
    }
    if (record.stackTrace != null) {
      print('  stackTrace:\n${record.stackTrace}');
    }
  });
  runApp(MrtdEgApp());
}

class MrtdEgApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return PlatformApp(
      localizationsDelegates: [
        DefaultMaterialLocalizations.delegate,
        DefaultCupertinoLocalizations.delegate,
        DefaultWidgetsLocalizations.delegate,
      ],
      material: (_, __) => MaterialAppData(),
      cupertino: (_, __) => CupertinoAppData(),
      home: MrtdHomePage(),
    );
  }
}

class MrtdHomePage extends StatefulWidget {
  @override
  // ignore: library_private_types_in_public_api
  _MrtdHomePageState createState() => _MrtdHomePageState();
}

class _MrtdHomePageState extends State<MrtdHomePage>
    with TickerProviderStateMixin {
  var _alertMessage = "";
  final _log = Logger("mrtdeg.app");
  var _isNfcAvailable = false;
  var _isReading = false;
  final _mrzData = GlobalKey<FormState>();
  final _canData = GlobalKey<FormState>();

  // mrz data
  final _docNumber = TextEditingController();
  final _dob = TextEditingController(); // date of birth
  final _doe = TextEditingController();
  final _can = TextEditingController();
  bool _checkBoxPACE = false;

  MrtdData? _mrtdData;

  final NfcProvider _nfc = NfcProvider();

  // ignore: unused_field
  late Timer _timerStateUpdater;
  final _scrollController = ScrollController();
  late final TabController _tabController;

  @override
  void initState() {
    super.initState();

    _tabController = TabController(length: 2, vsync: this);
    //_tabController.addListener(_handleTabSelection);

    SystemChrome.setPreferredOrientations([
      DeviceOrientation.portraitUp,
      DeviceOrientation.portraitDown,
    ]);

    _initPlatformState();

    // Update platform state every 3 sec
    _timerStateUpdater = Timer.periodic(Duration(seconds: 3), (Timer t) {
      _initPlatformState();
    });
  }

  // Platform messages are asynchronous, so we initialize in an async method.
  Future<void> _initPlatformState() async {
    bool isNfcAvailable;
    try {
      NfcStatus status = await NfcProvider.nfcStatus;
      isNfcAvailable = status == NfcStatus.enabled;
    } on PlatformException {
      isNfcAvailable = false;
    }

    // If the widget was removed from the tree while the asynchronous platform
    // message was in flight, we want to discard the reply rather than calling
    // setState to update our non-existent appearance.
    if (!mounted) return;

    setState(() {
      _isNfcAvailable = isNfcAvailable;
    });
  }

  @override
  void dispose() {
    _timerStateUpdater.cancel();
    _scrollController.dispose();
    _tabController.dispose();
    super.dispose();
  }

  DateTime? _getDOBDate() {
    if (_dob.text.isEmpty) {
      return null;
    }
    return DateFormat.yMd().parse(_dob.text);
  }

  DateTime? _getDOEDate() {
    if (_doe.text.isEmpty) {
      return null;
    }
    return DateFormat.yMd().parse(_doe.text);
  }

  Future<String?> _pickDate(
    BuildContext context,
    DateTime firstDate,
    DateTime initDate,
    DateTime lastDate,
  ) async {
    final locale = Localizations.localeOf(context);
    final DateTime? picked = await showDatePicker(
      context: context,
      firstDate: firstDate,
      initialDate: initDate,
      lastDate: lastDate,
      locale: locale,
    );

    if (picked != null) {
      return DateFormat.yMd().format(picked);
    }
    return null;
  }

  void _buttonPressed() async {
    print("Button pressed");
    //Check on what tab we are
    if (_tabController.index == 0) {
      //DBA tab
      String errorText = "";
      if (_doe.text.isEmpty) {
        errorText += "Please enter date of expiry!\n";
      }
      if (_dob.text.isEmpty) {
        errorText += "Please enter date of birth!\n";
      }
      if (_docNumber.text.isEmpty) {
        errorText += "Please enter passport number!";
      }

      setState(() {
        _alertMessage = errorText;
      });
      //If there is an error, just jump out of the function
      if (errorText.isNotEmpty) return;

      final bacKeySeed = DBAKey(
        _docNumber.text,
        _getDOBDate()!,
        _getDOEDate()!,
        paceMode: _checkBoxPACE,
      );
      _readMRTD(accessKey: bacKeySeed, isPace: _checkBoxPACE);
    } else {
      //PACE tab
      String errorText = "";
      if (_can.text.isEmpty) {
        errorText = "Please enter CAN number!";
      } else if (_can.text.length != 6) {
        errorText = "CAN number must be exactly 6 digits long!";
      }

      setState(() {
        _alertMessage = errorText;
      });
      //If there is an error, just jump out of the function
      if (errorText.isNotEmpty) return;

      final canKeySeed = CanKey(_can.text);
      _readMRTD(accessKey: canKeySeed, isPace: true);
    }
  }

  void _readMRTD({required AccessKey accessKey, bool isPace = false}) async {
    try {
      setState(() {
        _mrtdData = null;
        _alertMessage = "Waiting for Passport tag ...";
        _isReading = true;
      });
      try {
        bool demo = false;
        if (!demo) {
          await _nfc.connect(
            iosAlertMessage: "Hold your phone near Biometric Passport",
          );
        }

        final passport = Passport(_nfc);

        setState(() {
          _alertMessage = "Reading Passport ...";
        });

        _nfc.setIosAlertMessage("Trying to read EF.CardAccess ...");
        final mrtdData = MrtdData();

        try {
          mrtdData.cardAccess = await passport.readEfCardAccess();
        } on PassportError {
          //if (e.code != StatusWord.fileNotFound) rethrow;
        }

        _nfc.setIosAlertMessage("Trying to read EF.CardSecurity ...");

        try {
          //mrtdData.cardSecurity = await passport.readEfCardSecurity();
        } on PassportError {
          //if (e.code != StatusWord.fileNotFound) rethrow;
        }

        _nfc.setIosAlertMessage("Initiating session with PACE...");
        //set MrtdData
        mrtdData.isPACE = isPace;
        mrtdData.isDBA = accessKey.PACE_REF_KEY_TAG == 0x01 ? true : false;

        if (isPace) {
          //PACE session
          await passport.startSessionPACE(accessKey, mrtdData.cardAccess!);
        } else {
          //BAC session
          await passport.startSession(accessKey as DBAKey);
        }

        _nfc.setIosAlertMessage(formatProgressMsg("Reading EF.COM ...", 0));
        mrtdData.com = await passport.readEfCOM();

        _nfc.setIosAlertMessage(
          formatProgressMsg("Reading Data Groups ...", 20),
        );

        if (mrtdData.com!.dgTags.contains(EfDG1.TAG)) {
          mrtdData.dg1 = await passport.readEfDG1();
        }

        if (mrtdData.com!.dgTags.contains(EfDG2.TAG)) {
          mrtdData.dg2 = await passport.readEfDG2();
        }

        // To read DG3 and DG4 session has to be established with CVCA certificate (not supported).
        // if(mrtdData.com!.dgTags.contains(EfDG3.TAG)) {
        //   mrtdData.dg3 = await passport.readEfDG3();
        // }

        // if(mrtdData.com!.dgTags.contains(EfDG4.TAG)) {
        //   mrtdData.dg4 = await passport.readEfDG4();
        // }

        if (mrtdData.com!.dgTags.contains(EfDG5.TAG)) {
          mrtdData.dg5 = await passport.readEfDG5();
        }

        if (mrtdData.com!.dgTags.contains(EfDG6.TAG)) {
          mrtdData.dg6 = await passport.readEfDG6();
        }

        if (mrtdData.com!.dgTags.contains(EfDG7.TAG)) {
          mrtdData.dg7 = await passport.readEfDG7();
        }

        if (mrtdData.com!.dgTags.contains(EfDG8.TAG)) {
          mrtdData.dg8 = await passport.readEfDG8();
        }

        if (mrtdData.com!.dgTags.contains(EfDG9.TAG)) {
          mrtdData.dg9 = await passport.readEfDG9();
        }

        if (mrtdData.com!.dgTags.contains(EfDG10.TAG)) {
          mrtdData.dg10 = await passport.readEfDG10();
        }

        if (mrtdData.com!.dgTags.contains(EfDG11.TAG)) {
          mrtdData.dg11 = await passport.readEfDG11();
        }

        if (mrtdData.com!.dgTags.contains(EfDG12.TAG)) {
          mrtdData.dg12 = await passport.readEfDG12();
        }

        if (mrtdData.com!.dgTags.contains(EfDG13.TAG)) {
          mrtdData.dg13 = await passport.readEfDG13();
        }

        if (mrtdData.com!.dgTags.contains(EfDG14.TAG)) {
          mrtdData.dg14 = await passport.readEfDG14();
        }

        if (mrtdData.com!.dgTags.contains(EfDG15.TAG)) {
          mrtdData.dg15 = await passport.readEfDG15();
          _nfc.setIosAlertMessage(formatProgressMsg("Doing AA ...", 60));
          mrtdData.aaSig = await passport.activeAuthenticate(Uint8List(8));
        }

        if (mrtdData.com!.dgTags.contains(EfDG16.TAG)) {
          mrtdData.dg16 = await passport.readEfDG16();
        }

        _nfc.setIosAlertMessage(formatProgressMsg("Reading EF.SOD ...", 80));
        mrtdData.sod = await passport.readEfSOD();

        setState(() {
          _mrtdData = mrtdData;
        });

        setState(() {
          _alertMessage = "";
        });

        _scrollController.animateTo(
          300.0,
          duration: Duration(milliseconds: 500),
          curve: Curves.ease,
        );
      } on Exception catch (e) {
        final se = e.toString().toLowerCase();
        String alertMsg = "An error has occurred while reading Passport!";
        if (e is PassportError) {
          if (se.contains("security status not satisfied")) {
            alertMsg =
                "Failed to initiate session with passport.\nCheck input data!";
          }
          _log.error("PassportError: ${e.message}");
        } else {
          _log.error(
            "An exception was encountered while trying to read Passport: $e",
          );
        }

        if (se.contains('timeout')) {
          alertMsg = "Timeout while waiting for Passport tag";
        } else if (se.contains("tag was lost")) {
          alertMsg = "Tag was lost. Please try again!";
        } else if (se.contains("invalidated by user")) {
          alertMsg = "";
        }

        setState(() {
          _alertMessage = alertMsg;
        });
      } finally {
        if (_alertMessage.isNotEmpty) {
          await _nfc.disconnect(iosErrorMessage: _alertMessage);
        } else {
          await _nfc.disconnect(
            iosAlertMessage: formatProgressMsg("Finished", 100),
          );
        }
        setState(() {
          _isReading = false;
        });
      }
    } on Exception catch (e) {
      _log.error("Read MRTD error: $e");
    }
  }

  // ignore: unused_element
  void _readMRTDOld() async {
    try {
      setState(() {
        _mrtdData = null;
        _alertMessage = "Waiting for Passport tag ...";
        _isReading = true;
      });

      await _nfc.connect(
        iosAlertMessage: "Hold your phone near Biometric Passport",
      );
      final passport = Passport(_nfc);

      setState(() {
        _alertMessage = "Reading Passport ...";
      });

      _nfc.setIosAlertMessage("Trying to read EF.CardAccess ...");
      final mrtdData = MrtdData();

      try {
        mrtdData.cardAccess = await passport.readEfCardAccess();
      } on PassportError {
        //if (e.code != StatusWord.fileNotFound) rethrow;
      }

      _nfc.setIosAlertMessage("Trying to read EF.CardSecurity ...");

      try {
        mrtdData.cardSecurity = await passport.readEfCardSecurity();
      } on PassportError {
        //if (e.code != StatusWord.fileNotFound) rethrow;
      }

      _nfc.setIosAlertMessage("Initiating session ...");
      final bacKeySeed = DBAKey(
        _docNumber.text,
        _getDOBDate()!,
        _getDOEDate()!,
      );
      await passport.startSession(bacKeySeed);

      _nfc.setIosAlertMessage(formatProgressMsg("Reading EF.COM ...", 0));
      mrtdData.com = await passport.readEfCOM();

      _nfc.setIosAlertMessage(formatProgressMsg("Reading Data Groups ...", 20));

      if (mrtdData.com!.dgTags.contains(EfDG1.TAG)) {
        mrtdData.dg1 = await passport.readEfDG1();
      }

      if (mrtdData.com!.dgTags.contains(EfDG2.TAG)) {
        mrtdData.dg2 = await passport.readEfDG2();
      }

      // To read DG3 and DG4 session has to be established with CVCA certificate (not supported).
      // if(mrtdData.com!.dgTags.contains(EfDG3.TAG)) {
      //   mrtdData.dg3 = await passport.readEfDG3();
      // }

      // if(mrtdData.com!.dgTags.contains(EfDG4.TAG)) {
      //   mrtdData.dg4 = await passport.readEfDG4();
      // }

      if (mrtdData.com!.dgTags.contains(EfDG5.TAG)) {
        mrtdData.dg5 = await passport.readEfDG5();
      }

      if (mrtdData.com!.dgTags.contains(EfDG6.TAG)) {
        mrtdData.dg6 = await passport.readEfDG6();
      }

      if (mrtdData.com!.dgTags.contains(EfDG7.TAG)) {
        mrtdData.dg7 = await passport.readEfDG7();
      }

      if (mrtdData.com!.dgTags.contains(EfDG8.TAG)) {
        mrtdData.dg8 = await passport.readEfDG8();
      }

      if (mrtdData.com!.dgTags.contains(EfDG9.TAG)) {
        mrtdData.dg9 = await passport.readEfDG9();
      }

      if (mrtdData.com!.dgTags.contains(EfDG10.TAG)) {
        mrtdData.dg10 = await passport.readEfDG10();
      }

      if (mrtdData.com!.dgTags.contains(EfDG11.TAG)) {
        mrtdData.dg11 = await passport.readEfDG11();
      }

      if (mrtdData.com!.dgTags.contains(EfDG12.TAG)) {
        mrtdData.dg12 = await passport.readEfDG12();
      }

      if (mrtdData.com!.dgTags.contains(EfDG13.TAG)) {
        mrtdData.dg13 = await passport.readEfDG13();
      }

      if (mrtdData.com!.dgTags.contains(EfDG14.TAG)) {
        mrtdData.dg14 = await passport.readEfDG14();
      }

      if (mrtdData.com!.dgTags.contains(EfDG15.TAG)) {
        mrtdData.dg15 = await passport.readEfDG15();
        _nfc.setIosAlertMessage(formatProgressMsg("Doing AA ...", 60));
        mrtdData.aaSig = await passport.activeAuthenticate(Uint8List(8));
      }

      if (mrtdData.com!.dgTags.contains(EfDG16.TAG)) {
        mrtdData.dg16 = await passport.readEfDG16();
      }

      _nfc.setIosAlertMessage(formatProgressMsg("Reading EF.SOD ...", 80));
      mrtdData.sod = await passport.readEfSOD();

      setState(() {
        _mrtdData = mrtdData;
      });

      setState(() {
        _alertMessage = "";
      });

      _scrollController.animateTo(
        300.0,
        duration: Duration(milliseconds: 500),
        curve: Curves.ease,
      );
    } on Exception catch (e) {
      final se = e.toString().toLowerCase();
      String alertMsg = "An error has occurred while reading Passport!";
      if (e is PassportError) {
        if (se.contains("security status not satisfied")) {
          alertMsg =
              "Failed to initiate session with passport.\nCheck input data!";
        }
        _log.error("PassportError: ${e.message}");
      } else {
        _log.error(
          "An exception was encountered while trying to read Passport: $e",
        );
      }

      if (se.contains('timeout')) {
        alertMsg = "Timeout while waiting for Passport tag";
      } else if (se.contains("tag was lost")) {
        alertMsg = "Tag was lost. Please try again!";
      } else if (se.contains("invalidated by user")) {
        alertMsg = "";
      }

      setState(() {
        _alertMessage = alertMsg;
      });
    } finally {
      if (_alertMessage.isNotEmpty) {
        await _nfc.disconnect(iosErrorMessage: _alertMessage);
      } else {
        await _nfc.disconnect(
          iosAlertMessage: formatProgressMsg("Finished", 100),
        );
      }
      setState(() {
        _isReading = false;
      });
    }
  }

  @override
  Widget build(BuildContext context) {
    return PlatformProvider(
      builder: (BuildContext context) => _buildPage(context),
    );
  }

  bool _disabledInput() {
    //return true;
    return _isReading || !_isNfcAvailable;
  }

  Widget _makeMrtdDataWidget({
    required String header,
    required String collapsedText,
    required dataText,
  }) {
    return ExpandablePanel(
      theme: const ExpandableThemeData(
        headerAlignment: ExpandablePanelHeaderAlignment.center,
        tapBodyToCollapse: true,
        hasIcon: true,
        iconColor: Colors.red,
      ),
      header: Text(header),
      collapsed: Text(
        collapsedText,
        softWrap: true,
        maxLines: 2,
        overflow: TextOverflow.ellipsis,
      ),
      expanded: Container(
        padding: const EdgeInsets.all(18),
        color: Color.fromARGB(255, 239, 239, 239),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: [
            PlatformTextButton(
              child: Text('Copy'),
              onPressed: () => Clipboard.setData(ClipboardData(text: dataText)),
              padding: const EdgeInsets.all(8),
            ),
            SelectableText(dataText, textAlign: TextAlign.left),
          ],
        ),
      ),
    );
  }

  List<Widget> _mrtdDataWidgets() {
    List<Widget> list = [];
    if (_mrtdData == null) return list;

    if (_mrtdData!.isPACE != null && _mrtdData!.isDBA != null) {
      list.add(
        _makeMrtdAccessDataWidget(
          header: "Access protocol",
          collapsedText: '',
          isDBA: _mrtdData!.isDBA!,
          isPACE: _mrtdData!.isPACE!,
        ),
      );
    }

    if (_mrtdData!.cardAccess != null) {
      list.add(
        _makeMrtdDataWidget(
          header: 'EF.CardAccess',
          collapsedText: '',
          dataText: _mrtdData!.cardAccess!.toBytes().hex(),
        ),
      );
    }

    if (_mrtdData!.cardSecurity != null) {
      list.add(
        _makeMrtdDataWidget(
          header: 'EF.CardSecurity',
          collapsedText: '',
          dataText: _mrtdData!.cardSecurity!.toBytes().hex(),
        ),
      );
    }

    if (_mrtdData!.sod != null) {
      list.add(
        _makeMrtdDataWidget(
          header: 'EF.SOD',
          collapsedText: '',
          dataText: _mrtdData!.sod!.toBytes().hex(),
        ),
      );
    }

    if (_mrtdData!.com != null) {
      list.add(
        _makeMrtdDataWidget(
          header: 'EF.COM',
          collapsedText: '',
          dataText: formatEfCom(_mrtdData!.com!),
        ),
      );
    }

    if (_mrtdData!.dg1 != null) {
      list.add(
        _makeMrtdDataWidget(
          header: 'EF.DG1',
          collapsedText: '',
          dataText: formatMRZ(_mrtdData!.dg1!.mrz),
        ),
      );
    }

    if (_mrtdData!.dg2 != null) {
      list.add(
        _makeMrtdDataWidget(
          header: 'EF.DG2',
          collapsedText: '',
          dataText: _mrtdData!.dg2!.toBytes().hex(),
        ),
      );
    }

    if (_mrtdData!.dg3 != null) {
      list.add(
        _makeMrtdDataWidget(
          header: 'EF.DG3',
          collapsedText: '',
          dataText: _mrtdData!.dg3!.toBytes().hex(),
        ),
      );
    }

    if (_mrtdData!.dg4 != null) {
      list.add(
        _makeMrtdDataWidget(
          header: 'EF.DG4',
          collapsedText: '',
          dataText: _mrtdData!.dg4!.toBytes().hex(),
        ),
      );
    }

    if (_mrtdData!.dg5 != null) {
      list.add(
        _makeMrtdDataWidget(
          header: 'EF.DG5',
          collapsedText: '',
          dataText: _mrtdData!.dg5!.toBytes().hex(),
        ),
      );
    }

    if (_mrtdData!.dg6 != null) {
      list.add(
        _makeMrtdDataWidget(
          header: 'EF.DG6',
          collapsedText: '',
          dataText: _mrtdData!.dg6!.toBytes().hex(),
        ),
      );
    }

    if (_mrtdData!.dg7 != null) {
      list.add(
        _makeMrtdDataWidget(
          header: 'EF.DG7',
          collapsedText: '',
          dataText: _mrtdData!.dg7!.toBytes().hex(),
        ),
      );
    }

    if (_mrtdData!.dg8 != null) {
      list.add(
        _makeMrtdDataWidget(
          header: 'EF.DG8',
          collapsedText: '',
          dataText: _mrtdData!.dg8!.toBytes().hex(),
        ),
      );
    }

    if (_mrtdData!.dg9 != null) {
      list.add(
        _makeMrtdDataWidget(
          header: 'EF.DG9',
          collapsedText: '',
          dataText: _mrtdData!.dg9!.toBytes().hex(),
        ),
      );
    }

    if (_mrtdData!.dg10 != null) {
      list.add(
        _makeMrtdDataWidget(
          header: 'EF.DG10',
          collapsedText: '',
          dataText: _mrtdData!.dg10!.toBytes().hex(),
        ),
      );
    }

    if (_mrtdData!.dg11 != null) {
      list.add(
        _makeMrtdDataWidget(
          header: 'EF.DG11',
          collapsedText: '',
          dataText: _mrtdData!.dg11!.toBytes().hex(),
        ),
      );
    }

    if (_mrtdData!.dg12 != null) {
      list.add(
        _makeMrtdDataWidget(
          header: 'EF.DG12',
          collapsedText: '',
          dataText: _mrtdData!.dg12!.toBytes().hex(),
        ),
      );
    }

    if (_mrtdData!.dg13 != null) {
      list.add(
        _makeMrtdDataWidget(
          header: 'EF.DG13',
          collapsedText: '',
          dataText: _mrtdData!.dg13!.toBytes().hex(),
        ),
      );
    }

    if (_mrtdData!.dg14 != null) {
      list.add(
        _makeMrtdDataWidget(
          header: 'EF.DG14',
          collapsedText: '',
          dataText: _mrtdData!.dg14!.toBytes().hex(),
        ),
      );
    }

    if (_mrtdData!.dg15 != null) {
      list.add(
        _makeMrtdDataWidget(
          header: 'EF.DG15',
          collapsedText: '',
          dataText: _mrtdData!.dg15!.toBytes().hex(),
        ),
      );
    }

    if (_mrtdData!.aaSig != null) {
      list.add(
        _makeMrtdDataWidget(
          header: 'Active Authentication signature',
          collapsedText: '',
          dataText: _mrtdData!.aaSig!.hex(),
        ),
      );
    }

    if (_mrtdData!.dg16 != null) {
      list.add(
        _makeMrtdDataWidget(
          header: 'EF.DG16',
          collapsedText: '',
          dataText: _mrtdData!.dg16!.toBytes().hex(),
        ),
      );
    }

    return list;
  }

  PlatformScaffold _buildPage(BuildContext context) => PlatformScaffold(
    appBar: PlatformAppBar(title: const Text('CCCD NFC Demo')),
    iosContentPadding: false,
    iosContentBottomPadding: false,
    body: Material(
      child: SafeArea(
        child: SingleChildScrollView(
          controller: _scrollController,
          padding: const EdgeInsets.all(16),
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.stretch,
            children: [
              _buildHeroCard(context),
              const SizedBox(height: 24),
              Text(
                'CÔNG CỤ KHÁC',
                style: TextStyle(
                  fontSize: 12,
                  fontWeight: FontWeight.w600,
                  color: Colors.grey.shade600,
                  letterSpacing: 0.6,
                ),
              ),
              const SizedBox(height: 8),
              _buildLegacyPassportCard(context),
            ],
          ),
        ),
      ),
    ),
  );

  Widget _buildHeroCard(BuildContext context) {
    return Container(
      padding: const EdgeInsets.all(24),
      decoration: BoxDecoration(
        gradient: const LinearGradient(
          begin: Alignment.topLeft,
          end: Alignment.bottomRight,
          colors: [Color(0xFF0D47A1), Color(0xFF1976D2)],
        ),
        borderRadius: BorderRadius.circular(24),
      ),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Container(
            padding: const EdgeInsets.all(10),
            decoration: BoxDecoration(
              color: Colors.white.withValues(alpha: 0.15),
              shape: BoxShape.circle,
            ),
            child: const Icon(Icons.credit_card, color: Colors.white, size: 26),
          ),
          const SizedBox(height: 16),
          const Text(
            'Xác minh CCCD gắn chip',
            style: TextStyle(
              color: Colors.white,
              fontSize: 20,
              fontWeight: FontWeight.bold,
            ),
          ),
          const SizedBox(height: 6),
          Text(
            'Chụp mặt trước, mặt sau → OCR → quét NFC → đọc dữ liệu chip.',
            style: TextStyle(
              color: Colors.white.withValues(alpha: 0.85),
              fontSize: 13,
              height: 1.4,
            ),
          ),
          const SizedBox(height: 16),
          Container(
            padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
            decoration: BoxDecoration(
              color: Colors.white.withValues(alpha: 0.15),
              borderRadius: BorderRadius.circular(20),
            ),
            child: Row(
              mainAxisSize: MainAxisSize.min,
              children: [
                Icon(
                  _isNfcAvailable ? Icons.nfc : Icons.nfc_outlined,
                  size: 14,
                  color: Colors.white,
                ),
                const SizedBox(width: 6),
                Text(
                  _isNfcAvailable ? 'NFC sẵn sàng' : 'NFC không khả dụng',
                  style: const TextStyle(color: Colors.white, fontSize: 12),
                ),
              ],
            ),
          ),
          const SizedBox(height: 20),
          SizedBox(
            width: double.infinity,
            child: FilledButton.icon(
              style: FilledButton.styleFrom(
                backgroundColor: Colors.white,
                foregroundColor: const Color(0xFF0D47A1),
                padding: const EdgeInsets.symmetric(vertical: 14),
                shape: RoundedRectangleBorder(
                  borderRadius: BorderRadius.circular(14),
                ),
              ),
              onPressed: () => Navigator.of(context).push(
                MaterialPageRoute<void>(
                  builder: (_) => const CccdVerificationPage(),
                ),
              ),
              icon: const Icon(Icons.arrow_forward),
              label: const Text(
                'Bắt đầu xác minh',
                style: TextStyle(fontWeight: FontWeight.bold),
              ),
            ),
          ),
        ],
      ),
    );
  }

  Widget _buildLegacyPassportCard(BuildContext context) {
    return Container(
      decoration: BoxDecoration(
        border: Border.all(color: Colors.grey.shade300),
        borderRadius: BorderRadius.circular(16),
      ),
      child: Theme(
        data: Theme.of(context).copyWith(dividerColor: Colors.transparent),
        child: ExpansionTile(
          tilePadding: const EdgeInsets.symmetric(horizontal: 16),
          leading: const Icon(Icons.flight_takeoff_outlined),
          title: const Text(
            'Đọc hộ chiếu quốc tế (nâng cao)',
            style: TextStyle(fontWeight: FontWeight.w600, fontSize: 14),
          ),
          subtitle: const Text(
            'DBA / PACE — kiểm thử thư viện dmrtd gốc',
            style: TextStyle(fontSize: 12),
          ),
          childrenPadding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
          children: [
            _buildForm(context),
            const SizedBox(height: 16),
            SizedBox(
              width: double.infinity,
              child: PlatformElevatedButton(
                onPressed: _buttonPressed,
                child: PlatformText(
                  _isReading ? 'Reading ...' : 'Read Passport',
                ),
              ),
            ),
            if (_alertMessage.isNotEmpty) ...[
              const SizedBox(height: 16),
              Text(
                _alertMessage,
                textAlign: TextAlign.center,
                style: const TextStyle(
                  fontSize: 14,
                  fontWeight: FontWeight.bold,
                ),
              ),
            ],
            if (_mrtdData != null) ...[
              const SizedBox(height: 16),
              const Text(
                'Passport Data:',
                style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold),
              ),
              Padding(
                padding: const EdgeInsets.only(top: 8),
                child: Column(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: _mrtdDataWidgets(),
                ),
              ),
            ],
          ],
        ),
      ),
    );
  }

  Widget _buildForm(BuildContext context) {
    return Column(
      children: <Widget>[
        TabBar(
          controller: _tabController,
          labelColor: Colors.blue,
          tabs: const <Widget>[
            Tab(text: 'DBA'),
            Tab(text: 'PACE'),
          ],
        ),
        Container(
          height: 350,
          child: TabBarView(
            controller: _tabController,

            children: <Widget>[
              Card(
                borderOnForeground: false,
                elevation: 0,
                color: Colors.white,
                //shadowColor: Colors.white,
                margin: const EdgeInsets.all(16.0),
                child: Form(
                  key: _mrzData,
                  child: Column(
                    crossAxisAlignment: CrossAxisAlignment.start,
                    children: <Widget>[
                      TextFormField(
                        enabled: !_disabledInput(),
                        controller: _docNumber,
                        decoration: const InputDecoration(
                          border: OutlineInputBorder(),
                          labelText: 'Passport number',
                          fillColor: Colors.white,
                        ),
                        inputFormatters: <TextInputFormatter>[
                          FilteringTextInputFormatter.allow(
                            RegExp(r'[A-Z0-9]+'),
                          ),
                          LengthLimitingTextInputFormatter(14),
                        ],
                        textInputAction: TextInputAction.done,
                        textCapitalization: TextCapitalization.characters,
                        autofocus: true,
                        validator: (value) {
                          if (value?.isEmpty ?? false) {
                            return 'Please enter passport number';
                          }
                          return null;
                        },
                      ),
                      SizedBox(height: 12),
                      TextFormField(
                        enabled: !_disabledInput(),
                        controller: _dob,
                        decoration: const InputDecoration(
                          border: OutlineInputBorder(),
                          labelText: 'Date of Birth',
                          fillColor: Colors.white,
                        ),
                        autofocus: false,
                        validator: (value) {
                          if (value?.isEmpty ?? false) {
                            return 'Please select Date of Birth';
                          }
                          return null;
                        },
                        onTap: () async {
                          FocusScope.of(context).requestFocus(FocusNode());
                          // Can pick date which dates 15 years back or more
                          final now = DateTime.now();
                          final firstDate = DateTime(
                            now.year - 90,
                            now.month,
                            now.day,
                          );
                          final lastDate = DateTime(
                            now.year - 15,
                            now.month,
                            now.day,
                          );
                          final initDate = _getDOBDate();
                          final date = await _pickDate(
                            context,
                            firstDate,
                            initDate ?? lastDate,
                            lastDate,
                          );

                          FocusScope.of(context).requestFocus(FocusNode());
                          if (date != null) {
                            _dob.text = date;
                          }
                        },
                      ),
                      SizedBox(height: 12),
                      TextFormField(
                        enabled: !_disabledInput(),
                        controller: _doe,
                        decoration: const InputDecoration(
                          border: OutlineInputBorder(),
                          labelText: 'Date of Expiry',
                          fillColor: Colors.white,
                        ),
                        autofocus: false,
                        validator: (value) {
                          if (value?.isEmpty ?? false) {
                            return 'Please select Date of Expiry';
                          }
                          return null;
                        },
                        onTap: () async {
                          FocusScope.of(context).requestFocus(FocusNode());
                          // Can pick date from tomorrow and up to 10 years
                          final now = DateTime.now();
                          final firstDate = DateTime(
                            now.year,
                            now.month,
                            now.day + 1,
                          );
                          final lastDate = DateTime(
                            now.year + 10,
                            now.month + 6,
                            now.day,
                          );
                          final initDate = _getDOEDate();
                          final date = await _pickDate(
                            context,
                            firstDate,
                            initDate ?? firstDate,
                            lastDate,
                          );

                          FocusScope.of(context).requestFocus(FocusNode());
                          if (date != null) {
                            _doe.text = date;
                          }
                        },
                      ),
                      SizedBox(height: 12),
                      CheckboxListTile(
                        title: Text('DBA with PACE'),
                        value: _checkBoxPACE,
                        onChanged: (newValue) {
                          setState(() {
                            _checkBoxPACE = !_checkBoxPACE;
                          });
                        },
                      ),
                    ],
                  ),
                ),
              ),
              Card(
                borderOnForeground: false,
                elevation: 0,
                color: Colors.white,
                //shadowColor: Colors.white,
                margin: const EdgeInsets.all(16.0),
                child: Form(
                  key: _canData,
                  child: Column(
                    crossAxisAlignment: CrossAxisAlignment.start,
                    children: <Widget>[
                      TextFormField(
                        enabled: !_disabledInput(),
                        controller: _can,
                        decoration: const InputDecoration(
                          border: OutlineInputBorder(),
                          labelText: 'CAN number',
                          fillColor: Colors.white,
                        ),
                        inputFormatters: <TextInputFormatter>[
                          FilteringTextInputFormatter.allow(RegExp(r'[0-9]+')),
                          LengthLimitingTextInputFormatter(6),
                        ],
                        textInputAction: TextInputAction.done,
                        textCapitalization: TextCapitalization.characters,
                        autofocus: true,
                        validator: (value) {
                          if (value?.isEmpty ?? false) {
                            return 'Please enter CAN number';
                          }
                          return null;
                        },
                      ),
                    ],
                  ),
                ),
              ),
            ],
          ),
        ),
      ],
    );
  }
}
1
likes
120
points
0
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

ICAO Doc 9303 MRTD (biometric passport) Dart library with a Vietnamese CCCD (căn cước công dân) NFC reading adapter — BAC/PACE, OCR-driven credentials, and a typed CCCD data/verification API.

Repository (GitHub)
View/report issues

Topics

#nfc #passport #mrtd #icao #cccd

License

unknown (license)

Dependencies

archive, collection, convert, crypto, crypto_keys_plus, diffie_hellman, expandable, fixnum, flutter_nfc_kit, logging, meta, pointycastle, tripledes_nullsafety

More

Packages that depend on flutter_cccd_vn