credit_card_nfc_reader 0.2.1 copy "credit_card_nfc_reader: ^0.2.1" to clipboard
credit_card_nfc_reader: ^0.2.1 copied to clipboard

PlatformAndroid

Android NFC EMV card reader wrapping devnied's EMV NFC Paycard Enrollment library.

example/lib/main.dart

import 'dart:convert';
import 'package:credit_card_nfc_reader/credit_card_nfc_reader.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'feature_catalog.dart';

void main() => runApp(
  MaterialApp(
    theme: ThemeData(colorSchemeSeed: Colors.indigo, useMaterial3: true),
    home: const ReaderPage(),
  ),
);

class ReaderPage extends StatefulWidget {
  const ReaderPage({super.key});
  @override
  State<ReaderPage> createState() => _ReaderPageState();
}

class _ReaderPageState extends State<ReaderPage> {
  final _reader = EmvNfcReader();
  final _country = TextEditingController(text: 'FR');
  final _terminal = TextEditingController(text: '{}');
  final _timeout = TextEditingController(text: '30');
  final _exchangeTimeout = TextEditingController(text: '5000');
  final _input = TextEditingController(text: decoderExamples['tlv']);
  final _tag = TextEditingController(text: '82');
  final _replayInput = TextEditingController();
  String _preset = 'Quick read';
  String _decoder = 'tlv';
  String _group = 'All fields';
  String _search = '';
  String _message =
      'Choose a preset, then read a card or run a synthetic replay.';
  String _source = '';
  String _status = 'Not checked';
  int _page = 0;
  bool _busy = false;
  bool _reveal = false;
  EmvParserMode _mode = EmvParserMode.all;
  Map<String, bool> _flags = {};
  Map<String, Object?>? _result;
  Object? _decoded;
  Map<String, dynamic>? _catalogs;

  @override
  void initState() {
    super.initState();
    _applyPreset(_preset);
  }

  @override
  void dispose() {
    for (final controller in [
      _country,
      _terminal,
      _timeout,
      _exchangeTimeout,
      _input,
      _tag,
      _replayInput,
    ]) {
      controller.dispose();
    }
    super.dispose();
  }

  void _applyPreset(String name) {
    final options = featurePresets[name]!;
    _preset = name;
    _flags = {
      'contactLess': options.contactLess,
      'readAllAids': options.readAllAids,
      'readTransactions': options.readTransactions,
      'readAllRecords': options.readAllRecords,
      'readAt': options.readAt,
      'readCplc': options.readCplc,
      'readExtendedData': options.readExtendedData,
      'extendedSelectionSupported': options.extendedSelectionSupported,
      'captureApduTrace': options.captureApduTrace,
    };
    _country.text = options.terminalCountryCode;
    _terminal.text = jsonEncode(options.terminalValues);
    _mode = options.parserMode;
    _exchangeTimeout.text = '${options.transceiveTimeout.inMilliseconds}';
  }

  EmvReadOptions _options() => EmvReadOptions(
    contactLess: _flags['contactLess']!,
    readAllAids: _flags['readAllAids']!,
    readTransactions: _flags['readTransactions']!,
    readAllRecords: _flags['readAllRecords']!,
    readAt: _flags['readAt']!,
    readCplc: _flags['readCplc']!,
    readExtendedData: _flags['readExtendedData']!,
    extendedSelectionSupported: _flags['extendedSelectionSupported']!,
    captureApduTrace: _flags['captureApduTrace']!,
    terminalCountryCode: _country.text.trim(),
    parserMode: _mode,
    terminalValues: Map<String, String>.from(jsonDecode(_terminal.text) as Map),
    transceiveTimeout: Duration(milliseconds: int.parse(_exchangeTimeout.text)),
  );

  Future<void> _run(Future<void> Function() action) async {
    setState(() {
      _busy = true;
      _message = 'Running…';
    });
    try {
      await action();
      if (mounted) setState(() => _message = 'Completed');
    } on PlatformException catch (e) {
      if (mounted) setState(() => _message = '${e.code}: ${e.message}');
    } catch (e) {
      if (mounted) setState(() => _message = 'Unable to run: $e');
    } finally {
      if (mounted) setState(() => _busy = false);
    }
  }

  Future<void> _read() => _run(() async {
    final options = _options();
    setState(() {
      _result = null;
      _message = 'Hold a card against the NFC antenna…';
    });
    final card = await _reader.readCard(
      timeout: Duration(seconds: int.parse(_timeout.text)),
      options: options,
    );
    if (mounted) {
      setState(() {
        _result = card.toJson();
        _source = 'Live NFC result';
        _page = 1;
      });
    }
  });

  Future<void> _replay() => _run(() async {
    final text = _replayInput.text.trim().isEmpty
        ? await rootBundle.loadString('assets/replay.json')
        : _replayInput.text;
    final fixture = jsonDecode(text) as Map;
    final responses = (fixture['responses'] as List)
        .map(
          (row) => EmvReplayResponse(
            command: row['command'] as String,
            response: row['response'] as String,
            prefix: row['prefix'] == true,
          ),
        )
        .toList();
    final card = await _reader.replay(
      responses: responses,
      at: fixture['at'] as String? ?? '',
      options: _options(),
    );
    if (mounted) {
      setState(() {
        _result = card.toJson();
        _source = 'Synthetic APDU replay — parsed by Android library';
        _page = 1;
      });
    }
  });

  Future<void> _loadDemo() => _run(() async {
    final data =
        jsonDecode(await rootBundle.loadString('assets/all_fields.json'))
            as Map;
    if (mounted) {
      setState(() {
        _result = Map<String, Object?>.from(data);
        _source = 'Synthetic UI fixture — every field, not a parsed card';
        _page = 1;
      });
    }
  });

  @override
  Widget build(BuildContext context) => Scaffold(
    appBar: AppBar(title: const Text('EMV Developer Console')),
    bottomNavigationBar: NavigationBar(
      selectedIndex: _page,
      onDestinationSelected: (page) => setState(() => _page = page),
      destinations: const [
        NavigationDestination(icon: Icon(Icons.nfc), label: 'Reader'),
        NavigationDestination(icon: Icon(Icons.account_tree), label: 'Results'),
        NavigationDestination(icon: Icon(Icons.build), label: 'Tools'),
        NavigationDestination(icon: Icon(Icons.menu_book), label: 'Catalogs'),
      ],
    ),
    body: Column(
      children: [
        if (_busy) const LinearProgressIndicator(),
        Padding(
          padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
          child: Row(
            children: [
              Expanded(child: Text(_message)),
              if (_busy)
                TextButton(
                  onPressed: () async {
                    await _reader.cancel();
                  },
                  child: const Text('Cancel'),
                ),
            ],
          ),
        ),
        Expanded(
          child: switch (_page) {
            0 => _readerPage(),
            1 => _resultsPage(),
            2 => _toolsPage(),
            _ => _catalogPage(),
          },
        ),
      ],
    ),
  );

  Widget _readerPage() => ListView(
    padding: const EdgeInsets.all(16),
    children: [
      Text('NFC: $_status'),
      Wrap(
        spacing: 8,
        children: [
          OutlinedButton(
            onPressed: _busy
                ? null
                : () => _run(() async {
                    final status = await _reader.getStatus();
                    if (mounted) setState(() => _status = status.name);
                  }),
            child: const Text('Check NFC status'),
          ),
          FilledButton(
            onPressed: _busy ? null : _read,
            child: const Text('Read card'),
          ),
          OutlinedButton(
            onPressed: _busy ? null : _replay,
            child: const Text('Run synthetic replay'),
          ),
          TextButton(
            onPressed: _busy ? null : _loadDemo,
            child: const Text('Load all-fields UI fixture'),
          ),
        ],
      ),
      const SizedBox(height: 16),
      DropdownButtonFormField<String>(
        initialValue: _preset,
        isExpanded: true,
        decoration: const InputDecoration(labelText: 'Feature example'),
        items: featurePresets.keys
            .map(
              (key) => DropdownMenuItem(
                value: key,
                child: Text(key, overflow: TextOverflow.ellipsis),
              ),
            )
            .toList(),
        onChanged: _busy
            ? null
            : (value) => setState(() => _applyPreset(value!)),
      ),
      const SizedBox(height: 8),
      const Text(
        'Presets fill the controls below. Edit any option before running. Each card exposes a different subset of data.',
      ),
      for (final key in _flags.keys)
        SwitchListTile(
          contentPadding: EdgeInsets.zero,
          title: Text(key),
          value: _flags[key]!,
          onChanged: _busy
              ? null
              : (value) => setState(() => _flags[key] = value),
        ),
      DropdownButtonFormField<EmvParserMode>(
        key: ValueKey(_mode),
        initialValue: _mode,
        decoration: const InputDecoration(labelText: 'Parser registration'),
        items: EmvParserMode.values
            .map(
              (mode) => DropdownMenuItem(value: mode, child: Text(mode.name)),
            )
            .toList(),
        onChanged: _busy ? null : (value) => setState(() => _mode = value!),
      ),
      ExpansionTile(
        title: const Text('Native extension examples'),
        children: [
          const Text(
            'These example-only hooks replace the builder terminal/provider or add a Java parser. For custom parser select mode none. For provider use replay. Reset restores the normal pipeline.',
          ),
          Wrap(
            spacing: 8,
            children: [
              for (final mode in ['terminal', 'parser', 'provider', 'reset'])
                OutlinedButton(
                  onPressed: _busy
                      ? null
                      : () => _run(() async {
                          await const MethodChannel(
                            'credit_card_nfc_reader_example/extensions',
                          ).invokeMethod<void>(mode);
                          if (mounted) {
                            setState(() => _source = 'Native extension: $mode');
                          }
                        }),
                  child: Text('Native $mode'),
                ),
            ],
          ),
        ],
      ),
      _field(_country, 'Terminal country enum (FR, TR, CA, DE…)'),
      _field(_timeout, 'Session timeout in seconds (1–300)'),
      _field(_exchangeTimeout, 'APDU timeout in milliseconds (1–60000)'),
      _field(
        _terminal,
        'Custom terminal values JSON: tag → exact-length hex',
        lines: 3,
      ),
      ExpansionTile(
        title: const Text('Custom replay provider input'),
        children: [
          const Text(
            'Leave empty for the bundled synthetic Visa script. Exact commands take priority over prefix rules. Unmatched commands return file/record not found.',
          ),
          _field(
            _replayInput,
            '{"at":"", "responses":[{"command":"…","response":"…","prefix":false}]}',
            lines: 8,
          ),
        ],
      ),
    ],
  );

  Widget _resultsPage() {
    final result = _result;
    if (result == null) {
      return const Center(
        child: Text('Read a card, replay APDUs, or load the UI fixture.'),
      );
    }
    final terms = resultGroups[_group]!;
    final entries = flatten(result).entries.where((e) {
      final path = e.key.toLowerCase();
      return (terms.isEmpty ||
              terms.any((term) => path.contains(term.toLowerCase()))) &&
          path.contains(_search.toLowerCase());
    }).toList();
    return Column(
      children: [
        Padding(
          padding: const EdgeInsets.all(16),
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
              Text(_source, style: Theme.of(context).textTheme.titleSmall),
              DropdownButton<String>(
                value: _group,
                isExpanded: true,
                items: resultGroups.keys
                    .map((g) => DropdownMenuItem(value: g, child: Text(g)))
                    .toList(),
                onChanged: (g) => setState(() => _group = g!),
              ),
              TextField(
                decoration: const InputDecoration(
                  labelText: 'Filter field path',
                ),
                onChanged: (text) => setState(() => _search = text),
              ),
              Row(
                children: [
                  Checkbox(
                    value: _reveal,
                    onChanged: (value) => setState(() => _reveal = value!),
                  ),
                  const Expanded(child: Text('Reveal card data and raw bytes')),
                  TextButton(
                    onPressed: _reveal
                        ? () async {
                            await Clipboard.setData(
                              ClipboardData(
                                text: const JsonEncoder.withIndent(
                                  '  ',
                                ).convert(result),
                              ),
                            );
                            if (mounted) {
                              setState(
                                () =>
                                    _message = 'Full JSON copied to clipboard',
                              );
                            }
                          }
                        : null,
                    child: const Text('Copy JSON'),
                  ),
                ],
              ),
              Text(
                '${entries.length} fields • null = unavailable • -1 = upstream UNKNOWN',
              ),
            ],
          ),
        ),
        Expanded(
          child: ListView.builder(
            itemCount: entries.length,
            itemBuilder: (_, index) {
              final entry = entries[index];
              final sensitive = RegExp(
                r'pan|cardnumber|holder|track|raw|record|apdu|discretionary|iban|paymentaccount',
                caseSensitive: false,
              ).hasMatch(entry.key);
              return ListTile(
                dense: true,
                title: Text(entry.key),
                subtitle: SelectableText(
                  sensitive && !_reveal && entry.value != null
                      ? 'Hidden — enable Reveal'
                      : '${entry.value}',
                ),
              );
            },
          ),
        ),
      ],
    );
  }

  Widget _toolsPage() => ListView(
    padding: const EdgeInsets.all(16),
    children: [
      const Text(
        'Offline upstream decoders. Each tool has an editable synthetic example; no NFC hardware required. Android runtime is required.',
      ),
      DropdownButton<String>(
        value: _decoder,
        isExpanded: true,
        items: decoderExamples.keys
            .map((tool) => DropdownMenuItem(value: tool, child: Text(tool)))
            .toList(),
        onChanged: _busy
            ? null
            : (tool) => setState(() {
                _decoder = tool!;
                _input.text = decoderExamples[tool]!;
                _decoded = null;
              }),
      ),
      _field(
        _input,
        _decoder == 'schemeByPan' ? 'Synthetic PAN digits' : 'Input hex',
        lines: 4,
      ),
      if (_decoder == 'tag') _field(_tag, 'Tag hex'),
      FilledButton(
        onPressed: _busy
            ? null
            : () => _run(() async {
                final data = await _reader.decode(
                  _decoder,
                  _input.text,
                  tag: _decoder == 'tag' ? _tag.text : null,
                );
                if (mounted) {
                  setState(() => _decoded = data ?? 'No matching data');
                }
              }),
        child: const Text('Run decoder'),
      ),
      const SizedBox(height: 16),
      SelectableText(const JsonEncoder.withIndent('  ').convert(_decoded)),
    ],
  );

  Widget _catalogPage() => ListView(
    padding: const EdgeInsets.all(16),
    children: [
      const Text(
        'Countries, currencies and supported AID schemes from the installed upstream library.',
      ),
      FilledButton(
        onPressed: _busy
            ? null
            : () => _run(() async {
                final catalogs = await _reader.getCatalogs();
                if (mounted) setState(() => _catalogs = catalogs);
              }),
        child: const Text('Load catalogs'),
      ),
      if (_catalogs case final catalogs?) ...[
        Text('Library ${catalogs['libraryVersion']}'),
        for (final key in ['countries', 'currencies', 'schemes'])
          ExpansionTile(
            title: Text('$key (${(catalogs[key] as List).length})'),
            children: [
              for (final item in catalogs[key] as List)
                ListTile(
                  title: Text('${item['code']} — ${item['label']}'),
                  subtitle: Text(jsonEncode(item)),
                ),
            ],
          ),
      ],
    ],
  );

  Widget _field(
    TextEditingController controller,
    String label, {
    int lines = 1,
  }) => Padding(
    padding: const EdgeInsets.symmetric(vertical: 8),
    child: TextField(
      controller: controller,
      enabled: !_busy,
      minLines: lines,
      maxLines: lines + 2,
      decoration: InputDecoration(
        labelText: label,
        border: const OutlineInputBorder(),
      ),
    ),
  );
}

/// Paths retain list indices so every application and nested model is testable.
Map<String, Object?> flatten(Object? value, [String prefix = '']) {
  final result = <String, Object?>{};
  if (value is Map && value.isNotEmpty) {
    for (final entry in value.entries) {
      result.addAll(
        flatten(
          entry.value,
          prefix.isEmpty ? '${entry.key}' : '$prefix.${entry.key}',
        ),
      );
    }
  } else if (value is List && value.isNotEmpty) {
    for (var i = 0; i < value.length; i++) {
      result.addAll(flatten(value[i], '$prefix[$i]'));
    }
  } else {
    result[prefix] = value;
  }
  return result;
}
1
likes
145
points
159
downloads

Documentation

Documentation
API reference

Publisher

verified publishersolike.app

Weekly Downloads

Android NFC EMV card reader wrapping devnied's EMV NFC Paycard Enrollment library.

Repository (GitHub)

Topics

#nfc #emv #android #credit-card

License

MIT (license)

Dependencies

flutter

More

Packages that depend on credit_card_nfc_reader

Packages that implement credit_card_nfc_reader