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