@myazahq/kyc-sdk-react-native 2.5.0 → 2.6.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 (167) hide show
  1. package/README.md +151 -5
  2. package/android/src/main/java/com/margelo/nitro/myazakyc/HybridMyazaFaceDetector.kt +6 -4
  3. package/app.plugin.js +89 -8
  4. package/ios/HybridMyazaFaceDetector.swift +8 -3
  5. package/package.json +14 -2
  6. package/src/MyazaBiometricAuth.tsx +119 -0
  7. package/src/components/CountryField.tsx +8 -0
  8. package/src/components/CountryRegionPicker.tsx +184 -0
  9. package/src/components/DialCodePicker.tsx +47 -68
  10. package/src/components/DialCodeRow.tsx +102 -0
  11. package/src/components/FramedMapPicker.tsx +122 -0
  12. package/src/components/GeoBadge.tsx +34 -0
  13. package/src/components/Icon.tsx +2 -185
  14. package/src/components/KycFlow.tsx +60 -32
  15. package/src/components/LineSkeleton.tsx +100 -0
  16. package/src/components/MapChrome.tsx +67 -0
  17. package/src/components/MapPinMarker.tsx +54 -0
  18. package/src/components/MapPinPicker.tsx +179 -0
  19. package/src/components/MilestoneTrack.tsx +155 -0
  20. package/src/components/MyazaInput.tsx +17 -1
  21. package/src/components/OptionRow.tsx +22 -3
  22. package/src/components/PhoneNumberInput.tsx +8 -0
  23. package/src/components/PoweredBy.tsx +5 -5
  24. package/src/components/PresenceBlocks.tsx +153 -0
  25. package/src/components/StepView.tsx +17 -0
  26. package/src/components/StickyActions.tsx +56 -0
  27. package/src/components/dialCodeRows.ts +64 -0
  28. package/src/components/icon-map.ts +176 -0
  29. package/src/components/stepHeaderMeta.tsx +42 -4
  30. package/src/config/addressCollection.ts +125 -0
  31. package/src/config/biometricOptions.ts +100 -0
  32. package/src/config/business.ts +20 -1
  33. package/src/config/businessSteps.ts +8 -2
  34. package/src/config/consentStep.ts +19 -0
  35. package/src/config/proofOfAddress.ts +54 -11
  36. package/src/config/regions.ts +24 -0
  37. package/src/config/stepOrder.ts +79 -5
  38. package/src/config/uploadLimits.ts +35 -0
  39. package/src/config/workflowMerge.ts +11 -5
  40. package/src/index.ts +54 -1
  41. package/src/lib/address-current-location.ts +171 -0
  42. package/src/lib/address-field-modes.ts +119 -0
  43. package/src/lib/address-flow.ts +172 -0
  44. package/src/lib/address-helpers.ts +48 -0
  45. package/src/lib/address-line.ts +76 -0
  46. package/src/lib/address-pin-move.ts +97 -0
  47. package/src/lib/address-step-recovery.ts +63 -0
  48. package/src/lib/authed-image.ts +78 -0
  49. package/src/lib/biometric-auth.ts +52 -0
  50. package/src/lib/biometric-copy.ts +51 -0
  51. package/src/lib/captureRing.ts +83 -0
  52. package/src/lib/country-adoption.ts +89 -0
  53. package/src/lib/inferred-country.ts +65 -0
  54. package/src/lib/livenessLayout.ts +49 -0
  55. package/src/lib/map-frame.ts +159 -0
  56. package/src/lib/map-tiles.ts +143 -0
  57. package/src/lib/poa-country-gate.ts +27 -0
  58. package/src/lib/result-copy.ts +116 -0
  59. package/src/lib/result-wait.ts +53 -0
  60. package/src/lib/review-map-surface.ts +26 -0
  61. package/src/lib/scope.ts +31 -0
  62. package/src/lib/selfie-upload-wait.ts +79 -0
  63. package/src/lib/street-view-fov.ts +42 -0
  64. package/src/lib/webview-available.ts +47 -0
  65. package/src/liveness/useLiveness.ts +6 -1
  66. package/src/presence/background-math.ts +100 -0
  67. package/src/presence/background-store.ts +82 -0
  68. package/src/presence/background.ts +165 -0
  69. package/src/presence/foreground-service.ts +193 -0
  70. package/src/presence/fs.ts +51 -0
  71. package/src/presence/geofence.ts +41 -0
  72. package/src/presence/math.ts +44 -0
  73. package/src/presence/post.ts +40 -0
  74. package/src/presence/report.ts +87 -0
  75. package/src/presence/sampler.ts +110 -0
  76. package/src/presence/status.ts +92 -0
  77. package/src/presence/store.ts +94 -0
  78. package/src/presence/tier.ts +36 -0
  79. package/src/presence/watch-wait.ts +81 -0
  80. package/src/screens/AddressCountryControl.tsx +115 -0
  81. package/src/screens/BusinessDetailsFields.tsx +3 -0
  82. package/src/screens/BusinessDetailsStep.tsx +2 -0
  83. package/src/screens/BusinessDocumentSlot.tsx +3 -2
  84. package/src/screens/BusinessDocumentsStep.tsx +4 -55
  85. package/src/screens/CompanyInfoFields.tsx +6 -1
  86. package/src/screens/ConsentStep.tsx +6 -17
  87. package/src/screens/ContactActions.tsx +54 -0
  88. package/src/screens/ContactDestinationField.tsx +4 -0
  89. package/src/screens/ContactEntryPanel.tsx +75 -0
  90. package/src/screens/ContactVerificationStep.tsx +26 -56
  91. package/src/screens/CountrySelectStep.tsx +13 -147
  92. package/src/screens/LivenessAvatar.tsx +15 -3
  93. package/src/screens/LivenessStep.tsx +154 -52
  94. package/src/screens/PoaDocumentTypeList.tsx +66 -0
  95. package/src/screens/ProofOfAddressParts.tsx +151 -0
  96. package/src/screens/ProofOfAddressStep.tsx +58 -99
  97. package/src/screens/SubmittedBadge.tsx +25 -0
  98. package/src/screens/SubmittedError.tsx +64 -0
  99. package/src/screens/SubmittedResult.tsx +117 -0
  100. package/src/screens/SubmittedStep.tsx +71 -164
  101. package/src/screens/SubmittedSuccess.tsx +127 -0
  102. package/src/screens/SubmittedWaiting.tsx +45 -0
  103. package/src/screens/address/AddressEntranceStep.tsx +161 -0
  104. package/src/screens/address/AddressIntroGate.tsx +148 -0
  105. package/src/screens/address/AddressMapStub.tsx +50 -0
  106. package/src/screens/address/AddressPinStep.tsx +178 -0
  107. package/src/screens/address/AddressReviewStep.tsx +162 -0
  108. package/src/screens/address/AddressSandboxTabs.tsx +158 -0
  109. package/src/screens/address/AddressSearchField.tsx +115 -0
  110. package/src/screens/address/AddressSearchStep.tsx +94 -0
  111. package/src/screens/address/CurrentLocationRow.tsx +135 -0
  112. package/src/screens/address/DetailsSheet.tsx +175 -0
  113. package/src/screens/address/DetailsSheetFields.tsx +184 -0
  114. package/src/screens/address/EntranceDropzone.tsx +178 -0
  115. package/src/screens/address/EntranceFraming.tsx +66 -0
  116. package/src/screens/address/EntrancePills.tsx +60 -0
  117. package/src/screens/address/FramedStreetView.tsx +155 -0
  118. package/src/screens/address/IntroDisclosures.tsx +210 -0
  119. package/src/screens/address/LabelDecisionRow.tsx +113 -0
  120. package/src/screens/address/PinSummaryRow.tsx +105 -0
  121. package/src/screens/address/ReviewAddressBand.tsx +111 -0
  122. package/src/screens/address/ReviewEntranceThumbs.tsx +64 -0
  123. package/src/screens/address/ReviewMapPicture.tsx +106 -0
  124. package/src/screens/address/SearchResults.tsx +105 -0
  125. package/src/screens/address/SearchScreen.tsx +176 -0
  126. package/src/screens/address/SkipForNow.tsx +43 -0
  127. package/src/screens/address/StreetViewChrome.tsx +81 -0
  128. package/src/screens/address/detail-values.ts +22 -0
  129. package/src/screens/address/fix-source.ts +27 -0
  130. package/src/screens/address/index.ts +8 -0
  131. package/src/screens/address/meta.ts +44 -0
  132. package/src/screens/address/use-address-flow.ts +200 -0
  133. package/src/screens/address/use-label-pin.ts +80 -0
  134. package/src/screens/address/use-pin-actions.ts +192 -0
  135. package/src/screens/biometric/BiometricAuthFlow.tsx +170 -0
  136. package/src/screens/consent/model.ts +79 -10
  137. package/src/screens/liveness/CaptureRing.tsx +91 -0
  138. package/src/screens/liveness/LivenessCamera.tsx +63 -0
  139. package/src/screens/liveness/LivenessHandover.tsx +43 -0
  140. package/src/screens/liveness/LivenessOutcome.tsx +4 -3
  141. package/src/screens/liveness/SelfiePreview.tsx +13 -4
  142. package/src/screens/liveness/ShutterFlash.tsx +25 -0
  143. package/src/screens/liveness/index.ts +4 -0
  144. package/src/screens/liveness/useSelfieUpload.ts +23 -3
  145. package/src/screens/nfc/NfcScanIllustration.tsx +7 -18
  146. package/src/screens/useAddressPhotoAttach.ts +77 -0
  147. package/src/screens/useBusinessDocumentAttach.ts +93 -0
  148. package/src/screens/usePoaAttach.ts +12 -15
  149. package/src/services/api-biometric.ts +37 -0
  150. package/src/services/api-types-biometric.ts +41 -0
  151. package/src/services/api-types.ts +93 -1
  152. package/src/services/api-verify-types.ts +37 -0
  153. package/src/services/api.ts +133 -3
  154. package/src/services/deviceMetadata.ts +1 -1
  155. package/src/services/location.ts +234 -0
  156. package/src/services/uploadErrors.ts +30 -0
  157. package/src/services/workflowGate.ts +7 -0
  158. package/src/store/address.ts +97 -0
  159. package/src/store/derive.ts +29 -4
  160. package/src/store/kycStore.ts +112 -3
  161. package/src/store/serverConfig.ts +18 -0
  162. package/src/store/session.ts +23 -4
  163. package/src/store/state.ts +134 -0
  164. package/src/store/submit.ts +18 -1
  165. package/src/types/config.ts +48 -1
  166. package/src/types/verification.ts +12 -0
  167. package/src/types/workflow.ts +97 -1
@@ -17,9 +17,21 @@ const GIFS: Record<LivenessChallenge, number> = {
17
17
  smile: require('../assets/liveness/Smile.gif'),
18
18
  };
19
19
 
20
- const SIZE = 96;
20
+ /** The badge on a tall phone; the step hands a smaller size down on a short one
21
+ * (lib/livenessLayout), so the gesture stays on screen beside the circle. */
22
+ const DEFAULT_SIZE = 96;
23
+ const DEFAULT_ICON = 40;
21
24
 
22
- export function LivenessAvatar({ challenge }: { challenge: LivenessChallenge }): React.ReactElement {
25
+ export function LivenessAvatar({
26
+ challenge,
27
+ size = DEFAULT_SIZE,
28
+ iconSize = DEFAULT_ICON,
29
+ }: {
30
+ challenge: LivenessChallenge;
31
+ size?: number;
32
+ iconSize?: number;
33
+ }): React.ReactElement {
34
+ const SIZE = size;
23
35
  const { colors } = useTheme();
24
36
  const [displayed, setDisplayed] = useState<LivenessChallenge>(challenge);
25
37
  const [broken, setBroken] = useState(false);
@@ -55,7 +67,7 @@ export function LivenessAvatar({ challenge }: { challenge: LivenessChallenge }):
55
67
  }}
56
68
  >
57
69
  {broken ? (
58
- <Icon name="scan-face" size={40} color={colors.primary} />
70
+ <Icon name="scan-face" size={iconSize} color={colors.primary} />
59
71
  ) : (
60
72
  <Image
61
73
  source={GIFS[displayed]}
@@ -1,8 +1,7 @@
1
1
  import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
- import { ActivityIndicator, Animated, Dimensions, Easing, Image, View } from 'react-native';
2
+ import { Animated, Easing, Image, useWindowDimensions, View } from 'react-native';
3
3
  import Svg, { Ellipse, Path } from 'react-native-svg';
4
4
  import {
5
- Camera,
6
5
  useCameraDevice,
7
6
  useCameraPermission,
8
7
  useFrameOutput,
@@ -35,9 +34,11 @@ import { useFaceModelReady } from '../liveness/useModelReady';
35
34
  import { ReadyPrimer } from '../components/ReadyPrimer';
36
35
  import { READY_LIVENESS } from '../components/readyPrimerContent';
37
36
  import { LivenessAvatar } from './LivenessAvatar';
37
+ import { livenessLayout } from '../lib/livenessLayout';
38
38
  import { detectFaceOnFrame } from '../liveness/visionCameraFaceDetector';
39
39
  import { buildLivenessIntegrity } from '../liveness/integritySignals';
40
40
  import { DEFAULT_LIVENESS_CONFIG } from '../liveness/types';
41
+ import { livenessProgress } from '../lib/captureRing';
41
42
  import { useFlashSequence } from './useFlashSequence';
42
43
  import { useFlashHole } from './useFlashHole';
43
44
  import {
@@ -45,13 +46,19 @@ import {
45
46
  INSTRUCTION_HEIGHT,
46
47
  LightingBanner,
47
48
  ProgressDots,
49
+ CaptureRing,
50
+ ShutterFlash,
48
51
  SelfiePreview,
49
52
  StyleAbsFill,
50
53
  resolveGuidance,
51
54
  useSelfieUpload,
55
+ LivenessCamera,
52
56
  LivenessComplete,
53
57
  LivenessFailed,
58
+ LivenessHandover,
59
+ useSelfieAutoAdvance,
54
60
  } from './liveness';
61
+ import { showsSelfieReview } from '../config/biometricOptions';
55
62
  import {
56
63
  lightingGuidanceText,
57
64
  positionGuidanceText,
@@ -85,6 +92,7 @@ export function LivenessStep(): React.ReactElement {
85
92
  const setMediaId = useKyc((s) => s.setMediaId);
86
93
  const setCaptureIntegrity = useKyc((s) => s.setCaptureIntegrity);
87
94
  const nextStep = useKyc((s) => s.nextStep);
95
+ const clearSelfie = useKyc((s) => s.clearSelfie);
88
96
 
89
97
  const device = useCameraDevice('front');
90
98
  const { hasPermission, requestPermission } = useCameraPermission();
@@ -176,6 +184,16 @@ export function LivenessStep(): React.ReactElement {
176
184
  uploadSelfieAndVideo,
177
185
  } = upload;
178
186
 
187
+ // A stored selfie with no uploaded mediaId is an interrupted upload (failed,
188
+ // or the user left mid-flight): resume it once on mount. Idempotent — with a
189
+ // mediaId there is nothing to do.
190
+ useEffect(() => {
191
+ if (selfieUri && !selfieIdRef.current && !uploading && !uploadError) {
192
+ void uploadSelfieAndVideo(selfieUri, videoPathRef.current);
193
+ }
194
+ // eslint-disable-next-line react-hooks/exhaustive-deps
195
+ }, []);
196
+
179
197
  // ── Liveness state machine ─────────────────────────────────────────────────
180
198
  // `handleCapture` is created after `liveness` (it calls liveness.markComplete),
181
199
  // so the machine gets it via a ref to avoid a definition cycle.
@@ -274,6 +292,66 @@ export function LivenessStep(): React.ReactElement {
274
292
  // always reads the latest state-machine callbacks.
275
293
  const livenessRef = useRef(liveness);
276
294
  livenessRef.current = liveness;
295
+
296
+ // The circle and the gesture avatar are sized from the WINDOW, not the width
297
+ // alone, so a short phone keeps the avatar on screen (lib/livenessLayout).
298
+ // A hook, so it lives up here with the ring refs, above the gates below.
299
+ const windowSize = useWindowDimensions();
300
+ const layout = livenessLayout(windowSize);
301
+
302
+ // Progress for the ring, read every frame from a ref — never from render.
303
+ // Declared HERE, above the early returns below: a hook after a conditional
304
+ // return renders "more hooks than during the previous render" the moment the
305
+ // gate dismisses, which is exactly what the ready primer does on its way out.
306
+ const phaseStartedRef = useRef(Date.now());
307
+ const phaseKeyRef = useRef(`${liveness.phase}:${liveness.completedCount}`);
308
+ const phaseKey = `${liveness.phase}:${liveness.completedCount}`;
309
+ if (phaseKeyRef.current !== phaseKey) {
310
+ phaseKeyRef.current = phaseKey;
311
+ phaseStartedRef.current = Date.now();
312
+ }
313
+ const timeoutRef = useRef(
314
+ liveness.timeoutRemaining || DEFAULT_LIVENESS_CONFIG.timeoutPerChallenge,
315
+ );
316
+ if (liveness.phase === 'challenge' && liveness.timeoutRemaining > timeoutRef.current) {
317
+ timeoutRef.current = liveness.timeoutRemaining;
318
+ }
319
+ const getRingTarget = useCallback(() => {
320
+ const l = livenessRef.current;
321
+ return livenessProgress({
322
+ phase: l.phase,
323
+ completedCount: l.completedCount,
324
+ totalCount: l.totalCount,
325
+ elapsedInPhase: (Date.now() - phaseStartedRef.current) / 1000,
326
+ challengeTimeout: timeoutRef.current,
327
+ });
328
+ }, []);
329
+
330
+ // The selfie URI lands BEFORE the machine reaches `complete` (capture →
331
+ // compress → setSelfieUri → markComplete), and the review is gated on that
332
+ // URI — so the camera circle used to unmount on the very frame the ring's
333
+ // target reached the end: the close, the green, the shutter, none of it
334
+ // was ever seen. Hold the frame for the closing beat, then hand over.
335
+ const [closeSeen, setCloseSeen] = useState(false);
336
+ useEffect(() => {
337
+ if (liveness.phase !== 'complete') return;
338
+ const t = setTimeout(() => setCloseSeen(true), 700);
339
+ return () => clearTimeout(t);
340
+ }, [liveness.phase]);
341
+ const holdingForClose =
342
+ (liveness.phase === 'capturing' || liveness.phase === 'complete') && !closeSeen;
343
+ // The biometric scopes skip the review by default: hand over the moment the
344
+ // ring has closed, WITHOUT waiting for the upload (see LivenessHandover).
345
+ // The upload reports to the store and the submitted step waits on it, so
346
+ // the person sees one loading screen rather than one per step. A restored
347
+ // selfie (media id, no local file) is ready at once; an upload that already
348
+ // failed keeps the review, whose Try Again is the recovery.
349
+ const selfieReview = showsSelfieReview(config);
350
+ useSelfieAutoAdvance({
351
+ enabled: !selfieReview,
352
+ ready: (!!selfieUri || !!selfieIdRef.current) && !holdingForClose && !uploadError,
353
+ onAdvance: nextStep,
354
+ });
277
355
  // A plain JS function the worklet hands the per-frame result to. It is wrapped
278
356
  // with `runOnJS(...)` INSIDE the worklet (the canonical react-native-worklets
279
357
  // pattern) rather than pre-wrapped — pre-wrapping produced a "non-worklet
@@ -353,6 +431,38 @@ export function LivenessStep(): React.ReactElement {
353
431
  },
354
432
  });
355
433
 
434
+ // ── Review (selfie already exists) ──────────────────────────────────────────
435
+ // Checked BEFORE every capture-flow gate (ready primer, permission, model):
436
+ // a selfie captured on an earlier visit — or a restored session where only
437
+ // the uploaded mediaId survived — means there is nothing to capture, so the
438
+ // step opens on review exactly as the document steps reopen on theirs.
439
+ // A restored selfie (earlier visit) opens straight on review; the one being
440
+ // captured RIGHT NOW waits for the ring to close first.
441
+ if (selfieIdRef.current || (selfieUri && !holdingForClose)) {
442
+ // Review hidden: the hook above advances on this render; the view is the
443
+ // submitted step's own loading screen, so the hand-over is invisible. A
444
+ // FAILED upload still gets the review, for its retry.
445
+ if (!selfieReview && !uploadError) {
446
+ return <LivenessHandover />;
447
+ }
448
+ return (
449
+ <LivenessComplete
450
+ selfieUri={selfieUri}
451
+ upload={upload}
452
+ onRetake={() => {
453
+ setSelfieUri(null);
454
+ setCloseSeen(false);
455
+ setUploadError(null);
456
+ selfieIdRef.current = null;
457
+ videoPathRef.current = null;
458
+ clearSelfie();
459
+ liveness.reset();
460
+ }}
461
+ onContinue={nextStep}
462
+ />
463
+ );
464
+ }
465
+
356
466
  // ── Error states ────────────────────────────────────────────────────────────
357
467
  if (cameraUnavailable) {
358
468
  return <CameraUnavailableView />;
@@ -383,24 +493,6 @@ export function LivenessStep(): React.ReactElement {
383
493
  return <CameraPermissionView onRetry={() => setPerm('requesting')} />;
384
494
  }
385
495
 
386
- // ── Review (selfie captured) ─────────────────────────────────────────────────
387
- if (liveness.phase === 'complete' && selfieUri) {
388
- return (
389
- <LivenessComplete
390
- selfieUri={selfieUri}
391
- upload={upload}
392
- onRetake={() => {
393
- setSelfieUri(null);
394
- setUploadError(null);
395
- selfieIdRef.current = null;
396
- videoPathRef.current = null;
397
- liveness.reset();
398
- }}
399
- onContinue={nextStep}
400
- />
401
- );
402
- }
403
-
404
496
  if (liveness.phase === 'failed') {
405
497
  return <LivenessFailed reason={liveness.failureReason} onRetry={liveness.reset} />;
406
498
  }
@@ -416,31 +508,32 @@ export function LivenessStep(): React.ReactElement {
416
508
  liveness.lightingGuidance != null;
417
509
  const isCamLoading = phase === 'loading';
418
510
 
511
+ // The ring is on the frame from positioning to the shutter, so for that whole
512
+ // stretch the border is its TRACK. Phase colour stays on the instruction text
513
+ // and the passed flash; a warning still turns the track red under the arc.
514
+ // `failed` has already returned above, so loading is the only phase without it.
515
+ const ringOnFrame = !isCamLoading;
419
516
  const ringColor = isCamLoading
420
517
  ? colors.gray300
421
518
  : hasWarning
422
519
  ? colors.error
423
- : phase === 'challenge_passed' || phase === 'capturing'
424
- ? colors.success
425
- : phase === 'challenge'
426
- ? colors.warning
427
- : liveness.faceDetected
428
- ? colors.primary
429
- : colors.gray300;
520
+ : ringOnFrame
521
+ ? `${colors.primary}33`
522
+ : colors.gray300;
430
523
 
431
524
  const guidance = resolveGuidance(liveness);
432
525
  const instrColor = isCamLoading
433
526
  ? colors.textMuted
434
527
  : guidance.tone === 'error'
435
528
  ? colors.error
436
- : phase === 'challenge_passed'
529
+ : phase === 'challenge_passed' || phase === 'complete'
437
530
  ? colors.success
438
531
  : phase === 'challenge'
439
532
  ? colors.warning
440
533
  : colors.textDark;
441
534
 
442
535
  // Fixed circle, sized to fit the sheet width (caps at 300).
443
- const CIRCLE = Math.min(Dimensions.get('window').width - spacing.md * 4, 300);
536
+ const CIRCLE = layout.circle;
444
537
  const lighting = liveness.lightingGuidance;
445
538
 
446
539
  // Rendered INSIDE the sheet, so the SDK header and the "powered by" footer
@@ -456,7 +549,10 @@ export function LivenessStep(): React.ReactElement {
456
549
  {phase === 'flash' ? '' : guidance.text}
457
550
  </MyazaText>
458
551
 
459
- {/* Circular camera with a thick colour-state ring */}
552
+ {/* Circular camera. Two boxes: the inner one CLIPS the preview to a circle,
553
+ the outer one lets the progress ring sit on that border rather than be
554
+ clipped by it. */}
555
+ <View style={{ width: CIRCLE, height: CIRCLE }}>
460
556
  <View
461
557
  ref={flashHoleRef}
462
558
  collapsable={false}
@@ -470,21 +566,14 @@ export function LivenessStep(): React.ReactElement {
470
566
  backgroundColor: '#111111',
471
567
  }}
472
568
  >
473
- {device ? (
474
- <Camera
475
- style={{ flex: 1 }}
476
- device={device}
477
- isActive={perm === 'granted'}
478
- outputs={[photoOutput, videoOutput, frameOutput]}
479
- // Mirror the front-camera preview + capture so it reads like a
480
- // mirror (matches the web SDK's scaleX(-1) video).
481
- mirrorMode="on"
482
- />
483
- ) : (
484
- <View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
485
- <ActivityIndicator color="#FFFFFF" />
486
- </View>
487
- )}
569
+ {/* The Camera lives in its own borderless wrapper — see LivenessCamera
570
+ for why a bordered parent shifts the Android preview by one border. */}
571
+ <LivenessCamera
572
+ device={device}
573
+ active={perm === 'granted'}
574
+ outputs={[photoOutput, videoOutput, frameOutput]}
575
+ circle={CIRCLE}
576
+ />
488
577
 
489
578
  {/* Dashed face-guide ellipse (taller than wide) */}
490
579
  <Svg style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0 }} width={CIRCLE} height={CIRCLE}>
@@ -519,12 +608,9 @@ export function LivenessStep(): React.ReactElement {
519
608
  </View>
520
609
  ) : null}
521
610
 
522
- {/* Capturing flash — white wash + "Got it!" */}
523
- {phase === 'capturing' ? (
524
- <View style={[StyleAbsFill, { backgroundColor: 'rgba(255,255,255,0.3)', alignItems: 'center', justifyContent: 'center' }]}>
525
- <MyazaText variant="heading3" color="#FFFFFF">Got it!</MyazaText>
526
- </View>
527
- ) : null}
611
+ {/* The shutter: a white flash as the still is taken. It used to sit on
612
+ `capturing` reading "Got it!", announcing a photo not yet taken. */}
613
+ {phase === 'complete' ? <ShutterFlash /> : null}
528
614
 
529
615
  {/* Timeout countdown */}
530
616
  {phase === 'challenge' && liveness.timeoutRemaining > 0 ? (
@@ -534,6 +620,20 @@ export function LivenessStep(): React.ReactElement {
534
620
  ) : null}
535
621
  </View>
536
622
 
623
+ {/* The ring — outside the clipping box, on the frame's own edge. One line,
624
+ building one way for the whole test; primary while it builds, green
625
+ only as it closes, on the same frame as the shutter. */}
626
+ {ringOnFrame ? (
627
+ <CaptureRing
628
+ size={CIRCLE}
629
+ getTarget={getRingTarget}
630
+ color={colors.primary}
631
+ successColor={colors.success}
632
+ complete={phase === 'complete'}
633
+ />
634
+ ) : null}
635
+ </View>
636
+
537
637
  {/* Lighting warning banner — mirrors Flutter (amber-50/200/800 + lightbulb) */}
538
638
  {lighting ? <LightingBanner text={lightingGuidanceText(lighting)} /> : null}
539
639
 
@@ -541,7 +641,9 @@ export function LivenessStep(): React.ReactElement {
541
641
  <ProgressDots total={liveness.totalCount} completed={liveness.completedCount} active={phase === 'challenge' || phase === 'positioning' || phase === 'flash'} />
542
642
 
543
643
  {/* Gesture demo avatar */}
544
- {liveness.activeChallenge ? <LivenessAvatar challenge={liveness.activeChallenge} /> : null}
644
+ {liveness.activeChallenge ? (
645
+ <LivenessAvatar challenge={liveness.activeChallenge} size={layout.avatar} iconSize={layout.avatarIcon} />
646
+ ) : null}
545
647
  </View>
546
648
  );
547
649
  }
@@ -0,0 +1,66 @@
1
+ import React from 'react';
2
+ import { View } from 'react-native';
3
+
4
+ import { useTheme } from '../components/runtime';
5
+ import { Icon, type IconName } from '../components/Icon';
6
+ import { OptionRow } from '../components/OptionRow';
7
+ import type { PoaDocumentType } from '../types/workflow';
8
+
9
+ // ---------------------------------------------------------------------------
10
+ // The document kinds LISTED OUT as the questionnaire's multi-select cards (a
11
+ // square check, primary when picked, an icon beside the label) rather than
12
+ // hidden behind a select — the options are the information, and a sheet makes
13
+ // the person open it to find out what is on offer (user decision 2026-09-05).
14
+ // ONE kind is picked, because one document is uploaded: the cards are radios
15
+ // wearing the multi-select's square check. Locked once a file is attached, so
16
+ // the kind cannot drift from the document already uploaded. Mirrors the web
17
+ // SDK's steps/PoaDocumentTypeList and Flutter's proof_of_address_kinds.
18
+ // ---------------------------------------------------------------------------
19
+
20
+ /** One glyph per kind — the SAME Lucide names the web and Flutter maps carry. */
21
+ export const POA_TYPE_ICONS: Record<PoaDocumentType, IconName> = {
22
+ utility_bill: 'zap',
23
+ bank_statement: 'landmark',
24
+ tenancy_agreement: 'house',
25
+ government_document: 'stamp',
26
+ other: 'file-text',
27
+ };
28
+
29
+ export function PoaDocumentTypeList({
30
+ options,
31
+ value,
32
+ disabled,
33
+ onChange,
34
+ }: {
35
+ options: Array<{ value: PoaDocumentType; label: string }>;
36
+ value: PoaDocumentType;
37
+ disabled?: boolean;
38
+ onChange: (value: PoaDocumentType) => void;
39
+ }): React.ReactElement {
40
+ const { colors } = useTheme();
41
+ return (
42
+ <View>
43
+ {options.map((opt) => {
44
+ const selected = opt.value === value;
45
+ return (
46
+ <OptionRow
47
+ key={opt.value}
48
+ label={opt.label}
49
+ selected={selected}
50
+ multi
51
+ role="radio"
52
+ disabled={disabled}
53
+ icon={
54
+ <Icon
55
+ name={POA_TYPE_ICONS[opt.value]}
56
+ size={20}
57
+ color={selected ? colors.primary : colors.textSecondary}
58
+ />
59
+ }
60
+ onPress={() => onChange(opt.value)}
61
+ />
62
+ );
63
+ })}
64
+ </View>
65
+ );
66
+ }
@@ -0,0 +1,151 @@
1
+ import React from 'react';
2
+ import { ActivityIndicator, Image, Pressable, View } from 'react-native';
3
+
4
+ import { radius, spacing } from '../config/theme';
5
+ import { UPLOAD_HINT } from '../config/uploadLimits';
6
+ import { useTheme } from '../components/runtime';
7
+ import { MyazaText } from '../components/Typography';
8
+ import { Icon } from '../components/Icon';
9
+ import { DashedBorder } from '../components/DashedBorder';
10
+ import { CountryFlag } from '../components/CountryFlag';
11
+
12
+ // ---------------------------------------------------------------------------
13
+ // Proof of Address — the two states of the attachment area, split out of the
14
+ // step (200-line rule; mirrors the Flutter SDK's proof_of_address_parts).
15
+ // Pure presentation: the step owns the picking, uploading and removing.
16
+ //
17
+ // Both states carry the country the document is for (user decision
18
+ // 2026-09-05): the flag before the call to action on the drop zone, and before
19
+ // the document kind on the uploaded row, so a person on a multi-market flow
20
+ // sees which market the paper is being read against. Null when the flow does
21
+ // not know it yet (the address scope before a pick) — nothing is invented.
22
+ // ---------------------------------------------------------------------------
23
+
24
+ function Flag({ country, size }: { country: string | null; size: number }): React.ReactElement | null {
25
+ if (!country) return null;
26
+ return (
27
+ <>
28
+ <CountryFlag country={country} size={size} />
29
+ <View style={{ width: spacing.xs }} />
30
+ </>
31
+ );
32
+ }
33
+
34
+ export function PoaUploadedRow({
35
+ preview,
36
+ fileName,
37
+ typeLabel,
38
+ country,
39
+ onRemove,
40
+ }: {
41
+ preview: { uri: string; isPdf: boolean } | null;
42
+ fileName: string | null;
43
+ typeLabel: string;
44
+ country: string | null;
45
+ onRemove: () => void;
46
+ }): React.ReactElement {
47
+ const { colors } = useTheme();
48
+ return (
49
+ <View
50
+ style={{
51
+ flexDirection: 'row',
52
+ alignItems: 'center',
53
+ padding: spacing.md,
54
+ borderRadius: radius.md,
55
+ borderWidth: 1,
56
+ borderColor: colors.border,
57
+ backgroundColor: colors.backgroundSecondary,
58
+ }}
59
+ >
60
+ {preview && !preview.isPdf ? (
61
+ <Image
62
+ source={{ uri: preview.uri }}
63
+ style={{ width: 48, height: 48, borderRadius: radius.sm }}
64
+ resizeMode="cover"
65
+ />
66
+ ) : (
67
+ <View
68
+ style={{
69
+ width: 48,
70
+ height: 48,
71
+ borderRadius: radius.sm,
72
+ borderWidth: 1,
73
+ borderColor: colors.border,
74
+ alignItems: 'center',
75
+ justifyContent: 'center',
76
+ }}
77
+ >
78
+ <Icon name="file-text" size={22} color={colors.primary} />
79
+ </View>
80
+ )}
81
+ <View style={{ width: spacing.md, flexShrink: 0 }} />
82
+ <View style={{ flex: 1, minWidth: 0 }}>
83
+ <MyazaText variant="bodySmall" style={{ fontWeight: '600' }} numberOfLines={1}>
84
+ {fileName ?? 'Document uploaded'}
85
+ </MyazaText>
86
+ <View style={{ flexDirection: 'row', alignItems: 'center' }}>
87
+ <Flag country={country} size={14} />
88
+ <MyazaText variant="bodySmall" color={colors.textSecondary} numberOfLines={1} style={{ flexShrink: 1 }}>
89
+ {typeLabel}
90
+ </MyazaText>
91
+ </View>
92
+ </View>
93
+ <Pressable
94
+ onPress={onRemove}
95
+ accessibilityRole="button"
96
+ accessibilityLabel="Remove document"
97
+ hitSlop={8}
98
+ >
99
+ <Icon name="x" size={18} color={colors.textSecondary} />
100
+ </Pressable>
101
+ </View>
102
+ );
103
+ }
104
+
105
+ /** The DASHED drop zone that NAMES the document being asked for. */
106
+ export function PoaDropzone({
107
+ busy,
108
+ typeLabel,
109
+ country,
110
+ onPress,
111
+ }: {
112
+ busy: boolean;
113
+ typeLabel: string;
114
+ country: string | null;
115
+ onPress: () => void;
116
+ }): React.ReactElement {
117
+ const { colors } = useTheme();
118
+ return (
119
+ <Pressable
120
+ onPress={() => {
121
+ if (!busy) onPress();
122
+ }}
123
+ accessibilityRole="button"
124
+ accessibilityLabel={`Upload your ${typeLabel.toLowerCase()}`}
125
+ style={{
126
+ alignItems: 'center',
127
+ justifyContent: 'center',
128
+ paddingVertical: spacing.xl,
129
+ paddingHorizontal: spacing.lg,
130
+ borderRadius: radius.md,
131
+ }}
132
+ >
133
+ <DashedBorder color={colors.border} radius={radius.md} strokeWidth={1.5} />
134
+ {busy ? (
135
+ <ActivityIndicator size="small" color={colors.primary} />
136
+ ) : (
137
+ <Icon name="upload" size={30} color={colors.textSecondary} />
138
+ )}
139
+ <View style={{ height: spacing.sm }} />
140
+ <View style={{ flexDirection: 'row', alignItems: 'center' }}>
141
+ <Flag country={country} size={18} />
142
+ <MyazaText variant="bodySmall" style={{ fontWeight: '600' }}>
143
+ {busy ? 'Uploading…' : `Upload your ${typeLabel.toLowerCase()}`}
144
+ </MyazaText>
145
+ </View>
146
+ <MyazaText variant="bodySmall" color={colors.textSecondary}>
147
+ {UPLOAD_HINT}
148
+ </MyazaText>
149
+ </Pressable>
150
+ );
151
+ }