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 ScanResult and 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 ScanToPayAnalyticsDelegate so 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:

  1. Re-implementing the camera lifecycle + permissions + disposal.
  2. Re-implementing NV21/BGRA8888 + InputImage rotation handling.
  3. Re-implementing the frame throttle + multi-frame vote + post-lock buffer.
  4. Re-inventing the NUBAN regex, digit/letter transliteration, context scoring.
  5. 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

MIT

Libraries

scan_to_pay
Reusable scan-to-pay SDK: camera + OCR + multi-frame confirmation