@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/README.md +1 -1
- package/dist/index.d.ts +95 -50
- package/dist/index.esm.js +1171 -580
- package/dist/index.esm.js.map +1 -1
- package/dist/index.js +1169 -576
- package/dist/index.js.map +1 -1
- package/package.json +2 -9
- package/src/KernaqVerify.tsx +172 -176
- package/src/index.ts +9 -4
- package/src/locale.ts +32 -29
- package/src/quality.ts +180 -0
- package/src/steps/DocumentStep.tsx +214 -77
- package/src/steps/IntroStep.tsx +58 -37
- package/src/steps/LivenessStep.tsx +225 -125
- package/src/steps/ProcessingStep.tsx +10 -34
- package/src/steps/ResultStep.tsx +99 -35
- package/src/steps/SelfieStep.tsx +165 -96
- package/src/theme.ts +22 -14
- package/src/types.ts +27 -51
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
|
|
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:
|
|
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
|
-
|
|
11
|
-
|
|
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
|
-
|
|
15
|
-
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
processing_title: 'Verifying
|
|
25
|
-
|
|
26
|
-
|
|
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
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
error_quality: 'Image quality too low. Try better lighting or
|
|
35
|
-
error_network: 'Network error.
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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.
|
|
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.
|
|
133
|
-
}, locale.intro_body), /*#__PURE__*/ React.createElement(
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
steps: {
|
|
146
|
-
flexDirection: 'row',
|
|
130
|
+
flex: 1,
|
|
131
|
+
padding: 28,
|
|
147
132
|
justifyContent: 'center',
|
|
148
|
-
gap:
|
|
149
|
-
marginBottom: 28
|
|
150
|
-
},
|
|
151
|
-
step: {
|
|
152
|
-
alignItems: 'center',
|
|
153
|
-
gap: 8
|
|
133
|
+
gap: 16
|
|
154
134
|
},
|
|
155
|
-
|
|
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
|
-
|
|
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
|
-
|
|
144
|
+
textAlign: 'center',
|
|
145
|
+
letterSpacing: -0.4
|
|
177
146
|
},
|
|
178
|
-
|
|
147
|
+
subtitle: {
|
|
179
148
|
fontSize: 14,
|
|
180
149
|
color: t.subtext,
|
|
181
150
|
lineHeight: 21,
|
|
182
|
-
|
|
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:
|
|
188
|
-
|
|
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:
|
|
192
|
-
fontSize:
|
|
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
|
-
|
|
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 [
|
|
247
|
-
const [
|
|
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 (
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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:
|
|
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
|
-
},
|
|
475
|
+
}, title), /*#__PURE__*/ React.createElement(Text, {
|
|
270
476
|
style: s.subtitle
|
|
271
|
-
},
|
|
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:
|
|
485
|
+
uri: frontUri
|
|
274
486
|
},
|
|
275
|
-
style: s.
|
|
487
|
+
style: s.thumb,
|
|
276
488
|
resizeMode: "cover"
|
|
277
|
-
}), /*#__PURE__*/ React.createElement(
|
|
278
|
-
style: s.
|
|
279
|
-
|
|
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
|
-
}
|
|
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.
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
565
|
+
textAlign: 'center',
|
|
566
|
+
letterSpacing: -0.3
|
|
323
567
|
},
|
|
324
568
|
subtitle: {
|
|
325
|
-
fontSize:
|
|
569
|
+
fontSize: 13,
|
|
326
570
|
color: t.subtext,
|
|
327
|
-
lineHeight:
|
|
328
|
-
|
|
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
|
-
|
|
332
|
-
|
|
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: '
|
|
343
|
-
right: '
|
|
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
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
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
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
marginBottom: 10
|
|
670
|
+
hintText: {
|
|
671
|
+
flex: 1,
|
|
672
|
+
fontSize: 12,
|
|
673
|
+
color: t.subtext,
|
|
674
|
+
lineHeight: 16
|
|
363
675
|
},
|
|
364
|
-
|
|
365
|
-
backgroundColor: t.
|
|
366
|
-
borderRadius: t.radius,
|
|
367
|
-
paddingVertical:
|
|
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.
|
|
683
|
+
opacity: 0.5
|
|
374
684
|
},
|
|
375
685
|
btnText: {
|
|
376
|
-
color:
|
|
377
|
-
fontSize:
|
|
686
|
+
color: t.accentInv,
|
|
687
|
+
fontSize: 14,
|
|
378
688
|
fontWeight: '600'
|
|
379
689
|
},
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
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
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
const
|
|
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
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
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),
|
|
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:
|
|
803
|
+
isActive: !capturing,
|
|
435
804
|
photo: true
|
|
436
|
-
}), /*#__PURE__*/ React.createElement(View, {
|
|
437
|
-
style:
|
|
805
|
+
}), /*#__PURE__*/ React.createElement(Animated.View, {
|
|
806
|
+
style: [
|
|
807
|
+
s.oval,
|
|
808
|
+
{
|
|
809
|
+
borderColor: ovalBorder
|
|
810
|
+
}
|
|
811
|
+
],
|
|
438
812
|
pointerEvents: "none"
|
|
439
|
-
})
|
|
813
|
+
}), /*#__PURE__*/ React.createElement(View, {
|
|
440
814
|
style: [
|
|
441
|
-
s.
|
|
442
|
-
|
|
815
|
+
s.pill,
|
|
816
|
+
pillOk && s.pillOk
|
|
443
817
|
],
|
|
444
|
-
|
|
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.
|
|
457
|
-
},
|
|
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
|
-
|
|
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
|
-
|
|
844
|
+
textAlign: 'center',
|
|
845
|
+
letterSpacing: -0.3
|
|
468
846
|
},
|
|
469
847
|
subtitle: {
|
|
470
|
-
fontSize:
|
|
848
|
+
fontSize: 13,
|
|
471
849
|
color: t.subtext,
|
|
472
|
-
lineHeight:
|
|
473
|
-
|
|
850
|
+
lineHeight: 19,
|
|
851
|
+
textAlign: 'center'
|
|
474
852
|
},
|
|
475
853
|
viewport: {
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
borderRadius: t.radius,
|
|
854
|
+
flex: 1,
|
|
855
|
+
borderRadius: t.radius - 4,
|
|
479
856
|
overflow: 'hidden',
|
|
480
857
|
backgroundColor: '#000',
|
|
481
|
-
|
|
482
|
-
position: 'relative',
|
|
483
|
-
alignSelf: 'center',
|
|
484
|
-
maxWidth: 280
|
|
858
|
+
position: 'relative'
|
|
485
859
|
},
|
|
486
|
-
|
|
860
|
+
oval: {
|
|
487
861
|
position: 'absolute',
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
bottom: '
|
|
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
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
borderRadius: t.radius,
|
|
500
|
-
marginBottom: 16,
|
|
869
|
+
pill: {
|
|
870
|
+
position: 'absolute',
|
|
871
|
+
bottom: 14,
|
|
501
872
|
alignSelf: 'center',
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
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
|
-
|
|
521
|
-
|
|
880
|
+
pillOk: {
|
|
881
|
+
backgroundColor: 'rgba(22,163,74,0.8)',
|
|
882
|
+
borderColor: 'rgba(134,239,172,0.5)'
|
|
522
883
|
},
|
|
523
|
-
|
|
884
|
+
pillText: {
|
|
524
885
|
color: '#fff',
|
|
525
|
-
fontSize:
|
|
886
|
+
fontSize: 13,
|
|
526
887
|
fontWeight: '600'
|
|
527
888
|
},
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
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
|
-
|
|
536
|
-
|
|
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
|
-
|
|
539
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
565
|
-
if (!
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
setRecording(
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
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
|
-
},
|
|
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 (
|
|
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
|
-
},
|
|
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:
|
|
1071
|
+
audio: false
|
|
609
1072
|
}), /*#__PURE__*/ React.createElement(View, {
|
|
610
1073
|
style: s.ovalGuide,
|
|
611
1074
|
pointerEvents: "none"
|
|
612
|
-
}), recording && /*#__PURE__*/ React.createElement(
|
|
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.
|
|
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.
|
|
627
|
-
},
|
|
628
|
-
style: s.
|
|
629
|
-
onPress:
|
|
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
|
-
},
|
|
634
|
-
style: s.
|
|
635
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1133
|
+
textAlign: 'center',
|
|
1134
|
+
letterSpacing: -0.3
|
|
650
1135
|
},
|
|
651
1136
|
subtitle: {
|
|
652
|
-
fontSize:
|
|
1137
|
+
fontSize: 13,
|
|
653
1138
|
color: t.subtext,
|
|
654
|
-
lineHeight:
|
|
655
|
-
|
|
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
|
-
|
|
659
|
-
|
|
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
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
bottom: '
|
|
1168
|
+
left: '21%',
|
|
1169
|
+
right: '21%',
|
|
1170
|
+
top: '6%',
|
|
1171
|
+
bottom: '6%',
|
|
674
1172
|
borderWidth: 2,
|
|
675
|
-
borderColor: 'rgba(255,255,255,0.
|
|
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
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
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:
|
|
692
|
-
height:
|
|
693
|
-
borderRadius:
|
|
694
|
-
backgroundColor: '#
|
|
1191
|
+
width: 7,
|
|
1192
|
+
height: 7,
|
|
1193
|
+
borderRadius: 3.5,
|
|
1194
|
+
backgroundColor: '#ef4444'
|
|
695
1195
|
},
|
|
696
1196
|
recText: {
|
|
697
|
-
color: '#
|
|
698
|
-
fontSize:
|
|
699
|
-
fontWeight: '700'
|
|
1197
|
+
color: '#ef4444',
|
|
1198
|
+
fontSize: 11,
|
|
1199
|
+
fontWeight: '700',
|
|
1200
|
+
letterSpacing: 0.5
|
|
700
1201
|
},
|
|
701
|
-
|
|
1202
|
+
overlay: {
|
|
702
1203
|
position: 'absolute',
|
|
703
|
-
bottom:
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
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
|
-
|
|
1216
|
+
gap: 8
|
|
711
1217
|
},
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
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
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
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
|
-
|
|
725
|
-
backgroundColor: t.
|
|
726
|
-
borderRadius: t.radius,
|
|
727
|
-
paddingVertical:
|
|
1241
|
+
btn: {
|
|
1242
|
+
backgroundColor: t.accent,
|
|
1243
|
+
borderRadius: t.radius - 4,
|
|
1244
|
+
paddingVertical: 12,
|
|
1245
|
+
flexDirection: 'row',
|
|
728
1246
|
alignItems: 'center',
|
|
729
|
-
|
|
730
|
-
borderColor: t.border
|
|
1247
|
+
justifyContent: 'center'
|
|
731
1248
|
},
|
|
732
1249
|
btnText: {
|
|
733
|
-
color:
|
|
734
|
-
fontSize:
|
|
1250
|
+
color: t.accentInv,
|
|
1251
|
+
fontSize: 14,
|
|
735
1252
|
fontWeight: '600'
|
|
736
1253
|
},
|
|
737
|
-
|
|
738
|
-
color: t.
|
|
739
|
-
fontSize:
|
|
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(
|
|
769
|
-
|
|
770
|
-
|
|
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
|
-
},
|
|
1272
|
+
}, "This usually takes under 10 seconds."));
|
|
784
1273
|
}
|
|
785
1274
|
const styles$2 = (t)=>StyleSheet.create({
|
|
786
1275
|
container: {
|
|
787
|
-
|
|
1276
|
+
flex: 1,
|
|
788
1277
|
alignItems: 'center',
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
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:
|
|
1283
|
+
fontSize: 16,
|
|
802
1284
|
fontWeight: '700',
|
|
803
1285
|
color: t.text,
|
|
804
1286
|
textAlign: 'center'
|
|
805
1287
|
},
|
|
806
1288
|
subtitle: {
|
|
807
|
-
fontSize:
|
|
1289
|
+
fontSize: 13,
|
|
808
1290
|
color: t.subtext,
|
|
809
1291
|
textAlign: 'center'
|
|
810
1292
|
}
|
|
811
1293
|
});
|
|
812
1294
|
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
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: '#
|
|
1302
|
+
pass: '#dcfce7',
|
|
820
1303
|
fail: '#fee2e2',
|
|
821
|
-
review: '#
|
|
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.
|
|
1318
|
+
s.iconBox,
|
|
831
1319
|
{
|
|
832
|
-
backgroundColor:
|
|
1320
|
+
backgroundColor: ICON_BG.fail
|
|
833
1321
|
}
|
|
834
1322
|
]
|
|
835
1323
|
}, /*#__PURE__*/ React.createElement(Text, {
|
|
836
|
-
style:
|
|
837
|
-
|
|
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
|
-
},
|
|
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.
|
|
1381
|
+
s.iconBox,
|
|
864
1382
|
{
|
|
865
|
-
backgroundColor: ICON_BG[
|
|
1383
|
+
backgroundColor: ICON_BG[v]
|
|
866
1384
|
}
|
|
867
1385
|
]
|
|
868
1386
|
}, /*#__PURE__*/ React.createElement(Text, {
|
|
869
|
-
style:
|
|
870
|
-
|
|
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[
|
|
1395
|
+
}, titles[v]), /*#__PURE__*/ React.createElement(Text, {
|
|
873
1396
|
style: s.body
|
|
874
|
-
}, bodies[
|
|
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
|
-
},
|
|
1416
|
+
}, "Try again")));
|
|
881
1417
|
}
|
|
882
1418
|
const styles$1 = (t)=>StyleSheet.create({
|
|
883
1419
|
container: {
|
|
884
|
-
|
|
1420
|
+
flex: 1,
|
|
1421
|
+
padding: 28,
|
|
885
1422
|
alignItems: 'center',
|
|
886
|
-
gap:
|
|
1423
|
+
gap: 14,
|
|
1424
|
+
justifyContent: 'center'
|
|
887
1425
|
},
|
|
888
|
-
|
|
889
|
-
width:
|
|
890
|
-
height:
|
|
891
|
-
borderRadius:
|
|
1426
|
+
iconBox: {
|
|
1427
|
+
width: 52,
|
|
1428
|
+
height: 52,
|
|
1429
|
+
borderRadius: 14,
|
|
892
1430
|
alignItems: 'center',
|
|
893
1431
|
justifyContent: 'center',
|
|
894
|
-
marginBottom:
|
|
1432
|
+
marginBottom: 4
|
|
895
1433
|
},
|
|
896
1434
|
icon: {
|
|
897
|
-
fontSize:
|
|
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:
|
|
1446
|
+
fontSize: 13.5,
|
|
907
1447
|
color: t.subtext,
|
|
908
1448
|
textAlign: 'center',
|
|
909
|
-
lineHeight:
|
|
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
|
-
|
|
1480
|
+
width: '100%',
|
|
913
1481
|
backgroundColor: t.accent,
|
|
914
|
-
borderRadius: t.radius,
|
|
915
|
-
paddingVertical:
|
|
916
|
-
|
|
917
|
-
|
|
1482
|
+
borderRadius: t.radius - 4,
|
|
1483
|
+
paddingVertical: 12,
|
|
1484
|
+
alignItems: 'center',
|
|
1485
|
+
marginTop: 4
|
|
918
1486
|
},
|
|
919
1487
|
btnText: {
|
|
920
|
-
color:
|
|
921
|
-
fontSize:
|
|
1488
|
+
color: t.accentInv,
|
|
1489
|
+
fontSize: 14,
|
|
922
1490
|
fontWeight: '600'
|
|
923
1491
|
}
|
|
924
1492
|
});
|
|
925
1493
|
|
|
926
|
-
|
|
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
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
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(
|
|
968
|
-
if (
|
|
969
|
-
|
|
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 {
|
|
978
|
-
|
|
979
|
-
|
|
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
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
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',
|
|
1625
|
+
form.append('document_type', documentType);
|
|
1003
1626
|
form.append('country', country);
|
|
1004
1627
|
form.append('reference', reference ?? `kq_${Date.now()}`);
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
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}
|
|
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
|
-
|
|
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:
|
|
1671
|
+
onRequestClose: ()=>{}
|
|
1059
1672
|
}, /*#__PURE__*/ React.createElement(SafeAreaView, {
|
|
1060
1673
|
style: [
|
|
1061
|
-
s.
|
|
1674
|
+
s.safe,
|
|
1062
1675
|
{
|
|
1063
1676
|
backgroundColor: theme.bg
|
|
1064
1677
|
}
|
|
1065
1678
|
]
|
|
1066
1679
|
}, /*#__PURE__*/ React.createElement(View, {
|
|
1067
|
-
style: s.
|
|
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: ()=>
|
|
1104
|
-
|
|
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
|
-
|
|
1705
|
+
setCaptureSync((c)=>({
|
|
1706
|
+
...c,
|
|
1707
|
+
docBackUri: uri
|
|
1708
|
+
}));
|
|
1709
|
+
next('doc-back');
|
|
1710
|
+
},
|
|
1711
|
+
onRetakeFront: ()=>{
|
|
1712
|
+
setCaptureSync((c)=>({
|
|
1109
1713
|
...c,
|
|
1110
|
-
|
|
1714
|
+
docFrontUri: undefined
|
|
1111
1715
|
}));
|
|
1112
|
-
goto('
|
|
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
|
-
|
|
1722
|
+
setCaptureSync((c)=>({
|
|
1119
1723
|
...c,
|
|
1120
1724
|
selfieUri: uri
|
|
1121
1725
|
}));
|
|
1122
|
-
|
|
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
|
-
|
|
1734
|
+
setCaptureSync((c)=>({
|
|
1131
1735
|
...c,
|
|
1132
1736
|
videoUri: uri
|
|
1133
1737
|
}));
|
|
1134
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1767
|
+
safe: {
|
|
1768
|
+
flex: 1
|
|
1769
|
+
},
|
|
1770
|
+
body: {
|
|
1153
1771
|
flex: 1
|
|
1154
1772
|
},
|
|
1155
|
-
|
|
1773
|
+
footer: {
|
|
1156
1774
|
flexDirection: 'row',
|
|
1157
1775
|
alignItems: 'center',
|
|
1158
|
-
justifyContent: '
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
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
|
-
|
|
1174
|
-
fontSize:
|
|
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
|