@kernaq/verify-react-native 1.0.4 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.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, {
301
508
  style: [
302
- s.btnPrimary,
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, {
530
+ style: [
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,181 @@ 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
+ const progressTimer = useRef(null);
958
+ const taskTimer = useRef(null);
540
959
  const pulseAnim = useRef(new Animated.Value(1)).current;
541
- const s = styles$3(theme);
542
- // Pulse animation for recording indicator
960
+ // Pulse REC dot
543
961
  useEffect(()=>{
544
962
  if (recording) {
545
963
  Animated.loop(Animated.sequence([
546
964
  Animated.timing(pulseAnim, {
547
- toValue: 0.3,
965
+ toValue: 0.2,
548
966
  duration: 600,
549
967
  useNativeDriver: true
550
968
  }),
@@ -555,34 +973,60 @@ function LivenessStep({ theme, locale, duration, onCapture, onBack }) {
555
973
  })
556
974
  ])).start();
557
975
  } else {
976
+ pulseAnim.stopAnimation();
558
977
  pulseAnim.setValue(1);
559
978
  }
560
979
  }, [
561
980
  recording,
562
981
  pulseAnim
563
982
  ]);
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();
983
+ const startTask = ()=>{
984
+ if (!recording) {
985
+ // Begin recording when first task starts
986
+ startRecording((video)=>{
987
+ onCapture(video.uri);
988
+ }, ()=>{});
989
+ setRecording(true);
990
+ }
991
+ const task = tasks[taskIdx];
992
+ if (!task) return;
993
+ setStarted(true);
994
+ setProgress(0);
995
+ const start = Date.now();
996
+ progressTimer.current = setInterval(()=>{
997
+ const elapsed = Date.now() - start;
998
+ const pct = Math.min(100, elapsed / task.duration * 100);
999
+ setProgress(pct);
1000
+ if (elapsed >= task.duration) {
1001
+ clearInterval(progressTimer.current);
1002
+ progressTimer.current = null;
1003
+ nextTask();
580
1004
  }
581
- }, 100);
1005
+ }, 80);
1006
+ };
1007
+ const nextTask = ()=>{
1008
+ const next = taskIdx + 1;
1009
+ setTaskDone((prev)=>prev + 1);
1010
+ if (next >= tasks.length) {
1011
+ finishLiveness();
1012
+ return;
1013
+ }
1014
+ setTaskIdx(next);
1015
+ setStarted(false);
1016
+ setProgress(0);
1017
+ // Short pause then auto-start next task
1018
+ taskTimer.current = setTimeout(()=>startTask(), 500);
1019
+ };
1020
+ const finishLiveness = ()=>{
1021
+ setComplete(true);
1022
+ taskTimer.current = setTimeout(()=>{
1023
+ stopRecording();
1024
+ }, 1000);
582
1025
  };
583
1026
  useEffect(()=>{
584
1027
  return ()=>{
585
- if (timerRef.current) clearInterval(timerRef.current);
1028
+ if (progressTimer.current) clearInterval(progressTimer.current);
1029
+ if (taskTimer.current) clearTimeout(taskTimer.current);
586
1030
  };
587
1031
  }, []);
588
1032
  if (!device) return /*#__PURE__*/ React.createElement(ActivityIndicator, {
@@ -591,13 +1035,26 @@ function LivenessStep({ theme, locale, duration, onCapture, onBack }) {
591
1035
  margin: 40
592
1036
  }
593
1037
  });
1038
+ const currentTask = tasks[taskIdx];
1039
+ const s = styles$3(theme);
594
1040
  return /*#__PURE__*/ React.createElement(View, {
595
1041
  style: s.container
1042
+ }, /*#__PURE__*/ React.createElement(View, {
1043
+ style: s.heading
596
1044
  }, /*#__PURE__*/ React.createElement(Text, {
597
1045
  style: s.title
598
- }, locale.liveness_title), /*#__PURE__*/ React.createElement(Text, {
1046
+ }, complete ? locale.liveness_complete : locale.liveness_title), !complete && /*#__PURE__*/ React.createElement(Text, {
599
1047
  style: s.subtitle
600
- }, recording ? locale.liveness_recording : locale.liveness_instruction), /*#__PURE__*/ React.createElement(View, {
1048
+ }, locale.liveness_ready)), !complete && /*#__PURE__*/ React.createElement(View, {
1049
+ style: s.taskDots
1050
+ }, tasks.map((_, i)=>/*#__PURE__*/ React.createElement(View, {
1051
+ key: i,
1052
+ style: [
1053
+ s.dot,
1054
+ i < taskDone && s.dotDone,
1055
+ i === taskIdx && s.dotActive
1056
+ ]
1057
+ }))), /*#__PURE__*/ React.createElement(View, {
601
1058
  style: s.viewport
602
1059
  }, /*#__PURE__*/ React.createElement(Camera, {
603
1060
  ref: cameraRef,
@@ -605,11 +1062,11 @@ function LivenessStep({ theme, locale, duration, onCapture, onBack }) {
605
1062
  device: device,
606
1063
  isActive: true,
607
1064
  video: true,
608
- audio: true
1065
+ audio: false
609
1066
  }), /*#__PURE__*/ React.createElement(View, {
610
1067
  style: s.ovalGuide,
611
1068
  pointerEvents: "none"
612
- }), recording && /*#__PURE__*/ React.createElement(React.Fragment, null, /*#__PURE__*/ React.createElement(View, {
1069
+ }), recording && /*#__PURE__*/ React.createElement(View, {
613
1070
  style: s.recBadge
614
1071
  }, /*#__PURE__*/ React.createElement(Animated.View, {
615
1072
  style: [
@@ -620,59 +1077,94 @@ function LivenessStep({ theme, locale, duration, onCapture, onBack }) {
620
1077
  ]
621
1078
  }), /*#__PURE__*/ React.createElement(Text, {
622
1079
  style: s.recText
623
- }, "REC")), /*#__PURE__*/ React.createElement(View, {
624
- style: s.countdownBadge
1080
+ }, "REC")), currentTask && !complete && /*#__PURE__*/ React.createElement(View, {
1081
+ style: s.overlay
1082
+ }, /*#__PURE__*/ React.createElement(View, {
1083
+ style: s.overlayPill
1084
+ }, /*#__PURE__*/ React.createElement(Text, {
1085
+ style: s.overlayNum
1086
+ }, taskIdx + 1, "/", tasks.length), /*#__PURE__*/ React.createElement(Text, {
1087
+ style: s.overlayLabel
1088
+ }, currentTask.label)), started && /*#__PURE__*/ React.createElement(View, {
1089
+ style: s.barWrap
1090
+ }, /*#__PURE__*/ React.createElement(View, {
1091
+ style: [
1092
+ s.barFill,
1093
+ {
1094
+ width: `${progress}%`
1095
+ }
1096
+ ]
1097
+ }))), complete && /*#__PURE__*/ React.createElement(View, {
1098
+ style: s.overlay
1099
+ }, /*#__PURE__*/ React.createElement(View, {
1100
+ style: s.overlayPill
625
1101
  }, /*#__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,
1102
+ style: s.overlayLabel
1103
+ }, locale.liveness_complete)))), !started && !complete && currentTask && taskDone === 0 && /*#__PURE__*/ React.createElement(TouchableOpacity, {
1104
+ style: s.btn,
1105
+ onPress: startTask,
630
1106
  activeOpacity: 0.85
631
1107
  }, /*#__PURE__*/ React.createElement(Text, {
632
1108
  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))));
1109
+ }, "Begin"), /*#__PURE__*/ React.createElement(Text, {
1110
+ style: s.btnArrow
1111
+ }, " →")));
640
1112
  }
641
1113
  const styles$3 = (t)=>StyleSheet.create({
642
1114
  container: {
643
- padding: 24
1115
+ flex: 1,
1116
+ padding: 20,
1117
+ gap: 12
1118
+ },
1119
+ heading: {
1120
+ gap: 4,
1121
+ alignItems: 'center'
644
1122
  },
645
1123
  title: {
646
1124
  fontSize: 20,
647
1125
  fontWeight: '700',
648
1126
  color: t.text,
649
- marginBottom: 8
1127
+ textAlign: 'center',
1128
+ letterSpacing: -0.3
650
1129
  },
651
1130
  subtitle: {
652
- fontSize: 14,
1131
+ fontSize: 13,
653
1132
  color: t.subtext,
654
- lineHeight: 20,
655
- marginBottom: 16
1133
+ lineHeight: 19,
1134
+ textAlign: 'center'
1135
+ },
1136
+ taskDots: {
1137
+ flexDirection: 'row',
1138
+ gap: 6
1139
+ },
1140
+ dot: {
1141
+ flex: 1,
1142
+ height: 2.5,
1143
+ borderRadius: 999,
1144
+ backgroundColor: t.border
1145
+ },
1146
+ dotDone: {
1147
+ backgroundColor: t.accent,
1148
+ opacity: 0.35
1149
+ },
1150
+ dotActive: {
1151
+ backgroundColor: t.accent
656
1152
  },
657
1153
  viewport: {
658
- width: '100%',
659
- aspectRatio: 3 / 4,
660
- borderRadius: t.radius,
1154
+ flex: 1,
1155
+ borderRadius: t.radius - 4,
661
1156
  overflow: 'hidden',
662
1157
  backgroundColor: '#000',
663
- marginBottom: 16,
664
- alignSelf: 'center',
665
- maxWidth: 280,
666
1158
  position: 'relative'
667
1159
  },
668
1160
  ovalGuide: {
669
1161
  position: 'absolute',
670
- top: '8%',
671
- left: '12%',
672
- right: '12%',
673
- bottom: '8%',
1162
+ left: '21%',
1163
+ right: '21%',
1164
+ top: '6%',
1165
+ bottom: '6%',
674
1166
  borderWidth: 2,
675
- borderColor: 'rgba(255,255,255,0.7)',
1167
+ borderColor: 'rgba(255,255,255,0.75)',
676
1168
  borderRadius: 999
677
1169
  },
678
1170
  recBadge: {
@@ -681,144 +1173,134 @@ const styles$3 = (t)=>StyleSheet.create({
681
1173
  left: 10,
682
1174
  flexDirection: 'row',
683
1175
  alignItems: 'center',
684
- backgroundColor: 'rgba(220,38,38,0.85)',
685
- borderRadius: 999,
686
- paddingHorizontal: 10,
687
- paddingVertical: 4,
688
- gap: 6
1176
+ gap: 5,
1177
+ backgroundColor: 'rgba(0,0,0,0.65)',
1178
+ borderWidth: 1,
1179
+ borderColor: 'rgba(239,68,68,0.5)',
1180
+ borderRadius: 5,
1181
+ paddingHorizontal: 8,
1182
+ paddingVertical: 4
689
1183
  },
690
1184
  recDot: {
691
- width: 8,
692
- height: 8,
693
- borderRadius: 4,
694
- backgroundColor: '#fff'
1185
+ width: 7,
1186
+ height: 7,
1187
+ borderRadius: 3.5,
1188
+ backgroundColor: '#ef4444'
695
1189
  },
696
1190
  recText: {
697
- color: '#fff',
698
- fontSize: 12,
699
- fontWeight: '700'
1191
+ color: '#ef4444',
1192
+ fontSize: 11,
1193
+ fontWeight: '700',
1194
+ letterSpacing: 0.5
700
1195
  },
701
- countdownBadge: {
1196
+ overlay: {
702
1197
  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)',
1198
+ bottom: 0,
1199
+ left: 0,
1200
+ right: 0,
1201
+ paddingHorizontal: 12,
1202
+ paddingTop: 10,
1203
+ paddingBottom: 12,
1204
+ gap: 8,
1205
+ backgroundColor: 'transparent'
1206
+ },
1207
+ overlayPill: {
1208
+ flexDirection: 'row',
709
1209
  alignItems: 'center',
710
- justifyContent: 'center'
1210
+ gap: 8
711
1211
  },
712
- countdownText: {
713
- color: '#fff',
714
- fontSize: 18,
715
- fontWeight: '700'
1212
+ overlayNum: {
1213
+ fontSize: 11,
1214
+ fontWeight: '700',
1215
+ color: 'rgba(255,255,255,0.55)',
1216
+ textTransform: 'uppercase',
1217
+ letterSpacing: 0.5
716
1218
  },
717
- btnPrimary: {
718
- backgroundColor: t.accent,
719
- borderRadius: t.radius,
720
- paddingVertical: 14,
721
- alignItems: 'center',
722
- marginBottom: 10
1219
+ overlayLabel: {
1220
+ fontSize: 15,
1221
+ fontWeight: '700',
1222
+ color: '#fff'
1223
+ },
1224
+ barWrap: {
1225
+ height: 3,
1226
+ backgroundColor: 'rgba(255,255,255,0.2)',
1227
+ borderRadius: 999,
1228
+ overflow: 'hidden'
1229
+ },
1230
+ barFill: {
1231
+ height: '100%',
1232
+ backgroundColor: '#fff',
1233
+ borderRadius: 999
723
1234
  },
724
- btnSecondary: {
725
- backgroundColor: t.card,
726
- borderRadius: t.radius,
727
- paddingVertical: 14,
1235
+ btn: {
1236
+ backgroundColor: t.accent,
1237
+ borderRadius: t.radius - 4,
1238
+ paddingVertical: 12,
1239
+ flexDirection: 'row',
728
1240
  alignItems: 'center',
729
- borderWidth: 1,
730
- borderColor: t.border
1241
+ justifyContent: 'center'
731
1242
  },
732
1243
  btnText: {
733
- color: '#fff',
734
- fontSize: 15,
1244
+ color: t.accentInv,
1245
+ fontSize: 14,
735
1246
  fontWeight: '600'
736
1247
  },
737
- btnSecondaryText: {
738
- color: t.text,
739
- fontSize: 15,
1248
+ btnArrow: {
1249
+ color: t.accentInv,
1250
+ fontSize: 14,
740
1251
  fontWeight: '600'
741
1252
  }
742
1253
  });
743
1254
 
744
1255
  function ProcessingStep({ theme, locale }) {
745
- const spinAnim = useRef(new Animated.Value(0)).current;
746
1256
  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
1257
  return /*#__PURE__*/ React.createElement(View, {
767
1258
  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
- ]
1259
+ }, /*#__PURE__*/ React.createElement(ActivityIndicator, {
1260
+ color: theme.accent,
1261
+ size: "large"
779
1262
  }), /*#__PURE__*/ React.createElement(Text, {
780
1263
  style: s.title
781
1264
  }, locale.processing_title), /*#__PURE__*/ React.createElement(Text, {
782
1265
  style: s.subtitle
783
- }, locale.processing_subtitle));
1266
+ }, "This usually takes under 10 seconds."));
784
1267
  }
785
1268
  const styles$2 = (t)=>StyleSheet.create({
786
1269
  container: {
787
- padding: 48,
1270
+ flex: 1,
788
1271
  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
1272
+ justifyContent: 'center',
1273
+ gap: 16,
1274
+ padding: 32
799
1275
  },
800
1276
  title: {
801
- fontSize: 18,
1277
+ fontSize: 16,
802
1278
  fontWeight: '700',
803
1279
  color: t.text,
804
1280
  textAlign: 'center'
805
1281
  },
806
1282
  subtitle: {
807
- fontSize: 14,
1283
+ fontSize: 13,
808
1284
  color: t.subtext,
809
1285
  textAlign: 'center'
810
1286
  }
811
1287
  });
812
1288
 
813
- const ICONS = {
814
- pass: '✅',
815
- fail: '❌',
816
- review: '⏳'
1289
+ // Unicode block characters — no emoji, render cleanly on all platforms
1290
+ const ICON = {
1291
+ pass: '\u2714',
1292
+ fail: '\u2718',
1293
+ review: '\u29D6'
817
1294
  };
818
1295
  const ICON_BG = {
819
- pass: '#d1fae5',
1296
+ pass: '#dcfce7',
820
1297
  fail: '#fee2e2',
821
- review: '#fef3c7'
1298
+ review: '#fef9c3'
1299
+ };
1300
+ const ICON_COLOR = {
1301
+ pass: '#16a34a',
1302
+ fail: '#dc2626',
1303
+ review: '#ca8a04'
822
1304
  };
823
1305
  function ResultStep({ theme, locale, result, error, onRetry }) {
824
1306
  const s = styles$1(theme);
@@ -827,14 +1309,19 @@ function ResultStep({ theme, locale, result, error, onRetry }) {
827
1309
  style: s.container
828
1310
  }, /*#__PURE__*/ React.createElement(View, {
829
1311
  style: [
830
- s.iconWrap,
1312
+ s.iconBox,
831
1313
  {
832
- backgroundColor: '#fee2e2'
1314
+ backgroundColor: ICON_BG.fail
833
1315
  }
834
1316
  ]
835
1317
  }, /*#__PURE__*/ React.createElement(Text, {
836
- style: s.icon
837
- }, "❌")), /*#__PURE__*/ React.createElement(Text, {
1318
+ style: [
1319
+ s.icon,
1320
+ {
1321
+ color: ICON_COLOR.fail
1322
+ }
1323
+ ]
1324
+ }, ICON.fail)), /*#__PURE__*/ React.createElement(Text, {
838
1325
  style: s.title
839
1326
  }, locale.result_fail_title), /*#__PURE__*/ React.createElement(Text, {
840
1327
  style: s.body
@@ -844,8 +1331,9 @@ function ResultStep({ theme, locale, result, error, onRetry }) {
844
1331
  activeOpacity: 0.85
845
1332
  }, /*#__PURE__*/ React.createElement(Text, {
846
1333
  style: s.btnText
847
- }, locale.result_retry_btn)));
1334
+ }, "Try again")));
848
1335
  }
1336
+ const v = result.verdict;
849
1337
  const titles = {
850
1338
  pass: locale.result_pass_title,
851
1339
  fail: locale.result_fail_title,
@@ -856,85 +1344,148 @@ function ResultStep({ theme, locale, result, error, onRetry }) {
856
1344
  fail: locale.result_fail_body,
857
1345
  review: locale.result_review_body
858
1346
  };
1347
+ const rows = [
1348
+ {
1349
+ label: 'Verdict',
1350
+ value: v.charAt(0).toUpperCase() + v.slice(1),
1351
+ ok: v === 'pass',
1352
+ bad: v === 'fail'
1353
+ },
1354
+ {
1355
+ label: 'Risk score',
1356
+ value: String(result.score ?? '—')
1357
+ },
1358
+ {
1359
+ label: 'Face match',
1360
+ value: result.faceMatch ? 'Confirmed' : 'Not confirmed',
1361
+ ok: result.faceMatch,
1362
+ bad: !result.faceMatch
1363
+ },
1364
+ {
1365
+ label: 'Liveness',
1366
+ value: result.isLive ? 'Confirmed' : 'Not confirmed',
1367
+ ok: result.isLive,
1368
+ bad: !result.isLive
1369
+ }
1370
+ ];
859
1371
  return /*#__PURE__*/ React.createElement(View, {
860
1372
  style: s.container
861
1373
  }, /*#__PURE__*/ React.createElement(View, {
862
1374
  style: [
863
- s.iconWrap,
1375
+ s.iconBox,
864
1376
  {
865
- backgroundColor: ICON_BG[result.verdict]
1377
+ backgroundColor: ICON_BG[v]
866
1378
  }
867
1379
  ]
868
1380
  }, /*#__PURE__*/ React.createElement(Text, {
869
- style: s.icon
870
- }, ICONS[result.verdict])), /*#__PURE__*/ React.createElement(Text, {
1381
+ style: [
1382
+ s.icon,
1383
+ {
1384
+ color: ICON_COLOR[v]
1385
+ }
1386
+ ]
1387
+ }, ICON[v])), /*#__PURE__*/ React.createElement(Text, {
871
1388
  style: s.title
872
- }, titles[result.verdict]), /*#__PURE__*/ React.createElement(Text, {
1389
+ }, titles[v]), /*#__PURE__*/ React.createElement(Text, {
873
1390
  style: s.body
874
- }, bodies[result.verdict]), result.verdict === 'fail' && /*#__PURE__*/ React.createElement(TouchableOpacity, {
1391
+ }, bodies[v]), /*#__PURE__*/ React.createElement(View, {
1392
+ style: s.details
1393
+ }, rows.map((row)=>/*#__PURE__*/ React.createElement(View, {
1394
+ key: row.label,
1395
+ style: s.row
1396
+ }, /*#__PURE__*/ React.createElement(Text, {
1397
+ style: s.rowLabel
1398
+ }, row.label), /*#__PURE__*/ React.createElement(Text, {
1399
+ style: [
1400
+ s.rowVal,
1401
+ row.ok && s.rowOk,
1402
+ row.bad && s.rowBad
1403
+ ]
1404
+ }, row.value)))), v === 'fail' && /*#__PURE__*/ React.createElement(TouchableOpacity, {
875
1405
  style: s.btn,
876
1406
  onPress: onRetry,
877
1407
  activeOpacity: 0.85
878
1408
  }, /*#__PURE__*/ React.createElement(Text, {
879
1409
  style: s.btnText
880
- }, locale.result_retry_btn)));
1410
+ }, "Try again")));
881
1411
  }
882
1412
  const styles$1 = (t)=>StyleSheet.create({
883
1413
  container: {
884
- padding: 40,
1414
+ flex: 1,
1415
+ padding: 28,
885
1416
  alignItems: 'center',
886
- gap: 12
1417
+ gap: 14,
1418
+ justifyContent: 'center'
887
1419
  },
888
- iconWrap: {
889
- width: 76,
890
- height: 76,
891
- borderRadius: 38,
1420
+ iconBox: {
1421
+ width: 52,
1422
+ height: 52,
1423
+ borderRadius: 14,
892
1424
  alignItems: 'center',
893
1425
  justifyContent: 'center',
894
- marginBottom: 8
1426
+ marginBottom: 4
895
1427
  },
896
1428
  icon: {
897
- fontSize: 34
1429
+ fontSize: 22,
1430
+ fontWeight: '700'
898
1431
  },
899
1432
  title: {
900
1433
  fontSize: 20,
901
1434
  fontWeight: '700',
902
1435
  color: t.text,
903
- textAlign: 'center'
1436
+ textAlign: 'center',
1437
+ letterSpacing: -0.3
904
1438
  },
905
1439
  body: {
906
- fontSize: 14,
1440
+ fontSize: 13.5,
907
1441
  color: t.subtext,
908
1442
  textAlign: 'center',
909
- lineHeight: 21
1443
+ lineHeight: 19
1444
+ },
1445
+ details: {
1446
+ width: '100%',
1447
+ gap: 6
1448
+ },
1449
+ row: {
1450
+ flexDirection: 'row',
1451
+ justifyContent: 'space-between',
1452
+ alignItems: 'center',
1453
+ paddingVertical: 9,
1454
+ paddingHorizontal: 12,
1455
+ backgroundColor: t.surface,
1456
+ borderRadius: 8
1457
+ },
1458
+ rowLabel: {
1459
+ fontSize: 12.5,
1460
+ color: t.subtext
1461
+ },
1462
+ rowVal: {
1463
+ fontSize: 12.5,
1464
+ fontWeight: '600',
1465
+ color: t.text
1466
+ },
1467
+ rowOk: {
1468
+ color: '#16a34a'
1469
+ },
1470
+ rowBad: {
1471
+ color: '#dc2626'
910
1472
  },
911
1473
  btn: {
912
- marginTop: 8,
1474
+ width: '100%',
913
1475
  backgroundColor: t.accent,
914
- borderRadius: t.radius,
915
- paddingVertical: 14,
916
- paddingHorizontal: 32,
917
- alignItems: 'center'
1476
+ borderRadius: t.radius - 4,
1477
+ paddingVertical: 12,
1478
+ alignItems: 'center',
1479
+ marginTop: 4
918
1480
  },
919
1481
  btnText: {
920
- color: '#fff',
921
- fontSize: 15,
1482
+ color: t.accentInv,
1483
+ fontSize: 14,
922
1484
  fontWeight: '600'
923
1485
  }
924
1486
  });
925
1487
 
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 }) {
1488
+ 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
1489
  const [step, setStep] = useState('intro');
939
1490
  const [capture, setCapture] = useState({});
940
1491
  const [result, setResult] = useState(null);
@@ -950,48 +1501,79 @@ function KernaqVerify({ visible, apiKey, backendUrl, country = 'KEN', reference,
950
1501
  ]);
951
1502
  const { requestPermissions } = usePermissions();
952
1503
  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') {
1504
+ // Build the ordered flow from the steps prop
1505
+ const flow = useMemo(()=>{
1506
+ const wanted = stepsProp ?? [
1507
+ 'document',
1508
+ 'selfie',
1509
+ 'liveness'
1510
+ ];
1511
+ const out = [];
1512
+ if (wanted.includes('document')) {
1513
+ out.push('doc-front');
1514
+ out.push('doc-back');
1515
+ }
1516
+ if (wanted.includes('selfie')) out.push('selfie');
1517
+ if (wanted.includes('liveness')) out.push('liveness');
1518
+ out.push('processing');
1519
+ return out;
1520
+ }, [
1521
+ stepsProp
1522
+ ]);
1523
+ const next = useCallback((current)=>{
1524
+ const idx = flow.indexOf(current);
1525
+ const nextStep = flow[idx + 1] ?? 'processing';
1526
+ goto(nextStep);
1527
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1528
+ }, [
1529
+ flow
1530
+ ]);
1531
+ const goto = useCallback(async (s)=>{
1532
+ if (s === 'doc-front' || s === 'doc-back' || s === 'selfie' || s === 'liveness') {
961
1533
  const granted = await requestPermissions();
962
1534
  if (!granted) {
963
1535
  setError(locale.error_camera_denied);
964
1536
  return;
965
1537
  }
966
1538
  }
967
- setStep(next);
968
- if (next === 'processing') {
969
- submit();
970
- }
1539
+ setStep(s);
1540
+ if (s === 'processing') submit();
971
1541
  // eslint-disable-next-line react-hooks/exhaustive-deps
972
1542
  }, [
973
1543
  capture,
974
1544
  locale
975
1545
  ]);
1546
+ const reset = useCallback(()=>{
1547
+ setStep('intro');
1548
+ setCapture({});
1549
+ setResult(null);
1550
+ setError(null);
1551
+ }, []);
976
1552
  const submit = useCallback(async ()=>{
977
- const { documentUri, selfieUri, videoUri } = capture;
978
- if (!documentUri || !selfieUri) {
979
- setError('Missing captures — please restart.');
980
- setStep('result');
981
- return;
982
- }
1553
+ const { docFrontUri, docBackUri, selfieUri, videoUri } = capture;
983
1554
  const base = (backendUrl ?? 'https://api.identity.kernaq.com/v1').replace(/\/$/, '');
984
1555
  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
- });
1556
+ if (docFrontUri) {
1557
+ form.append('document', {
1558
+ uri: docFrontUri,
1559
+ name: 'doc_front.jpg',
1560
+ type: 'image/jpeg'
1561
+ });
1562
+ }
1563
+ if (docBackUri) {
1564
+ form.append('document_back', {
1565
+ uri: docBackUri,
1566
+ name: 'doc_back.jpg',
1567
+ type: 'image/jpeg'
1568
+ });
1569
+ }
1570
+ if (selfieUri) {
1571
+ form.append('selfie', {
1572
+ uri: selfieUri,
1573
+ name: 'selfie.jpg',
1574
+ type: 'image/jpeg'
1575
+ });
1576
+ }
995
1577
  if (videoUri) {
996
1578
  form.append('video', {
997
1579
  uri: videoUri,
@@ -999,7 +1581,7 @@ function KernaqVerify({ visible, apiKey, backendUrl, country = 'KEN', reference,
999
1581
  type: 'video/mp4'
1000
1582
  });
1001
1583
  }
1002
- form.append('document_type', documentTypes[0] ?? 'national_id');
1584
+ form.append('document_type', documentType);
1003
1585
  form.append('country', country);
1004
1586
  form.append('reference', reference ?? `kq_${Date.now()}`);
1005
1587
  if (sandbox) form.append('sandbox', 'true');
@@ -1030,6 +1612,7 @@ function KernaqVerify({ visible, apiKey, backendUrl, country = 'KEN', reference,
1030
1612
  } catch (err) {
1031
1613
  const msg = err.message;
1032
1614
  setError(`${locale.error_network} ${msg}`);
1615
+ setResult(null);
1033
1616
  setStep('result');
1034
1617
  onError?.({
1035
1618
  code: 'network_error',
@@ -1040,76 +1623,56 @@ function KernaqVerify({ visible, apiKey, backendUrl, country = 'KEN', reference,
1040
1623
  }, [
1041
1624
  capture
1042
1625
  ]);
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];
1054
1626
  return /*#__PURE__*/ React.createElement(Modal, {
1055
1627
  visible: visible,
1056
1628
  animationType: "slide",
1057
1629
  presentationStyle: "pageSheet",
1058
- onRequestClose: handleCancel
1630
+ onRequestClose: ()=>{}
1059
1631
  }, /*#__PURE__*/ React.createElement(SafeAreaView, {
1060
1632
  style: [
1061
- s.safeArea,
1633
+ s.safe,
1062
1634
  {
1063
1635
  backgroundColor: theme.bg
1064
1636
  }
1065
1637
  ]
1066
1638
  }, /*#__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
1639
+ style: s.body
1100
1640
  }, step === 'intro' && /*#__PURE__*/ React.createElement(IntroStep, {
1101
1641
  theme: theme,
1102
1642
  locale: locale,
1103
- onStart: ()=>goto('document')
1104
- }), step === 'document' && /*#__PURE__*/ React.createElement(DocumentStep, {
1643
+ onStart: ()=>{
1644
+ const first = flow[0];
1645
+ if (first) goto(first);
1646
+ }
1647
+ }), step === 'doc-front' && /*#__PURE__*/ React.createElement(DocumentStep, {
1105
1648
  theme: theme,
1106
1649
  locale: locale,
1650
+ side: "front",
1107
1651
  onCapture: (uri)=>{
1108
1652
  setCapture((c)=>({
1109
1653
  ...c,
1110
- documentUri: uri
1654
+ docFrontUri: uri
1111
1655
  }));
1112
- goto('selfie');
1656
+ next('doc-front');
1657
+ }
1658
+ }), step === 'doc-back' && /*#__PURE__*/ React.createElement(DocumentStep, {
1659
+ theme: theme,
1660
+ locale: locale,
1661
+ side: "back",
1662
+ frontUri: capture.docFrontUri,
1663
+ onCapture: (uri)=>{
1664
+ setCapture((c)=>({
1665
+ ...c,
1666
+ docBackUri: uri
1667
+ }));
1668
+ next('doc-back');
1669
+ },
1670
+ onRetakeFront: ()=>{
1671
+ setCapture((c)=>({
1672
+ ...c,
1673
+ docFrontUri: undefined
1674
+ }));
1675
+ goto('doc-front');
1113
1676
  }
1114
1677
  }), step === 'selfie' && /*#__PURE__*/ React.createElement(SelfieStep, {
1115
1678
  theme: theme,
@@ -1119,21 +1682,20 @@ function KernaqVerify({ visible, apiKey, backendUrl, country = 'KEN', reference,
1119
1682
  ...c,
1120
1683
  selfieUri: uri
1121
1684
  }));
1122
- goto('liveness');
1123
- },
1124
- onBack: ()=>setStep('document')
1685
+ next('selfie');
1686
+ }
1125
1687
  }), step === 'liveness' && /*#__PURE__*/ React.createElement(LivenessStep, {
1126
1688
  theme: theme,
1127
1689
  locale: locale,
1128
1690
  duration: livenessDuration,
1691
+ taskCount: livenessTaskCount,
1129
1692
  onCapture: (uri)=>{
1130
1693
  setCapture((c)=>({
1131
1694
  ...c,
1132
1695
  videoUri: uri
1133
1696
  }));
1134
- goto('processing');
1135
- },
1136
- onBack: ()=>setStep('selfie')
1697
+ next('liveness');
1698
+ }
1137
1699
  }), step === 'processing' && /*#__PURE__*/ React.createElement(ProcessingStep, {
1138
1700
  theme: theme,
1139
1701
  locale: locale
@@ -1144,55 +1706,43 @@ function KernaqVerify({ visible, apiKey, backendUrl, country = 'KEN', reference,
1144
1706
  error: error,
1145
1707
  onRetry: ()=>{
1146
1708
  reset();
1147
- setStep('intro');
1709
+ goto('intro');
1148
1710
  }
1149
- }))));
1711
+ })), step !== 'processing' && /*#__PURE__*/ React.createElement(View, {
1712
+ style: s.footer
1713
+ }, /*#__PURE__*/ React.createElement(Text, {
1714
+ style: s.footerText
1715
+ }, "Secured by "), /*#__PURE__*/ React.createElement(Text, {
1716
+ style: [
1717
+ s.footerText,
1718
+ {
1719
+ fontWeight: '600',
1720
+ color: theme.text
1721
+ }
1722
+ ]
1723
+ }, "Kernaq"))));
1150
1724
  }
1151
1725
  const styles = (t)=>StyleSheet.create({
1152
- safeArea: {
1726
+ safe: {
1727
+ flex: 1
1728
+ },
1729
+ body: {
1153
1730
  flex: 1
1154
1731
  },
1155
- header: {
1732
+ footer: {
1156
1733
  flexDirection: 'row',
1157
1734
  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
1735
+ justifyContent: 'center',
1736
+ paddingVertical: 10,
1737
+ paddingBottom: Platform.OS === 'ios' ? 4 : 10,
1738
+ borderTopWidth: StyleSheet.hairlineWidth,
1739
+ borderTopColor: t.border
1172
1740
  },
1173
- closeText: {
1174
- fontSize: 18,
1741
+ footerText: {
1742
+ fontSize: 11,
1175
1743
  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
1744
  }
1195
1745
  });
1196
1746
 
1197
- export { KernaqVerify };
1747
+ export { KernaqVerify, analyseImageUri, analyseSelfieFrame };
1198
1748
  //# sourceMappingURL=index.esm.js.map