@kernaq/verify-react-native 2.0.0 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -7,46 +7,47 @@ var reactNativeVisionCamera = require('react-native-vision-camera');
7
7
 
8
8
  const DEFAULT_LOCALE = {
9
9
  intro_title: 'Verify your identity',
10
- intro_body: "This takes about 60 seconds. You'll need your ID document and a clear view of your face.",
10
+ intro_body: 'This takes about 90 seconds. You\'ll need your ID document and a clear view of your face.',
11
11
  intro_cta: 'Get started',
12
- doc_title: 'Scan your document',
13
- doc_instruction: 'Hold your ID flat inside the frame. Make sure it\'s well-lit and all four corners are visible.',
12
+ doc_front_title: 'Document — front',
13
+ doc_front_instruction: 'Hold the front of your document flat inside the frame. Ensure all four corners are visible and there is no glare.',
14
+ doc_back_title: 'Document — back',
15
+ doc_back_instruction: 'Flip your document over and hold the back inside the frame.',
14
16
  doc_capture_btn: 'Capture',
15
17
  doc_retake_btn: 'Retake',
16
- doc_next_btn: 'Looks good',
17
- selfie_title: 'Take a selfie',
18
- selfie_instruction: 'Look straight at the camera. Keep your face inside the oval.',
19
- selfie_capture_btn: 'Take selfie',
20
- selfie_retake_btn: 'Retake',
21
- selfie_next_btn: 'Looks good',
18
+ selfie_title: 'Selfie',
19
+ selfie_instruction: 'Centre your face inside the oval. Look directly at the camera.',
22
20
  liveness_title: 'Liveness check',
23
- liveness_instruction: 'Press start, then slowly turn your head left and right.',
24
- liveness_start_btn: 'Start recording',
25
- liveness_recording: 'Recording…',
26
- processing_title: 'Verifying your identity',
27
- processing_subtitle: 'This usually takes a few seconds.',
28
- result_pass_title: 'Verification passed',
29
- result_pass_body: 'Your identity has been verified successfully.',
21
+ liveness_ready: 'Follow the instructions as they appear.',
22
+ liveness_task_prefix: 'Please',
23
+ liveness_complete: 'Hold still…',
24
+ processing_title: 'Verifying…',
25
+ result_pass_title: 'Identity verified',
26
+ result_pass_body: 'Your verification was successful.',
30
27
  result_fail_title: 'Verification failed',
31
- result_fail_body: 'We were unable to verify your identity. Please try again.',
28
+ result_fail_body: 'We could not verify your identity. Please try again with better lighting.',
32
29
  result_review_title: 'Under review',
33
- result_review_body: 'Your submission is being reviewed. This usually takes a few minutes.',
34
- result_retry_btn: 'Try again',
35
- error_camera_denied: 'Camera access denied. Please allow camera permissions in settings.',
36
- error_quality: 'Image quality too low. Try better lighting or hold the camera steady.',
37
- error_network: 'Network error. Please check your connection and try again.'
30
+ result_review_body: 'Your submission is being manually reviewed. This usually takes a few minutes.',
31
+ error_camera_denied: 'Camera access denied. Allow camera permissions and try again.',
32
+ error_blur: 'Image is too blurry — hold steady and ensure good lighting.',
33
+ error_quality: 'Image quality too low. Try better lighting or a steadier hand.',
34
+ error_network: 'Network error. Check your connection and try again.'
38
35
  };
39
36
 
40
37
  function resolveTheme(theme) {
41
38
  const dark = theme?.mode === 'dark';
42
39
  return {
43
- accent: theme?.accentColor ?? '#111827',
44
- bg: theme?.backgroundColor ?? (dark ? '#1a1a2e' : '#ffffff'),
45
- text: theme?.textColor ?? (dark ? '#f9fafb' : '#111827'),
46
- subtext: theme?.subtextColor ?? (dark ? '#9ca3af' : '#6b7280'),
47
- border: theme?.borderColor ?? (dark ? '#374151' : '#e5e7eb'),
48
- card: theme?.cardColor ?? (dark ? '#111827' : '#f9fafb'),
49
- radius: theme?.borderRadius ?? 12
40
+ accent: theme?.accentColor ?? (dark ? '#e5e7eb' : '#111827'),
41
+ accentInv: dark ? '#111827' : '#ffffff',
42
+ bg: theme?.backgroundColor ?? (dark ? '#0f0f0f' : '#ffffff'),
43
+ surface: theme?.cardColor ?? (dark ? '#1a1a1a' : '#f7f7f7'),
44
+ surface2: dark ? '#242424' : '#efefef',
45
+ text: theme?.textColor ?? (dark ? '#f5f5f5' : '#0f0f0f'),
46
+ subtext: theme?.subtextColor ?? (dark ? '#888888' : '#6b7280'),
47
+ border: theme?.borderColor ?? (dark ? '#2a2a2a' : '#e5e5e5'),
48
+ errorBg: dark ? 'rgba(239,68,68,0.1)' : '#fef2f2',
49
+ errorText: dark ? '#f87171' : '#b91c1c',
50
+ radius: theme?.borderRadius ?? 14
50
51
  };
51
52
  }
52
53
 
@@ -105,93 +106,90 @@ function IntroStep({ theme, locale, onStart }) {
105
106
  return /*#__PURE__*/ React.createElement(reactNative.View, {
106
107
  style: s.container
107
108
  }, /*#__PURE__*/ React.createElement(reactNative.View, {
108
- style: s.steps
109
- }, [
110
- {
111
- icon: '🪪',
112
- label: 'Document'
113
- },
114
- {
115
- icon: '🤳',
116
- label: 'Selfie'
117
- },
118
- {
119
- icon: '👁️',
120
- label: 'Liveness'
121
- }
122
- ].map((item)=>/*#__PURE__*/ React.createElement(reactNative.View, {
123
- key: item.label,
124
- style: s.step
125
- }, /*#__PURE__*/ React.createElement(reactNative.View, {
126
- style: s.stepIcon
127
- }, /*#__PURE__*/ React.createElement(reactNative.Text, {
128
- style: s.stepEmoji
129
- }, item.icon)), /*#__PURE__*/ React.createElement(reactNative.Text, {
130
- style: s.stepLabel
131
- }, item.label)))), /*#__PURE__*/ React.createElement(reactNative.Text, {
109
+ style: s.textBlock
110
+ }, /*#__PURE__*/ React.createElement(reactNative.Text, {
132
111
  style: s.title
133
112
  }, locale.intro_title), /*#__PURE__*/ React.createElement(reactNative.Text, {
134
- style: s.body
135
- }, locale.intro_body), /*#__PURE__*/ React.createElement(reactNative.TouchableOpacity, {
113
+ style: s.subtitle
114
+ }, locale.intro_body)), /*#__PURE__*/ React.createElement(reactNative.View, {
115
+ style: s.hint
116
+ }, /*#__PURE__*/ React.createElement(reactNative.Text, {
117
+ style: s.hintIcon
118
+ }, "🔒"), /*#__PURE__*/ React.createElement(reactNative.Text, {
119
+ style: s.hintText
120
+ }, "Your data is discarded immediately after processing. Nothing is stored.")), /*#__PURE__*/ React.createElement(reactNative.TouchableOpacity, {
136
121
  style: s.btn,
137
122
  onPress: onStart,
138
123
  activeOpacity: 0.85
139
124
  }, /*#__PURE__*/ React.createElement(reactNative.Text, {
140
125
  style: s.btnText
141
- }, locale.intro_cta)));
126
+ }, locale.intro_cta), /*#__PURE__*/ React.createElement(reactNative.Text, {
127
+ style: s.btnArrow
128
+ }, " →")));
142
129
  }
143
130
  const styles$6 = (t)=>reactNative.StyleSheet.create({
144
131
  container: {
145
- padding: 24
146
- },
147
- steps: {
148
- flexDirection: 'row',
132
+ flex: 1,
133
+ padding: 28,
149
134
  justifyContent: 'center',
150
- gap: 20,
151
- marginBottom: 28
152
- },
153
- step: {
154
- alignItems: 'center',
155
- gap: 8
135
+ gap: 16
156
136
  },
157
- stepIcon: {
158
- width: 56,
159
- height: 56,
160
- borderRadius: 14,
161
- backgroundColor: t.card,
162
- borderWidth: 1,
163
- borderColor: t.border,
137
+ textBlock: {
164
138
  alignItems: 'center',
165
- justifyContent: 'center'
166
- },
167
- stepEmoji: {
168
- fontSize: 24
169
- },
170
- stepLabel: {
171
- fontSize: 12,
172
- color: t.subtext
139
+ gap: 8,
140
+ marginBottom: 4
173
141
  },
174
142
  title: {
175
143
  fontSize: 22,
176
144
  fontWeight: '700',
177
145
  color: t.text,
178
- marginBottom: 10
146
+ textAlign: 'center',
147
+ letterSpacing: -0.4
179
148
  },
180
- body: {
149
+ subtitle: {
181
150
  fontSize: 14,
182
151
  color: t.subtext,
183
152
  lineHeight: 21,
184
- marginBottom: 28
153
+ textAlign: 'center'
154
+ },
155
+ hint: {
156
+ flexDirection: 'row',
157
+ alignItems: 'flex-start',
158
+ gap: 8,
159
+ padding: 12,
160
+ backgroundColor: t.surface,
161
+ borderRadius: t.radius - 4,
162
+ borderWidth: 1,
163
+ borderColor: t.border
164
+ },
165
+ hintIcon: {
166
+ fontSize: 12,
167
+ marginTop: 1
168
+ },
169
+ hintText: {
170
+ flex: 1,
171
+ fontSize: 12,
172
+ color: t.subtext,
173
+ lineHeight: 17
185
174
  },
186
175
  btn: {
187
176
  backgroundColor: t.accent,
188
- borderRadius: t.radius,
189
- paddingVertical: 14,
190
- alignItems: 'center'
177
+ borderRadius: t.radius - 4,
178
+ paddingVertical: 13,
179
+ paddingHorizontal: 20,
180
+ flexDirection: 'row',
181
+ alignItems: 'center',
182
+ justifyContent: 'center'
191
183
  },
192
184
  btnText: {
193
- color: '#fff',
194
- fontSize: 15,
185
+ color: t.accentInv,
186
+ fontSize: 14,
187
+ fontWeight: '600',
188
+ letterSpacing: -0.2
189
+ },
190
+ btnArrow: {
191
+ color: t.accentInv,
192
+ fontSize: 14,
195
193
  fontWeight: '600'
196
194
  }
197
195
  });
@@ -243,52 +241,256 @@ function useCamera(facing = 'back') {
243
241
  };
244
242
  }
245
243
 
246
- function DocumentStep({ theme, locale, onCapture }) {
244
+ /**
245
+ * quality.ts — Native port of @kernaq/capture-web analyseImage logic.
246
+ *
247
+ * React Native has no OffscreenCanvas or createImageBitmap, so we decode
248
+ * the JPEG manually from its base64 representation to extract a luminance
249
+ * sample, then run the same Laplacian variance + brightness + fill-ratio
250
+ * checks the web SDK uses.
251
+ *
252
+ * This is intentionally dependency-free — no expo-image-manipulator,
253
+ * no worklets, no native modules beyond what vision-camera already provides.
254
+ *
255
+ * The JPEG sampling approach:
256
+ * 1. takeSnapshot({ quality: 20 }) → tiny file (~5-15KB)
257
+ * 2. fetch as ArrayBuffer → raw bytes
258
+ * 3. Skip JPEG header bytes, sample raw DCT-domain luminance bytes
259
+ * as a brightness proxy (not exact, but sufficient for dark/overexposed detection)
260
+ * 4. For blur: compute variance of luminance differences between adjacent samples
261
+ *
262
+ * Limitations vs web SDK:
263
+ * - Fill ratio check is approximated via luminance variance spread
264
+ * - Glasses glare heuristic is the same eye-band brightness check
265
+ * - Blur detection is less precise but calibrated to the same thresholds
266
+ */ const DEFAULTS = {
267
+ minBlurScore: 25,
268
+ minBrightness: 40,
269
+ maxBrightness: 220,
270
+ minFillRatio: 0.08
271
+ };
272
+ /**
273
+ * Analyse an image from a file URI.
274
+ * Reads the raw bytes, samples luminance, returns a quality report.
275
+ */ async function analyseImageUri(uri, config = {}) {
276
+ const cfg = {
277
+ ...DEFAULTS,
278
+ ...config
279
+ };
280
+ try {
281
+ // Fetch the image as raw bytes
282
+ const resp = await fetch(uri);
283
+ const buffer = await resp.arrayBuffer();
284
+ const bytes = new Uint8Array(buffer);
285
+ // Sample luminance values from the raw bytes
286
+ // JPEG compressed bytes in the entropy-coded segment correlate with luminance
287
+ // We skip the JPEG header (first ~400-600 bytes typically) and sample every Nth byte
288
+ const start = Math.min(600, Math.floor(bytes.length * 0.1));
289
+ const end = Math.floor(bytes.length * 0.9);
290
+ const step = Math.max(1, Math.floor((end - start) / 2048));
291
+ const samples = [];
292
+ for(let i = start; i < end; i += step){
293
+ samples.push(bytes[i]);
294
+ }
295
+ if (samples.length === 0) {
296
+ return {
297
+ passed: false,
298
+ blurScore: 0,
299
+ brightness: 0,
300
+ fillRatio: 0,
301
+ failures: [
302
+ 'too_blurry'
303
+ ]
304
+ };
305
+ }
306
+ const brightness = computeMean(samples);
307
+ const blurScore = computeBlurScore(samples);
308
+ const fillRatio = computeFillProxy(samples);
309
+ const failures = [];
310
+ const cfg2 = cfg;
311
+ if (blurScore < cfg2.minBlurScore) failures.push('too_blurry');
312
+ if (brightness < cfg2.minBrightness) failures.push('too_dark');
313
+ if (brightness > cfg2.maxBrightness) failures.push('too_bright');
314
+ if (fillRatio < cfg2.minFillRatio) failures.push('too_small');
315
+ return {
316
+ passed: failures.length === 0,
317
+ blurScore,
318
+ brightness,
319
+ fillRatio,
320
+ failures
321
+ };
322
+ } catch {
323
+ // If we can't read the file, assume it's a transient error — don't block
324
+ return {
325
+ passed: true,
326
+ blurScore: 50,
327
+ brightness: 128,
328
+ fillRatio: 0.5,
329
+ failures: []
330
+ };
331
+ }
332
+ }
333
+ // ── Helpers ───────────────────────────────────────────────────────────────────
334
+ function computeMean(samples) {
335
+ return samples.reduce((a, b)=>a + b, 0) / samples.length;
336
+ }
337
+ /**
338
+ * Laplacian variance proxy: variance of first-difference between adjacent samples.
339
+ * Normalised to 0–100. Higher = more high-frequency content = sharper.
340
+ */ function computeBlurScore(samples) {
341
+ if (samples.length < 2) return 0;
342
+ let sumSq = 0;
343
+ let sum = 0;
344
+ for(let i = 1; i < samples.length; i++){
345
+ const d = samples[i] - samples[i - 1];
346
+ sum += d;
347
+ sumSq += d * d;
348
+ }
349
+ const n = samples.length - 1;
350
+ const mean = sum / n;
351
+ const variance = sumSq / n - mean * mean;
352
+ // Empirically ~0 = solid colour (blurry), ~800+ = sharp photo
353
+ return Math.min(100, Math.round(variance / 800 * 100));
354
+ }
355
+ /**
356
+ * Fill ratio proxy: fraction of samples that differ significantly from the mean.
357
+ * Analogous to the foreground pixel fraction in the web SDK.
358
+ */ function computeFillProxy(samples) {
359
+ const mean = computeMean(samples);
360
+ const threshold = 30;
361
+ const foreground = samples.filter((s)=>Math.abs(s - mean) > threshold).length;
362
+ return foreground / samples.length;
363
+ }
364
+ /**
365
+ * Selfie-specific quality check with glasses/glare heuristic.
366
+ * Takes snapshots from two regions: full frame and top-40% (eye area).
367
+ *
368
+ * Returns a guidance message (empty string = all good) and a passing flag.
369
+ */ async function analyseSelfieFrame(uri, config = {}) {
370
+ const cfg = {
371
+ ...DEFAULTS,
372
+ ...config
373
+ };
374
+ try {
375
+ const resp = await fetch(uri);
376
+ const buffer = await resp.arrayBuffer();
377
+ const bytes = new Uint8Array(buffer);
378
+ const total = bytes.length;
379
+ const start = Math.min(600, Math.floor(total * 0.1));
380
+ const step = Math.max(1, Math.floor((total - start) / 1024));
381
+ // Full-frame samples
382
+ const full = [];
383
+ for(let i = start; i < total; i += step)full.push(bytes[i]);
384
+ // Top-40% samples (eye region proxy — first 40% of the data after header)
385
+ const eyeEnd = start + Math.floor((total - start) * 0.4);
386
+ const eyeSamples = [];
387
+ for(let i = start; i < eyeEnd; i += step)eyeSamples.push(bytes[i]);
388
+ const brightness = computeMean(full);
389
+ const blurScore = computeBlurScore(full);
390
+ const eyeMean = computeMean(eyeSamples);
391
+ // Priority-ordered checks — mirrors web component exactly
392
+ if (brightness < cfg.minBrightness) return {
393
+ passing: false,
394
+ message: 'Move to better lighting'
395
+ };
396
+ if (brightness > cfg.maxBrightness) return {
397
+ passing: false,
398
+ message: 'Too bright — avoid direct light'
399
+ };
400
+ if (eyeMean > 210) return {
401
+ passing: false,
402
+ message: 'Remove glasses'
403
+ };
404
+ if (blurScore < cfg.minBlurScore) return {
405
+ passing: false,
406
+ message: 'Hold still'
407
+ };
408
+ return {
409
+ passing: true,
410
+ message: 'Hold still\u2026'
411
+ };
412
+ } catch {
413
+ return {
414
+ passing: false,
415
+ message: 'Hold still'
416
+ };
417
+ }
418
+ }
419
+
420
+ function DocumentStep({ theme, locale, side, frontUri, onCapture, onRetakeFront }) {
247
421
  const { cameraRef, device, capturePhoto } = useCamera('back');
248
- const [preview, setPreview] = React.useState(null);
249
422
  const [capturing, setCapturing] = React.useState(false);
423
+ const [error, setError] = React.useState(null);
250
424
  const s = styles$5(theme);
251
425
  const handleCapture = async ()=>{
426
+ if (!cameraRef.current) return;
252
427
  setCapturing(true);
428
+ setError(null);
253
429
  const result = await capturePhoto();
430
+ if (!result) {
431
+ setError(locale.error_blur);
432
+ setCapturing(false);
433
+ return;
434
+ }
435
+ // Run same quality checks as web SDK
436
+ const report = await analyseImageUri(result.uri, {
437
+ minBlurScore: 20,
438
+ minBrightness: 30,
439
+ maxBrightness: 230,
440
+ minFillRatio: 0.05
441
+ });
254
442
  setCapturing(false);
255
- if (result) setPreview(result.uri);
256
- };
257
- const handleConfirm = ()=>{
258
- if (preview) onCapture(preview);
443
+ if (!report.passed) {
444
+ const msgs = {
445
+ too_blurry: locale.error_blur,
446
+ too_dark: 'Move to better lighting',
447
+ too_bright: 'Reduce brightness or move away from light',
448
+ too_small: 'Move closer to the document'
449
+ };
450
+ setError(msgs[report.failures[0]] ?? locale.error_quality);
451
+ return;
452
+ }
453
+ onCapture(result.uri);
259
454
  };
455
+ const title = side === 'front' ? locale.doc_front_title : locale.doc_back_title;
456
+ const instruction = side === 'front' ? locale.doc_front_instruction : locale.doc_back_instruction;
457
+ const hint = side === 'front' ? 'Hold the camera parallel to the document. Move closer if text is hard to read.' : 'Flip the document over and keep it flat inside the frame.';
260
458
  if (!device) {
261
459
  return /*#__PURE__*/ React.createElement(reactNative.View, {
262
- style: s.container
460
+ style: [
461
+ s.container,
462
+ {
463
+ justifyContent: 'center',
464
+ alignItems: 'center'
465
+ }
466
+ ]
263
467
  }, /*#__PURE__*/ React.createElement(reactNative.ActivityIndicator, {
264
468
  color: theme.accent
265
469
  }));
266
470
  }
267
471
  return /*#__PURE__*/ React.createElement(reactNative.View, {
268
472
  style: s.container
473
+ }, /*#__PURE__*/ React.createElement(reactNative.View, {
474
+ style: s.heading
269
475
  }, /*#__PURE__*/ React.createElement(reactNative.Text, {
270
476
  style: s.title
271
- }, locale.doc_title), /*#__PURE__*/ React.createElement(reactNative.Text, {
477
+ }, title), /*#__PURE__*/ React.createElement(reactNative.Text, {
272
478
  style: s.subtitle
273
- }, locale.doc_instruction), preview ? /*#__PURE__*/ React.createElement(React.Fragment, null, /*#__PURE__*/ React.createElement(reactNative.Image, {
479
+ }, instruction)), error ? /*#__PURE__*/ React.createElement(reactNative.View, {
480
+ style: s.errorBanner
481
+ }, /*#__PURE__*/ React.createElement(reactNative.Text, {
482
+ style: s.errorText
483
+ }, error)) : null, side === 'back' && frontUri ? /*#__PURE__*/ React.createElement(reactNative.View, {
484
+ style: s.thumbRow
485
+ }, /*#__PURE__*/ React.createElement(reactNative.Image, {
274
486
  source: {
275
- uri: preview
487
+ uri: frontUri
276
488
  },
277
- style: s.preview,
489
+ style: s.thumb,
278
490
  resizeMode: "cover"
279
- }), /*#__PURE__*/ React.createElement(reactNative.TouchableOpacity, {
280
- style: s.btnPrimary,
281
- onPress: handleConfirm,
282
- activeOpacity: 0.85
283
- }, /*#__PURE__*/ React.createElement(reactNative.Text, {
284
- style: s.btnText
285
- }, locale.doc_next_btn)), /*#__PURE__*/ React.createElement(reactNative.TouchableOpacity, {
286
- style: s.btnSecondary,
287
- onPress: ()=>setPreview(null),
288
- activeOpacity: 0.75
289
- }, /*#__PURE__*/ React.createElement(reactNative.Text, {
290
- style: s.btnSecondaryText
291
- }, locale.doc_retake_btn))) : /*#__PURE__*/ React.createElement(React.Fragment, null, /*#__PURE__*/ React.createElement(reactNative.View, {
491
+ }), /*#__PURE__*/ React.createElement(reactNative.Text, {
492
+ style: s.thumbLabel
493
+ }, "✓ Front captured")) : null, /*#__PURE__*/ React.createElement(reactNative.View, {
292
494
  style: s.viewport
293
495
  }, /*#__PURE__*/ React.createElement(reactNativeVisionCamera.Camera, {
294
496
  ref: cameraRef,
@@ -299,104 +501,287 @@ function DocumentStep({ theme, locale, onCapture }) {
299
501
  }), /*#__PURE__*/ React.createElement(reactNative.View, {
300
502
  style: s.frameGuide,
301
503
  pointerEvents: "none"
302
- })), /*#__PURE__*/ React.createElement(reactNative.TouchableOpacity, {
504
+ }, /*#__PURE__*/ React.createElement(reactNative.View, {
505
+ style: [
506
+ s.corner,
507
+ s.tl
508
+ ]
509
+ }), /*#__PURE__*/ React.createElement(reactNative.View, {
303
510
  style: [
304
- s.btnPrimary,
511
+ s.corner,
512
+ s.tr
513
+ ]
514
+ }), /*#__PURE__*/ React.createElement(reactNative.View, {
515
+ style: [
516
+ s.corner,
517
+ s.bl
518
+ ]
519
+ }), /*#__PURE__*/ React.createElement(reactNative.View, {
520
+ style: [
521
+ s.corner,
522
+ s.br
523
+ ]
524
+ })), /*#__PURE__*/ React.createElement(reactNative.View, {
525
+ style: s.dimOverlay,
526
+ pointerEvents: "none"
527
+ })), /*#__PURE__*/ React.createElement(reactNative.View, {
528
+ style: s.hint
529
+ }, /*#__PURE__*/ React.createElement(reactNative.Text, {
530
+ style: s.hintText
531
+ }, hint)), /*#__PURE__*/ React.createElement(reactNative.TouchableOpacity, {
532
+ style: [
533
+ s.btn,
305
534
  capturing && s.btnDisabled
306
535
  ],
307
536
  onPress: handleCapture,
308
537
  disabled: capturing,
309
538
  activeOpacity: 0.85
310
539
  }, capturing ? /*#__PURE__*/ React.createElement(reactNative.ActivityIndicator, {
311
- color: "#fff"
540
+ color: theme.accentInv
312
541
  }) : /*#__PURE__*/ React.createElement(reactNative.Text, {
313
542
  style: s.btnText
314
- }, locale.doc_capture_btn))));
543
+ }, locale.doc_capture_btn)), side === 'back' && onRetakeFront ? /*#__PURE__*/ React.createElement(reactNative.TouchableOpacity, {
544
+ style: s.btnGhost,
545
+ onPress: onRetakeFront,
546
+ activeOpacity: 0.75
547
+ }, /*#__PURE__*/ React.createElement(reactNative.Text, {
548
+ style: s.btnGhostText
549
+ }, locale.doc_retake_btn, " front")) : null);
315
550
  }
551
+ const CORNER_SIZE = 20;
552
+ const CORNER_W = 2;
316
553
  const styles$5 = (t)=>reactNative.StyleSheet.create({
317
554
  container: {
318
- padding: 24
555
+ flex: 1,
556
+ padding: 20,
557
+ gap: 12
558
+ },
559
+ heading: {
560
+ gap: 4,
561
+ alignItems: 'center'
319
562
  },
320
563
  title: {
321
564
  fontSize: 20,
322
565
  fontWeight: '700',
323
566
  color: t.text,
324
- marginBottom: 8
567
+ textAlign: 'center',
568
+ letterSpacing: -0.3
325
569
  },
326
570
  subtitle: {
327
- fontSize: 14,
571
+ fontSize: 13,
328
572
  color: t.subtext,
329
- lineHeight: 20,
330
- marginBottom: 16
573
+ lineHeight: 19,
574
+ textAlign: 'center'
575
+ },
576
+ errorBanner: {
577
+ backgroundColor: t.errorBg,
578
+ borderRadius: 8,
579
+ padding: 10
580
+ },
581
+ errorText: {
582
+ fontSize: 12.5,
583
+ color: t.errorText,
584
+ lineHeight: 17
585
+ },
586
+ thumbRow: {
587
+ flexDirection: 'row',
588
+ alignItems: 'center',
589
+ gap: 10,
590
+ padding: 8,
591
+ backgroundColor: t.surface,
592
+ borderRadius: 8,
593
+ borderWidth: 1,
594
+ borderColor: t.border
595
+ },
596
+ thumb: {
597
+ width: 48,
598
+ height: 32,
599
+ borderRadius: 5,
600
+ borderWidth: 1,
601
+ borderColor: t.border
602
+ },
603
+ thumbLabel: {
604
+ fontSize: 12,
605
+ color: t.subtext
331
606
  },
332
607
  viewport: {
333
- width: '100%',
334
- aspectRatio: 4 / 3,
335
- borderRadius: t.radius,
608
+ flex: 1,
609
+ borderRadius: t.radius - 4,
336
610
  overflow: 'hidden',
337
611
  backgroundColor: '#000',
338
- marginBottom: 16,
339
612
  position: 'relative'
340
613
  },
341
614
  frameGuide: {
342
615
  position: 'absolute',
343
616
  top: '8%',
344
- left: '5%',
345
- right: '5%',
346
- bottom: '8%',
347
- borderWidth: 2,
348
- borderColor: 'rgba(255,255,255,0.7)',
349
- borderRadius: 8
617
+ left: '6%',
618
+ right: '6%',
619
+ bottom: '8%'
350
620
  },
351
- preview: {
352
- width: '100%',
353
- aspectRatio: 4 / 3,
354
- borderRadius: t.radius,
355
- marginBottom: 16,
621
+ corner: {
622
+ position: 'absolute',
623
+ width: CORNER_SIZE,
624
+ height: CORNER_SIZE,
625
+ borderColor: 'rgba(255,255,255,0.9)'
626
+ },
627
+ tl: {
628
+ top: 0,
629
+ left: 0,
630
+ borderTopWidth: CORNER_W,
631
+ borderLeftWidth: CORNER_W,
632
+ borderTopLeftRadius: 3
633
+ },
634
+ tr: {
635
+ top: 0,
636
+ right: 0,
637
+ borderTopWidth: CORNER_W,
638
+ borderRightWidth: CORNER_W,
639
+ borderTopRightRadius: 3
640
+ },
641
+ bl: {
642
+ bottom: 0,
643
+ left: 0,
644
+ borderBottomWidth: CORNER_W,
645
+ borderLeftWidth: CORNER_W,
646
+ borderBottomLeftRadius: 3
647
+ },
648
+ br: {
649
+ bottom: 0,
650
+ right: 0,
651
+ borderBottomWidth: CORNER_W,
652
+ borderRightWidth: CORNER_W,
653
+ borderBottomRightRadius: 3
654
+ },
655
+ dimOverlay: {
656
+ position: 'absolute',
657
+ top: 0,
658
+ left: 0,
659
+ right: 0,
660
+ bottom: 0,
661
+ backgroundColor: 'transparent'
662
+ },
663
+ hint: {
664
+ flexDirection: 'row',
665
+ gap: 7,
666
+ padding: 9,
667
+ backgroundColor: t.surface,
668
+ borderRadius: 8,
356
669
  borderWidth: 1,
357
670
  borderColor: t.border
358
671
  },
359
- btnPrimary: {
360
- backgroundColor: t.accent,
361
- borderRadius: t.radius,
362
- paddingVertical: 14,
363
- alignItems: 'center',
364
- marginBottom: 10
672
+ hintText: {
673
+ flex: 1,
674
+ fontSize: 12,
675
+ color: t.subtext,
676
+ lineHeight: 16
365
677
  },
366
- btnSecondary: {
367
- backgroundColor: t.card,
368
- borderRadius: t.radius,
369
- paddingVertical: 14,
370
- alignItems: 'center',
371
- borderWidth: 1,
372
- borderColor: t.border
678
+ btn: {
679
+ backgroundColor: t.accent,
680
+ borderRadius: t.radius - 4,
681
+ paddingVertical: 12,
682
+ alignItems: 'center'
373
683
  },
374
684
  btnDisabled: {
375
- opacity: 0.6
685
+ opacity: 0.5
376
686
  },
377
687
  btnText: {
378
- color: '#fff',
379
- fontSize: 15,
688
+ color: t.accentInv,
689
+ fontSize: 14,
380
690
  fontWeight: '600'
381
691
  },
382
- btnSecondaryText: {
383
- color: t.text,
384
- fontSize: 15,
385
- fontWeight: '600'
692
+ btnGhost: {
693
+ borderRadius: t.radius - 4,
694
+ paddingVertical: 11,
695
+ alignItems: 'center',
696
+ borderWidth: 1,
697
+ borderColor: t.border,
698
+ backgroundColor: 'transparent'
699
+ },
700
+ btnGhostText: {
701
+ color: t.subtext,
702
+ fontSize: 13.5,
703
+ fontWeight: '500'
386
704
  }
387
705
  });
388
706
 
389
- function SelfieStep({ theme, locale, onCapture, onBack }) {
390
- const { cameraRef, device, capturePhoto } = useCamera('front');
391
- const [preview, setPreview] = React.useState(null);
707
+ const HOLD_FRAMES = 12 // ~1.5s at 120ms per frame
708
+ ;
709
+ function SelfieStep({ theme, locale, onCapture }) {
710
+ const cameraRef = React.useRef(null);
711
+ const device = reactNativeVisionCamera.useCameraDevice('front');
712
+ const [pill, setPill] = React.useState('Align your face');
713
+ const [pillOk, setPillOk] = React.useState(false);
392
714
  const [capturing, setCapturing] = React.useState(false);
715
+ const readyFrames = React.useRef(0);
716
+ const timerRef = React.useRef(null);
717
+ const borderAnim = React.useRef(new reactNative.Animated.Value(0)).current // 0 = white, 1 = green
718
+ ;
393
719
  const s = styles$4(theme);
394
- const handleCapture = async ()=>{
395
- setCapturing(true);
396
- const result = await capturePhoto();
397
- setCapturing(false);
398
- if (result) setPreview(result.uri);
399
- };
720
+ // Animate oval border colour
721
+ React.useEffect(()=>{
722
+ reactNative.Animated.timing(borderAnim, {
723
+ toValue: pillOk ? 1 : 0,
724
+ duration: 200,
725
+ useNativeDriver: false
726
+ }).start();
727
+ }, [
728
+ pillOk,
729
+ borderAnim
730
+ ]);
731
+ const ovalBorder = borderAnim.interpolate({
732
+ inputRange: [
733
+ 0,
734
+ 1
735
+ ],
736
+ outputRange: [
737
+ 'rgba(255,255,255,0.75)',
738
+ '#4ade80'
739
+ ]
740
+ });
741
+ React.useEffect(()=>{
742
+ if (!device || capturing) return;
743
+ timerRef.current = setInterval(async ()=>{
744
+ if (!cameraRef.current || capturing) return;
745
+ try {
746
+ // Fast low-quality snapshot for analysis only
747
+ const snap = await cameraRef.current.takeSnapshot({
748
+ quality: 20
749
+ });
750
+ const uri = `file://${snap.path}`;
751
+ const { passing, message } = await analyseSelfieFrame(uri, {
752
+ minBlurScore: 22,
753
+ minBrightness: 40,
754
+ maxBrightness: 220
755
+ });
756
+ setPill(passing ? 'Hold still\u2026' : message);
757
+ setPillOk(passing);
758
+ if (passing) {
759
+ readyFrames.current++;
760
+ } else {
761
+ readyFrames.current = 0;
762
+ }
763
+ if (readyFrames.current >= HOLD_FRAMES) {
764
+ // Stop loop, take full-quality photo
765
+ clearInterval(timerRef.current);
766
+ timerRef.current = null;
767
+ setCapturing(true);
768
+ const full = await cameraRef.current.takePhoto({
769
+ qualityPrioritization: 'quality'
770
+ });
771
+ onCapture(`file://${full.path}`);
772
+ }
773
+ } catch {
774
+ readyFrames.current = 0;
775
+ }
776
+ }, 120);
777
+ return ()=>{
778
+ if (timerRef.current) clearInterval(timerRef.current);
779
+ };
780
+ }, [
781
+ device,
782
+ capturing,
783
+ onCapture
784
+ ]);
400
785
  if (!device) return /*#__PURE__*/ React.createElement(reactNative.ActivityIndicator, {
401
786
  color: theme.accent,
402
787
  style: {
@@ -405,148 +790,181 @@ function SelfieStep({ theme, locale, onCapture, onBack }) {
405
790
  });
406
791
  return /*#__PURE__*/ React.createElement(reactNative.View, {
407
792
  style: s.container
793
+ }, /*#__PURE__*/ React.createElement(reactNative.View, {
794
+ style: s.heading
408
795
  }, /*#__PURE__*/ React.createElement(reactNative.Text, {
409
796
  style: s.title
410
797
  }, locale.selfie_title), /*#__PURE__*/ React.createElement(reactNative.Text, {
411
798
  style: s.subtitle
412
- }, locale.selfie_instruction), preview ? /*#__PURE__*/ React.createElement(React.Fragment, null, /*#__PURE__*/ React.createElement(reactNative.Image, {
413
- source: {
414
- uri: preview
415
- },
416
- style: s.preview,
417
- resizeMode: "cover"
418
- }), /*#__PURE__*/ React.createElement(reactNative.TouchableOpacity, {
419
- style: s.btnPrimary,
420
- onPress: ()=>onCapture(preview),
421
- activeOpacity: 0.85
422
- }, /*#__PURE__*/ React.createElement(reactNative.Text, {
423
- style: s.btnText
424
- }, locale.selfie_next_btn)), /*#__PURE__*/ React.createElement(reactNative.TouchableOpacity, {
425
- style: s.btnSecondary,
426
- onPress: ()=>setPreview(null),
427
- activeOpacity: 0.75
428
- }, /*#__PURE__*/ React.createElement(reactNative.Text, {
429
- style: s.btnSecondaryText
430
- }, locale.selfie_retake_btn))) : /*#__PURE__*/ React.createElement(React.Fragment, null, /*#__PURE__*/ React.createElement(reactNative.View, {
799
+ }, locale.selfie_instruction)), /*#__PURE__*/ React.createElement(reactNative.View, {
431
800
  style: s.viewport
432
801
  }, /*#__PURE__*/ React.createElement(reactNativeVisionCamera.Camera, {
433
802
  ref: cameraRef,
434
803
  style: reactNative.StyleSheet.absoluteFill,
435
804
  device: device,
436
- isActive: true,
805
+ isActive: !capturing,
437
806
  photo: true
438
- }), /*#__PURE__*/ React.createElement(reactNative.View, {
439
- style: s.ovalGuide,
807
+ }), /*#__PURE__*/ React.createElement(reactNative.Animated.View, {
808
+ style: [
809
+ s.oval,
810
+ {
811
+ borderColor: ovalBorder
812
+ }
813
+ ],
440
814
  pointerEvents: "none"
441
- })), /*#__PURE__*/ React.createElement(reactNative.TouchableOpacity, {
815
+ }), /*#__PURE__*/ React.createElement(reactNative.View, {
442
816
  style: [
443
- s.btnPrimary,
444
- capturing && s.btnDisabled
817
+ s.pill,
818
+ pillOk && s.pillOk
445
819
  ],
446
- onPress: handleCapture,
447
- disabled: capturing,
448
- activeOpacity: 0.85
449
- }, capturing ? /*#__PURE__*/ React.createElement(reactNative.ActivityIndicator, {
450
- color: "#fff"
451
- }) : /*#__PURE__*/ React.createElement(reactNative.Text, {
452
- style: s.btnText
453
- }, locale.selfie_capture_btn)), /*#__PURE__*/ React.createElement(reactNative.TouchableOpacity, {
454
- style: s.btnSecondary,
455
- onPress: onBack,
456
- activeOpacity: 0.75
820
+ pointerEvents: "none"
457
821
  }, /*#__PURE__*/ React.createElement(reactNative.Text, {
458
- style: s.btnSecondaryText
459
- }, locale.doc_retake_btn))));
822
+ style: s.pillText
823
+ }, pill))), capturing && /*#__PURE__*/ React.createElement(reactNative.View, {
824
+ style: s.capturingRow
825
+ }, /*#__PURE__*/ React.createElement(reactNative.ActivityIndicator, {
826
+ color: theme.accent,
827
+ size: "small"
828
+ }), /*#__PURE__*/ React.createElement(reactNative.Text, {
829
+ style: s.capturingText
830
+ }, "Capturing…")));
460
831
  }
461
832
  const styles$4 = (t)=>reactNative.StyleSheet.create({
462
833
  container: {
463
- padding: 24
834
+ flex: 1,
835
+ padding: 20,
836
+ gap: 12
837
+ },
838
+ heading: {
839
+ gap: 4,
840
+ alignItems: 'center'
464
841
  },
465
842
  title: {
466
843
  fontSize: 20,
467
844
  fontWeight: '700',
468
845
  color: t.text,
469
- marginBottom: 8
846
+ textAlign: 'center',
847
+ letterSpacing: -0.3
470
848
  },
471
849
  subtitle: {
472
- fontSize: 14,
850
+ fontSize: 13,
473
851
  color: t.subtext,
474
- lineHeight: 20,
475
- marginBottom: 16
852
+ lineHeight: 19,
853
+ textAlign: 'center'
476
854
  },
477
855
  viewport: {
478
- width: '100%',
479
- aspectRatio: 3 / 4,
480
- borderRadius: t.radius,
856
+ flex: 1,
857
+ borderRadius: t.radius - 4,
481
858
  overflow: 'hidden',
482
859
  backgroundColor: '#000',
483
- marginBottom: 16,
484
- position: 'relative',
485
- alignSelf: 'center',
486
- maxWidth: 280
860
+ position: 'relative'
487
861
  },
488
- ovalGuide: {
862
+ oval: {
489
863
  position: 'absolute',
490
- top: '8%',
491
- left: '12%',
492
- right: '12%',
493
- bottom: '8%',
864
+ left: '21%',
865
+ right: '21%',
866
+ top: '6%',
867
+ bottom: '6%',
494
868
  borderWidth: 2,
495
- borderColor: 'rgba(255,255,255,0.7)',
496
869
  borderRadius: 999
497
870
  },
498
- preview: {
499
- width: 240,
500
- height: 320,
501
- borderRadius: t.radius,
502
- marginBottom: 16,
871
+ pill: {
872
+ position: 'absolute',
873
+ bottom: 14,
503
874
  alignSelf: 'center',
504
- borderWidth: 1,
505
- borderColor: t.border
506
- },
507
- btnPrimary: {
508
- backgroundColor: t.accent,
509
- borderRadius: t.radius,
510
- paddingVertical: 14,
511
- alignItems: 'center',
512
- marginBottom: 10
513
- },
514
- btnSecondary: {
515
- backgroundColor: t.card,
516
- borderRadius: t.radius,
517
- paddingVertical: 14,
518
- alignItems: 'center',
519
- borderWidth: 1,
520
- borderColor: t.border
875
+ backgroundColor: 'rgba(0,0,0,0.65)',
876
+ borderRadius: 999,
877
+ paddingHorizontal: 16,
878
+ paddingVertical: 7,
879
+ borderWidth: 1.5,
880
+ borderColor: 'rgba(255,255,255,0.15)'
521
881
  },
522
- btnDisabled: {
523
- opacity: 0.6
882
+ pillOk: {
883
+ backgroundColor: 'rgba(22,163,74,0.8)',
884
+ borderColor: 'rgba(134,239,172,0.5)'
524
885
  },
525
- btnText: {
886
+ pillText: {
526
887
  color: '#fff',
527
- fontSize: 15,
888
+ fontSize: 13,
528
889
  fontWeight: '600'
529
890
  },
530
- btnSecondaryText: {
531
- color: t.text,
532
- fontSize: 15,
533
- fontWeight: '600'
891
+ capturingRow: {
892
+ flexDirection: 'row',
893
+ alignItems: 'center',
894
+ justifyContent: 'center',
895
+ gap: 8,
896
+ paddingVertical: 8
897
+ },
898
+ capturingText: {
899
+ fontSize: 13,
900
+ color: t.subtext
534
901
  }
535
902
  });
536
903
 
537
- function LivenessStep({ theme, locale, duration, onCapture, onBack }) {
538
- const { cameraRef, device, startRecording, stopRecording } = useCamera('front');
904
+ const TASK_POOL = [
905
+ {
906
+ id: 'blink',
907
+ label: 'Blink twice',
908
+ duration: 3000
909
+ },
910
+ {
911
+ id: 'turn-left',
912
+ label: 'Turn your head left',
913
+ duration: 3000
914
+ },
915
+ {
916
+ id: 'turn-right',
917
+ label: 'Turn your head right',
918
+ duration: 3000
919
+ },
920
+ {
921
+ id: 'nod',
922
+ label: 'Nod your head',
923
+ duration: 3000
924
+ },
925
+ {
926
+ id: 'open-mouth',
927
+ label: 'Open your mouth',
928
+ duration: 3000
929
+ }
930
+ ];
931
+ function shuffle(arr) {
932
+ const a = [
933
+ ...arr
934
+ ];
935
+ for(let i = a.length - 1; i > 0; i--){
936
+ const j = Math.floor(Math.random() * (i + 1));
937
+ [a[i], a[j]] = [
938
+ a[j],
939
+ a[i]
940
+ ];
941
+ }
942
+ return a;
943
+ }
944
+ function LivenessStep({ theme, locale, duration, taskCount = 2, onCapture }) {
945
+ const { cameraRef, startRecording, stopRecording } = useCamera('front');
946
+ const device = reactNativeVisionCamera.useCameraDevice('front');
947
+ const tasks = React.useMemo(()=>shuffle(TASK_POOL).slice(0, Math.min(taskCount, TASK_POOL.length)), [
948
+ taskCount
949
+ ]);
950
+ const [taskIdx, setTaskIdx] = React.useState(0);
951
+ const [started, setStarted] = React.useState(false) // task in progress
952
+ ;
953
+ const [taskDone, setTaskDone] = React.useState(0) // completed tasks count
954
+ ;
955
+ const [progress, setProgress] = React.useState(0) // 0–100 for current task bar
956
+ ;
957
+ const [complete, setComplete] = React.useState(false);
539
958
  const [recording, setRecording] = React.useState(false);
540
- const [countdown, setCountdown] = React.useState(Math.ceil(duration / 1000));
541
- const timerRef = React.useRef(null);
959
+ const progressTimer = React.useRef(null);
960
+ const taskTimer = React.useRef(null);
542
961
  const pulseAnim = React.useRef(new reactNative.Animated.Value(1)).current;
543
- const s = styles$3(theme);
544
- // Pulse animation for recording indicator
962
+ // Pulse REC dot
545
963
  React.useEffect(()=>{
546
964
  if (recording) {
547
965
  reactNative.Animated.loop(reactNative.Animated.sequence([
548
966
  reactNative.Animated.timing(pulseAnim, {
549
- toValue: 0.3,
967
+ toValue: 0.2,
550
968
  duration: 600,
551
969
  useNativeDriver: true
552
970
  }),
@@ -557,34 +975,60 @@ function LivenessStep({ theme, locale, duration, onCapture, onBack }) {
557
975
  })
558
976
  ])).start();
559
977
  } else {
978
+ pulseAnim.stopAnimation();
560
979
  pulseAnim.setValue(1);
561
980
  }
562
981
  }, [
563
982
  recording,
564
983
  pulseAnim
565
984
  ]);
566
- const handleStart = ()=>{
567
- if (!cameraRef.current) return;
568
- setRecording(true);
569
- setCountdown(Math.ceil(duration / 1000));
570
- startRecording((video)=>{
571
- setRecording(false);
572
- onCapture(video.uri);
573
- }, ()=>setRecording(false));
574
- // Countdown tick
575
- let remaining = duration / 1000;
576
- timerRef.current = setInterval(()=>{
577
- remaining -= 0.1;
578
- setCountdown(Math.max(0, Math.ceil(remaining)));
579
- if (remaining <= 0) {
580
- if (timerRef.current) clearInterval(timerRef.current);
581
- stopRecording();
985
+ const startTask = ()=>{
986
+ if (!recording) {
987
+ // Begin recording when first task starts
988
+ startRecording((video)=>{
989
+ onCapture(video.uri);
990
+ }, ()=>{});
991
+ setRecording(true);
992
+ }
993
+ const task = tasks[taskIdx];
994
+ if (!task) return;
995
+ setStarted(true);
996
+ setProgress(0);
997
+ const start = Date.now();
998
+ progressTimer.current = setInterval(()=>{
999
+ const elapsed = Date.now() - start;
1000
+ const pct = Math.min(100, elapsed / task.duration * 100);
1001
+ setProgress(pct);
1002
+ if (elapsed >= task.duration) {
1003
+ clearInterval(progressTimer.current);
1004
+ progressTimer.current = null;
1005
+ nextTask();
582
1006
  }
583
- }, 100);
1007
+ }, 80);
1008
+ };
1009
+ const nextTask = ()=>{
1010
+ const next = taskIdx + 1;
1011
+ setTaskDone((prev)=>prev + 1);
1012
+ if (next >= tasks.length) {
1013
+ finishLiveness();
1014
+ return;
1015
+ }
1016
+ setTaskIdx(next);
1017
+ setStarted(false);
1018
+ setProgress(0);
1019
+ // Short pause then auto-start next task
1020
+ taskTimer.current = setTimeout(()=>startTask(), 500);
1021
+ };
1022
+ const finishLiveness = ()=>{
1023
+ setComplete(true);
1024
+ taskTimer.current = setTimeout(()=>{
1025
+ stopRecording();
1026
+ }, 1000);
584
1027
  };
585
1028
  React.useEffect(()=>{
586
1029
  return ()=>{
587
- if (timerRef.current) clearInterval(timerRef.current);
1030
+ if (progressTimer.current) clearInterval(progressTimer.current);
1031
+ if (taskTimer.current) clearTimeout(taskTimer.current);
588
1032
  };
589
1033
  }, []);
590
1034
  if (!device) return /*#__PURE__*/ React.createElement(reactNative.ActivityIndicator, {
@@ -593,13 +1037,26 @@ function LivenessStep({ theme, locale, duration, onCapture, onBack }) {
593
1037
  margin: 40
594
1038
  }
595
1039
  });
1040
+ const currentTask = tasks[taskIdx];
1041
+ const s = styles$3(theme);
596
1042
  return /*#__PURE__*/ React.createElement(reactNative.View, {
597
1043
  style: s.container
1044
+ }, /*#__PURE__*/ React.createElement(reactNative.View, {
1045
+ style: s.heading
598
1046
  }, /*#__PURE__*/ React.createElement(reactNative.Text, {
599
1047
  style: s.title
600
- }, locale.liveness_title), /*#__PURE__*/ React.createElement(reactNative.Text, {
1048
+ }, complete ? locale.liveness_complete : locale.liveness_title), !complete && /*#__PURE__*/ React.createElement(reactNative.Text, {
601
1049
  style: s.subtitle
602
- }, recording ? locale.liveness_recording : locale.liveness_instruction), /*#__PURE__*/ React.createElement(reactNative.View, {
1050
+ }, locale.liveness_ready)), !complete && /*#__PURE__*/ React.createElement(reactNative.View, {
1051
+ style: s.taskDots
1052
+ }, tasks.map((_, i)=>/*#__PURE__*/ React.createElement(reactNative.View, {
1053
+ key: i,
1054
+ style: [
1055
+ s.dot,
1056
+ i < taskDone && s.dotDone,
1057
+ i === taskIdx && s.dotActive
1058
+ ]
1059
+ }))), /*#__PURE__*/ React.createElement(reactNative.View, {
603
1060
  style: s.viewport
604
1061
  }, /*#__PURE__*/ React.createElement(reactNativeVisionCamera.Camera, {
605
1062
  ref: cameraRef,
@@ -607,11 +1064,11 @@ function LivenessStep({ theme, locale, duration, onCapture, onBack }) {
607
1064
  device: device,
608
1065
  isActive: true,
609
1066
  video: true,
610
- audio: true
1067
+ audio: false
611
1068
  }), /*#__PURE__*/ React.createElement(reactNative.View, {
612
1069
  style: s.ovalGuide,
613
1070
  pointerEvents: "none"
614
- }), recording && /*#__PURE__*/ React.createElement(React.Fragment, null, /*#__PURE__*/ React.createElement(reactNative.View, {
1071
+ }), recording && /*#__PURE__*/ React.createElement(reactNative.View, {
615
1072
  style: s.recBadge
616
1073
  }, /*#__PURE__*/ React.createElement(reactNative.Animated.View, {
617
1074
  style: [
@@ -622,59 +1079,94 @@ function LivenessStep({ theme, locale, duration, onCapture, onBack }) {
622
1079
  ]
623
1080
  }), /*#__PURE__*/ React.createElement(reactNative.Text, {
624
1081
  style: s.recText
625
- }, "REC")), /*#__PURE__*/ React.createElement(reactNative.View, {
626
- style: s.countdownBadge
1082
+ }, "REC")), currentTask && !complete && /*#__PURE__*/ React.createElement(reactNative.View, {
1083
+ style: s.overlay
1084
+ }, /*#__PURE__*/ React.createElement(reactNative.View, {
1085
+ style: s.overlayPill
1086
+ }, /*#__PURE__*/ React.createElement(reactNative.Text, {
1087
+ style: s.overlayNum
1088
+ }, taskIdx + 1, "/", tasks.length), /*#__PURE__*/ React.createElement(reactNative.Text, {
1089
+ style: s.overlayLabel
1090
+ }, currentTask.label)), started && /*#__PURE__*/ React.createElement(reactNative.View, {
1091
+ style: s.barWrap
1092
+ }, /*#__PURE__*/ React.createElement(reactNative.View, {
1093
+ style: [
1094
+ s.barFill,
1095
+ {
1096
+ width: `${progress}%`
1097
+ }
1098
+ ]
1099
+ }))), complete && /*#__PURE__*/ React.createElement(reactNative.View, {
1100
+ style: s.overlay
1101
+ }, /*#__PURE__*/ React.createElement(reactNative.View, {
1102
+ style: s.overlayPill
627
1103
  }, /*#__PURE__*/ React.createElement(reactNative.Text, {
628
- style: s.countdownText
629
- }, countdown)))), !recording && /*#__PURE__*/ React.createElement(React.Fragment, null, /*#__PURE__*/ React.createElement(reactNative.TouchableOpacity, {
630
- style: s.btnPrimary,
631
- onPress: handleStart,
1104
+ style: s.overlayLabel
1105
+ }, locale.liveness_complete)))), !started && !complete && currentTask && taskDone === 0 && /*#__PURE__*/ React.createElement(reactNative.TouchableOpacity, {
1106
+ style: s.btn,
1107
+ onPress: startTask,
632
1108
  activeOpacity: 0.85
633
1109
  }, /*#__PURE__*/ React.createElement(reactNative.Text, {
634
1110
  style: s.btnText
635
- }, locale.liveness_start_btn)), /*#__PURE__*/ React.createElement(reactNative.TouchableOpacity, {
636
- style: s.btnSecondary,
637
- onPress: onBack,
638
- activeOpacity: 0.75
639
- }, /*#__PURE__*/ React.createElement(reactNative.Text, {
640
- style: s.btnSecondaryText
641
- }, locale.selfie_retake_btn))));
1111
+ }, "Begin"), /*#__PURE__*/ React.createElement(reactNative.Text, {
1112
+ style: s.btnArrow
1113
+ }, " →")));
642
1114
  }
643
1115
  const styles$3 = (t)=>reactNative.StyleSheet.create({
644
1116
  container: {
645
- padding: 24
1117
+ flex: 1,
1118
+ padding: 20,
1119
+ gap: 12
1120
+ },
1121
+ heading: {
1122
+ gap: 4,
1123
+ alignItems: 'center'
646
1124
  },
647
1125
  title: {
648
1126
  fontSize: 20,
649
1127
  fontWeight: '700',
650
1128
  color: t.text,
651
- marginBottom: 8
1129
+ textAlign: 'center',
1130
+ letterSpacing: -0.3
652
1131
  },
653
1132
  subtitle: {
654
- fontSize: 14,
1133
+ fontSize: 13,
655
1134
  color: t.subtext,
656
- lineHeight: 20,
657
- marginBottom: 16
1135
+ lineHeight: 19,
1136
+ textAlign: 'center'
1137
+ },
1138
+ taskDots: {
1139
+ flexDirection: 'row',
1140
+ gap: 6
1141
+ },
1142
+ dot: {
1143
+ flex: 1,
1144
+ height: 2.5,
1145
+ borderRadius: 999,
1146
+ backgroundColor: t.border
1147
+ },
1148
+ dotDone: {
1149
+ backgroundColor: t.accent,
1150
+ opacity: 0.35
1151
+ },
1152
+ dotActive: {
1153
+ backgroundColor: t.accent
658
1154
  },
659
1155
  viewport: {
660
- width: '100%',
661
- aspectRatio: 3 / 4,
662
- borderRadius: t.radius,
1156
+ flex: 1,
1157
+ borderRadius: t.radius - 4,
663
1158
  overflow: 'hidden',
664
1159
  backgroundColor: '#000',
665
- marginBottom: 16,
666
- alignSelf: 'center',
667
- maxWidth: 280,
668
1160
  position: 'relative'
669
1161
  },
670
1162
  ovalGuide: {
671
1163
  position: 'absolute',
672
- top: '8%',
673
- left: '12%',
674
- right: '12%',
675
- bottom: '8%',
1164
+ left: '21%',
1165
+ right: '21%',
1166
+ top: '6%',
1167
+ bottom: '6%',
676
1168
  borderWidth: 2,
677
- borderColor: 'rgba(255,255,255,0.7)',
1169
+ borderColor: 'rgba(255,255,255,0.75)',
678
1170
  borderRadius: 999
679
1171
  },
680
1172
  recBadge: {
@@ -683,144 +1175,134 @@ const styles$3 = (t)=>reactNative.StyleSheet.create({
683
1175
  left: 10,
684
1176
  flexDirection: 'row',
685
1177
  alignItems: 'center',
686
- backgroundColor: 'rgba(220,38,38,0.85)',
687
- borderRadius: 999,
688
- paddingHorizontal: 10,
689
- paddingVertical: 4,
690
- gap: 6
1178
+ gap: 5,
1179
+ backgroundColor: 'rgba(0,0,0,0.65)',
1180
+ borderWidth: 1,
1181
+ borderColor: 'rgba(239,68,68,0.5)',
1182
+ borderRadius: 5,
1183
+ paddingHorizontal: 8,
1184
+ paddingVertical: 4
691
1185
  },
692
1186
  recDot: {
693
- width: 8,
694
- height: 8,
695
- borderRadius: 4,
696
- backgroundColor: '#fff'
1187
+ width: 7,
1188
+ height: 7,
1189
+ borderRadius: 3.5,
1190
+ backgroundColor: '#ef4444'
697
1191
  },
698
1192
  recText: {
699
- color: '#fff',
700
- fontSize: 12,
701
- fontWeight: '700'
1193
+ color: '#ef4444',
1194
+ fontSize: 11,
1195
+ fontWeight: '700',
1196
+ letterSpacing: 0.5
702
1197
  },
703
- countdownBadge: {
1198
+ overlay: {
704
1199
  position: 'absolute',
705
- bottom: 12,
706
- alignSelf: 'center',
707
- width: 44,
708
- height: 44,
709
- borderRadius: 22,
710
- backgroundColor: 'rgba(0,0,0,0.5)',
1200
+ bottom: 0,
1201
+ left: 0,
1202
+ right: 0,
1203
+ paddingHorizontal: 12,
1204
+ paddingTop: 10,
1205
+ paddingBottom: 12,
1206
+ gap: 8,
1207
+ backgroundColor: 'transparent'
1208
+ },
1209
+ overlayPill: {
1210
+ flexDirection: 'row',
711
1211
  alignItems: 'center',
712
- justifyContent: 'center'
1212
+ gap: 8
713
1213
  },
714
- countdownText: {
715
- color: '#fff',
716
- fontSize: 18,
717
- fontWeight: '700'
1214
+ overlayNum: {
1215
+ fontSize: 11,
1216
+ fontWeight: '700',
1217
+ color: 'rgba(255,255,255,0.55)',
1218
+ textTransform: 'uppercase',
1219
+ letterSpacing: 0.5
718
1220
  },
719
- btnPrimary: {
720
- backgroundColor: t.accent,
721
- borderRadius: t.radius,
722
- paddingVertical: 14,
723
- alignItems: 'center',
724
- marginBottom: 10
1221
+ overlayLabel: {
1222
+ fontSize: 15,
1223
+ fontWeight: '700',
1224
+ color: '#fff'
1225
+ },
1226
+ barWrap: {
1227
+ height: 3,
1228
+ backgroundColor: 'rgba(255,255,255,0.2)',
1229
+ borderRadius: 999,
1230
+ overflow: 'hidden'
1231
+ },
1232
+ barFill: {
1233
+ height: '100%',
1234
+ backgroundColor: '#fff',
1235
+ borderRadius: 999
725
1236
  },
726
- btnSecondary: {
727
- backgroundColor: t.card,
728
- borderRadius: t.radius,
729
- paddingVertical: 14,
1237
+ btn: {
1238
+ backgroundColor: t.accent,
1239
+ borderRadius: t.radius - 4,
1240
+ paddingVertical: 12,
1241
+ flexDirection: 'row',
730
1242
  alignItems: 'center',
731
- borderWidth: 1,
732
- borderColor: t.border
1243
+ justifyContent: 'center'
733
1244
  },
734
1245
  btnText: {
735
- color: '#fff',
736
- fontSize: 15,
1246
+ color: t.accentInv,
1247
+ fontSize: 14,
737
1248
  fontWeight: '600'
738
1249
  },
739
- btnSecondaryText: {
740
- color: t.text,
741
- fontSize: 15,
1250
+ btnArrow: {
1251
+ color: t.accentInv,
1252
+ fontSize: 14,
742
1253
  fontWeight: '600'
743
1254
  }
744
1255
  });
745
1256
 
746
1257
  function ProcessingStep({ theme, locale }) {
747
- const spinAnim = React.useRef(new reactNative.Animated.Value(0)).current;
748
1258
  const s = styles$2(theme);
749
- React.useEffect(()=>{
750
- reactNative.Animated.loop(reactNative.Animated.timing(spinAnim, {
751
- toValue: 1,
752
- duration: 800,
753
- useNativeDriver: true
754
- })).start();
755
- }, [
756
- spinAnim
757
- ]);
758
- const spin = spinAnim.interpolate({
759
- inputRange: [
760
- 0,
761
- 1
762
- ],
763
- outputRange: [
764
- '0deg',
765
- '360deg'
766
- ]
767
- });
768
1259
  return /*#__PURE__*/ React.createElement(reactNative.View, {
769
1260
  style: s.container
770
- }, /*#__PURE__*/ React.createElement(reactNative.Animated.View, {
771
- style: [
772
- s.spinner,
773
- {
774
- transform: [
775
- {
776
- rotate: spin
777
- }
778
- ]
779
- }
780
- ]
1261
+ }, /*#__PURE__*/ React.createElement(reactNative.ActivityIndicator, {
1262
+ color: theme.accent,
1263
+ size: "large"
781
1264
  }), /*#__PURE__*/ React.createElement(reactNative.Text, {
782
1265
  style: s.title
783
1266
  }, locale.processing_title), /*#__PURE__*/ React.createElement(reactNative.Text, {
784
1267
  style: s.subtitle
785
- }, locale.processing_subtitle));
1268
+ }, "This usually takes under 10 seconds."));
786
1269
  }
787
1270
  const styles$2 = (t)=>reactNative.StyleSheet.create({
788
1271
  container: {
789
- padding: 48,
1272
+ flex: 1,
790
1273
  alignItems: 'center',
791
- gap: 16
792
- },
793
- spinner: {
794
- width: 52,
795
- height: 52,
796
- borderRadius: 26,
797
- borderWidth: 4,
798
- borderColor: t.border,
799
- borderTopColor: t.accent,
800
- marginBottom: 8
1274
+ justifyContent: 'center',
1275
+ gap: 16,
1276
+ padding: 32
801
1277
  },
802
1278
  title: {
803
- fontSize: 18,
1279
+ fontSize: 16,
804
1280
  fontWeight: '700',
805
1281
  color: t.text,
806
1282
  textAlign: 'center'
807
1283
  },
808
1284
  subtitle: {
809
- fontSize: 14,
1285
+ fontSize: 13,
810
1286
  color: t.subtext,
811
1287
  textAlign: 'center'
812
1288
  }
813
1289
  });
814
1290
 
815
- const ICONS = {
816
- pass: '✅',
817
- fail: '❌',
818
- review: '⏳'
1291
+ // Unicode block characters — no emoji, render cleanly on all platforms
1292
+ const ICON = {
1293
+ pass: '\u2714',
1294
+ fail: '\u2718',
1295
+ review: '\u29D6'
819
1296
  };
820
1297
  const ICON_BG = {
821
- pass: '#d1fae5',
1298
+ pass: '#dcfce7',
822
1299
  fail: '#fee2e2',
823
- review: '#fef3c7'
1300
+ review: '#fef9c3'
1301
+ };
1302
+ const ICON_COLOR = {
1303
+ pass: '#16a34a',
1304
+ fail: '#dc2626',
1305
+ review: '#ca8a04'
824
1306
  };
825
1307
  function ResultStep({ theme, locale, result, error, onRetry }) {
826
1308
  const s = styles$1(theme);
@@ -829,14 +1311,19 @@ function ResultStep({ theme, locale, result, error, onRetry }) {
829
1311
  style: s.container
830
1312
  }, /*#__PURE__*/ React.createElement(reactNative.View, {
831
1313
  style: [
832
- s.iconWrap,
1314
+ s.iconBox,
833
1315
  {
834
- backgroundColor: '#fee2e2'
1316
+ backgroundColor: ICON_BG.fail
835
1317
  }
836
1318
  ]
837
1319
  }, /*#__PURE__*/ React.createElement(reactNative.Text, {
838
- style: s.icon
839
- }, "❌")), /*#__PURE__*/ React.createElement(reactNative.Text, {
1320
+ style: [
1321
+ s.icon,
1322
+ {
1323
+ color: ICON_COLOR.fail
1324
+ }
1325
+ ]
1326
+ }, ICON.fail)), /*#__PURE__*/ React.createElement(reactNative.Text, {
840
1327
  style: s.title
841
1328
  }, locale.result_fail_title), /*#__PURE__*/ React.createElement(reactNative.Text, {
842
1329
  style: s.body
@@ -846,8 +1333,9 @@ function ResultStep({ theme, locale, result, error, onRetry }) {
846
1333
  activeOpacity: 0.85
847
1334
  }, /*#__PURE__*/ React.createElement(reactNative.Text, {
848
1335
  style: s.btnText
849
- }, locale.result_retry_btn)));
1336
+ }, "Try again")));
850
1337
  }
1338
+ const v = result.verdict;
851
1339
  const titles = {
852
1340
  pass: locale.result_pass_title,
853
1341
  fail: locale.result_fail_title,
@@ -858,85 +1346,148 @@ function ResultStep({ theme, locale, result, error, onRetry }) {
858
1346
  fail: locale.result_fail_body,
859
1347
  review: locale.result_review_body
860
1348
  };
1349
+ const rows = [
1350
+ {
1351
+ label: 'Verdict',
1352
+ value: v.charAt(0).toUpperCase() + v.slice(1),
1353
+ ok: v === 'pass',
1354
+ bad: v === 'fail'
1355
+ },
1356
+ {
1357
+ label: 'Risk score',
1358
+ value: String(result.score ?? '—')
1359
+ },
1360
+ {
1361
+ label: 'Face match',
1362
+ value: result.faceMatch ? 'Confirmed' : 'Not confirmed',
1363
+ ok: result.faceMatch,
1364
+ bad: !result.faceMatch
1365
+ },
1366
+ {
1367
+ label: 'Liveness',
1368
+ value: result.isLive ? 'Confirmed' : 'Not confirmed',
1369
+ ok: result.isLive,
1370
+ bad: !result.isLive
1371
+ }
1372
+ ];
861
1373
  return /*#__PURE__*/ React.createElement(reactNative.View, {
862
1374
  style: s.container
863
1375
  }, /*#__PURE__*/ React.createElement(reactNative.View, {
864
1376
  style: [
865
- s.iconWrap,
1377
+ s.iconBox,
866
1378
  {
867
- backgroundColor: ICON_BG[result.verdict]
1379
+ backgroundColor: ICON_BG[v]
868
1380
  }
869
1381
  ]
870
1382
  }, /*#__PURE__*/ React.createElement(reactNative.Text, {
871
- style: s.icon
872
- }, ICONS[result.verdict])), /*#__PURE__*/ React.createElement(reactNative.Text, {
1383
+ style: [
1384
+ s.icon,
1385
+ {
1386
+ color: ICON_COLOR[v]
1387
+ }
1388
+ ]
1389
+ }, ICON[v])), /*#__PURE__*/ React.createElement(reactNative.Text, {
873
1390
  style: s.title
874
- }, titles[result.verdict]), /*#__PURE__*/ React.createElement(reactNative.Text, {
1391
+ }, titles[v]), /*#__PURE__*/ React.createElement(reactNative.Text, {
875
1392
  style: s.body
876
- }, bodies[result.verdict]), result.verdict === 'fail' && /*#__PURE__*/ React.createElement(reactNative.TouchableOpacity, {
1393
+ }, bodies[v]), /*#__PURE__*/ React.createElement(reactNative.View, {
1394
+ style: s.details
1395
+ }, rows.map((row)=>/*#__PURE__*/ React.createElement(reactNative.View, {
1396
+ key: row.label,
1397
+ style: s.row
1398
+ }, /*#__PURE__*/ React.createElement(reactNative.Text, {
1399
+ style: s.rowLabel
1400
+ }, row.label), /*#__PURE__*/ React.createElement(reactNative.Text, {
1401
+ style: [
1402
+ s.rowVal,
1403
+ row.ok && s.rowOk,
1404
+ row.bad && s.rowBad
1405
+ ]
1406
+ }, row.value)))), v === 'fail' && /*#__PURE__*/ React.createElement(reactNative.TouchableOpacity, {
877
1407
  style: s.btn,
878
1408
  onPress: onRetry,
879
1409
  activeOpacity: 0.85
880
1410
  }, /*#__PURE__*/ React.createElement(reactNative.Text, {
881
1411
  style: s.btnText
882
- }, locale.result_retry_btn)));
1412
+ }, "Try again")));
883
1413
  }
884
1414
  const styles$1 = (t)=>reactNative.StyleSheet.create({
885
1415
  container: {
886
- padding: 40,
1416
+ flex: 1,
1417
+ padding: 28,
887
1418
  alignItems: 'center',
888
- gap: 12
1419
+ gap: 14,
1420
+ justifyContent: 'center'
889
1421
  },
890
- iconWrap: {
891
- width: 76,
892
- height: 76,
893
- borderRadius: 38,
1422
+ iconBox: {
1423
+ width: 52,
1424
+ height: 52,
1425
+ borderRadius: 14,
894
1426
  alignItems: 'center',
895
1427
  justifyContent: 'center',
896
- marginBottom: 8
1428
+ marginBottom: 4
897
1429
  },
898
1430
  icon: {
899
- fontSize: 34
1431
+ fontSize: 22,
1432
+ fontWeight: '700'
900
1433
  },
901
1434
  title: {
902
1435
  fontSize: 20,
903
1436
  fontWeight: '700',
904
1437
  color: t.text,
905
- textAlign: 'center'
1438
+ textAlign: 'center',
1439
+ letterSpacing: -0.3
906
1440
  },
907
1441
  body: {
908
- fontSize: 14,
1442
+ fontSize: 13.5,
909
1443
  color: t.subtext,
910
1444
  textAlign: 'center',
911
- lineHeight: 21
1445
+ lineHeight: 19
1446
+ },
1447
+ details: {
1448
+ width: '100%',
1449
+ gap: 6
1450
+ },
1451
+ row: {
1452
+ flexDirection: 'row',
1453
+ justifyContent: 'space-between',
1454
+ alignItems: 'center',
1455
+ paddingVertical: 9,
1456
+ paddingHorizontal: 12,
1457
+ backgroundColor: t.surface,
1458
+ borderRadius: 8
1459
+ },
1460
+ rowLabel: {
1461
+ fontSize: 12.5,
1462
+ color: t.subtext
1463
+ },
1464
+ rowVal: {
1465
+ fontSize: 12.5,
1466
+ fontWeight: '600',
1467
+ color: t.text
1468
+ },
1469
+ rowOk: {
1470
+ color: '#16a34a'
1471
+ },
1472
+ rowBad: {
1473
+ color: '#dc2626'
912
1474
  },
913
1475
  btn: {
914
- marginTop: 8,
1476
+ width: '100%',
915
1477
  backgroundColor: t.accent,
916
- borderRadius: t.radius,
917
- paddingVertical: 14,
918
- paddingHorizontal: 32,
919
- alignItems: 'center'
1478
+ borderRadius: t.radius - 4,
1479
+ paddingVertical: 12,
1480
+ alignItems: 'center',
1481
+ marginTop: 4
920
1482
  },
921
1483
  btnText: {
922
- color: '#fff',
923
- fontSize: 15,
1484
+ color: t.accentInv,
1485
+ fontSize: 14,
924
1486
  fontWeight: '600'
925
1487
  }
926
1488
  });
927
1489
 
928
- const STEP_INDEX = {
929
- intro: -1,
930
- document: 0,
931
- selfie: 1,
932
- liveness: 2,
933
- processing: 3,
934
- result: 3
935
- };
936
- function KernaqVerify({ visible, apiKey, backendUrl, country = 'KEN', reference, sandbox = false, livenessDuration = 3000, documentTypes = [
937
- 'national_id',
938
- 'passport'
939
- ], theme: themeProp, locale: localeProp, onCancel, onComplete, onError }) {
1490
+ function KernaqVerify({ visible, apiKey, backendUrl, country = 'KEN', reference, sandbox = false, livenessDuration = 3000, livenessTaskCount = 2, documentType = 'national_id', steps: stepsProp, theme: themeProp, locale: localeProp, onComplete, onError }) {
940
1491
  const [step, setStep] = React.useState('intro');
941
1492
  const [capture, setCapture] = React.useState({});
942
1493
  const [result, setResult] = React.useState(null);
@@ -952,48 +1503,79 @@ function KernaqVerify({ visible, apiKey, backendUrl, country = 'KEN', reference,
952
1503
  ]);
953
1504
  const { requestPermissions } = usePermissions();
954
1505
  const s = styles(theme);
955
- const reset = React.useCallback(()=>{
956
- setStep('intro');
957
- setCapture({});
958
- setResult(null);
959
- setError(null);
960
- }, []);
961
- const goto = React.useCallback(async (next)=>{
962
- if (next === 'document' || next === 'selfie' || next === 'liveness') {
1506
+ // Build the ordered flow from the steps prop
1507
+ const flow = React.useMemo(()=>{
1508
+ const wanted = stepsProp ?? [
1509
+ 'document',
1510
+ 'selfie',
1511
+ 'liveness'
1512
+ ];
1513
+ const out = [];
1514
+ if (wanted.includes('document')) {
1515
+ out.push('doc-front');
1516
+ out.push('doc-back');
1517
+ }
1518
+ if (wanted.includes('selfie')) out.push('selfie');
1519
+ if (wanted.includes('liveness')) out.push('liveness');
1520
+ out.push('processing');
1521
+ return out;
1522
+ }, [
1523
+ stepsProp
1524
+ ]);
1525
+ const next = React.useCallback((current)=>{
1526
+ const idx = flow.indexOf(current);
1527
+ const nextStep = flow[idx + 1] ?? 'processing';
1528
+ goto(nextStep);
1529
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1530
+ }, [
1531
+ flow
1532
+ ]);
1533
+ const goto = React.useCallback(async (s)=>{
1534
+ if (s === 'doc-front' || s === 'doc-back' || s === 'selfie' || s === 'liveness') {
963
1535
  const granted = await requestPermissions();
964
1536
  if (!granted) {
965
1537
  setError(locale.error_camera_denied);
966
1538
  return;
967
1539
  }
968
1540
  }
969
- setStep(next);
970
- if (next === 'processing') {
971
- submit();
972
- }
1541
+ setStep(s);
1542
+ if (s === 'processing') submit();
973
1543
  // eslint-disable-next-line react-hooks/exhaustive-deps
974
1544
  }, [
975
1545
  capture,
976
1546
  locale
977
1547
  ]);
1548
+ const reset = React.useCallback(()=>{
1549
+ setStep('intro');
1550
+ setCapture({});
1551
+ setResult(null);
1552
+ setError(null);
1553
+ }, []);
978
1554
  const submit = React.useCallback(async ()=>{
979
- const { documentUri, selfieUri, videoUri } = capture;
980
- if (!documentUri || !selfieUri) {
981
- setError('Missing captures — please restart.');
982
- setStep('result');
983
- return;
984
- }
1555
+ const { docFrontUri, docBackUri, selfieUri, videoUri } = capture;
985
1556
  const base = (backendUrl ?? 'https://api.identity.kernaq.com/v1').replace(/\/$/, '');
986
1557
  const form = new FormData();
987
- form.append('document', {
988
- uri: documentUri,
989
- name: 'document.jpg',
990
- type: 'image/jpeg'
991
- });
992
- form.append('selfie', {
993
- uri: selfieUri,
994
- name: 'selfie.jpg',
995
- type: 'image/jpeg'
996
- });
1558
+ if (docFrontUri) {
1559
+ form.append('document', {
1560
+ uri: docFrontUri,
1561
+ name: 'doc_front.jpg',
1562
+ type: 'image/jpeg'
1563
+ });
1564
+ }
1565
+ if (docBackUri) {
1566
+ form.append('document_back', {
1567
+ uri: docBackUri,
1568
+ name: 'doc_back.jpg',
1569
+ type: 'image/jpeg'
1570
+ });
1571
+ }
1572
+ if (selfieUri) {
1573
+ form.append('selfie', {
1574
+ uri: selfieUri,
1575
+ name: 'selfie.jpg',
1576
+ type: 'image/jpeg'
1577
+ });
1578
+ }
997
1579
  if (videoUri) {
998
1580
  form.append('video', {
999
1581
  uri: videoUri,
@@ -1001,7 +1583,7 @@ function KernaqVerify({ visible, apiKey, backendUrl, country = 'KEN', reference,
1001
1583
  type: 'video/mp4'
1002
1584
  });
1003
1585
  }
1004
- form.append('document_type', documentTypes[0] ?? 'national_id');
1586
+ form.append('document_type', documentType);
1005
1587
  form.append('country', country);
1006
1588
  form.append('reference', reference ?? `kq_${Date.now()}`);
1007
1589
  if (sandbox) form.append('sandbox', 'true');
@@ -1032,6 +1614,7 @@ function KernaqVerify({ visible, apiKey, backendUrl, country = 'KEN', reference,
1032
1614
  } catch (err) {
1033
1615
  const msg = err.message;
1034
1616
  setError(`${locale.error_network} ${msg}`);
1617
+ setResult(null);
1035
1618
  setStep('result');
1036
1619
  onError?.({
1037
1620
  code: 'network_error',
@@ -1042,76 +1625,56 @@ function KernaqVerify({ visible, apiKey, backendUrl, country = 'KEN', reference,
1042
1625
  }, [
1043
1626
  capture
1044
1627
  ]);
1045
- const handleCancel = ()=>{
1046
- reset();
1047
- onCancel?.();
1048
- };
1049
- const progressSteps = [
1050
- 'document',
1051
- 'selfie',
1052
- 'liveness',
1053
- 'processing'
1054
- ];
1055
- const currentIdx = STEP_INDEX[step];
1056
1628
  return /*#__PURE__*/ React.createElement(reactNative.Modal, {
1057
1629
  visible: visible,
1058
1630
  animationType: "slide",
1059
1631
  presentationStyle: "pageSheet",
1060
- onRequestClose: handleCancel
1632
+ onRequestClose: ()=>{}
1061
1633
  }, /*#__PURE__*/ React.createElement(reactNative.SafeAreaView, {
1062
1634
  style: [
1063
- s.safeArea,
1635
+ s.safe,
1064
1636
  {
1065
1637
  backgroundColor: theme.bg
1066
1638
  }
1067
1639
  ]
1068
1640
  }, /*#__PURE__*/ React.createElement(reactNative.View, {
1069
- style: s.header
1070
- }, /*#__PURE__*/ React.createElement(reactNative.Text, {
1071
- style: s.logo
1072
- }, "Kernaq"), step !== 'processing' && /*#__PURE__*/ React.createElement(reactNative.TouchableOpacity, {
1073
- onPress: handleCancel,
1074
- style: s.closeBtn,
1075
- hitSlop: {
1076
- top: 8,
1077
- bottom: 8,
1078
- left: 8,
1079
- right: 8
1080
- }
1081
- }, /*#__PURE__*/ React.createElement(reactNative.Text, {
1082
- style: s.closeText
1083
- }, "✕"))), currentIdx >= 0 && /*#__PURE__*/ React.createElement(reactNative.View, {
1084
- style: s.progress
1085
- }, progressSteps.map((_, i)=>/*#__PURE__*/ React.createElement(reactNative.View, {
1086
- key: i,
1087
- style: [
1088
- s.progressDot,
1089
- i < currentIdx && {
1090
- backgroundColor: theme.accent,
1091
- opacity: 0.4
1092
- },
1093
- i === currentIdx && {
1094
- backgroundColor: theme.accent
1095
- }
1096
- ]
1097
- }))), /*#__PURE__*/ React.createElement(reactNative.ScrollView, {
1098
- style: s.scroll,
1099
- contentContainerStyle: s.scrollContent,
1100
- keyboardShouldPersistTaps: "handled",
1101
- bounces: false
1641
+ style: s.body
1102
1642
  }, step === 'intro' && /*#__PURE__*/ React.createElement(IntroStep, {
1103
1643
  theme: theme,
1104
1644
  locale: locale,
1105
- onStart: ()=>goto('document')
1106
- }), step === 'document' && /*#__PURE__*/ React.createElement(DocumentStep, {
1645
+ onStart: ()=>{
1646
+ const first = flow[0];
1647
+ if (first) goto(first);
1648
+ }
1649
+ }), step === 'doc-front' && /*#__PURE__*/ React.createElement(DocumentStep, {
1107
1650
  theme: theme,
1108
1651
  locale: locale,
1652
+ side: "front",
1109
1653
  onCapture: (uri)=>{
1110
1654
  setCapture((c)=>({
1111
1655
  ...c,
1112
- documentUri: uri
1656
+ docFrontUri: uri
1113
1657
  }));
1114
- goto('selfie');
1658
+ next('doc-front');
1659
+ }
1660
+ }), step === 'doc-back' && /*#__PURE__*/ React.createElement(DocumentStep, {
1661
+ theme: theme,
1662
+ locale: locale,
1663
+ side: "back",
1664
+ frontUri: capture.docFrontUri,
1665
+ onCapture: (uri)=>{
1666
+ setCapture((c)=>({
1667
+ ...c,
1668
+ docBackUri: uri
1669
+ }));
1670
+ next('doc-back');
1671
+ },
1672
+ onRetakeFront: ()=>{
1673
+ setCapture((c)=>({
1674
+ ...c,
1675
+ docFrontUri: undefined
1676
+ }));
1677
+ goto('doc-front');
1115
1678
  }
1116
1679
  }), step === 'selfie' && /*#__PURE__*/ React.createElement(SelfieStep, {
1117
1680
  theme: theme,
@@ -1121,21 +1684,20 @@ function KernaqVerify({ visible, apiKey, backendUrl, country = 'KEN', reference,
1121
1684
  ...c,
1122
1685
  selfieUri: uri
1123
1686
  }));
1124
- goto('liveness');
1125
- },
1126
- onBack: ()=>setStep('document')
1687
+ next('selfie');
1688
+ }
1127
1689
  }), step === 'liveness' && /*#__PURE__*/ React.createElement(LivenessStep, {
1128
1690
  theme: theme,
1129
1691
  locale: locale,
1130
1692
  duration: livenessDuration,
1693
+ taskCount: livenessTaskCount,
1131
1694
  onCapture: (uri)=>{
1132
1695
  setCapture((c)=>({
1133
1696
  ...c,
1134
1697
  videoUri: uri
1135
1698
  }));
1136
- goto('processing');
1137
- },
1138
- onBack: ()=>setStep('selfie')
1699
+ next('liveness');
1700
+ }
1139
1701
  }), step === 'processing' && /*#__PURE__*/ React.createElement(ProcessingStep, {
1140
1702
  theme: theme,
1141
1703
  locale: locale
@@ -1146,55 +1708,45 @@ function KernaqVerify({ visible, apiKey, backendUrl, country = 'KEN', reference,
1146
1708
  error: error,
1147
1709
  onRetry: ()=>{
1148
1710
  reset();
1149
- setStep('intro');
1711
+ goto('intro');
1150
1712
  }
1151
- }))));
1713
+ })), step !== 'processing' && /*#__PURE__*/ React.createElement(reactNative.View, {
1714
+ style: s.footer
1715
+ }, /*#__PURE__*/ React.createElement(reactNative.Text, {
1716
+ style: s.footerText
1717
+ }, "Secured by "), /*#__PURE__*/ React.createElement(reactNative.Text, {
1718
+ style: [
1719
+ s.footerText,
1720
+ {
1721
+ fontWeight: '600',
1722
+ color: theme.text
1723
+ }
1724
+ ]
1725
+ }, "Kernaq"))));
1152
1726
  }
1153
1727
  const styles = (t)=>reactNative.StyleSheet.create({
1154
- safeArea: {
1728
+ safe: {
1729
+ flex: 1
1730
+ },
1731
+ body: {
1155
1732
  flex: 1
1156
1733
  },
1157
- header: {
1734
+ footer: {
1158
1735
  flexDirection: 'row',
1159
1736
  alignItems: 'center',
1160
- justifyContent: 'space-between',
1161
- paddingHorizontal: 20,
1162
- paddingTop: reactNative.Platform.OS === 'android' ? 16 : 8,
1163
- paddingBottom: 8
1164
- },
1165
- logo: {
1166
- fontSize: 14,
1167
- fontWeight: '700',
1168
- color: t.accent,
1169
- letterSpacing: 1.2,
1170
- textTransform: 'uppercase'
1171
- },
1172
- closeBtn: {
1173
- padding: 4
1737
+ justifyContent: 'center',
1738
+ paddingVertical: 10,
1739
+ paddingBottom: reactNative.Platform.OS === 'ios' ? 4 : 10,
1740
+ borderTopWidth: reactNative.StyleSheet.hairlineWidth,
1741
+ borderTopColor: t.border
1174
1742
  },
1175
- closeText: {
1176
- fontSize: 18,
1743
+ footerText: {
1744
+ fontSize: 11,
1177
1745
  color: t.subtext
1178
- },
1179
- progress: {
1180
- flexDirection: 'row',
1181
- gap: 6,
1182
- paddingHorizontal: 20,
1183
- paddingBottom: 8
1184
- },
1185
- progressDot: {
1186
- flex: 1,
1187
- height: 3,
1188
- borderRadius: 999,
1189
- backgroundColor: t.border
1190
- },
1191
- scroll: {
1192
- flex: 1
1193
- },
1194
- scrollContent: {
1195
- flexGrow: 1
1196
1746
  }
1197
1747
  });
1198
1748
 
1199
1749
  exports.KernaqVerify = KernaqVerify;
1750
+ exports.analyseImageUri = analyseImageUri;
1751
+ exports.analyseSelfieFrame = analyseSelfieFrame;
1200
1752
  //# sourceMappingURL=index.js.map