@kernaq/verify-react-native 2.0.0 → 2.0.2

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, {
510
+ style: [
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, {
303
532
  style: [
304
- s.btnPrimary,
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,188 @@ 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
+ // Keep a ref in sync with taskIdx so setTimeout/setInterval closures always
960
+ // read the latest value and don't stall on a stale snapshot.
961
+ const taskIdxRef = React.useRef(0);
962
+ const setTaskIdxSynced = (n)=>{
963
+ taskIdxRef.current = n;
964
+ setTaskIdx(n);
965
+ };
966
+ const progressTimer = React.useRef(null);
967
+ const taskTimer = React.useRef(null);
542
968
  const pulseAnim = React.useRef(new reactNative.Animated.Value(1)).current;
543
- const s = styles$3(theme);
544
- // Pulse animation for recording indicator
969
+ // Pulse REC dot
545
970
  React.useEffect(()=>{
546
971
  if (recording) {
547
972
  reactNative.Animated.loop(reactNative.Animated.sequence([
548
973
  reactNative.Animated.timing(pulseAnim, {
549
- toValue: 0.3,
974
+ toValue: 0.2,
550
975
  duration: 600,
551
976
  useNativeDriver: true
552
977
  }),
@@ -557,34 +982,59 @@ function LivenessStep({ theme, locale, duration, onCapture, onBack }) {
557
982
  })
558
983
  ])).start();
559
984
  } else {
985
+ pulseAnim.stopAnimation();
560
986
  pulseAnim.setValue(1);
561
987
  }
562
988
  }, [
563
989
  recording,
564
990
  pulseAnim
565
991
  ]);
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();
992
+ const startTask = ()=>{
993
+ if (!recording) {
994
+ startRecording((video)=>{
995
+ onCapture(video.uri);
996
+ }, ()=>{});
997
+ setRecording(true);
998
+ }
999
+ const task = tasks[taskIdxRef.current];
1000
+ if (!task) return;
1001
+ setStarted(true);
1002
+ setProgress(0);
1003
+ const start = Date.now();
1004
+ progressTimer.current = setInterval(()=>{
1005
+ const elapsed = Date.now() - start;
1006
+ const pct = Math.min(100, elapsed / task.duration * 100);
1007
+ setProgress(pct);
1008
+ if (elapsed >= task.duration) {
1009
+ clearInterval(progressTimer.current);
1010
+ progressTimer.current = null;
1011
+ nextTask();
582
1012
  }
583
- }, 100);
1013
+ }, 80);
1014
+ };
1015
+ const nextTask = ()=>{
1016
+ const next = taskIdxRef.current + 1;
1017
+ setTaskDone((prev)=>prev + 1);
1018
+ if (next >= tasks.length) {
1019
+ finishLiveness();
1020
+ return;
1021
+ }
1022
+ setTaskIdxSynced(next);
1023
+ setStarted(false);
1024
+ setProgress(0);
1025
+ // Use the ref-based startTask via the timer so it reads the fresh taskIdx
1026
+ taskTimer.current = setTimeout(()=>startTask(), 500);
1027
+ };
1028
+ const finishLiveness = ()=>{
1029
+ setComplete(true);
1030
+ taskTimer.current = setTimeout(()=>{
1031
+ stopRecording();
1032
+ }, 1000);
584
1033
  };
585
1034
  React.useEffect(()=>{
586
1035
  return ()=>{
587
- if (timerRef.current) clearInterval(timerRef.current);
1036
+ if (progressTimer.current) clearInterval(progressTimer.current);
1037
+ if (taskTimer.current) clearTimeout(taskTimer.current);
588
1038
  };
589
1039
  }, []);
590
1040
  if (!device) return /*#__PURE__*/ React.createElement(reactNative.ActivityIndicator, {
@@ -593,13 +1043,26 @@ function LivenessStep({ theme, locale, duration, onCapture, onBack }) {
593
1043
  margin: 40
594
1044
  }
595
1045
  });
1046
+ const currentTask = tasks[taskIdx];
1047
+ const s = styles$3(theme);
596
1048
  return /*#__PURE__*/ React.createElement(reactNative.View, {
597
1049
  style: s.container
1050
+ }, /*#__PURE__*/ React.createElement(reactNative.View, {
1051
+ style: s.heading
598
1052
  }, /*#__PURE__*/ React.createElement(reactNative.Text, {
599
1053
  style: s.title
600
- }, locale.liveness_title), /*#__PURE__*/ React.createElement(reactNative.Text, {
1054
+ }, complete ? locale.liveness_complete : locale.liveness_title), !complete && /*#__PURE__*/ React.createElement(reactNative.Text, {
601
1055
  style: s.subtitle
602
- }, recording ? locale.liveness_recording : locale.liveness_instruction), /*#__PURE__*/ React.createElement(reactNative.View, {
1056
+ }, locale.liveness_ready)), !complete && /*#__PURE__*/ React.createElement(reactNative.View, {
1057
+ style: s.taskDots
1058
+ }, tasks.map((_, i)=>/*#__PURE__*/ React.createElement(reactNative.View, {
1059
+ key: i,
1060
+ style: [
1061
+ s.dot,
1062
+ i < taskDone && s.dotDone,
1063
+ i === taskIdx && s.dotActive
1064
+ ]
1065
+ }))), /*#__PURE__*/ React.createElement(reactNative.View, {
603
1066
  style: s.viewport
604
1067
  }, /*#__PURE__*/ React.createElement(reactNativeVisionCamera.Camera, {
605
1068
  ref: cameraRef,
@@ -607,11 +1070,11 @@ function LivenessStep({ theme, locale, duration, onCapture, onBack }) {
607
1070
  device: device,
608
1071
  isActive: true,
609
1072
  video: true,
610
- audio: true
1073
+ audio: false
611
1074
  }), /*#__PURE__*/ React.createElement(reactNative.View, {
612
1075
  style: s.ovalGuide,
613
1076
  pointerEvents: "none"
614
- }), recording && /*#__PURE__*/ React.createElement(React.Fragment, null, /*#__PURE__*/ React.createElement(reactNative.View, {
1077
+ }), recording && /*#__PURE__*/ React.createElement(reactNative.View, {
615
1078
  style: s.recBadge
616
1079
  }, /*#__PURE__*/ React.createElement(reactNative.Animated.View, {
617
1080
  style: [
@@ -622,59 +1085,94 @@ function LivenessStep({ theme, locale, duration, onCapture, onBack }) {
622
1085
  ]
623
1086
  }), /*#__PURE__*/ React.createElement(reactNative.Text, {
624
1087
  style: s.recText
625
- }, "REC")), /*#__PURE__*/ React.createElement(reactNative.View, {
626
- style: s.countdownBadge
1088
+ }, "REC")), currentTask && !complete && /*#__PURE__*/ React.createElement(reactNative.View, {
1089
+ style: s.overlay
1090
+ }, /*#__PURE__*/ React.createElement(reactNative.View, {
1091
+ style: s.overlayPill
1092
+ }, /*#__PURE__*/ React.createElement(reactNative.Text, {
1093
+ style: s.overlayNum
1094
+ }, taskIdx + 1, "/", tasks.length), /*#__PURE__*/ React.createElement(reactNative.Text, {
1095
+ style: s.overlayLabel
1096
+ }, currentTask.label)), started && /*#__PURE__*/ React.createElement(reactNative.View, {
1097
+ style: s.barWrap
1098
+ }, /*#__PURE__*/ React.createElement(reactNative.View, {
1099
+ style: [
1100
+ s.barFill,
1101
+ {
1102
+ width: `${progress}%`
1103
+ }
1104
+ ]
1105
+ }))), complete && /*#__PURE__*/ React.createElement(reactNative.View, {
1106
+ style: s.overlay
1107
+ }, /*#__PURE__*/ React.createElement(reactNative.View, {
1108
+ style: s.overlayPill
627
1109
  }, /*#__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,
1110
+ style: s.overlayLabel
1111
+ }, locale.liveness_complete)))), !started && !complete && currentTask && taskDone === 0 && /*#__PURE__*/ React.createElement(reactNative.TouchableOpacity, {
1112
+ style: s.btn,
1113
+ onPress: startTask,
632
1114
  activeOpacity: 0.85
633
1115
  }, /*#__PURE__*/ React.createElement(reactNative.Text, {
634
1116
  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))));
1117
+ }, "Begin"), /*#__PURE__*/ React.createElement(reactNative.Text, {
1118
+ style: s.btnArrow
1119
+ }, " →")));
642
1120
  }
643
1121
  const styles$3 = (t)=>reactNative.StyleSheet.create({
644
1122
  container: {
645
- padding: 24
1123
+ flex: 1,
1124
+ padding: 20,
1125
+ gap: 12
1126
+ },
1127
+ heading: {
1128
+ gap: 4,
1129
+ alignItems: 'center'
646
1130
  },
647
1131
  title: {
648
1132
  fontSize: 20,
649
1133
  fontWeight: '700',
650
1134
  color: t.text,
651
- marginBottom: 8
1135
+ textAlign: 'center',
1136
+ letterSpacing: -0.3
652
1137
  },
653
1138
  subtitle: {
654
- fontSize: 14,
1139
+ fontSize: 13,
655
1140
  color: t.subtext,
656
- lineHeight: 20,
657
- marginBottom: 16
1141
+ lineHeight: 19,
1142
+ textAlign: 'center'
1143
+ },
1144
+ taskDots: {
1145
+ flexDirection: 'row',
1146
+ gap: 6
1147
+ },
1148
+ dot: {
1149
+ flex: 1,
1150
+ height: 2.5,
1151
+ borderRadius: 999,
1152
+ backgroundColor: t.border
1153
+ },
1154
+ dotDone: {
1155
+ backgroundColor: t.accent,
1156
+ opacity: 0.35
1157
+ },
1158
+ dotActive: {
1159
+ backgroundColor: t.accent
658
1160
  },
659
1161
  viewport: {
660
- width: '100%',
661
- aspectRatio: 3 / 4,
662
- borderRadius: t.radius,
1162
+ flex: 1,
1163
+ borderRadius: t.radius - 4,
663
1164
  overflow: 'hidden',
664
1165
  backgroundColor: '#000',
665
- marginBottom: 16,
666
- alignSelf: 'center',
667
- maxWidth: 280,
668
1166
  position: 'relative'
669
1167
  },
670
1168
  ovalGuide: {
671
1169
  position: 'absolute',
672
- top: '8%',
673
- left: '12%',
674
- right: '12%',
675
- bottom: '8%',
1170
+ left: '21%',
1171
+ right: '21%',
1172
+ top: '6%',
1173
+ bottom: '6%',
676
1174
  borderWidth: 2,
677
- borderColor: 'rgba(255,255,255,0.7)',
1175
+ borderColor: 'rgba(255,255,255,0.75)',
678
1176
  borderRadius: 999
679
1177
  },
680
1178
  recBadge: {
@@ -683,144 +1181,134 @@ const styles$3 = (t)=>reactNative.StyleSheet.create({
683
1181
  left: 10,
684
1182
  flexDirection: 'row',
685
1183
  alignItems: 'center',
686
- backgroundColor: 'rgba(220,38,38,0.85)',
687
- borderRadius: 999,
688
- paddingHorizontal: 10,
689
- paddingVertical: 4,
690
- gap: 6
1184
+ gap: 5,
1185
+ backgroundColor: 'rgba(0,0,0,0.65)',
1186
+ borderWidth: 1,
1187
+ borderColor: 'rgba(239,68,68,0.5)',
1188
+ borderRadius: 5,
1189
+ paddingHorizontal: 8,
1190
+ paddingVertical: 4
691
1191
  },
692
1192
  recDot: {
693
- width: 8,
694
- height: 8,
695
- borderRadius: 4,
696
- backgroundColor: '#fff'
1193
+ width: 7,
1194
+ height: 7,
1195
+ borderRadius: 3.5,
1196
+ backgroundColor: '#ef4444'
697
1197
  },
698
1198
  recText: {
699
- color: '#fff',
700
- fontSize: 12,
701
- fontWeight: '700'
1199
+ color: '#ef4444',
1200
+ fontSize: 11,
1201
+ fontWeight: '700',
1202
+ letterSpacing: 0.5
702
1203
  },
703
- countdownBadge: {
1204
+ overlay: {
704
1205
  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)',
1206
+ bottom: 0,
1207
+ left: 0,
1208
+ right: 0,
1209
+ paddingHorizontal: 12,
1210
+ paddingTop: 10,
1211
+ paddingBottom: 12,
1212
+ gap: 8,
1213
+ backgroundColor: 'transparent'
1214
+ },
1215
+ overlayPill: {
1216
+ flexDirection: 'row',
711
1217
  alignItems: 'center',
712
- justifyContent: 'center'
1218
+ gap: 8
713
1219
  },
714
- countdownText: {
715
- color: '#fff',
716
- fontSize: 18,
717
- fontWeight: '700'
1220
+ overlayNum: {
1221
+ fontSize: 11,
1222
+ fontWeight: '700',
1223
+ color: 'rgba(255,255,255,0.55)',
1224
+ textTransform: 'uppercase',
1225
+ letterSpacing: 0.5
718
1226
  },
719
- btnPrimary: {
720
- backgroundColor: t.accent,
721
- borderRadius: t.radius,
722
- paddingVertical: 14,
723
- alignItems: 'center',
724
- marginBottom: 10
1227
+ overlayLabel: {
1228
+ fontSize: 15,
1229
+ fontWeight: '700',
1230
+ color: '#fff'
1231
+ },
1232
+ barWrap: {
1233
+ height: 3,
1234
+ backgroundColor: 'rgba(255,255,255,0.2)',
1235
+ borderRadius: 999,
1236
+ overflow: 'hidden'
1237
+ },
1238
+ barFill: {
1239
+ height: '100%',
1240
+ backgroundColor: '#fff',
1241
+ borderRadius: 999
725
1242
  },
726
- btnSecondary: {
727
- backgroundColor: t.card,
728
- borderRadius: t.radius,
729
- paddingVertical: 14,
1243
+ btn: {
1244
+ backgroundColor: t.accent,
1245
+ borderRadius: t.radius - 4,
1246
+ paddingVertical: 12,
1247
+ flexDirection: 'row',
730
1248
  alignItems: 'center',
731
- borderWidth: 1,
732
- borderColor: t.border
1249
+ justifyContent: 'center'
733
1250
  },
734
1251
  btnText: {
735
- color: '#fff',
736
- fontSize: 15,
1252
+ color: t.accentInv,
1253
+ fontSize: 14,
737
1254
  fontWeight: '600'
738
1255
  },
739
- btnSecondaryText: {
740
- color: t.text,
741
- fontSize: 15,
1256
+ btnArrow: {
1257
+ color: t.accentInv,
1258
+ fontSize: 14,
742
1259
  fontWeight: '600'
743
1260
  }
744
1261
  });
745
1262
 
746
1263
  function ProcessingStep({ theme, locale }) {
747
- const spinAnim = React.useRef(new reactNative.Animated.Value(0)).current;
748
1264
  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
1265
  return /*#__PURE__*/ React.createElement(reactNative.View, {
769
1266
  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
- ]
1267
+ }, /*#__PURE__*/ React.createElement(reactNative.ActivityIndicator, {
1268
+ color: theme.accent,
1269
+ size: "large"
781
1270
  }), /*#__PURE__*/ React.createElement(reactNative.Text, {
782
1271
  style: s.title
783
1272
  }, locale.processing_title), /*#__PURE__*/ React.createElement(reactNative.Text, {
784
1273
  style: s.subtitle
785
- }, locale.processing_subtitle));
1274
+ }, "This usually takes under 10 seconds."));
786
1275
  }
787
1276
  const styles$2 = (t)=>reactNative.StyleSheet.create({
788
1277
  container: {
789
- padding: 48,
1278
+ flex: 1,
790
1279
  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
1280
+ justifyContent: 'center',
1281
+ gap: 16,
1282
+ padding: 32
801
1283
  },
802
1284
  title: {
803
- fontSize: 18,
1285
+ fontSize: 16,
804
1286
  fontWeight: '700',
805
1287
  color: t.text,
806
1288
  textAlign: 'center'
807
1289
  },
808
1290
  subtitle: {
809
- fontSize: 14,
1291
+ fontSize: 13,
810
1292
  color: t.subtext,
811
1293
  textAlign: 'center'
812
1294
  }
813
1295
  });
814
1296
 
815
- const ICONS = {
816
- pass: '✅',
817
- fail: '❌',
818
- review: '⏳'
1297
+ // Unicode block characters — no emoji, render cleanly on all platforms
1298
+ const ICON = {
1299
+ pass: '\u2714',
1300
+ fail: '\u2718',
1301
+ review: '\u29D6'
819
1302
  };
820
1303
  const ICON_BG = {
821
- pass: '#d1fae5',
1304
+ pass: '#dcfce7',
822
1305
  fail: '#fee2e2',
823
- review: '#fef3c7'
1306
+ review: '#fef9c3'
1307
+ };
1308
+ const ICON_COLOR = {
1309
+ pass: '#16a34a',
1310
+ fail: '#dc2626',
1311
+ review: '#ca8a04'
824
1312
  };
825
1313
  function ResultStep({ theme, locale, result, error, onRetry }) {
826
1314
  const s = styles$1(theme);
@@ -829,14 +1317,19 @@ function ResultStep({ theme, locale, result, error, onRetry }) {
829
1317
  style: s.container
830
1318
  }, /*#__PURE__*/ React.createElement(reactNative.View, {
831
1319
  style: [
832
- s.iconWrap,
1320
+ s.iconBox,
833
1321
  {
834
- backgroundColor: '#fee2e2'
1322
+ backgroundColor: ICON_BG.fail
835
1323
  }
836
1324
  ]
837
1325
  }, /*#__PURE__*/ React.createElement(reactNative.Text, {
838
- style: s.icon
839
- }, "❌")), /*#__PURE__*/ React.createElement(reactNative.Text, {
1326
+ style: [
1327
+ s.icon,
1328
+ {
1329
+ color: ICON_COLOR.fail
1330
+ }
1331
+ ]
1332
+ }, ICON.fail)), /*#__PURE__*/ React.createElement(reactNative.Text, {
840
1333
  style: s.title
841
1334
  }, locale.result_fail_title), /*#__PURE__*/ React.createElement(reactNative.Text, {
842
1335
  style: s.body
@@ -846,8 +1339,9 @@ function ResultStep({ theme, locale, result, error, onRetry }) {
846
1339
  activeOpacity: 0.85
847
1340
  }, /*#__PURE__*/ React.createElement(reactNative.Text, {
848
1341
  style: s.btnText
849
- }, locale.result_retry_btn)));
1342
+ }, "Try again")));
850
1343
  }
1344
+ const v = result.verdict;
851
1345
  const titles = {
852
1346
  pass: locale.result_pass_title,
853
1347
  fail: locale.result_fail_title,
@@ -858,89 +1352,162 @@ function ResultStep({ theme, locale, result, error, onRetry }) {
858
1352
  fail: locale.result_fail_body,
859
1353
  review: locale.result_review_body
860
1354
  };
1355
+ const rows = [
1356
+ {
1357
+ label: 'Verdict',
1358
+ value: v.charAt(0).toUpperCase() + v.slice(1),
1359
+ ok: v === 'pass',
1360
+ bad: v === 'fail'
1361
+ },
1362
+ {
1363
+ label: 'Risk score',
1364
+ value: String(result.score ?? '—')
1365
+ },
1366
+ {
1367
+ label: 'Face match',
1368
+ value: result.faceMatch ? 'Confirmed' : 'Not confirmed',
1369
+ ok: result.faceMatch,
1370
+ bad: !result.faceMatch
1371
+ },
1372
+ {
1373
+ label: 'Liveness',
1374
+ value: result.isLive ? 'Confirmed' : 'Not confirmed',
1375
+ ok: result.isLive,
1376
+ bad: !result.isLive
1377
+ }
1378
+ ];
861
1379
  return /*#__PURE__*/ React.createElement(reactNative.View, {
862
1380
  style: s.container
863
1381
  }, /*#__PURE__*/ React.createElement(reactNative.View, {
864
1382
  style: [
865
- s.iconWrap,
1383
+ s.iconBox,
866
1384
  {
867
- backgroundColor: ICON_BG[result.verdict]
1385
+ backgroundColor: ICON_BG[v]
868
1386
  }
869
1387
  ]
870
1388
  }, /*#__PURE__*/ React.createElement(reactNative.Text, {
871
- style: s.icon
872
- }, ICONS[result.verdict])), /*#__PURE__*/ React.createElement(reactNative.Text, {
1389
+ style: [
1390
+ s.icon,
1391
+ {
1392
+ color: ICON_COLOR[v]
1393
+ }
1394
+ ]
1395
+ }, ICON[v])), /*#__PURE__*/ React.createElement(reactNative.Text, {
873
1396
  style: s.title
874
- }, titles[result.verdict]), /*#__PURE__*/ React.createElement(reactNative.Text, {
1397
+ }, titles[v]), /*#__PURE__*/ React.createElement(reactNative.Text, {
875
1398
  style: s.body
876
- }, bodies[result.verdict]), result.verdict === 'fail' && /*#__PURE__*/ React.createElement(reactNative.TouchableOpacity, {
1399
+ }, bodies[v]), /*#__PURE__*/ React.createElement(reactNative.View, {
1400
+ style: s.details
1401
+ }, rows.map((row)=>/*#__PURE__*/ React.createElement(reactNative.View, {
1402
+ key: row.label,
1403
+ style: s.row
1404
+ }, /*#__PURE__*/ React.createElement(reactNative.Text, {
1405
+ style: s.rowLabel
1406
+ }, row.label), /*#__PURE__*/ React.createElement(reactNative.Text, {
1407
+ style: [
1408
+ s.rowVal,
1409
+ row.ok && s.rowOk,
1410
+ row.bad && s.rowBad
1411
+ ]
1412
+ }, row.value)))), v === 'fail' && /*#__PURE__*/ React.createElement(reactNative.TouchableOpacity, {
877
1413
  style: s.btn,
878
1414
  onPress: onRetry,
879
1415
  activeOpacity: 0.85
880
1416
  }, /*#__PURE__*/ React.createElement(reactNative.Text, {
881
1417
  style: s.btnText
882
- }, locale.result_retry_btn)));
1418
+ }, "Try again")));
883
1419
  }
884
1420
  const styles$1 = (t)=>reactNative.StyleSheet.create({
885
1421
  container: {
886
- padding: 40,
1422
+ flex: 1,
1423
+ padding: 28,
887
1424
  alignItems: 'center',
888
- gap: 12
1425
+ gap: 14,
1426
+ justifyContent: 'center'
889
1427
  },
890
- iconWrap: {
891
- width: 76,
892
- height: 76,
893
- borderRadius: 38,
1428
+ iconBox: {
1429
+ width: 52,
1430
+ height: 52,
1431
+ borderRadius: 14,
894
1432
  alignItems: 'center',
895
1433
  justifyContent: 'center',
896
- marginBottom: 8
1434
+ marginBottom: 4
897
1435
  },
898
1436
  icon: {
899
- fontSize: 34
1437
+ fontSize: 22,
1438
+ fontWeight: '700'
900
1439
  },
901
1440
  title: {
902
1441
  fontSize: 20,
903
1442
  fontWeight: '700',
904
1443
  color: t.text,
905
- textAlign: 'center'
1444
+ textAlign: 'center',
1445
+ letterSpacing: -0.3
906
1446
  },
907
1447
  body: {
908
- fontSize: 14,
1448
+ fontSize: 13.5,
909
1449
  color: t.subtext,
910
1450
  textAlign: 'center',
911
- lineHeight: 21
1451
+ lineHeight: 19
1452
+ },
1453
+ details: {
1454
+ width: '100%',
1455
+ gap: 6
1456
+ },
1457
+ row: {
1458
+ flexDirection: 'row',
1459
+ justifyContent: 'space-between',
1460
+ alignItems: 'center',
1461
+ paddingVertical: 9,
1462
+ paddingHorizontal: 12,
1463
+ backgroundColor: t.surface,
1464
+ borderRadius: 8
1465
+ },
1466
+ rowLabel: {
1467
+ fontSize: 12.5,
1468
+ color: t.subtext
1469
+ },
1470
+ rowVal: {
1471
+ fontSize: 12.5,
1472
+ fontWeight: '600',
1473
+ color: t.text
1474
+ },
1475
+ rowOk: {
1476
+ color: '#16a34a'
1477
+ },
1478
+ rowBad: {
1479
+ color: '#dc2626'
912
1480
  },
913
1481
  btn: {
914
- marginTop: 8,
1482
+ width: '100%',
915
1483
  backgroundColor: t.accent,
916
- borderRadius: t.radius,
917
- paddingVertical: 14,
918
- paddingHorizontal: 32,
919
- alignItems: 'center'
1484
+ borderRadius: t.radius - 4,
1485
+ paddingVertical: 12,
1486
+ alignItems: 'center',
1487
+ marginTop: 4
920
1488
  },
921
1489
  btnText: {
922
- color: '#fff',
923
- fontSize: 15,
1490
+ color: t.accentInv,
1491
+ fontSize: 14,
924
1492
  fontWeight: '600'
925
1493
  }
926
1494
  });
927
1495
 
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 }) {
1496
+ 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
1497
  const [step, setStep] = React.useState('intro');
941
1498
  const [capture, setCapture] = React.useState({});
942
1499
  const [result, setResult] = React.useState(null);
943
1500
  const [error, setError] = React.useState(null);
1501
+ // Mirror capture in a ref so submit() always reads the latest value even
1502
+ // when called from inside a stale closure (e.g. goto → submit chain).
1503
+ const captureRef = React.useRef({});
1504
+ const setCaptureSync = (updater)=>{
1505
+ setCapture((prev)=>{
1506
+ const next = updater(prev);
1507
+ captureRef.current = next;
1508
+ return next;
1509
+ });
1510
+ };
944
1511
  const theme = React.useMemo(()=>resolveTheme(themeProp), [
945
1512
  themeProp
946
1513
  ]);
@@ -952,48 +1519,104 @@ function KernaqVerify({ visible, apiKey, backendUrl, country = 'KEN', reference,
952
1519
  ]);
953
1520
  const { requestPermissions } = usePermissions();
954
1521
  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') {
1522
+ // Build the ordered flow from the steps prop
1523
+ const flow = React.useMemo(()=>{
1524
+ const wanted = stepsProp ?? [
1525
+ 'document',
1526
+ 'selfie',
1527
+ 'liveness'
1528
+ ];
1529
+ const out = [];
1530
+ if (wanted.includes('document')) {
1531
+ out.push('doc-front');
1532
+ out.push('doc-back');
1533
+ }
1534
+ if (wanted.includes('selfie')) out.push('selfie');
1535
+ if (wanted.includes('liveness')) out.push('liveness');
1536
+ out.push('processing');
1537
+ return out;
1538
+ }, [
1539
+ stepsProp
1540
+ ]);
1541
+ const next = React.useCallback((current)=>{
1542
+ const idx = flow.indexOf(current);
1543
+ const nextStep = flow[idx + 1] ?? 'processing';
1544
+ goto(nextStep);
1545
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1546
+ }, [
1547
+ flow
1548
+ ]);
1549
+ const goto = React.useCallback(async (s)=>{
1550
+ if (s === 'doc-front' || s === 'doc-back' || s === 'selfie' || s === 'liveness') {
963
1551
  const granted = await requestPermissions();
964
1552
  if (!granted) {
965
1553
  setError(locale.error_camera_denied);
966
1554
  return;
967
1555
  }
968
1556
  }
969
- setStep(next);
970
- if (next === 'processing') {
971
- submit();
972
- }
1557
+ setStep(s);
1558
+ if (s === 'processing') submit();
1559
+ // submit is stable (no deps that change); locale/requestPermissions are stable refs
973
1560
  // eslint-disable-next-line react-hooks/exhaustive-deps
974
1561
  }, [
975
- capture,
976
1562
  locale
977
1563
  ]);
1564
+ const reset = React.useCallback(()=>{
1565
+ setStep('intro');
1566
+ captureRef.current = {};
1567
+ setCapture({});
1568
+ setResult(null);
1569
+ setError(null);
1570
+ }, []);
978
1571
  const submit = React.useCallback(async ()=>{
979
- const { documentUri, selfieUri, videoUri } = capture;
980
- if (!documentUri || !selfieUri) {
981
- setError('Missing captures — please restart.');
1572
+ const { docFrontUri, docBackUri, selfieUri, videoUri } = captureRef.current;
1573
+ const steps = stepsProp ?? [
1574
+ 'document',
1575
+ 'selfie',
1576
+ 'liveness'
1577
+ ];
1578
+ if (steps.includes('document') && !docFrontUri) {
1579
+ setError('Document capture is missing — please restart.');
1580
+ setStep('result');
1581
+ onError?.({
1582
+ code: 'capture_missing',
1583
+ message: 'Document capture missing'
1584
+ });
1585
+ return;
1586
+ }
1587
+ if (steps.includes('selfie') && !selfieUri) {
1588
+ setError('Selfie capture is missing — please restart.');
982
1589
  setStep('result');
1590
+ onError?.({
1591
+ code: 'capture_missing',
1592
+ message: 'Selfie capture missing'
1593
+ });
983
1594
  return;
984
1595
  }
985
1596
  const base = (backendUrl ?? 'https://api.identity.kernaq.com/v1').replace(/\/$/, '');
1597
+ const endpoint = sandbox ? '/verify/sandbox' : '/verify';
986
1598
  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
- });
1599
+ if (docFrontUri) {
1600
+ form.append('doc_front', {
1601
+ uri: docFrontUri,
1602
+ name: 'doc_front.jpg',
1603
+ type: 'image/jpeg'
1604
+ });
1605
+ }
1606
+ if (docBackUri) {
1607
+ form.append('doc_back', {
1608
+ uri: docBackUri,
1609
+ name: 'doc_back.jpg',
1610
+ type: 'image/jpeg'
1611
+ });
1612
+ }
1613
+ if (selfieUri) {
1614
+ form.append('selfie', {
1615
+ uri: selfieUri,
1616
+ name: 'selfie.jpg',
1617
+ type: 'image/jpeg'
1618
+ });
1619
+ }
997
1620
  if (videoUri) {
998
1621
  form.append('video', {
999
1622
  uri: videoUri,
@@ -1001,16 +1624,17 @@ function KernaqVerify({ visible, apiKey, backendUrl, country = 'KEN', reference,
1001
1624
  type: 'video/mp4'
1002
1625
  });
1003
1626
  }
1004
- form.append('document_type', documentTypes[0] ?? 'national_id');
1627
+ form.append('document_type', documentType);
1005
1628
  form.append('country', country);
1006
1629
  form.append('reference', reference ?? `kq_${Date.now()}`);
1007
- if (sandbox) form.append('sandbox', 'true');
1008
- const headers = {
1009
- 'Content-Type': 'multipart/form-data'
1010
- };
1630
+ // sandbox is handled via URL routing above — no form field needed
1631
+ // Do NOT set Content-Type manually — RN fetch will set multipart/form-data with
1632
+ // the correct boundary automatically. Setting it manually omits the boundary and
1633
+ // causes the server to reject the request.
1634
+ const headers = {};
1011
1635
  if (apiKey) headers['X-API-Key'] = apiKey;
1012
1636
  try {
1013
- const res = await fetch(`${base}/verify`, {
1637
+ const res = await fetch(`${base}${endpoint}`, {
1014
1638
  method: 'POST',
1015
1639
  headers,
1016
1640
  body: form
@@ -1032,6 +1656,7 @@ function KernaqVerify({ visible, apiKey, backendUrl, country = 'KEN', reference,
1032
1656
  } catch (err) {
1033
1657
  const msg = err.message;
1034
1658
  setError(`${locale.error_network} ${msg}`);
1659
+ setResult(null);
1035
1660
  setStep('result');
1036
1661
  onError?.({
1037
1662
  code: 'network_error',
@@ -1039,103 +1664,81 @@ function KernaqVerify({ visible, apiKey, backendUrl, country = 'KEN', reference,
1039
1664
  });
1040
1665
  }
1041
1666
  // eslint-disable-next-line react-hooks/exhaustive-deps
1042
- }, [
1043
- capture
1044
- ]);
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];
1667
+ }, []) // no deps — always reads from captureRef.current, never from stale closure
1668
+ ;
1056
1669
  return /*#__PURE__*/ React.createElement(reactNative.Modal, {
1057
1670
  visible: visible,
1058
1671
  animationType: "slide",
1059
1672
  presentationStyle: "pageSheet",
1060
- onRequestClose: handleCancel
1673
+ onRequestClose: ()=>{}
1061
1674
  }, /*#__PURE__*/ React.createElement(reactNative.SafeAreaView, {
1062
1675
  style: [
1063
- s.safeArea,
1676
+ s.safe,
1064
1677
  {
1065
1678
  backgroundColor: theme.bg
1066
1679
  }
1067
1680
  ]
1068
1681
  }, /*#__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
1682
+ style: s.body
1102
1683
  }, step === 'intro' && /*#__PURE__*/ React.createElement(IntroStep, {
1103
1684
  theme: theme,
1104
1685
  locale: locale,
1105
- onStart: ()=>goto('document')
1106
- }), step === 'document' && /*#__PURE__*/ React.createElement(DocumentStep, {
1686
+ onStart: ()=>{
1687
+ const first = flow[0];
1688
+ if (first) goto(first);
1689
+ }
1690
+ }), step === 'doc-front' && /*#__PURE__*/ React.createElement(DocumentStep, {
1691
+ theme: theme,
1692
+ locale: locale,
1693
+ side: "front",
1694
+ onCapture: (uri)=>{
1695
+ setCaptureSync((c)=>({
1696
+ ...c,
1697
+ docFrontUri: uri
1698
+ }));
1699
+ next('doc-front');
1700
+ }
1701
+ }), step === 'doc-back' && /*#__PURE__*/ React.createElement(DocumentStep, {
1107
1702
  theme: theme,
1108
1703
  locale: locale,
1704
+ side: "back",
1705
+ frontUri: capture.docFrontUri,
1109
1706
  onCapture: (uri)=>{
1110
- setCapture((c)=>({
1707
+ setCaptureSync((c)=>({
1708
+ ...c,
1709
+ docBackUri: uri
1710
+ }));
1711
+ next('doc-back');
1712
+ },
1713
+ onRetakeFront: ()=>{
1714
+ setCaptureSync((c)=>({
1111
1715
  ...c,
1112
- documentUri: uri
1716
+ docFrontUri: undefined
1113
1717
  }));
1114
- goto('selfie');
1718
+ goto('doc-front');
1115
1719
  }
1116
1720
  }), step === 'selfie' && /*#__PURE__*/ React.createElement(SelfieStep, {
1117
1721
  theme: theme,
1118
1722
  locale: locale,
1119
1723
  onCapture: (uri)=>{
1120
- setCapture((c)=>({
1724
+ setCaptureSync((c)=>({
1121
1725
  ...c,
1122
1726
  selfieUri: uri
1123
1727
  }));
1124
- goto('liveness');
1125
- },
1126
- onBack: ()=>setStep('document')
1728
+ next('selfie');
1729
+ }
1127
1730
  }), step === 'liveness' && /*#__PURE__*/ React.createElement(LivenessStep, {
1128
1731
  theme: theme,
1129
1732
  locale: locale,
1130
1733
  duration: livenessDuration,
1734
+ taskCount: livenessTaskCount,
1131
1735
  onCapture: (uri)=>{
1132
- setCapture((c)=>({
1736
+ setCaptureSync((c)=>({
1133
1737
  ...c,
1134
1738
  videoUri: uri
1135
1739
  }));
1136
- goto('processing');
1137
- },
1138
- onBack: ()=>setStep('selfie')
1740
+ next('liveness');
1741
+ }
1139
1742
  }), step === 'processing' && /*#__PURE__*/ React.createElement(ProcessingStep, {
1140
1743
  theme: theme,
1141
1744
  locale: locale
@@ -1146,55 +1749,45 @@ function KernaqVerify({ visible, apiKey, backendUrl, country = 'KEN', reference,
1146
1749
  error: error,
1147
1750
  onRetry: ()=>{
1148
1751
  reset();
1149
- setStep('intro');
1752
+ goto('intro');
1150
1753
  }
1151
- }))));
1754
+ })), step !== 'processing' && /*#__PURE__*/ React.createElement(reactNative.View, {
1755
+ style: s.footer
1756
+ }, /*#__PURE__*/ React.createElement(reactNative.Text, {
1757
+ style: s.footerText
1758
+ }, "Secured by "), /*#__PURE__*/ React.createElement(reactNative.Text, {
1759
+ style: [
1760
+ s.footerText,
1761
+ {
1762
+ fontWeight: '600',
1763
+ color: theme.text
1764
+ }
1765
+ ]
1766
+ }, "Kernaq"))));
1152
1767
  }
1153
1768
  const styles = (t)=>reactNative.StyleSheet.create({
1154
- safeArea: {
1769
+ safe: {
1770
+ flex: 1
1771
+ },
1772
+ body: {
1155
1773
  flex: 1
1156
1774
  },
1157
- header: {
1775
+ footer: {
1158
1776
  flexDirection: 'row',
1159
1777
  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
1778
+ justifyContent: 'center',
1779
+ paddingVertical: 10,
1780
+ paddingBottom: reactNative.Platform.OS === 'ios' ? 4 : 10,
1781
+ borderTopWidth: reactNative.StyleSheet.hairlineWidth,
1782
+ borderTopColor: t.border
1174
1783
  },
1175
- closeText: {
1176
- fontSize: 18,
1784
+ footerText: {
1785
+ fontSize: 11,
1177
1786
  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
1787
  }
1197
1788
  });
1198
1789
 
1199
1790
  exports.KernaqVerify = KernaqVerify;
1791
+ exports.analyseImageUri = analyseImageUri;
1792
+ exports.analyseSelfieFrame = analyseSelfieFrame;
1200
1793
  //# sourceMappingURL=index.js.map