Custom UI primitives
The default EkycLivenessWidget and EkycBurstFaceVerifyWidget cover the most common KYC flows. For apps that want their own UI on top of the camera — different overlay design, a multi-step wizard, a specialized document capture screen — v2.2.0 ships six composable primitives. Each handles one concern (permission, camera, face quality, image preprocessing, burst loop) so you can mix and match without inheriting opinionated widget chrome.
Quick map:
| Primitive | Use when |
|---|---|
EkycPermissions | You want to request camera or photo-library permission from outside a capture widget. |
EkycCameraView | You want a live camera preview with your own layout / shutter UI. |
EkycIdDocumentCapture | You want a ready-made document-capture screen (CR80 ID, passport, custom aspect). |
EkycFaceQualityCheck | You want a cheap on-device pre-check before paying for face.verify. |
EkycImagePreprocessor | You want to resize / crop / re-encode bytes before upload. |
BurstFaceVerifier | You want the burst face-verify loop without mounting EkycBurstFaceVerifyWidget. |
All of these are exported from package:drukverify_flutter/drukverify_flutter.dart — no separate import.
Friendly error messages
EkycException.userMessage maps the structured type field to a short end-user-safe English sentence. Use it to drive snackbars / toasts without each caller hand-rolling the same switch statement:
try {
await client.faceVerifyImages(imageA: a, imageB: b);
} on EkycException catch (e) {
showSnackBar(e.userMessage);
log('${e.type}: ${e.message}', requestId: e.requestId);
}
Unknown error types fall back to "Something went wrong. Please try again." — the fallback deliberately does not leak .message because that field is developer-oriented (connect ECONNREFUSED 10.0.0.1, etc.). Keep logging .message and .requestId separately for diagnostics.
English-only in v2.2.0. A future revision will layer an override hook for localized copy without breaking this API.
Permissions
EkycPermissions is a thin facade over package:permission_handler covering the two permissions the SDK's capture flows need. Throw-on-denial semantics let you write linear code:
try {
await EkycPermissions.requestCamera();
// ... open the camera ...
} on EkycException catch (e) {
if (await EkycPermissions.isCameraPermanentlyDenied()) {
// OS prompt won't appear again — deep-link to Settings
EkycPermissions.openAppSettings();
} else {
// First denial — show rationale and let the user retry
showRationale(e.userMessage);
}
}
Photo library: requestPhotoLibrary() is iOS-only. On Android 13+, the system photo picker grants access implicitly through its own UI, so this method is a no-op (older Android isn't supported). "Limited Photos Access" on iOS 14+ counts as a successful grant since the picker still works under that scope.
iOS prerequisite: NSPhotoLibraryUsageDescription must be set in Info.plist or the gallery picker fails silently at the platform level.
A camera view you control
EkycCameraView gives you a live preview + a capture callback. You write the layout — the SDK handles permission, camera lifecycle, and JPEG encoding.
EkycCameraView(
facing: CameraFacing.front,
resolutionPreset: ResolutionPreset.high,
onError: (e) => showSnackBar(e.userMessage),
builder: (ctx, preview, capture) {
if (preview == null) {
return const Center(child: CircularProgressIndicator());
}
return Stack(
children: [
Positioned.fill(child: preview),
Positioned(
bottom: 80,
right: 24,
child: FloatingActionButton(
onPressed: () async {
final bytes = await capture();
await client.faceVerifyImages(/* ... */);
},
),
),
],
);
},
)
preview is null while the camera is initialising or after a permission denial — gate your shutter UI on preview != null. Calling capture() before the camera is ready throws EkycException(type: 'internal_error'). Errors during permission or controller setup fire onError once with the exception.
Front/back lens is locked at construction in v1. Runtime lens switching, torch / exposure / focus / zoom, and video recording are out of scope.
ResolutionPreset from package:camera is re-exported via drukverify_flutter, so you don't need a separate package:camera import to construct the widget.
ID-document capture, prebuilt
EkycIdDocumentCapture is a ready-made screen for capturing CID cards, driver's licenses, and passports. It composes EkycCameraView for the preview and EkycImagePreprocessor.crop for the overlay crop — no duplicated plumbing.
EkycIdDocumentCapture(
aspect: IdDocumentAspect.cr80,
overlayLabel: 'Position your ID inside the frame',
onCapture: (bytes) async {
await client.ocrScanImages(
image: File('id.jpg')..writeAsBytesSync(bytes),
);
},
onError: (e) => showSnackBar(e.userMessage),
)
Aspect presets:
IdDocumentAspect | Ratio | Use for |
|---|---|---|
cr80 (default) | 1.586:1 | Bhutanese CID, ATM-card-sized IDs, driver's licenses |
passport | 1.4:1 | Open passport spread |
custom | customAspectRatio: | Anything else (constructor asserts the value is non-null) |
The captured frame is cropped to the overlay rect by default — what the user frames is what onCapture receives. Set cropToOverlay: false if you want the raw camera frame.
v1 caveats: no auto-capture (manual shutter only), no glare / blur detection, no MRZ / barcode scanning, and the overlay-to-image coordinate mapping is proportional (slightly imprecise when widget aspect ≠ image aspect — close enough for OCR / face-match where the server is the source of truth).
On-device face quality pre-check
Before paying for a face.verify call, run EkycFaceQualityCheck.evaluate to gate on the obvious failure modes: no face, multiple faces, face too small, head turned, eyes closed. Saves a server round-trip and gives the user a tighter feedback loop.
final result = await EkycFaceQualityCheck.evaluate(bytes);
if (!result.ok) {
showSnackBar('Please ${_hintFor(result.issues.first)}');
return;
}
final verify = await client.faceVerifyImages(/* ... */);
Issues are a stable enum so you can build your own copy / icon catalog:
enum FaceQualityIssue {
noFace,
multipleFaces,
faceTooSmall,
faceNotForward,
eyesClosed,
}
Thresholds are tunable per call:
| Argument | Default | Notes |
|---|---|---|
minFaceAreaRatio | 0.05 | Face bbox area / image area floor. 0.05 ≈ 5% of the frame. |
maxYawDegrees | 20 | Head left/right rotation. |
maxPitchDegrees | 20 | Head up/down rotation. |
minEyeOpenProbability | 0.5 | ML Kit eye-open probability floor. |
checkEyesOpen | true | Skip the eyes check entirely (useful for low-light flows). |
Conservative defaults: unknown head angles count as forward, unknown eye probabilities count as open. The check is biased toward letting frames through rather than blocking legitimate users when ML Kit can't compute a particular signal.
For repeated calls (e.g., a continuous "lining up" loop), construct an EkycFaceDetector once and reuse via evaluateWith:
final detector = EkycFaceDetector();
try {
for (final frame in frames) {
final r = await EkycFaceQualityCheck.evaluateWith(detector, frame);
if (r.ok) break;
}
} finally {
await detector.dispose();
}
EkycFaceDetector.detect(bytes) returns the raw List<Face> if you need landmarks / contours / classification data directly. Face, FaceLandmark, FaceLandmarkType, FaceContour, and FaceContourType are re-exported from the top-level library — no separate package:google_mlkit_face_detection import needed.
Image preprocessing
EkycImagePreprocessor handles the common pre-upload massaging consumers were each reimplementing. All methods take JPEG bytes in and return JPEG bytes out.
// Camera output is often 4000×3000+ — shrink before upload.
final small = await EkycImagePreprocessor.resize(bytes, maxLongEdge: 1024);
// Tight face crop for a better face.verify match.
final q = await EkycFaceQualityCheck.evaluate(bytes);
if (q.ok && q.face != null) {
final tight = await EkycImagePreprocessor.cropToFace(bytes, q.face!);
// tight is ~face.bbox + 20% padding
}
// Drop quality without resizing.
final lite = await EkycImagePreprocessor.reencodeJpeg(bytes, jpegQuality: 70);
| Method | What it does |
|---|---|
resize(bytes, {maxLongEdge, jpegQuality}) | Scales down preserving aspect. No-op when source already within maxLongEdge (still re-encodes at the requested quality). |
crop(bytes, Rect, {paddingRatio, jpegQuality}) | Crops to Rect (in pixel coordinates of the source image). paddingRatio adds margin on all sides; output is clamped to image bounds. Rect is from dart:ui. |
cropToFace(bytes, Face, {paddingRatio, jpegQuality}) | Forwards face.boundingBox to crop. |
reencodeJpeg(bytes, {jpegQuality}) | Re-encodes at the requested quality, no resize. |
Implementation uses package:image (pure Dart, runs on every Flutter target). Methods are synchronous internally but exposed as Future so you can wrap calls in compute() for off-main-thread on slow devices without breaking the API. Non-image input throws ArgumentError.
Headless burst face-verify
BurstFaceVerifier runs the same multi-frame loop as EkycBurstFaceVerifyWidget (Spec F) but without mounting any UI. Useful for background verification, integration tests, or any flow that owns its own UI and just needs the loop logic.
// Permission is the caller's responsibility — the verifier assumes
// camera permission is already granted.
await EkycPermissions.requestCamera();
final verifier = BurstFaceVerifier(
client: ekyc,
referenceImage: enrolledSelfie,
);
final outcome = await verifier.run(onProgress: (s) {
print('${s.stage}: burst ${s.burstIndex + 1}/${s.totalBursts}');
});
switch (outcome) {
case BurstMatched(:final result):
handleMatch(result); // FaceVerify200Response
case BurstFailed(:final failure):
handleFail(failure); // burstsAttempted, lastResult, lastError
case BurstCancelled():
// dispose / nav-away mid-run
break;
}
cancel() is cooperative — checked after every await in the loop. The in-flight run() returns BurstCancelled(lastStatus) at the next checkpoint, never throws.
By default the verifier owns its own CameraFrameSource (initialises + disposes around run). Pass an explicit frameSource: to keep ownership yourself (which is what the widget wrapper does, since it has its own dispose semantics around the source).
Observing progress as a Stream
BurstFaceVerifier.progressStream is a broadcast Stream<BurstStatus> that fires the same sequence as the onProgress callback. Use it with StreamBuilder, Riverpod's StreamProvider, or BLoC:
final verifier = BurstFaceVerifier(client: ekyc, referenceImage: enrolled);
// Subscribe BEFORE calling run() — the stream is closed after run returns.
final sub = verifier.progressStream.listen((s) {
setState(() => _stage = s.stage);
});
final outcome = await verifier.run();
await sub.cancel();
Multiple subscribers are allowed (e.g., progress UI + analytics logger). There's no replay buffer — late subscribers see only events from their subscription point on. The stream closes when run returns (matched, failed, or cancelled); the verifier is single-use, so construct a new one for each attempt.
Putting it together
A bank-grade selfie-and-verify screen, fully custom UI:
class SelfieVerifyScreen extends StatefulWidget {
const SelfieVerifyScreen({super.key, required this.enrolled});
final Uint8List enrolled;
@override
State<SelfieVerifyScreen> createState() => _SelfieVerifyScreenState();
}
class _SelfieVerifyScreenState extends State<SelfieVerifyScreen> {
Future<void> _onCaptured(Uint8List bytes) async {
// 1) On-device pre-check.
final q = await EkycFaceQualityCheck.evaluate(bytes);
if (!q.ok) {
showSnackBar('Please ${_hintFor(q.issues.first)}');
return;
}
// 2) Tighten + shrink before upload.
final tight = await EkycImagePreprocessor.cropToFace(bytes, q.face!);
final small = await EkycImagePreprocessor.resize(tight, maxLongEdge: 1024);
// 3) Server-side verdict.
try {
final file = File('${(await getTemporaryDirectory()).path}/selfie.jpg')
..writeAsBytesSync(small);
final r = await context.read<EkycClient>().faceVerifyImages(
imageA: File('${(await getTemporaryDirectory()).path}/enrolled.jpg')
..writeAsBytesSync(widget.enrolled),
imageB: file,
);
Navigator.of(context).pop(r.match);
} on EkycException catch (e) {
showSnackBar(e.userMessage);
}
}
@override
Widget build(BuildContext context) {
return EkycCameraView(
facing: CameraFacing.front,
onError: (e) => showSnackBar(e.userMessage),
builder: (ctx, preview, capture) {
if (preview == null) {
return const Center(child: CircularProgressIndicator());
}
return Stack(
children: [
Positioned.fill(child: preview),
Positioned(
bottom: 48,
left: 0,
right: 0,
child: Center(
child: GestureDetector(
onTap: () async => _onCaptured(await capture()),
child: const _ShutterButton(),
),
),
),
],
);
},
);
}
}
Six primitives, one screen, server is still the source of truth — the pre-check just saves obvious failures from costing a round-trip.