@myazahq/kyc-sdk-react-native 2.0.0

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.
Files changed (87) hide show
  1. package/KycSdkReactNative.podspec +32 -0
  2. package/LICENSE +21 -0
  3. package/README.md +164 -0
  4. package/android/CMakeLists.txt +29 -0
  5. package/android/build.gradle +110 -0
  6. package/android/src/main/AndroidManifest.xml +12 -0
  7. package/android/src/main/cpp/cpp-adapter.cpp +12 -0
  8. package/android/src/main/java/co/myazahq/kyc/rn/MyazaFaceDetectorPackage.kt +36 -0
  9. package/android/src/main/java/co/myazahq/kyc/rn/MyazaStatusBarModule.kt +64 -0
  10. package/android/src/main/java/com/margelo/nitro/myazakyc/HybridMyazaFaceDetector.kt +134 -0
  11. package/app.plugin.js +55 -0
  12. package/expo-module.config.json +6 -0
  13. package/ios/HybridMyazaFaceDetector.swift +233 -0
  14. package/package.json +84 -0
  15. package/react-native.config.js +23 -0
  16. package/src/MyazaKYC.tsx +235 -0
  17. package/src/__tests__/cardCrop.test.ts +39 -0
  18. package/src/__tests__/deviceMetadata.test.ts +34 -0
  19. package/src/__tests__/errors.test.ts +34 -0
  20. package/src/__tests__/flow.test.ts +61 -0
  21. package/src/__tests__/gestureDetector.test.ts +37 -0
  22. package/src/__tests__/liveness.test.ts +112 -0
  23. package/src/__tests__/resolveUrl.test.ts +64 -0
  24. package/src/__tests__/validators.test.ts +38 -0
  25. package/src/assets/liveness/Blink.gif +0 -0
  26. package/src/assets/liveness/Nod.gif +0 -0
  27. package/src/assets/liveness/Smile.gif +0 -0
  28. package/src/assets/liveness/Turn.gif +0 -0
  29. package/src/components/CameraPermissionView.tsx +116 -0
  30. package/src/components/CameraViewfinder.tsx +156 -0
  31. package/src/components/CountryFlag.tsx +52 -0
  32. package/src/components/DocumentCropper.tsx +325 -0
  33. package/src/components/GlassIconButton.tsx +92 -0
  34. package/src/components/Icon.tsx +125 -0
  35. package/src/components/KycFlow.tsx +205 -0
  36. package/src/components/KycSheet.tsx +224 -0
  37. package/src/components/MyazaAlert.tsx +57 -0
  38. package/src/components/MyazaButton.tsx +101 -0
  39. package/src/components/MyazaCard.tsx +48 -0
  40. package/src/components/MyazaInput.tsx +112 -0
  41. package/src/components/MyazaPulseLoader.tsx +71 -0
  42. package/src/components/StatusBarController.tsx +42 -0
  43. package/src/components/StepHeader.tsx +56 -0
  44. package/src/components/StepIndicator.tsx +74 -0
  45. package/src/components/Typography.tsx +69 -0
  46. package/src/components/fonts.ts +49 -0
  47. package/src/components/glass/GlassGroup.tsx +34 -0
  48. package/src/components/glass/GlassSurface.tsx +64 -0
  49. package/src/components/runtime.tsx +93 -0
  50. package/src/components/toast.tsx +154 -0
  51. package/src/components/useBranding.ts +27 -0
  52. package/src/components/useVideoRecorder.ts +122 -0
  53. package/src/config/captureSettings.ts +67 -0
  54. package/src/config/idTypes.ts +79 -0
  55. package/src/config/theme.ts +186 -0
  56. package/src/index.ts +54 -0
  57. package/src/liveness/challengeManager.ts +130 -0
  58. package/src/liveness/faceDetector.ts +79 -0
  59. package/src/liveness/gestureDetector.ts +80 -0
  60. package/src/liveness/speech.ts +66 -0
  61. package/src/liveness/types.ts +99 -0
  62. package/src/liveness/useLiveness.ts +484 -0
  63. package/src/liveness/visionCameraFaceDetector.ts +118 -0
  64. package/src/screens/ConsentStep.tsx +164 -0
  65. package/src/screens/DocumentCaptureStep.tsx +500 -0
  66. package/src/screens/IdInputStep.tsx +79 -0
  67. package/src/screens/IdTypeStep.tsx +142 -0
  68. package/src/screens/LivenessAvatar.tsx +69 -0
  69. package/src/screens/LivenessStep.tsx +615 -0
  70. package/src/screens/SubmittedStep.tsx +177 -0
  71. package/src/services/api.ts +291 -0
  72. package/src/services/cardCrop.ts +52 -0
  73. package/src/services/deviceMetadata.ts +185 -0
  74. package/src/services/errors.ts +92 -0
  75. package/src/services/mediaCompress.ts +129 -0
  76. package/src/services/resolveUrl.ts +98 -0
  77. package/src/services/retry.ts +70 -0
  78. package/src/services/validators.ts +103 -0
  79. package/src/specs/MyazaFaceDetector.nitro.ts +44 -0
  80. package/src/store/kycStore.ts +288 -0
  81. package/src/store/serverConfig.ts +78 -0
  82. package/src/types/config.ts +239 -0
  83. package/src/types/country-flag-icons.d.ts +6 -0
  84. package/src/types/verification.ts +79 -0
  85. package/src/utils/platform.ts +23 -0
  86. package/src/utils/tokens.ts +10 -0
  87. package/src/utils/uuid.ts +31 -0
@@ -0,0 +1,615 @@
1
+ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
+ import { ActivityIndicator, Animated, Dimensions, Easing, Image, View } from 'react-native';
3
+ import Svg, { Ellipse, Path } from 'react-native-svg';
4
+ import {
5
+ Camera,
6
+ useCameraDevice,
7
+ useCameraPermission,
8
+ useFrameOutput,
9
+ usePhotoOutput,
10
+ useVideoOutput,
11
+ } from 'react-native-vision-camera';
12
+ import { runOnJS } from 'react-native-worklets';
13
+
14
+ import { radius, spacing } from '../config/theme';
15
+ import { withRetry } from '../services/retry';
16
+ import { mapToKycError, safeReportError } from '../services/errors';
17
+ import { compressSelfieImage, compressVideo } from '../services/mediaCompress';
18
+ import { KYCError } from '../types/verification';
19
+ import { useKyc, useKycConfig, useTheme } from '../components/runtime';
20
+ import { MyazaText } from '../components/Typography';
21
+ import { MyazaButton } from '../components/MyazaButton';
22
+ import { MyazaPulseLoader } from '../components/MyazaPulseLoader';
23
+ import { useVideoRecorder } from '../components/useVideoRecorder';
24
+ import {
25
+ LIVENESS_VIDEO_BITRATE,
26
+ MAX_VIDEO_BYTES,
27
+ SELFIE_SETTLE_MS,
28
+ VIDEO_CAPTURE_RESOLUTION,
29
+ } from '../config/captureSettings';
30
+ import { Icon } from '../components/Icon';
31
+ import { useToast } from '../components/toast';
32
+ import { CameraPermissionView, CameraUnavailableView } from '../components/CameraPermissionView';
33
+ import { LivenessAvatar } from './LivenessAvatar';
34
+ import { detectFaceOnFrame } from '../liveness/visionCameraFaceDetector';
35
+ import {
36
+ lightingGuidanceText,
37
+ positionGuidanceText,
38
+ useLiveness,
39
+ } from '../liveness/useLiveness';
40
+ import type { LivenessFaceData } from '../liveness/types';
41
+
42
+ // Liveness — the RN mirror of the Flutter/web liveness flow. A front-camera
43
+ // VisionCamera preview drives a frame processor that calls the native face
44
+ // detector ("detectFace": Apple Vision on iOS / Google ML Kit on Android). The
45
+ // per-frame signal is marshalled to JS (runOnJS) and fed to the `useLiveness`
46
+ // state machine (challenges, single-face + position + lighting guards, timeouts,
47
+ // wrong-gesture flash — all mirroring Flutter's LivenessNotifier). When every
48
+ // challenge passes, the SDK auto-captures a selfie + a short liveness video and
49
+ // uploads both eagerly (with retry) on the review screen — same as Flutter.
50
+ //
51
+ // Title/description ("Face Verification" / "Follow the on-screen instructions")
52
+ // live in the SHEET HEADER (KycFlow); this body is the camera + guidance.
53
+ //
54
+ // Anti-spoofing: the selfie is AUTO-captured (never user-triggered) so a static
55
+ // image can't pass; challenges are randomized; lighting/single-face gates block
56
+ // auto-capture in poor conditions. (Brightness is gated server-side too; the
57
+ // client lighting hint here uses the native detector's presence as a proxy and
58
+ // is intentionally conservative — see the lighting note below.)
59
+
60
+ export function LivenessStep(): React.ReactElement {
61
+ const { colors } = useTheme();
62
+ const toast = useToast();
63
+ const config = useKycConfig();
64
+ const api = useKyc((s) => s.api);
65
+ const setMediaId = useKyc((s) => s.setMediaId);
66
+ const nextStep = useKyc((s) => s.nextStep);
67
+
68
+ const device = useCameraDevice('front');
69
+ const { hasPermission, requestPermission } = useCameraPermission();
70
+
71
+ // v5 capture is via output objects attached to <Camera outputs={[...]}> —
72
+ // there is no camera ref / takePhoto(). Selfie → photoOutput.capturePhotoToFile;
73
+ // liveness video → videoOutput.createRecorder().startRecording(); face signals
74
+ // → frameOutput's onFrame worklet.
75
+ const photoOutput = usePhotoOutput({ qualityPrioritization: 'balanced' });
76
+ const videoOutput = useVideoOutput({
77
+ enableAudio: false,
78
+ fileType: 'mp4',
79
+ targetBitRate: LIVENESS_VIDEO_BITRATE,
80
+ targetResolution: VIDEO_CAPTURE_RESOLUTION,
81
+ // iOS only honours targetBitRate/targetResolution on the AVAssetWriter
82
+ // pipeline; without this it uses AVCaptureMovieFileOutput at full quality
83
+ // (clips blew past the 25MB upload cap). This keeps the file small.
84
+ enablePersistentRecorder: true,
85
+ });
86
+ const videoRecorder = useVideoRecorder(videoOutput, !!device);
87
+
88
+ const [perm, setPerm] = useState<'checking' | 'granted' | 'denied'>(
89
+ hasPermission ? 'granted' : 'checking',
90
+ );
91
+ const askedRef = useRef(false);
92
+ const permReportedRef = useRef(false);
93
+
94
+ // Camera-availability grace (a simulator has no front camera).
95
+ const [cameraGrace, setCameraGrace] = useState(false);
96
+ useEffect(() => {
97
+ const t = setTimeout(() => setCameraGrace(true), 1500);
98
+ return () => clearTimeout(t);
99
+ }, []);
100
+ const cameraUnavailable = cameraGrace && !device;
101
+ const permissionDenied = perm === 'denied' && !!device;
102
+
103
+ useEffect(() => {
104
+ if (hasPermission) {
105
+ setPerm('granted');
106
+ return;
107
+ }
108
+ if (askedRef.current) return;
109
+ askedRef.current = true;
110
+ void (async () => {
111
+ const granted = await requestPermission();
112
+ setPerm(granted ? 'granted' : 'denied');
113
+ })();
114
+ }, [hasPermission, requestPermission]);
115
+
116
+ useEffect(() => {
117
+ if (permissionDenied && !permReportedRef.current) {
118
+ permReportedRef.current = true;
119
+ safeReportError(
120
+ config.onError,
121
+ new KYCError('camera_permission_denied', 'Camera access is required for the liveness check.'),
122
+ );
123
+ }
124
+ if (!permissionDenied) permReportedRef.current = false;
125
+ }, [permissionDenied, config.onError]);
126
+
127
+ // ── Selfie capture + eager upload state (mirrors Flutter _handleComplete) ───
128
+ const [selfieUri, setSelfieUri] = useState<string | null>(null);
129
+ const [uploading, setUploading] = useState(false);
130
+ const [retryInfo, setRetryInfo] = useState<{ attempt: number; total: number } | null>(null);
131
+ const [uploadError, setUploadError] = useState<string | null>(null);
132
+ const selfieIdRef = useRef<string | null>(null);
133
+ // Holds the finished liveness-video path (from the recorder) so the review-screen
134
+ // "Try Again" can re-upload it. The recorder itself lives in useVideoRecorder.
135
+ const videoPathRef = useRef<string | null>(null);
136
+
137
+ const uploadSelfieAndVideo = useCallback(
138
+ async (selfie: string, videoPath: string | null) => {
139
+ setUploading(true);
140
+ setUploadError(null);
141
+ setRetryInfo(null);
142
+ const onRetry = (attempt: number, total: number) => setRetryInfo({ attempt, total });
143
+ try {
144
+ const selfieId = await withRetry(
145
+ () => api.upload({ uri: selfie, type: 'image/jpeg' }, 'selfie'),
146
+ { onRetry },
147
+ );
148
+ selfieIdRef.current = selfieId;
149
+ setMediaId('selfie', selfieId);
150
+ // Liveness video is best-effort — a failure here must not block the user.
151
+ if (videoPath) {
152
+ try {
153
+ // Transcode the raw recording down to a small evidence clip first.
154
+ const small = await compressVideo(videoPath);
155
+ const videoId = await withRetry(
156
+ () => api.upload({ uri: small, type: 'video/mp4' }, 'liveness_video', MAX_VIDEO_BYTES),
157
+ { onRetry },
158
+ );
159
+ setMediaId('livenessVideo', videoId);
160
+ } catch {
161
+ /* keep the selfie, drop the video (incl. if it exceeded the 5MB cap) */
162
+ }
163
+ }
164
+ setRetryInfo(null);
165
+ setUploading(false);
166
+ } catch (err) {
167
+ setRetryInfo(null);
168
+ setUploading(false);
169
+ const kycError = mapToKycError(err, 'upload');
170
+ setUploadError(kycError.message);
171
+ toast.show({ variant: 'error', title: 'Upload failed', message: kycError.message });
172
+ safeReportError(config.onError, kycError);
173
+ }
174
+ },
175
+ [api, setMediaId, config.onError, toast],
176
+ );
177
+
178
+ // ── Liveness state machine ─────────────────────────────────────────────────
179
+ // `handleCapture` is created after `liveness` (it calls liveness.markComplete),
180
+ // so the machine gets it via a ref to avoid a definition cycle.
181
+ const captureRef = useRef<() => void>(() => {});
182
+ const liveness = useLiveness({
183
+ voiceGuidance: config.voiceGuidance,
184
+ onReadyToCapture: () => captureRef.current(),
185
+ });
186
+
187
+ const handleCapture = useCallback(async () => {
188
+ if (!device) {
189
+ liveness.markComplete();
190
+ return;
191
+ }
192
+ try {
193
+ // Settle delay before the shutter: the final gesture (e.g. a head turn)
194
+ // leaves the user still moving, so a brief pause lets them steady up for a
195
+ // clean, non-blurred selfie. The "Kindly hold still…" prompt shows during it.
196
+ await new Promise((r) => setTimeout(r, SELFIE_SETTLE_MS));
197
+ // v5: capture the selfie via the photo output (no camera ref). filePath is
198
+ // a plain fs path — prepend file:// for the uploader/compressor.
199
+ const file = await photoOutput.capturePhotoToFile({ flashMode: 'off' }, {});
200
+ const raw = `file://${file.filePath}`;
201
+ const compressed = await compressSelfieImage(raw).catch(() => raw);
202
+ setSelfieUri(compressed);
203
+ liveness.markComplete();
204
+ // Stop the liveness video (best-effort) and start the eager upload. The
205
+ // recorder resolves the finished file path (or null if nothing recorded);
206
+ // keep it so the review-screen retry can re-upload it.
207
+ const videoPath = await videoRecorder.stop();
208
+ videoPathRef.current = videoPath;
209
+ void uploadSelfieAndVideo(compressed, videoPath);
210
+ } catch {
211
+ liveness.reset();
212
+ }
213
+ }, [device, liveness, uploadSelfieAndVideo, photoOutput, videoRecorder]);
214
+
215
+ useEffect(() => {
216
+ captureRef.current = () => void handleCapture();
217
+ }, [handleCapture]);
218
+
219
+ // Record a short liveness video across the challenge phases (best-effort).
220
+ useEffect(() => {
221
+ if (liveness.phase === 'positioning' || liveness.phase === 'challenge') {
222
+ videoRecorder.start();
223
+ }
224
+ }, [liveness.phase, videoRecorder]);
225
+
226
+ // ── Frame output: native face detect → runOnJS into the state machine ───────
227
+ // `liveness` changes each render; keep it in a ref so the (stable) JS handler
228
+ // always reads the latest state-machine callbacks.
229
+ const livenessRef = useRef(liveness);
230
+ livenessRef.current = liveness;
231
+ // A plain JS function the worklet hands the per-frame result to. It is wrapped
232
+ // with `runOnJS(...)` INSIDE the worklet (the canonical react-native-worklets
233
+ // pattern) rather than pre-wrapped — pre-wrapping produced a "non-worklet
234
+ // function" error when called from the frame-processor worklet.
235
+ const handleFace = useCallback((data: LivenessFaceData | null) => {
236
+ const l = livenessRef.current;
237
+ if (!data) {
238
+ l.onNoFace();
239
+ return;
240
+ }
241
+ // Low-light gate (thresholds mirror Flutter's _BrightnessSampler: <62 dark,
242
+ // >200 bright). Feeds the lighting warning + speech and blocks challenges so
243
+ // gestures don't misbehave in poor light.
244
+ l.setLighting(data.brightness < 62 ? 'dark' : data.brightness > 200 ? 'bright' : null);
245
+ if (data.faceCount > 0) l.onFace(data);
246
+ else l.onNoFace();
247
+ }, []);
248
+
249
+ const frameOutput = useFrameOutput({
250
+ // ML Kit (Android) and Apple Vision both consume YUV efficiently.
251
+ pixelFormat: 'yuv',
252
+ // Face detection (esp. Apple Vision) runs longer than one frame interval, so
253
+ // skip incoming frames while the detector is busy instead of queuing them
254
+ // late — keeps the pipeline smooth and quiets the "frame-was-late" drops.
255
+ // Liveness gestures are slow, so a reduced effective rate is fine.
256
+ dropFramesWhileBusy: true,
257
+ onFrame: (frame) => {
258
+ 'worklet';
259
+ const face = detectFaceOnFrame(frame);
260
+ frame.dispose();
261
+ runOnJS(handleFace)(face);
262
+ },
263
+ // Dropped frames are expected here; swallow the per-drop log noise.
264
+ onFrameDropped: () => {
265
+ 'worklet';
266
+ },
267
+ });
268
+
269
+ // ── Error states ────────────────────────────────────────────────────────────
270
+ if (cameraUnavailable) {
271
+ return <CameraUnavailableView />;
272
+ }
273
+ if (permissionDenied) {
274
+ return (
275
+ <CameraPermissionView
276
+ onRetry={() => {
277
+ askedRef.current = false;
278
+ setPerm('checking');
279
+ }}
280
+ />
281
+ );
282
+ }
283
+
284
+ // ── Review (selfie captured) ─────────────────────────────────────────────────
285
+ if (liveness.phase === 'complete' && selfieUri) {
286
+ return (
287
+ <View>
288
+ <SelfiePreview uri={selfieUri} uploading={uploading && !uploadError} />
289
+ {retryInfo && uploading ? (
290
+ <MyazaText variant="bodySmall" color={colors.warning} style={{ textAlign: 'center', marginTop: spacing.sm }}>
291
+ {`Upload failed — retrying (${retryInfo.attempt}/${retryInfo.total})…`}
292
+ </MyazaText>
293
+ ) : null}
294
+ <View style={{ height: spacing.md }} />
295
+ {uploadError ? (
296
+ // The error message itself is shown as a top toast; keep a retry action.
297
+ <MyazaButton
298
+ label="Try Again"
299
+ loading={uploading}
300
+ onPress={() => void uploadSelfieAndVideo(selfieUri, videoPathRef.current)}
301
+ />
302
+ ) : (
303
+ <View style={{ flexDirection: 'row', gap: spacing.md }}>
304
+ <View style={{ flex: 1 }}>
305
+ <MyazaButton
306
+ label="Retake"
307
+ variant="outline"
308
+ leadingIcon="refresh"
309
+ disabled={uploading}
310
+ onPress={() => {
311
+ setSelfieUri(null);
312
+ setUploadError(null);
313
+ selfieIdRef.current = null;
314
+ videoPathRef.current = null;
315
+ liveness.reset();
316
+ }}
317
+ />
318
+ </View>
319
+ <View style={{ flex: 1 }}>
320
+ <MyazaButton
321
+ label="Continue"
322
+ loading={uploading}
323
+ disabled={uploading || !selfieIdRef.current}
324
+ onPress={nextStep}
325
+ />
326
+ </View>
327
+ </View>
328
+ )}
329
+ </View>
330
+ );
331
+ }
332
+
333
+ // ── Failed (timeout / face lost) — mirrors the Flutter failed view: a centred
334
+ // red message and a full-width "Try Again" button (no icon). ───────────────
335
+ if (liveness.phase === 'failed') {
336
+ return (
337
+ <View style={{ alignItems: 'center', justifyContent: 'center', paddingVertical: spacing.xl, gap: spacing.lg }}>
338
+ <MyazaText variant="bodyMedium" color={colors.error} style={{ textAlign: 'center', fontWeight: '500' }}>
339
+ {liveness.failureReason === 'timeout'
340
+ ? "Time's up. Let's try again."
341
+ : 'Face lost. Please try again.'}
342
+ </MyazaText>
343
+ <View style={{ alignSelf: 'stretch' }}>
344
+ <MyazaButton label="Try Again" onPress={liveness.reset} />
345
+ </View>
346
+ </View>
347
+ );
348
+ }
349
+
350
+ // ── Live camera (circular) + ring state machine + guidance ──────────────────
351
+ // Mirrors the web/Flutter SDKs: a fixed circular preview with a thick,
352
+ // colour-state ring, a dashed face-guide ellipse, and pass/capture flashes.
353
+ const phase = liveness.phase;
354
+ const hasWarning =
355
+ liveness.wrongGesture ||
356
+ liveness.multipleFaces ||
357
+ liveness.positionGuidance != null ||
358
+ liveness.lightingGuidance != null;
359
+ const isCamLoading = phase === 'loading';
360
+
361
+ const ringColor = isCamLoading
362
+ ? colors.gray300
363
+ : hasWarning
364
+ ? colors.error
365
+ : phase === 'challenge_passed' || phase === 'capturing'
366
+ ? colors.success
367
+ : phase === 'challenge'
368
+ ? colors.warning
369
+ : liveness.faceDetected
370
+ ? colors.primary
371
+ : colors.gray300;
372
+
373
+ const guidance = resolveGuidance(liveness);
374
+ const instrColor = isCamLoading
375
+ ? colors.textMuted
376
+ : guidance.tone === 'error'
377
+ ? colors.error
378
+ : phase === 'challenge_passed'
379
+ ? colors.success
380
+ : phase === 'challenge'
381
+ ? colors.warning
382
+ : colors.textDark;
383
+
384
+ // Fixed circle, sized to fit the sheet width (caps at 300).
385
+ const CIRCLE = Math.min(Dimensions.get('window').width - spacing.md * 4, 300);
386
+ const lighting = liveness.lightingGuidance;
387
+
388
+ return (
389
+ <View style={{ alignItems: 'center', gap: spacing.md }}>
390
+ {/* Instruction text — above the circle */}
391
+ <MyazaText variant="heading3" style={{ textAlign: 'center', minHeight: 26 }} color={instrColor}>
392
+ {guidance.text}
393
+ </MyazaText>
394
+
395
+ {/* Circular camera with a thick colour-state ring */}
396
+ <View
397
+ style={{
398
+ width: CIRCLE,
399
+ height: CIRCLE,
400
+ borderRadius: CIRCLE / 2,
401
+ borderWidth: 4,
402
+ borderColor: ringColor,
403
+ overflow: 'hidden',
404
+ backgroundColor: '#111111',
405
+ }}
406
+ >
407
+ {device ? (
408
+ <Camera
409
+ style={{ flex: 1 }}
410
+ device={device}
411
+ isActive={perm === 'granted'}
412
+ outputs={[photoOutput, videoOutput, frameOutput]}
413
+ // Mirror the front-camera preview + capture so it reads like a
414
+ // mirror (matches the web SDK's scaleX(-1) video).
415
+ mirrorMode="on"
416
+ />
417
+ ) : (
418
+ <View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
419
+ <ActivityIndicator color="#FFFFFF" />
420
+ </View>
421
+ )}
422
+
423
+ {/* Dashed face-guide ellipse (taller than wide) */}
424
+ <Svg style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0 }} width={CIRCLE} height={CIRCLE}>
425
+ <Ellipse
426
+ cx={CIRCLE / 2}
427
+ cy={CIRCLE * 0.46}
428
+ rx={CIRCLE * 0.3}
429
+ ry={CIRCLE * 0.38}
430
+ fill="none"
431
+ stroke={colors.success}
432
+ strokeWidth={2}
433
+ strokeDasharray="8 5"
434
+ opacity={liveness.faceDetected ? 0.8 : 0.4}
435
+ />
436
+ </Svg>
437
+
438
+ {/* Loading overlay */}
439
+ {isCamLoading ? (
440
+ <View style={[StyleAbsFill, { backgroundColor: 'rgba(0,0,0,0.4)', alignItems: 'center', justifyContent: 'center' }]}>
441
+ <MyazaPulseLoader size={64} />
442
+ </View>
443
+ ) : null}
444
+
445
+ {/* Challenge-passed flash — green wash + checkmark badge */}
446
+ {phase === 'challenge_passed' ? (
447
+ <View style={[StyleAbsFill, { backgroundColor: `${colors.success}33`, alignItems: 'center', justifyContent: 'center' }]}>
448
+ <View style={{ width: 56, height: 56, borderRadius: 28, backgroundColor: colors.success, alignItems: 'center', justifyContent: 'center' }}>
449
+ <Svg width={28} height={28} viewBox="0 0 24 24">
450
+ <Path d="M5 13l4 4L19 7" fill="none" stroke="#FFFFFF" strokeWidth={3} strokeLinecap="round" strokeLinejoin="round" />
451
+ </Svg>
452
+ </View>
453
+ </View>
454
+ ) : null}
455
+
456
+ {/* Capturing flash — white wash + "Got it!" */}
457
+ {phase === 'capturing' ? (
458
+ <View style={[StyleAbsFill, { backgroundColor: 'rgba(255,255,255,0.3)', alignItems: 'center', justifyContent: 'center' }]}>
459
+ <MyazaText variant="heading3" color="#FFFFFF">Got it!</MyazaText>
460
+ </View>
461
+ ) : null}
462
+
463
+ {/* Timeout countdown */}
464
+ {phase === 'challenge' && liveness.timeoutRemaining > 0 ? (
465
+ <View style={{ position: 'absolute', top: spacing.sm, right: spacing.sm, backgroundColor: 'rgba(0,0,0,0.45)', borderRadius: radius.full, paddingHorizontal: 10, paddingVertical: 4 }}>
466
+ <MyazaText variant="bodySmall" color="#FFFFFF">{`${liveness.timeoutRemaining}s`}</MyazaText>
467
+ </View>
468
+ ) : null}
469
+ </View>
470
+
471
+ {/* Lighting warning banner — mirrors Flutter (amber-50/200/800 + lightbulb) */}
472
+ {lighting ? <LightingBanner text={lightingGuidanceText(lighting)} /> : null}
473
+
474
+ {/* Numbered progress dots with connectors */}
475
+ <ProgressDots total={liveness.totalCount} completed={liveness.completedCount} active={phase === 'challenge' || phase === 'positioning'} />
476
+
477
+ {/* Gesture demo avatar */}
478
+ {liveness.activeChallenge ? <LivenessAvatar challenge={liveness.activeChallenge} /> : null}
479
+ </View>
480
+ );
481
+ }
482
+
483
+ const StyleAbsFill = { position: 'absolute' as const, top: 0, left: 0, right: 0, bottom: 0 };
484
+
485
+ // Lighting warning — 1:1 with the Flutter `_LightingWarningBanner`: amber-50 bg,
486
+ // amber-200 border, amber-800 lightbulb + text, fading/sliding in (~300ms).
487
+ const AMBER_50 = '#FFFBEB';
488
+ const AMBER_200 = '#FDE68A';
489
+ const AMBER_800 = '#92400E';
490
+
491
+ function LightingBanner({ text }: { text: string }): React.ReactElement {
492
+ const anim = useRef(new Animated.Value(0)).current;
493
+ useEffect(() => {
494
+ Animated.timing(anim, { toValue: 1, duration: 300, easing: Easing.out(Easing.cubic), useNativeDriver: true }).start();
495
+ }, [anim]);
496
+ return (
497
+ <Animated.View
498
+ style={{
499
+ alignSelf: 'stretch',
500
+ opacity: anim,
501
+ transform: [{ translateY: anim.interpolate({ inputRange: [0, 1], outputRange: [-8, 0] }) }],
502
+ }}
503
+ >
504
+ <View
505
+ style={{
506
+ flexDirection: 'row',
507
+ alignItems: 'flex-start',
508
+ gap: spacing.sm,
509
+ borderRadius: radius.sm,
510
+ borderWidth: 1,
511
+ borderColor: AMBER_200,
512
+ backgroundColor: AMBER_50,
513
+ paddingHorizontal: spacing.sm + 4,
514
+ paddingVertical: 10,
515
+ }}
516
+ >
517
+ <Icon name="lightbulb" size={16} color={AMBER_800} />
518
+ <MyazaText variant="bodySmall" color={AMBER_800} style={{ flexShrink: 1, lineHeight: 17 }}>
519
+ {text}
520
+ </MyazaText>
521
+ </View>
522
+ </Animated.View>
523
+ );
524
+ }
525
+
526
+ /** The single guidance string + tone to show under the camera. */
527
+ function resolveGuidance(l: ReturnType<typeof useLiveness>): { text: string; tone: 'normal' | 'error' } {
528
+ if (l.multipleFaces) return { text: l.instruction, tone: 'error' };
529
+ // Lighting is shown in its own banner (below), not as the main instruction.
530
+ if (l.phase === 'positioning' && !l.faceDetected) {
531
+ return { text: 'Position your face in the circle', tone: 'normal' };
532
+ }
533
+ if (l.positionGuidance) return { text: positionGuidanceText(l.positionGuidance), tone: 'error' };
534
+ if (l.wrongGesture) return { text: 'Wrong gesture — follow the prompt', tone: 'error' };
535
+ return { text: l.instruction, tone: 'normal' };
536
+ }
537
+
538
+ // Numbered progress steps with connectors — mirrors the web/Flutter SDKs:
539
+ // passed = solid green + checkmark, active = green-outlined number, pending =
540
+ // grey number. `active` marks the current (completed-th) step as in-progress.
541
+ function ProgressDots({
542
+ total,
543
+ completed,
544
+ active,
545
+ }: {
546
+ total: number;
547
+ completed: number;
548
+ active: boolean;
549
+ }): React.ReactElement {
550
+ const { colors } = useTheme();
551
+ return (
552
+ <View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'center' }}>
553
+ {Array.from({ length: total }).map((_, i) => {
554
+ const state: 'passed' | 'active' | 'pending' =
555
+ i < completed ? 'passed' : i === completed && active ? 'active' : 'pending';
556
+ return (
557
+ <React.Fragment key={i}>
558
+ <View
559
+ style={{
560
+ width: 28,
561
+ height: 28,
562
+ borderRadius: 14,
563
+ alignItems: 'center',
564
+ justifyContent: 'center',
565
+ backgroundColor: state === 'passed' ? colors.success : state === 'active' ? colors.background : colors.gray300,
566
+ borderWidth: state === 'active' ? 2 : 0,
567
+ borderColor: colors.success,
568
+ }}
569
+ >
570
+ {state === 'passed' ? (
571
+ <Svg width={14} height={14} viewBox="0 0 24 24">
572
+ <Path d="M5 13l4 4L19 7" fill="none" stroke="#FFFFFF" strokeWidth={3} strokeLinecap="round" strokeLinejoin="round" />
573
+ </Svg>
574
+ ) : (
575
+ <MyazaText variant="bodySmall" color={state === 'active' ? colors.success : colors.textMuted} style={{ fontWeight: '700' }}>
576
+ {String(i + 1)}
577
+ </MyazaText>
578
+ )}
579
+ </View>
580
+ {i < total - 1 ? (
581
+ <View style={{ width: 28, height: 2, backgroundColor: i < completed ? colors.success : colors.gray300 }} />
582
+ ) : null}
583
+ </React.Fragment>
584
+ );
585
+ })}
586
+ </View>
587
+ );
588
+ }
589
+
590
+ function SelfiePreview({ uri, uploading }: { uri: string; uploading?: boolean }): React.ReactElement {
591
+ const { colors } = useTheme();
592
+ const S = 200;
593
+ return (
594
+ <View style={{ alignItems: 'center', justifyContent: 'center', alignSelf: 'stretch' }}>
595
+ <View style={{ width: S, height: S, borderRadius: S / 2, borderWidth: 4, backgroundColor: '#111111', borderColor: `${colors.primary}33`, overflow: 'hidden' }}>
596
+ {/* borderRadius repeated on the Image — iOS doesn't reliably clip an Image
597
+ child to a rounded parent (same fix as the header brand bar). */}
598
+ <Image source={{ uri }} style={{ width: '100%', height: '100%', borderRadius: S / 2 }} resizeMode="cover" />
599
+ {/* Standard upload loader inside the preview circle (mirrors web/Flutter). */}
600
+ {uploading ? (
601
+ <View style={[StyleAbsFill, { backgroundColor: 'rgba(0,0,0,0.45)', alignItems: 'center', justifyContent: 'center' }]}>
602
+ <MyazaPulseLoader size={64} />
603
+ </View>
604
+ ) : null}
605
+ </View>
606
+ <View style={{ height: spacing.md }} />
607
+ <MyazaText variant="heading3" style={{ textAlign: 'center' }}>
608
+ Looking good!
609
+ </MyazaText>
610
+ <MyazaText variant="bodySmall" color={colors.textSecondary} style={{ textAlign: 'center' }}>
611
+ Tap Continue to submit, or Retake to try again.
612
+ </MyazaText>
613
+ </View>
614
+ );
615
+ }