scan_to_pay
Reusable Flutter SDK for scan-to-pay: point a camera at a written/printed account number, get back a confirmed ScanResult (account number + bank + confidence) ready for your transfer flow.
This package packages a production-proven scan-to-pay architecture with a mature OCR pipeline, multi-frame confirmation, and fuzzy bank matching — now as a one-widget drop-in any Flutter app can integrate.
- Zero shared state. The SDK never decides what happens next; it hands you a
ScanResultand your app takes it from there. - Themeable end-to-end. Accent color, fonts, and full widget slots for the viewfinder and confirmation sheet.
- Pluggable bank resolver. A built-in Nigerian bank resolver is used by default, or you can inject your own (remote API, wallet switcher, etc.).
- Analytics hook. Optional
ScanToPayAnalyticsDelegateso host apps can pipe frame timings, lock events, and errors into their own telemetry.
Install
dependencies:
scan_to_pay: ^1.0.1
Quick start
import 'package:flutter/material.dart';
import 'package:scan_to_pay/scan_to_pay.dart';
Future<void> openScanner(BuildContext context) async {
await ScanToPayLauncher.push(
context,
config: ScanToPayConfig(
theme: const ScanToPayTheme(accentColor: Color(0xFFD32F2F)),
onAccountResolved: (ScanResult result) async {
await Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => TransferConfirmPage(result: result),
),
);
},
),
);
}
That's it. The SDK wires up the camera, ML Kit OCR, multi-frame voting, the default Nigerian bank resolver, the viewfinder, and the confirmation sheet. When the user taps Continue, your onAccountResolved callback receives a ScanResult and you drive the rest of the flow.
Permissions
iOS
Add to ios/Runner/Info.plist:
<key>NSCameraUsageDescription</key>
<string>Used to scan account numbers for payments.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>Used to pick an existing photo to scan an account number.</string>
Minimum iOS deployment target: 15.5.
Android
Add to android/app/src/main/AndroidManifest.xml:
<uses-permission android:name="android.permission.CAMERA" />
Minimum minSdkVersion: 21.
Customisation
Theming
ScanToPayConfig(
theme: ScanToPayTheme(
accentColor: Colors.teal,
backgroundColor: Colors.black,
sheetBackgroundColor: const Color(0xFF0F0F0F),
fontFamily: 'Kumbh Sans',
title: 'Scan to Pay',
),
onAccountResolved: ...,
);
Replace the viewfinder or confirmation sheet entirely
ScanToPayTheme(
accentColor: Colors.teal,
viewfinderBuilder: (context, state) => MyCustomViewfinder(state: state),
accountSheetBuilder: (context, result, {required onConfirm, required onRescan}) {
return MyCustomSheet(result: result, onConfirm: onConfirm, onRescan: onRescan);
},
);
Plug in a custom bank resolver
Default (Nigerian bank list, no network):
ScanToPayConfig(
// bankResolver is optional; DefaultNigerianBankResolver is used when omitted.
onAccountResolved: ...,
);
Remote-first, fall back to built-in list:
final resolver = ChainedBankResolver([
RemoteBankResolver(
uri: Uri.parse('https://your-api.com/v1/banks'),
headers: {'Authorization': 'Bearer $token'},
),
DefaultNigerianBankResolver(),
]);
ScanToPayConfig(
bankResolver: resolver,
onAccountResolved: ...,
);
Write your own:
class MyBankResolver extends BankResolver {
@override
Future<BankInfo?> resolve({
required String accountNumber,
required String rawOcrText,
}) async {
final bank = await myApi.lookupBank(accountNumber);
return bank == null ? null : BankInfo(name: bank.name, code: bank.code);
}
@override
List<String> get knownBankNames => myApi.cachedNames;
}
Behaviour knobs
ScanToPayConfig(
requiredConfirmations: 3, // frames that must agree
processInterval: Duration(milliseconds: 600), // frame throttle
enableGalleryPicker: true, // show "Scan from photo"
hintMessages: ['Align the digits', 'Hold steady'],
focusRefreshInterval: Duration(seconds: 2),
minAccountLength: 10, // NUBAN = 10 on both edges
maxAccountLength: 10,
onAccountResolved: ...,
);
Telemetry
class MyAnalytics extends ScanToPayAnalyticsDelegate {
@override
void onAccountLocked(ScanResult result) => track('scan_locked', result);
@override
void onError(Object error, StackTrace stack) => track('scan_error', error);
}
ScanToPayConfig(
analyticsDelegate: MyAnalytics(),
onAccountResolved: ...,
);
Architecture at a glance
┌──────────────────────────────────────────────────────────────┐
│ Host app (your Flutter app) │
│ Provides: ScanToPayTheme · onAccountResolved · bankResolver │
└───────────────────────┬──────────────────────────────────────┘
│
┌───────────────────────▼──────────────────────────────────────┐
│ ScanToPayLauncher.push(context, config: ...) │
└───────────────────────┬──────────────────────────────────────┘
│
┌──────────────┼──────────────┐
▼ ▼ ▼
CameraEngine OcrEngine AccountNumberExtractor
(camera) (ML Kit) + HandwritingValidator
│ │ │
└──────────────▼──────────────┘
ScanOrchestrator
(vote bus · post-lock buffer · session state)
│
▼
BankResolver
│
▼
ScanResult ──► onAccountResolved (host)
The orchestrator is pure Dart (no BuildContext, no setState). Its state stream is what ScanToPayView and any custom UI you build consume.
Why not just copy the code?
Because every project that adds scan-to-pay ends up:
- Re-implementing the camera lifecycle + permissions + disposal.
- Re-implementing NV21/BGRA8888 +
InputImagerotation handling. - Re-implementing the frame throttle + multi-frame vote + post-lock buffer.
- Re-inventing the NUBAN regex, digit/letter transliteration, context scoring.
- Re-building a viewfinder + account-found sheet + bank picker.
This package ships all of that behind a single ScanToPayLauncher.push(...) call while leaving theming, bank resolution, and the post-scan flow entirely in the host app's hands.
License
Libraries
- scan_to_pay
- Reusable scan-to-pay SDK: camera + OCR + multi-frame confirmation