scan_to_pay 1.0.1
scan_to_pay: ^1.0.1 copied to clipboard
Reusable Flutter SDK for scan-to-pay: OCR-based account-number capture, multi-frame confirmation, pluggable bank resolver, and themeable UI.
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.