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