Scan a payment card with the camera and get its number, expiry date and cardholder name back as a typed result. Recognition runs fully on the device using the platform OCR engines: Vision on iOS and ML Kit on Android. There is no third party camera plugin, no cloud service and no network access.
Supported networks: Visa, Mastercard, American Express, Discover, JCB, Diners Club, UnionPay.
Highlights
- One line integration.
CardScannerPage.show(context)opens a ready screen and returns aCardScanResult. - Bring your own UI.
CardScannerViewrenders the camera preview and lets you draw anything on top withoverlayBuilder. - Robust recognition. Luhn check, BIN detection, repair of common OCR confusions (
Ovs0,Ivs1,Svs5), and multi frame voting so a single misread never leaks into the result. - Configurable fields. Decide which fields are required, which are nice to have, and how long to wait for them.
- Static images too.
CardScanner.scanImage(bytes)recognizes a card in a photo from the gallery. - Privacy by design. Frames never leave the native layer. Only recognized strings and bounding boxes cross the platform channel.
toString()on results masks the number.
Install
flutter pub add flutter_card_scanner_plus
iOS
Add a camera usage description to ios/Runner/Info.plist:
<key>NSCameraUsageDescription</key>
<string>The camera is used to scan your payment card.</string>
Minimum deployment target is iOS 15.
Android
Nothing to configure. The plugin declares the CAMERA permission and requests it at runtime. Minimum SDK is 24 (Android 7.0). The ML Kit text recognition model is bundled with the app, so scanning works offline and does not depend on Google Play Services being up to date.
Quick start
import 'package:flutter_card_scanner_plus/flutter_card_scanner_plus.dart';
final card = await CardScannerPage.show(context);
if (card != null) {
print(card.formattedNumber); // 4242 4242 4242 4242
print(card.brand); // CardBrand.visa
print(card.formattedExpiry); // 12/28
print(card.cardholderName); // JOHN A SMITH (may be null)
}
CardScannerPage shows a full screen scanner with a card shaped frame, a torch toggle and a close button. It pops with the result as soon as the scan is complete, or with null if the user dismisses it.
Custom UI
Use CardScannerController and CardScannerView when the default page does not fit your design. The controller is a ValueNotifier<CardScannerState>, so it works with ValueListenableBuilder or any state management you already use.
class ScanScreen extends StatefulWidget {
const ScanScreen({super.key});
@override
State<ScanScreen> createState() => _ScanScreenState();
}
class _ScanScreenState extends State<ScanScreen> {
final _controller = CardScannerController();
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return CardScannerView(
controller: _controller,
overlayBuilder: (context, state, cardRect) {
// cardRect is where the card frame sits, in this widget's coordinates.
return Stack(
children: [
Positioned.fromRect(
rect: cardRect,
child: DecoratedBox(
decoration: BoxDecoration(
border: Border.all(
color: state.isComplete ? Colors.green : Colors.white,
width: 2,
),
borderRadius: BorderRadius.circular(12),
),
),
),
if (state.result.hasNumber)
Positioned(
left: 0,
right: 0,
top: cardRect.bottom + 24,
child: Text(
state.result.formattedNumber!,
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.white, fontSize: 22),
),
),
],
);
},
);
}
}
CardScannerState gives you everything needed for live feedback:
| Field | What it is |
|---|---|
result |
Aggregated CardScanResult, updated as fields get confirmed |
lastFrame |
Raw candidates from the most recent OCR frame, useful for "live" highlighting |
isRunning, torchEnabled, error |
Camera state |
The view keeps the OCR region of interest in sync with the card frame automatically, so text outside the frame is ignored.
One shot scanning
final controller = CardScannerController();
final card = await controller.scanOnce(); // starts the camera, completes when done
Reusing the default overlay
CardFrameOverlay is exported, so you can place it inside your own builder and add controls around it:
overlayBuilder: (context, state, cardRect) => Stack(
children: [
CardFrameOverlay(state: state, cardRect: cardRect, hint: 'Hold steady'),
Positioned(bottom: 40, left: 0, right: 0, child: MyCancelButton()),
],
),
Choosing which fields you need
ScanRequirements controls when a scan is considered complete.
CardScannerPage.show(
context,
requirements: const ScanRequirements(
required: {CardField.number, CardField.expiry}, // wait for these
preferred: {CardField.name}, // wait, then skip
preferredTimeout: Duration(milliseconds: 1500),
preferredGrace: Duration(seconds: 5), // if one is mid-confirm
),
);
| Preset | Required | Preferred | Use when |
|---|---|---|---|
standard |
number, expiry | name | Default. Fast, and the name is included when it is legible. |
numberOnly |
number | none | You only need the PAN. |
full |
number, expiry, name | none | The name is mandatory. Waits until it is recognized. |
fast |
number, expiry | none | Demos and tests. Accepts the first Luhn valid frame. |
twoSided |
number, expiry | name | The name is printed on the other side. Waits 15 s for it. |
The number is always required. A field that is neither required nor preferred is still filled in when it happens to be recognized, but never delays completion.
preferredTimeout is measured from the frames themselves, not the wall clock, so a scan that sits in the background does not time out while nothing is being recognized. When the timeout runs out while a preferred field already has votes, preferredGrace buys it a little more time instead of throwing the half-recognized value away.
Cards with the name on the back
A confirmed field is kept. Frames that recognize nothing, which is every frame while a card is being turned over, cannot take it away, and only a different number confirmed with more votes than the current one starts a new card. That is what lets number and expiry be read from one side and the name from the other:
final card = await CardScannerPage.show(
context,
requirements: ScanRequirements.twoSided,
);
CardScannerState.confirming lists the fields that have a candidate but not yet enough agreement between frames, which is what to show as progress rather than as a result.
Your own rules
ScanSession is the interface the controller drives. Implement it to change how frames become a result without touching the camera pipeline:
final controller = CardScannerController(session: MyOwnSession());
Given and family name
Cards print one line and never say where the surname starts, so a payment form that wants two fields has to guess. CardholderName guesses the usual way: the last word is the surname, anything before it is the given name, and a particle in front of the surname belongs to it.
final name = result.splitName; // null when no name was recognized
name?.given; // MARIA
name?.family; // DE LA CRUZ
It handles middle names and initials ("WREN A. NGUYEN") and the particles da, das, de, del, della, den, der, di, do, dos, du, la, le, van and von. It will get some names wrong, so let people correct it.
Text, prompts and failures
The built-in page tells the user what it is still looking for, shows a spinner while it works, and offers a way out when the camera will not open.
CardScannerPage.show(
context,
strings: CardScannerStrings(
alignCard: l10n.alignCard,
lookingForName: l10n.turnTheCardOver,
permissionDenied: l10n.cameraAccessNeeded,
// every string has an English default
),
onOpenSettings: openAppSettings, // from permission_handler, if you use it
enableHaptics: true, // one light tap per prompt, off by default
);
CardScannerStrings holds every string the scanner can show, with English defaults. onOpenSettings is a callback rather than a dependency, so this package does not pull a permissions plugin into your app; leave it out and no such button is offered. The close button is always reachable, including on the error screen.
Scanning a photo
final bytes = await pickedFile.readAsBytes();
final card = await CardScanner.scanImage(bytes);
A single image is a single frame, so no voting is applied. isComplete tells you whether the required fields were found.
The result
class CardScanResult {
String? number; // 4242424242424242
CardBrand? brand; // visa, mastercard, amex
int? expiryMonth; // 1..12
int? expiryYear; // 2028
String? cardholderName; // JOHN A SMITH
bool isComplete;
String? formattedNumber; // 4242 4242 4242 4242 (4-6-5 for Amex)
String? maskedNumber; // •••• 4242
String? last4;
String? formattedExpiry; // 12/28
bool isExpired({DateTime? now});
}
toString() prints the masked number only, so results are safe to log.
How it works
- The native camera session renders into a Flutter texture and hands the same frame buffer to the OCR engine. There is no copy into Dart.
- OCR runs only inside the region under the card frame, about eight times per second.
- Recognized lines cross the platform channel as strings with normalized bounding boxes.
- Dart merges fragments that sit on one visual row, then runs three parsers:
- Number: candidate digit runs are repaired for OCR confusions, validated with Luhn, matched against Visa, Mastercard and Amex BIN ranges and length rules.
- Expiry:
MM/YYandMM/YYYY. When a card prints several dates (valid from, member since, valid thru) the latest one wins. - Name: upper case Latin lines without digits, filtered by a stop list of labels, tiers, networks and issuers, ranked by position relative to the number.
- A sliding window vote confirms each field across frames. The scan completes when the required fields are confirmed and the preferred ones either arrived or timed out.
Everything in step 4 and 5 is plain Dart with no platform dependencies, and it is covered by unit tests with realistic OCR output, including misreads.
Security notes
- Camera frames are processed in memory and never written to disk, logged or transmitted.
- The package does not store the result anywhere. What you do with it is up to your app.
- This package is not a PCI DSS certified component. If your app is in scope for PCI, treat the scanned PAN as cardholder data from the moment it reaches your code.
- Consider hiding the scanner screen from screenshots and the app switcher in sensitive apps. That is intentionally left to the app, since the right behaviour differs per product.
Limitations
- Landscape works, but a card is easiest to frame with the phone upright, so the default frame is sized for portrait.
- The preview follows the orientation of your UI, not of the handset. An app locked to portrait keeps an upright guide and an upright preview whichever way the phone is held.
- The CVV / CVC is never read, by design. It is the proof that the cardholder is entering it knowingly, and capturing it from the camera would put every app using this package deeper into PCI DSS scope. Ask for it in a text field after the scan.
- Only the networks listed above. Anything else is rejected even when the number is Luhn valid, and so are the few UnionPay ranges issued outside the Luhn checksum.
- Cardholder name detection is heuristic. Embossed names on busy backgrounds, names with non Latin characters, and cards without a printed name will come back as
null.
Example
The example app shows the default page, the presets, a custom overlay with live per frame fields, and scanning from the gallery.
cd example
flutter run
Contributing
Issues and pull requests are welcome. If a card is not recognized, the most helpful report includes the raw OCR lines: run the example's custom overlay, which prints the candidates from each frame, and paste what you see (with the number partially masked).
Run the checks locally before opening a PR:
flutter analyze
flutter test
Support
This package is free and maintained in my own time. If it saved you some, buy me a coffee.
License
MIT. See LICENSE.
Libraries
- flutter_card_scanner_plus
- On-device bank card scanner for Flutter.