ITC SUNMI Card Reader

A Flutter plugin that provides card reading functionality for SUNMI POS devices. This plugin enables EMV card processing, data extraction, and seamless integration with SUNMI's hardware capabilities.

pub package License: MIT

⚠️ Important Notice

This plugin ONLY works on SUNMI Android devices. It will not function on regular Android devices, iOS devices, or other POS terminals. Please ensure you are using a SUNMI device before implementing this plugin.

Features

Card Scanning - Support for EMV chip cards, magnetic stripe cards, and contactless payments
Data Extraction - Extract card number, expiry date, cardholder name, and service codes
Track Data - Access to Track 1 and Track 2 data
EMV Processing - Full EMV transaction support with app selection
Real-time Callbacks - Status updates during the card reading process
Multiple Card Types - Supports Visa, MasterCard, UnionPay, Amex, JCB, RuPay
Error Handling - Comprehensive error management and recovery

Supported SUNMI Devices

  • SUNMI P2 series
  • SUNMI P2 Pro
  • SUNMI P2 Lite
  • SUNMI V2 series
  • Other SUNMI POS terminals with card reading capabilities

Installation

Add this to your package's pubspec.yaml file:

dependencies:
  itc_sunmi_card_reader: ^1.0.0

Then run:

flutter pub get

Platform Setup

Android Requirements

  • Min SDK: 21 (Android 5.0)
  • Target SDK: 34
  • Device: SUNMI POS terminal only

The plugin automatically adds required SUNMI permissions. No additional setup needed.

Usage

Basic Usage

import 'package:itc_sunmi_card_reader/itc_sunmi_card_reader.dart';

// Simple card scan
final result = await ItcSunmiCardReaderService.startCardScan(amount: 25.50);

if (result != null) {
  print('Card Number: ${result.cardNumber}');
  print('Expiry Date: ${result.expiryDate}');
  print('Cardholder: ${result.cardholderName}');
  print('Card Type: ${result.cardType}');
} else {
  print('Scan cancelled or failed');
}

With Status Updates

import 'package:itc_sunmi_card_reader/itc_sunmi_card_reader.dart';

Future<void> scanCard() async {
  try {
    final result = await ItcSunmiCardReaderService.startCardScan(
      amount: 50.00,
      onStatusUpdate: (status) {
        print('Status: $status');
        // Update your UI with scan progress
      },
      onCardDetected: (cardType) {
        print('Card detected: $cardType');
        // Show detected card type to user
      },
    );

    if (result != null) {
      print('Scan successful!');
      print('Card: ${result.cardNumber}');
      print('Expiry: ${result.expiryDate}');
      print('Name: ${result.cardholderName}');
      print('Track 1: ${result.track1}');
      print('Track 2: ${result.track2}');
    }
  } catch (e) {
    print('Error: $e');
  }
}

Handling App Selection

Future<void> scanCardWithAppSelection() async {
  try {
    final result = await ItcSunmiCardReaderService.startCardScan(
      amount: 100.00,
      onAppSelectionRequired: (apps, selectApp) {
        // Show dialog for user to select payment app
        showDialog(
          context: context,
          builder: (context) => AlertDialog(
            title: Text('Select Payment App'),
            content: Column(
              mainAxisSize: MainAxisSize.min,
              children: apps.asMap().entries.map((entry) {
                return ListTile(
                  title: Text(entry.value),
                  onTap: () {
                    Navigator.pop(context);
                    selectApp(entry.key); // Select the app
                  },
                );
              }).toList(),
            ),
          ),
        );
      },
    );

    if (result != null) {
      // Process payment with selected app
      processPayment(result);
    }
  } catch (e) {
    handleError(e);
  }
}

Complete Example

import 'package:flutter/material.dart';
import 'package:itc_sunmi_card_reader/itc_sunmi_card_reader.dart';

class PaymentScreen extends StatefulWidget {
  @override
  _PaymentScreenState createState() => _PaymentScreenState();
}

class _PaymentScreenState extends State<PaymentScreen> {
  CardScanResult? _scanResult;
  bool _isScanning = false;
  String _scanStatus = '';

  Future<void> _startCardScan() async {
    // Check if device supports card reading
    if (!ItcSunmiCardReaderService.isSupported) {
      _showSnackBar('Card reading not supported on this device', Colors.red);
      return;
    }

    setState(() {
      _isScanning = true;
      _scanStatus = 'Initializing...';
      _scanResult = null;
    });

    try {
      final result = await ItcSunmiCardReaderService.startCardScan(
        amount: 25.50,
        onStatusUpdate: (status) {
          setState(() {
            _scanStatus = status;
          });
        },
        onCardDetected: (cardType) {
          setState(() {
            _scanStatus = '$cardType card detected - Processing...';
          });
        },
        onAppSelectionRequired: (apps, selectApp) {
          _showAppSelectionDialog(apps, selectApp);
        },
      );

      setState(() {
        _isScanning = false;
        _scanResult = result;
      });

      if (result != null) {
        _showSnackBar('Card scanned successfully!', Colors.green);
      } else {
        _showSnackBar('Scan cancelled', Colors.orange);
      }
    } catch (e) {
      setState(() {
        _isScanning = false;
        _scanStatus = '';
      });
      _showSnackBar('Error: ${e.toString()}', Colors.red);
    }
  }

  void _showAppSelectionDialog(List<String> apps, Function(int) selectApp) {
    showDialog(
      context: context,
      barrierDismissible: false,
      builder: (context) => AlertDialog(
        title: Text('Select Payment App'),
        content: Column(
          mainAxisSize: MainAxisSize.min,
          children: apps.asMap().entries.map((entry) {
            return ListTile(
              title: Text(entry.value),
              onTap: () {
                Navigator.pop(context);
                selectApp(entry.key);
              },
            );
          }).toList(),
        ),
      ),
    );
  }

  void _showSnackBar(String message, Color color) {
    ScaffoldMessenger.of(context).showSnackBar(
      SnackBar(
        content: Text(message),
        backgroundColor: color,
        duration: Duration(seconds: 3),
      ),
    );
  }

  Future<void> _stopScan() async {
    try {
      await ItcSunmiCardReaderService.stopCardScan();
      setState(() {
        _isScanning = false;
        _scanStatus = '';
      });
      _showSnackBar('Scan cancelled', Colors.orange);
    } catch (e) {
      print('Error stopping scan: $e');
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('SUNMI Card Reader'),
        backgroundColor: Colors.blue,
      ),
      body: Padding(
        padding: EdgeInsets.all(20.0),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: [
            // Amount Display
            Container(
              padding: EdgeInsets.all(20),
              decoration: BoxDecoration(
                color: Colors.blue.withOpacity(0.1),
                borderRadius: BorderRadius.circular(12),
                border: Border.all(color: Colors.blue.withOpacity(0.3)),
              ),
              child: Column(
                children: [
                  Text(
                    'Amount to Charge',
                    style: TextStyle(fontSize: 16, color: Colors.grey[600]),
                  ),
                  SizedBox(height: 8),
                  Text(
                    'GH₵ 25.50',
                    style: TextStyle(
                      fontSize: 32,
                      fontWeight: FontWeight.bold,
                      color: Colors.blue,
                    ),
                  ),
                ],
              ),
            ),

            SizedBox(height: 30),

            // Scan Button
            SizedBox(
              height: 54,
              child: ElevatedButton.icon(
                onPressed: _isScanning ? null : _startCardScan,
                icon: _isScanning
                    ? SizedBox(
                        width: 20,
                        height: 20,
                        child: CircularProgressIndicator(
                          strokeWidth: 2,
                          valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
                        ),
                      )
                    : Icon(Icons.credit_card, color: Colors.white),
                label: Text(
                  _isScanning ? 'Scanning...' : 'Scan Card',
                  style: TextStyle(fontSize: 18, color: Colors.white),
                ),
                style: ElevatedButton.styleFrom(
                  backgroundColor: Colors.blue,
                  shape: RoundedRectangleBorder(
                    borderRadius: BorderRadius.circular(12),
                  ),
                ),
              ),
            ),

            SizedBox(height: 16),

            // Cancel Button (only show when scanning)
            if (_isScanning)
              SizedBox(
                height: 48,
                child: OutlinedButton(
                  onPressed: _stopScan,
                  child: Text('Cancel Scan'),
                  style: OutlinedButton.styleFrom(
                    side: BorderSide(color: Colors.red),
                    foregroundColor: Colors.red,
                  ),
                ),
              ),

            SizedBox(height: 20),

            // Scan Status
            if (_isScanning && _scanStatus.isNotEmpty)
              Container(
                padding: EdgeInsets.all(16),
                decoration: BoxDecoration(
                  color: Colors.blue.withOpacity(0.1),
                  borderRadius: BorderRadius.circular(8),
                ),
                child: Row(
                  children: [
                    SizedBox(
                      width: 16,
                      height: 16,
                      child: CircularProgressIndicator(strokeWidth: 2),
                    ),
                    SizedBox(width: 12),
                    Expanded(
                      child: Text(
                        _scanStatus,
                        style: TextStyle(color: Colors.blue[800]),
                      ),
                    ),
                  ],
                ),
              ),

            SizedBox(height: 20),

            // Results Display
            if (_scanResult != null)
              Container(
                padding: EdgeInsets.all(16),
                decoration: BoxDecoration(
                  color: Colors.green.withOpacity(0.1),
                  borderRadius: BorderRadius.circular(12),
                  border: Border.all(color: Colors.green.withOpacity(0.3)),
                ),
                child: Column(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: [
                    Row(
                      children: [
                        Icon(Icons.check_circle, color: Colors.green, size: 20),
                        SizedBox(width: 8),
                        Text(
                          'Card Scan Results',
                          style: TextStyle(
                            fontSize: 18,
                            fontWeight: FontWeight.bold,
                            color: Colors.green,
                          ),
                        ),
                      ],
                    ),
                    SizedBox(height: 16),
                    _buildResultRow('Card Number', 
                        ItcSunmiCardReaderService.formatCardNumber(_scanResult!.cardNumber)),
                    _buildResultRow('Card Type', _scanResult!.cardType),
                    if (_scanResult!.expiryDate.isNotEmpty)
                      _buildResultRow('Expiry Date', 
                          ItcSunmiCardReaderService.formatExpiryDate(_scanResult!.expiryDate)),
                    if (_scanResult!.cardholderName.isNotEmpty)
                      _buildResultRow('Cardholder', _scanResult!.cardholderName),
                    if (_scanResult!.serviceCode.isNotEmpty)
                      _buildResultRow('Service Code', _scanResult!.serviceCode),
                    if (_scanResult!.track1.isNotEmpty)
                      _buildResultRow('Track 1', _scanResult!.track1, isLong: true),
                    if (_scanResult!.track2.isNotEmpty)
                      _buildResultRow('Track 2', _scanResult!.track2, isLong: true),
                  ],
                ),
              ),

            SizedBox(height: 20),

            // Clear Results Button
            if (_scanResult != null)
              OutlinedButton(
                onPressed: () {
                  setState(() {
                    _scanResult = null;
                  });
                },
                child: Text('Clear Results'),
              ),
          ],
        ),
      ),
    );
  }

  Widget _buildResultRow(String label, String value, {bool isLong = false}) {
    return Padding(
      padding: EdgeInsets.only(bottom: 8),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Text(
            '$label:',
            style: TextStyle(
              fontSize: 12,
              fontWeight: FontWeight.w500,
              color: Colors.grey[600],
            ),
          ),
          SizedBox(height: 2),
          Text(
            value.isNotEmpty ? value : 'N/A',
            style: TextStyle(
              fontSize: isLong ? 10 : 14,
              fontWeight: FontWeight.w600,
              color: Colors.black87,
            ),
            maxLines: isLong ? 2 : 1,
            overflow: TextOverflow.ellipsis,
          ),
        ],
      ),
    );
  }
}

API Reference

ItcSunmiCardReaderService

Methods

startCardScan({required double amount, ...})

Starts the card scanning process.

Parameters:

  • amount (double, required): Transaction amount
  • onStatusUpdate (Function(String)?, optional): Status update callback
  • onCardDetected (Function(String)?, optional): Card detection callback
  • onAppSelectionRequired (Function(List

Returns: Future<CardScanResult?>

Example:

final result = await ItcSunmiCardReaderService.startCardScan(
  amount: 50.00,
  onStatusUpdate: (status) => print('Status: $status'),
  onCardDetected: (type) => print('Detected: $type'),
);
stopCardScan()

Stops the current card scanning process.

Returns: Future<void>

Example:

await ItcSunmiCardReaderService.stopCardScan();
selectApp(int index)

Selects a payment app when multiple options are available.

Parameters:

  • index (int): Index of the selected app

Returns: Future<void>

CardScanResult

Contains the extracted card data.

class CardScanResult {
  final String cardNumber;        // Card PAN (Primary Account Number)
  final String expiryDate;        // Expiry date in MMYY format
  final String serviceCode;       // 3-digit service code
  final String cardType;          // Card type (Visa, MasterCard, etc.)
  final String cardholderName;    // Cardholder name (if available)
  final String track1;            // Complete Track 1 data
  final String track2;            // Complete Track 2 data
}

Utility Methods

// Format card number with spaces (1234 5678 9012 3456)
String formatted = ItcSunmiCardReaderService.formatCardNumber(cardNumber);

// Format expiry date (MM/YY)
String expiry = ItcSunmiCardReaderService.formatExpiryDate(expiryDate);

// Get display name for card type
String displayName = ItcSunmiCardReaderService.getCardTypeDisplayName(cardType);

Error Handling

try {
  final result = await ItcSunmiCardReaderService.startCardScan(amount: 50.0);
  if (result != null) {
    // Handle successful scan
  }
} catch (e) {
  if (e.toString().contains('CARD_SCAN_ERROR')) {
    // Handle card scanning errors
    print('Card scan error: $e');
  } else if (e.toString().contains('INVALID_ARGUMENT')) {
    // Handle invalid arguments
    print('Invalid argument: $e');
  } else if (e.toString().contains('SCAN_IN_PROGRESS')) {
    // Handle scan already in progress
    print('Scan already running: $e');
  } else {
    // Handle other errors
    print('Unknown error: $e');
  }
}

Troubleshooting

Common Issues

1. "Card reading not supported"

  • ✅ Ensure you're running on a SUNMI device
  • ✅ Check device has card reading hardware

2. "Payment SDK not connected"

  • ✅ Wait 2-3 seconds after app start for SDK initialization
  • ✅ Try restarting the application

3. "EMV not initialized"

  • ✅ Wait for EMV system to initialize (5-10 seconds)
  • ✅ Retry scan after brief delay

4. "Card scan timeout"

  • ✅ Ensure card is properly inserted/swiped/tapped
  • ✅ Check card reader hardware functionality
  • ✅ Verify card is not damaged

Device-Specific Notes

  • P2 Series: Full support for chip, swipe, and tap
  • P2 Lite: May have limited contactless support
  • V2 Series: Varies by model - test thoroughly

Performance Tips

  • Wait for initialization: Allow 3-5 seconds after app launch
  • Clean card readers: Ensure hardware is clean and functional
  • Proper card insertion: Follow device-specific card insertion guidelines
  • Handle timeouts: Implement proper timeout and retry logic

Requirements

  • Flutter: >= 3.3.0
  • Dart: >= 3.8.0
  • Android: API level 21+ (Android 5.0+)
  • Device: SUNMI POS terminal with card reading capability

Security & Privacy

  • 🔒 Local Processing: All card data processed on device
  • 🚫 No Network: No data transmitted to external servers
  • 💾 No Storage: Card data not stored on device
  • 🛡️ Secure: EMV-compliant processing

Support & Contact

For technical support and inquiries:

License

This project is licensed under the MIT License - see the LICENSE file for details.


Developed by ITC Consortium Ghana
Empowering businesses with innovative payment solutions