@myazahq/kyc-sdk-react-native 2.5.0 → 3.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.
- package/README.md +277 -7
- package/android/build.gradle +30 -16
- package/android/consumer-rules.pro +22 -0
- package/android/src/main/AndroidManifest.xml +17 -6
- package/android/src/main/java/com/margelo/nitro/myazakyc/HybridMyazaFaceDetector.kt +27 -70
- package/android/src/main/java/com/margelo/nitro/myazakyc/HybridMyazaTextRecognizer.kt +14 -0
- package/android/src/main/java/com/margelo/nitro/myazakyc/MlKitModelReadiness.kt +154 -0
- package/app.plugin.js +146 -10
- package/ios/HybridMyazaFaceDetector.swift +8 -3
- package/ios/HybridMyazaTextRecognizer.swift +8 -0
- package/nitrogen/generated/android/c++/JHybridMyazaTextRecognizerSpec.cpp +21 -0
- package/nitrogen/generated/android/c++/JHybridMyazaTextRecognizerSpec.hpp +2 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/HybridMyazaTextRecognizerSpec.kt +8 -0
- package/nitrogen/generated/ios/c++/HybridMyazaTextRecognizerSpecSwift.hpp +16 -0
- package/nitrogen/generated/ios/swift/HybridMyazaTextRecognizerSpec.swift +2 -0
- package/nitrogen/generated/ios/swift/HybridMyazaTextRecognizerSpec_cxx.swift +31 -0
- package/nitrogen/generated/shared/c++/HybridMyazaTextRecognizerSpec.cpp +2 -0
- package/nitrogen/generated/shared/c++/HybridMyazaTextRecognizerSpec.hpp +2 -0
- package/package.json +30 -9
- package/src/MyazaKYC.tsx +4 -11
- package/src/assets/fonts/Karla_400Regular.ttf +0 -0
- package/src/assets/fonts/Karla_500Medium.ttf +0 -0
- package/src/assets/fonts/Karla_600SemiBold.ttf +0 -0
- package/src/assets/fonts/Karla_700Bold.ttf +0 -0
- package/src/assets/fonts/OFL-Karla.txt +93 -0
- package/src/assets/fonts/OFL-SpaceGrotesk.txt +93 -0
- package/src/assets/fonts/README.md +12 -0
- package/src/assets/fonts/SpaceGrotesk_500Medium.ttf +0 -0
- package/src/assets/fonts/SpaceGrotesk_600SemiBold.ttf +0 -0
- package/src/assets/fonts/SpaceGrotesk_700Bold.ttf +0 -0
- package/src/capture/useAutoCapture.ts +8 -1
- package/src/components/CountryField.tsx +8 -0
- package/src/components/CountryRegionPicker.tsx +184 -0
- package/src/components/DialCodePicker.tsx +47 -68
- package/src/components/DialCodeRow.tsx +102 -0
- package/src/components/DocumentCropper.tsx +30 -24
- package/src/components/DocumentReview.tsx +11 -1
- package/src/components/DocumentReviewSide.tsx +6 -2
- package/src/components/DocumentReviewZoom.tsx +5 -1
- package/src/components/FramedMapPicker.tsx +122 -0
- package/src/components/GeoBadge.tsx +34 -0
- package/src/components/Icon.tsx +10 -192
- package/src/components/KycFlow.tsx +69 -34
- package/src/components/LineSkeleton.tsx +100 -0
- package/src/components/MapChrome.tsx +67 -0
- package/src/components/MapPinMarker.tsx +54 -0
- package/src/components/MapPinPicker.tsx +179 -0
- package/src/components/MilestoneTrack.tsx +155 -0
- package/src/components/MyazaButton.tsx +9 -0
- package/src/components/MyazaInput.tsx +17 -1
- package/src/components/OptionRow.tsx +22 -3
- package/src/components/PhoneNumberInput.tsx +8 -0
- package/src/components/PoweredBy.tsx +5 -5
- package/src/components/PresenceBlocks.tsx +153 -0
- package/src/components/RequiredDocumentPill.tsx +114 -0
- package/src/components/StepHeader.tsx +23 -3
- package/src/components/StepView.tsx +17 -0
- package/src/components/StickyActions.tsx +56 -0
- package/src/components/dialCodeRows.ts +64 -0
- package/src/components/documentReviewCopy.ts +39 -0
- package/src/components/fonts.ts +14 -17
- package/src/components/icon-map.ts +176 -0
- package/src/components/stepHeaderMeta.tsx +48 -5
- package/src/config/addressCollection.ts +125 -0
- package/src/config/biometricOptions.ts +100 -0
- package/src/config/business.ts +20 -1
- package/src/config/businessSteps.ts +8 -2
- package/src/config/consentStep.ts +19 -0
- package/src/config/documentCaptureMethods.ts +31 -0
- package/src/config/proofOfAddress.ts +54 -11
- package/src/config/regions.ts +24 -0
- package/src/config/stepOrder.ts +79 -5
- package/src/config/uploadLimits.ts +35 -0
- package/src/config/workflowMerge.ts +13 -5
- package/src/index.ts +49 -1
- package/src/lib/address-current-location.ts +171 -0
- package/src/lib/address-field-modes.ts +119 -0
- package/src/lib/address-flow.ts +172 -0
- package/src/lib/address-helpers.ts +48 -0
- package/src/lib/address-line.ts +76 -0
- package/src/lib/address-pin-move.ts +97 -0
- package/src/lib/address-step-recovery.ts +63 -0
- package/src/lib/authed-image.ts +78 -0
- package/src/lib/biometric-copy.ts +51 -0
- package/src/lib/captureRing.ts +83 -0
- package/src/lib/country-adoption.ts +89 -0
- package/src/lib/documentCaptureCheck.ts +136 -0
- package/src/lib/inferred-country.ts +65 -0
- package/src/lib/livenessLayout.ts +49 -0
- package/src/lib/map-frame.ts +159 -0
- package/src/lib/map-tiles.ts +143 -0
- package/src/lib/model-ready.ts +82 -0
- package/src/lib/poa-country-gate.ts +27 -0
- package/src/lib/prime-models.ts +50 -0
- package/src/lib/resubmit.ts +36 -7
- package/src/lib/result-copy.ts +116 -0
- package/src/lib/result-wait.ts +53 -0
- package/src/lib/review-map-surface.ts +26 -0
- package/src/lib/scope.ts +31 -0
- package/src/lib/selfie-sharpness.ts +211 -0
- package/src/lib/selfie-upload-wait.ts +79 -0
- package/src/lib/street-view-fov.ts +42 -0
- package/src/lib/webview-available.ts +47 -0
- package/src/liveness/avatarSource.ts +59 -0
- package/src/liveness/useLiveness.ts +6 -1
- package/src/liveness/useModelReady.ts +9 -55
- package/src/mrz/extract.ts +71 -4
- package/src/mrz/textRecognizer.ts +36 -0
- package/src/mrz/useTextModelReady.ts +21 -0
- package/src/presence/background-math.ts +100 -0
- package/src/presence/background-store.ts +82 -0
- package/src/presence/background.ts +165 -0
- package/src/presence/foreground-service.ts +193 -0
- package/src/presence/fs.ts +51 -0
- package/src/presence/geofence.ts +41 -0
- package/src/presence/math.ts +44 -0
- package/src/presence/post.ts +40 -0
- package/src/presence/report.ts +87 -0
- package/src/presence/sampler.ts +110 -0
- package/src/presence/status.ts +92 -0
- package/src/presence/store.ts +94 -0
- package/src/presence/tier.ts +36 -0
- package/src/presence/watch-wait.ts +81 -0
- package/src/screens/AddressCountryControl.tsx +115 -0
- package/src/screens/BusinessDetailsFields.tsx +3 -0
- package/src/screens/BusinessDetailsStep.tsx +2 -0
- package/src/screens/BusinessDocumentSlot.tsx +3 -2
- package/src/screens/BusinessDocumentsStep.tsx +4 -55
- package/src/screens/CompanyInfoFields.tsx +6 -1
- package/src/screens/ConsentStep.tsx +6 -17
- package/src/screens/ContactActions.tsx +54 -0
- package/src/screens/ContactDestinationField.tsx +4 -0
- package/src/screens/ContactEntryPanel.tsx +75 -0
- package/src/screens/ContactVerificationStep.tsx +26 -56
- package/src/screens/CountrySelectStep.tsx +13 -147
- package/src/screens/DocumentCaptureStep.tsx +177 -93
- package/src/screens/LivenessAvatar.tsx +33 -16
- package/src/screens/LivenessStep.tsx +188 -49
- package/src/screens/MrzScanView.tsx +33 -0
- package/src/screens/PoaDocumentTypeList.tsx +66 -0
- package/src/screens/ProofOfAddressParts.tsx +151 -0
- package/src/screens/ProofOfAddressStep.tsx +58 -99
- package/src/screens/SubmittedBadge.tsx +25 -0
- package/src/screens/SubmittedError.tsx +64 -0
- package/src/screens/SubmittedResult.tsx +117 -0
- package/src/screens/SubmittedStep.tsx +71 -164
- package/src/screens/SubmittedSuccess.tsx +127 -0
- package/src/screens/SubmittedWaiting.tsx +45 -0
- package/src/screens/address/AddressEntranceStep.tsx +161 -0
- package/src/screens/address/AddressIntroGate.tsx +148 -0
- package/src/screens/address/AddressMapStub.tsx +50 -0
- package/src/screens/address/AddressPinStep.tsx +178 -0
- package/src/screens/address/AddressReviewStep.tsx +162 -0
- package/src/screens/address/AddressSandboxTabs.tsx +158 -0
- package/src/screens/address/AddressSearchField.tsx +115 -0
- package/src/screens/address/AddressSearchStep.tsx +94 -0
- package/src/screens/address/CurrentLocationRow.tsx +135 -0
- package/src/screens/address/DetailsSheet.tsx +175 -0
- package/src/screens/address/DetailsSheetFields.tsx +184 -0
- package/src/screens/address/EntranceDropzone.tsx +178 -0
- package/src/screens/address/EntranceFraming.tsx +66 -0
- package/src/screens/address/EntrancePills.tsx +60 -0
- package/src/screens/address/FramedStreetView.tsx +155 -0
- package/src/screens/address/IntroDisclosures.tsx +210 -0
- package/src/screens/address/LabelDecisionRow.tsx +113 -0
- package/src/screens/address/PinSummaryRow.tsx +105 -0
- package/src/screens/address/ReviewAddressBand.tsx +111 -0
- package/src/screens/address/ReviewEntranceThumbs.tsx +64 -0
- package/src/screens/address/ReviewMapPicture.tsx +106 -0
- package/src/screens/address/SearchResults.tsx +105 -0
- package/src/screens/address/SearchScreen.tsx +176 -0
- package/src/screens/address/SkipForNow.tsx +43 -0
- package/src/screens/address/StreetViewChrome.tsx +81 -0
- package/src/screens/address/detail-values.ts +22 -0
- package/src/screens/address/fix-source.ts +27 -0
- package/src/screens/address/index.ts +8 -0
- package/src/screens/address/meta.ts +44 -0
- package/src/screens/address/use-address-flow.ts +200 -0
- package/src/screens/address/use-label-pin.ts +80 -0
- package/src/screens/address/use-pin-actions.ts +192 -0
- package/src/screens/consent/model.ts +79 -10
- package/src/screens/document/CaptureCheckNotice.tsx +77 -0
- package/src/screens/document/UploadPhase.tsx +177 -0
- package/src/screens/document/useDocumentCamera.ts +109 -0
- package/src/screens/liveness/CaptureRing.tsx +91 -0
- package/src/screens/liveness/LivenessCamera.tsx +63 -0
- package/src/screens/liveness/LivenessHandover.tsx +43 -0
- package/src/screens/liveness/LivenessOutcome.tsx +8 -4
- package/src/screens/liveness/SelfiePreview.tsx +31 -7
- package/src/screens/liveness/ShutterFlash.tsx +25 -0
- package/src/screens/liveness/index.ts +4 -0
- package/src/screens/liveness/useSelfieUpload.ts +23 -3
- package/src/screens/nfc/NfcScanIllustration.tsx +7 -18
- package/src/screens/useAddressPhotoAttach.ts +77 -0
- package/src/screens/useBusinessDocumentAttach.ts +93 -0
- package/src/screens/usePoaAttach.ts +12 -15
- package/src/services/api-types.ts +119 -1
- package/src/services/api-verify-types.ts +37 -0
- package/src/services/api.ts +149 -3
- package/src/services/deviceMetadata.ts +1 -1
- package/src/services/location.ts +234 -0
- package/src/services/mediaCompress.ts +23 -5
- package/src/services/uploadErrors.ts +30 -0
- package/src/services/workflowGate.ts +7 -0
- package/src/specs/MyazaTextRecognizer.nitro.ts +29 -0
- package/src/store/address.ts +97 -0
- package/src/store/derive.ts +29 -4
- package/src/store/kycStore.ts +120 -5
- package/src/store/serverConfig.ts +18 -0
- package/src/store/session.ts +23 -4
- package/src/store/state.ts +134 -0
- package/src/store/submit.ts +18 -1
- package/src/types/config.ts +63 -1
- package/src/types/verification.ts +12 -0
- package/src/types/workflow.ts +97 -1
- package/src/assets/liveness/Blink.gif +0 -0
- package/src/assets/liveness/Nod.gif +0 -0
- package/src/assets/liveness/Smile.gif +0 -0
- package/src/assets/liveness/Turn.gif +0 -0
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { View } from 'react-native';
|
|
3
|
+
|
|
4
|
+
import { spacing } from '../../config/theme';
|
|
5
|
+
import { MyazaAlert } from '../../components/MyazaAlert';
|
|
6
|
+
import { MyazaButton } from '../../components/MyazaButton';
|
|
7
|
+
import { documentReviewCopy } from '../../components/documentReviewCopy';
|
|
8
|
+
import {
|
|
9
|
+
CAPTURE_CHECK_CONTINUE_ANYWAY,
|
|
10
|
+
CAPTURE_CHECK_TITLE,
|
|
11
|
+
captureProblemMessage,
|
|
12
|
+
captureRetakeLabel,
|
|
13
|
+
captureRetakeSides,
|
|
14
|
+
type CaptureProblem,
|
|
15
|
+
} from '../../lib/documentCaptureCheck';
|
|
16
|
+
import type { DocumentCaptureSide } from '../../services/api-types';
|
|
17
|
+
|
|
18
|
+
// ─── The review footer when a document photo will not read ───────────────────
|
|
19
|
+
//
|
|
20
|
+
// Shown in place of Continue after the server's capture check found no face on
|
|
21
|
+
// the printed photo or no readable barcode (lib/documentCaptureCheck). The
|
|
22
|
+
// words come from that module so they stay in lockstep with the web and
|
|
23
|
+
// Flutter SDKs.
|
|
24
|
+
//
|
|
25
|
+
// A notice, never a gate: the detectors can miss, so "Continue anyway" is
|
|
26
|
+
// always offered and moves on with the photos already uploaded. The first
|
|
27
|
+
// retake is the primary action because a retake is the likelier fix.
|
|
28
|
+
|
|
29
|
+
export function CaptureCheckNotice({
|
|
30
|
+
problems,
|
|
31
|
+
uploadOnly,
|
|
32
|
+
onRetake,
|
|
33
|
+
onContinueAnyway,
|
|
34
|
+
}: {
|
|
35
|
+
problems: readonly CaptureProblem[];
|
|
36
|
+
/** The sides were picked from the device, so they are replaced, not retaken. */
|
|
37
|
+
uploadOnly: boolean;
|
|
38
|
+
onRetake: (side: DocumentCaptureSide) => void;
|
|
39
|
+
onContinueAnyway: () => void;
|
|
40
|
+
}): React.ReactElement {
|
|
41
|
+
// Once per kind: two sides with the same problem need one sentence, not two.
|
|
42
|
+
const messages = [...new Set(problems.map((p) => captureProblemMessage(p.kind)))];
|
|
43
|
+
const sides = captureRetakeSides(problems);
|
|
44
|
+
const copy = documentReviewCopy(uploadOnly ? 'upload' : 'scan', true);
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
<View style={{ gap: spacing.sm }}>
|
|
48
|
+
{/* Grouped and announced: the notice replaces the button the applicant
|
|
49
|
+
just pressed, so a screen reader has to say why nothing moved on. */}
|
|
50
|
+
<View
|
|
51
|
+
accessible
|
|
52
|
+
accessibilityRole="alert"
|
|
53
|
+
accessibilityLiveRegion="polite"
|
|
54
|
+
accessibilityLabel={`${CAPTURE_CHECK_TITLE}. ${messages.join(' ')}`}
|
|
55
|
+
>
|
|
56
|
+
<MyazaAlert variant="warning" title={CAPTURE_CHECK_TITLE} message={messages.join('\n\n')} />
|
|
57
|
+
</View>
|
|
58
|
+
{sides.map((side, i) => (
|
|
59
|
+
<MyazaButton
|
|
60
|
+
key={side}
|
|
61
|
+
label={captureRetakeLabel(side, uploadOnly)}
|
|
62
|
+
variant={i === 0 ? 'primary' : 'outline'}
|
|
63
|
+
leadingIcon="refresh"
|
|
64
|
+
accessibilityLabel={copy.redoAccessibility(side === 'front' ? 'Front' : 'Back')}
|
|
65
|
+
onPress={() => onRetake(side)}
|
|
66
|
+
/>
|
|
67
|
+
))}
|
|
68
|
+
<MyazaButton
|
|
69
|
+
label={CAPTURE_CHECK_CONTINUE_ANYWAY}
|
|
70
|
+
variant="outline"
|
|
71
|
+
accessibilityLabel={CAPTURE_CHECK_CONTINUE_ANYWAY}
|
|
72
|
+
accessibilityHint="Carries on with these photos as they are"
|
|
73
|
+
onPress={onContinueAnyway}
|
|
74
|
+
/>
|
|
75
|
+
</View>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { ActivityIndicator, Pressable, View } from 'react-native';
|
|
3
|
+
|
|
4
|
+
import { radius, spacing } from '../../config/theme';
|
|
5
|
+
import { useTheme } from '../../components/runtime';
|
|
6
|
+
import { MyazaText } from '../../components/Typography';
|
|
7
|
+
import { Icon, type IconName } from '../../components/Icon';
|
|
8
|
+
import { DashedBorder } from '../../components/DashedBorder';
|
|
9
|
+
import type { DocumentCapturePhase } from '../../store/state';
|
|
10
|
+
import { RequiredPill } from './RequiredPill';
|
|
11
|
+
|
|
12
|
+
// ─── Document capture without the camera ──────────────────────────────────────
|
|
13
|
+
//
|
|
14
|
+
// One side of the document when the workflow switches scanning off
|
|
15
|
+
// (`allowDocumentScan: false`): a photo chosen from the device, never a camera
|
|
16
|
+
// shot. It renders in place of the camera gates, and nothing here touches
|
|
17
|
+
// VisionCamera, so an upload-only flow never asks for camera access and never
|
|
18
|
+
// records a capture video.
|
|
19
|
+
//
|
|
20
|
+
// Only the ASK differs. What happens after the pick is the step's own and shared
|
|
21
|
+
// with the camera path: the interactive cropper, the compress, the MRZ read off
|
|
22
|
+
// the front, the front preview and the review.
|
|
23
|
+
|
|
24
|
+
/** The sheet header for an upload-only capture phase (see documentCaptureMeta). */
|
|
25
|
+
export function documentUploadMeta(
|
|
26
|
+
phase: DocumentCapturePhase,
|
|
27
|
+
documentLabel: string,
|
|
28
|
+
): { title: string; description: string } {
|
|
29
|
+
switch (phase) {
|
|
30
|
+
case 'front':
|
|
31
|
+
return {
|
|
32
|
+
title: `Upload Your ${documentLabel}`,
|
|
33
|
+
description: `Choose a clear photo of your ${documentLabel} from your device.`,
|
|
34
|
+
};
|
|
35
|
+
case 'front-preview':
|
|
36
|
+
return {
|
|
37
|
+
title: 'Front Side Added',
|
|
38
|
+
description: 'Looks good? Tap Next to add a photo of the back.',
|
|
39
|
+
};
|
|
40
|
+
case 'back':
|
|
41
|
+
return {
|
|
42
|
+
title: 'Upload Back Side',
|
|
43
|
+
description: `Now choose a photo of the back of your ${documentLabel}.`,
|
|
44
|
+
};
|
|
45
|
+
case 'review':
|
|
46
|
+
default:
|
|
47
|
+
return {
|
|
48
|
+
title: `Review Your ${documentLabel}`,
|
|
49
|
+
description: 'Tap Continue to upload and submit your document.',
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const TIPS: ReadonlyArray<{ icon: IconName; label: string }> = [
|
|
55
|
+
{ icon: 'id-card', label: 'The whole document in view, all four corners' },
|
|
56
|
+
{ icon: 'sun', label: 'Sharp and evenly lit, with no glare' },
|
|
57
|
+
];
|
|
58
|
+
|
|
59
|
+
export interface UploadPhaseProps {
|
|
60
|
+
isBack: boolean;
|
|
61
|
+
documentLabel: string;
|
|
62
|
+
isTwoSided: boolean;
|
|
63
|
+
/** A picked photo is being cropped and compressed. */
|
|
64
|
+
busy: boolean;
|
|
65
|
+
onUpload: () => void;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function UploadPhase(p: UploadPhaseProps): React.ReactElement {
|
|
69
|
+
const { colors } = useTheme();
|
|
70
|
+
const subject = !p.isTwoSided ? `your ${p.documentLabel}` : p.isBack ? 'the back' : 'the front';
|
|
71
|
+
const ask = `Add a photo of ${subject}`;
|
|
72
|
+
const choose = () => {
|
|
73
|
+
if (!p.busy) p.onUpload();
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
return (
|
|
77
|
+
<View>
|
|
78
|
+
<RequiredPill
|
|
79
|
+
documentLabel={p.documentLabel}
|
|
80
|
+
sideBadge={p.isTwoSided ? (p.isBack ? 'Back Side' : 'Front Side') : undefined}
|
|
81
|
+
stepLabel={p.isTwoSided ? (p.isBack ? 'Step 2 of 2' : 'Step 1 of 2') : undefined}
|
|
82
|
+
/>
|
|
83
|
+
{p.isBack ? (
|
|
84
|
+
<View style={{ flexDirection: 'row', alignItems: 'center', marginTop: spacing.sm, marginBottom: spacing.xs }}>
|
|
85
|
+
<Icon name="credit-card" size={14} color={colors.primary} />
|
|
86
|
+
<View style={{ width: 4 }} />
|
|
87
|
+
<MyazaText variant="bodySmall" color={colors.primary} style={{ fontWeight: '500' }}>
|
|
88
|
+
Turn the card over and add a photo of the other side
|
|
89
|
+
</MyazaText>
|
|
90
|
+
</View>
|
|
91
|
+
) : null}
|
|
92
|
+
<View style={{ height: spacing.md }} />
|
|
93
|
+
|
|
94
|
+
{/* The whole card is the target and there is no button beside it: on a
|
|
95
|
+
screen whose only job is to open the photo picker, the card is where
|
|
96
|
+
the thumb goes. A press deepens the fill and solidifies the border;
|
|
97
|
+
the primary line at the bottom says what a tap does. */}
|
|
98
|
+
<Pressable
|
|
99
|
+
testID={`kyc.document.upload.${p.isBack ? 'back' : 'front'}`}
|
|
100
|
+
onPress={choose}
|
|
101
|
+
disabled={p.busy}
|
|
102
|
+
accessibilityRole="button"
|
|
103
|
+
accessibilityLabel={p.busy ? 'Preparing your photo' : ask}
|
|
104
|
+
accessibilityHint="Opens your photos"
|
|
105
|
+
accessibilityState={{ busy: p.busy, disabled: p.busy }}
|
|
106
|
+
style={({ pressed }) => ({
|
|
107
|
+
alignItems: 'center',
|
|
108
|
+
paddingVertical: spacing.xl,
|
|
109
|
+
paddingHorizontal: spacing.lg,
|
|
110
|
+
borderRadius: radius.md,
|
|
111
|
+
backgroundColor: pressed ? `${colors.primary}1A` : `${colors.primary}0D`,
|
|
112
|
+
transform: [{ scale: pressed ? 0.99 : 1 }],
|
|
113
|
+
})}
|
|
114
|
+
>
|
|
115
|
+
{({ pressed }) => (
|
|
116
|
+
<>
|
|
117
|
+
<DashedBorder
|
|
118
|
+
color={pressed ? colors.primary : `${colors.primary}66`}
|
|
119
|
+
radius={radius.md}
|
|
120
|
+
strokeWidth={1.5}
|
|
121
|
+
/>
|
|
122
|
+
<View
|
|
123
|
+
style={{
|
|
124
|
+
width: 64,
|
|
125
|
+
height: 64,
|
|
126
|
+
borderRadius: radius.full,
|
|
127
|
+
backgroundColor: `${colors.primary}1A`,
|
|
128
|
+
alignItems: 'center',
|
|
129
|
+
justifyContent: 'center',
|
|
130
|
+
}}
|
|
131
|
+
>
|
|
132
|
+
{p.busy ? (
|
|
133
|
+
<ActivityIndicator size="small" color={colors.primary} />
|
|
134
|
+
) : (
|
|
135
|
+
<Icon name="image" size={30} color={colors.primary} />
|
|
136
|
+
)}
|
|
137
|
+
</View>
|
|
138
|
+
<View style={{ height: spacing.md }} />
|
|
139
|
+
<MyazaText variant="heading3" style={{ textAlign: 'center' }}>
|
|
140
|
+
{p.busy ? 'Preparing your photo…' : ask}
|
|
141
|
+
</MyazaText>
|
|
142
|
+
<View style={{ height: spacing.xs }} />
|
|
143
|
+
<MyazaText variant="bodySmall" color={colors.textSecondary} style={{ textAlign: 'center' }}>
|
|
144
|
+
Choose a clear photo from your device. You can crop it on the next screen.
|
|
145
|
+
</MyazaText>
|
|
146
|
+
<View style={{ height: spacing.md }} />
|
|
147
|
+
{/* Stays in the layout while busy so the card keeps its height. */}
|
|
148
|
+
<View
|
|
149
|
+
style={{ flexDirection: 'row', alignItems: 'center', opacity: p.busy ? 0 : 1 }}
|
|
150
|
+
accessibilityElementsHidden
|
|
151
|
+
importantForAccessibility="no-hide-descendants"
|
|
152
|
+
>
|
|
153
|
+
<Icon name="upload" size={16} color={colors.primary} />
|
|
154
|
+
<View style={{ width: spacing.xs }} />
|
|
155
|
+
<MyazaText variant="label" color={colors.primary} style={{ fontWeight: '600' }}>
|
|
156
|
+
Tap to choose a photo
|
|
157
|
+
</MyazaText>
|
|
158
|
+
<View style={{ width: 2 }} />
|
|
159
|
+
<Icon name="chevron-right" size={16} color={colors.primary} />
|
|
160
|
+
</View>
|
|
161
|
+
</>
|
|
162
|
+
)}
|
|
163
|
+
</Pressable>
|
|
164
|
+
|
|
165
|
+
<View style={{ height: spacing.md }} />
|
|
166
|
+
{TIPS.map((tip) => (
|
|
167
|
+
<View key={tip.label} style={{ flexDirection: 'row', alignItems: 'center', marginBottom: spacing.xs }}>
|
|
168
|
+
<Icon name={tip.icon} size={16} color={colors.textMuted} />
|
|
169
|
+
<View style={{ width: spacing.sm }} />
|
|
170
|
+
<MyazaText variant="bodySmall" color={colors.textSecondary} style={{ flexShrink: 1 }}>
|
|
171
|
+
{tip.label}
|
|
172
|
+
</MyazaText>
|
|
173
|
+
</View>
|
|
174
|
+
))}
|
|
175
|
+
</View>
|
|
176
|
+
);
|
|
177
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { useCameraDevice, useCameraPermission } from 'react-native-vision-camera';
|
|
3
|
+
|
|
4
|
+
import { safeReportError } from '../../services/errors';
|
|
5
|
+
import { KYCError } from '../../types/verification';
|
|
6
|
+
|
|
7
|
+
// The document camera's permission and hardware state, lifted out of
|
|
8
|
+
// DocumentCaptureStep so that only a workflow which SCANS ever calls
|
|
9
|
+
// VisionCamera's hooks. An upload-only workflow (`allowDocumentScan: false`)
|
|
10
|
+
// never mounts the component that uses this, so it never reads the camera
|
|
11
|
+
// permission, never enumerates devices and never prompts. The logic is the
|
|
12
|
+
// step's own, moved rather than changed.
|
|
13
|
+
|
|
14
|
+
export type DocumentCameraPermission = 'priming' | 'requesting' | 'granted' | 'denied';
|
|
15
|
+
|
|
16
|
+
export interface DocumentCamera {
|
|
17
|
+
perm: DocumentCameraPermission;
|
|
18
|
+
/** A back camera exists (false on a simulator, which has none). */
|
|
19
|
+
hasDevice: boolean;
|
|
20
|
+
/** No camera hardware at all, once the device list has had a moment. */
|
|
21
|
+
cameraUnavailable: boolean;
|
|
22
|
+
/** The "Allow camera access" primer, shown before the OS prompt. */
|
|
23
|
+
showPrimer: boolean;
|
|
24
|
+
/** A real camera exists but the OS blocked access. */
|
|
25
|
+
permissionDenied: boolean;
|
|
26
|
+
/** Fire the real OS prompt: the primer's "Grant access", or a retry. */
|
|
27
|
+
requestAccess: () => void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function useDocumentCamera(onError: ((error: KYCError) => void) | undefined): DocumentCamera {
|
|
31
|
+
// ── Camera permission ──────────────────────────────────────────────────────
|
|
32
|
+
// `perm` is derived from the ASYNC requestPermission result, not synchronously
|
|
33
|
+
// from `hasPermission` — otherwise the brief window while the OS prompt is open
|
|
34
|
+
// (hasPermission still false) would read as "denied" and fire onError early.
|
|
35
|
+
// 'priming' shows the "Allow camera access" screen BEFORE the OS prompt
|
|
36
|
+
// (Stripe-style); the prompt only fires (→ 'requesting') once the user taps
|
|
37
|
+
// "Grant access".
|
|
38
|
+
const { hasPermission, requestPermission } = useCameraPermission();
|
|
39
|
+
const [perm, setPerm] = useState<DocumentCameraPermission>(hasPermission ? 'granted' : 'priming');
|
|
40
|
+
const permReportedRef = useRef(false);
|
|
41
|
+
|
|
42
|
+
// ── Camera availability ─────────────────────────────────────────────────────
|
|
43
|
+
// Even with permission granted, there may be no usable back camera (the iOS/
|
|
44
|
+
// Android simulator has none; a real device may fail to init). Give the device
|
|
45
|
+
// list a moment to resolve, then surface a proper "Camera not available" error
|
|
46
|
+
// with an upload fallback — on every iOS version (glass or not) and Android.
|
|
47
|
+
// Device enumeration does NOT need camera permission (iOS AVCaptureDevice /
|
|
48
|
+
// Android CameraManager list hardware regardless), so `!device` reliably means
|
|
49
|
+
// "no back-camera hardware" — true on every simulator. That's a different state
|
|
50
|
+
// from "permission denied": no hardware → nothing to grant.
|
|
51
|
+
const device = useCameraDevice('back');
|
|
52
|
+
const [cameraGrace, setCameraGrace] = useState(false);
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
const t = setTimeout(() => setCameraGrace(true), 1500);
|
|
55
|
+
return () => clearTimeout(t);
|
|
56
|
+
}, []);
|
|
57
|
+
// No camera hardware at all → "Camera not available" (regardless of what the
|
|
58
|
+
// permission API says — on a camera-less sim it may even report denied).
|
|
59
|
+
const cameraUnavailable = cameraGrace && !device;
|
|
60
|
+
|
|
61
|
+
// Reflect an externally-granted permission.
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
if (hasPermission) setPerm('granted');
|
|
64
|
+
}, [hasPermission]);
|
|
65
|
+
|
|
66
|
+
// Fire the real OS prompt only after the user taps "Grant access" (or retry).
|
|
67
|
+
useEffect(() => {
|
|
68
|
+
if (perm !== 'requesting') return;
|
|
69
|
+
let cancelled = false;
|
|
70
|
+
void (async () => {
|
|
71
|
+
const granted = await requestPermission();
|
|
72
|
+
if (!cancelled) setPerm(granted ? 'granted' : 'denied');
|
|
73
|
+
})();
|
|
74
|
+
return () => {
|
|
75
|
+
cancelled = true;
|
|
76
|
+
};
|
|
77
|
+
}, [perm, requestPermission]);
|
|
78
|
+
|
|
79
|
+
// A *genuine* permission denial requires a camera to exist but be blocked. On a
|
|
80
|
+
// camera-less sim the OS may report denied — that's "not available", not a
|
|
81
|
+
// permission problem, so don't treat it as denied or report onError there.
|
|
82
|
+
const permissionDenied = perm === 'denied' && !!device;
|
|
83
|
+
useEffect(() => {
|
|
84
|
+
if (permissionDenied && !permReportedRef.current) {
|
|
85
|
+
permReportedRef.current = true;
|
|
86
|
+
safeReportError(
|
|
87
|
+
onError,
|
|
88
|
+
new KYCError(
|
|
89
|
+
'camera_permission_denied',
|
|
90
|
+
'Camera access is required to photograph your document. Allow camera access or upload a photo instead.',
|
|
91
|
+
),
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
if (!permissionDenied) permReportedRef.current = false;
|
|
95
|
+
}, [permissionDenied, onError]);
|
|
96
|
+
|
|
97
|
+
const requestAccess = useCallback(() => {
|
|
98
|
+
setPerm('requesting');
|
|
99
|
+
}, []);
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
perm,
|
|
103
|
+
hasDevice: !!device,
|
|
104
|
+
cameraUnavailable,
|
|
105
|
+
showPrimer: perm === 'priming' && !!device,
|
|
106
|
+
permissionDenied,
|
|
107
|
+
requestAccess,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import React, { useEffect, useRef } from 'react';
|
|
2
|
+
import Svg, { Circle } from 'react-native-svg';
|
|
3
|
+
import { advanceTarget, easeToward, mixHex } from '../../lib/captureRing';
|
|
4
|
+
|
|
5
|
+
// A single line traced around the camera circle's edge for the length of the
|
|
6
|
+
// liveness test: empty when it starts, closed at the shutter, one way only, no
|
|
7
|
+
// rotation. The frame's own border filling in, not a spinner on top of it.
|
|
8
|
+
//
|
|
9
|
+
// Driven OUTSIDE React. `getTarget` is read every frame in a requestAnimationFrame
|
|
10
|
+
// loop and the arc is written with setNativeProps — no state, no re-render.
|
|
11
|
+
// react-native-reanimated is deliberately not a dependency of this SDK, and a
|
|
12
|
+
// native module is the wrong price for one ring; setNativeProps per frame is
|
|
13
|
+
// the no-render path core React Native offers, and the write is a single prop
|
|
14
|
+
// on a single leaf node. Judge it on a release build on the slowest Android
|
|
15
|
+
// you support.
|
|
16
|
+
|
|
17
|
+
const STROKE = 4; // the frame's own border width
|
|
18
|
+
const GREEN_MS = 300;
|
|
19
|
+
|
|
20
|
+
export function CaptureRing({
|
|
21
|
+
size,
|
|
22
|
+
getTarget,
|
|
23
|
+
color,
|
|
24
|
+
successColor,
|
|
25
|
+
complete,
|
|
26
|
+
}: {
|
|
27
|
+
size: number;
|
|
28
|
+
/** Latest 0..1 progress. Read every frame; must be a stable function. */
|
|
29
|
+
getTarget: () => number;
|
|
30
|
+
color: string;
|
|
31
|
+
successColor: string;
|
|
32
|
+
/** Turns the closed ring green — on the same frame as the shutter. */
|
|
33
|
+
complete: boolean;
|
|
34
|
+
}) {
|
|
35
|
+
const ref = useRef<React.ElementRef<typeof Circle>>(null);
|
|
36
|
+
const targetRef = useRef(0);
|
|
37
|
+
const shownRef = useRef(0);
|
|
38
|
+
const completeRef = useRef(complete);
|
|
39
|
+
const greenSinceRef = useRef<number | null>(null);
|
|
40
|
+
completeRef.current = complete;
|
|
41
|
+
|
|
42
|
+
const r = size / 2 - STROKE / 2;
|
|
43
|
+
const circumference = 2 * Math.PI * r;
|
|
44
|
+
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
let frame = 0;
|
|
47
|
+
let last = Date.now();
|
|
48
|
+
const tick = () => {
|
|
49
|
+
const now = Date.now();
|
|
50
|
+
const dt = Math.min((now - last) / 1000, 0.1); // a backgrounded app must not jump
|
|
51
|
+
last = now;
|
|
52
|
+
targetRef.current = advanceTarget(targetRef.current, getTarget());
|
|
53
|
+
shownRef.current = easeToward(shownRef.current, targetRef.current, dt);
|
|
54
|
+
if (completeRef.current && greenSinceRef.current == null) greenSinceRef.current = now;
|
|
55
|
+
const green = greenSinceRef.current == null ? 0 : (now - greenSinceRef.current) / GREEN_MS;
|
|
56
|
+
ref.current?.setNativeProps({
|
|
57
|
+
strokeDashoffset: circumference * (1 - shownRef.current),
|
|
58
|
+
stroke: green > 0 ? mixHex(color, successColor, green) : color,
|
|
59
|
+
});
|
|
60
|
+
frame = requestAnimationFrame(tick);
|
|
61
|
+
};
|
|
62
|
+
frame = requestAnimationFrame(tick);
|
|
63
|
+
return () => cancelAnimationFrame(frame);
|
|
64
|
+
}, [getTarget, circumference, color, successColor]);
|
|
65
|
+
|
|
66
|
+
return (
|
|
67
|
+
<Svg
|
|
68
|
+
pointerEvents="none"
|
|
69
|
+
style={{ position: 'absolute', top: 0, left: 0 }}
|
|
70
|
+
width={size}
|
|
71
|
+
height={size}
|
|
72
|
+
// Static origin, not motion: the arc starts at twelve o'clock.
|
|
73
|
+
rotation={-90}
|
|
74
|
+
originX={size / 2}
|
|
75
|
+
originY={size / 2}
|
|
76
|
+
>
|
|
77
|
+
<Circle
|
|
78
|
+
ref={ref}
|
|
79
|
+
cx={size / 2}
|
|
80
|
+
cy={size / 2}
|
|
81
|
+
r={r}
|
|
82
|
+
fill="none"
|
|
83
|
+
stroke={color}
|
|
84
|
+
strokeWidth={STROKE}
|
|
85
|
+
strokeLinecap="butt"
|
|
86
|
+
strokeDasharray={[circumference]}
|
|
87
|
+
strokeDashoffset={circumference}
|
|
88
|
+
/>
|
|
89
|
+
</Svg>
|
|
90
|
+
);
|
|
91
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { ActivityIndicator, View } from 'react-native';
|
|
3
|
+
import { Camera, type CameraDevice, type CameraViewProps } from 'react-native-vision-camera';
|
|
4
|
+
|
|
5
|
+
// ─── The front camera inside the liveness circle ────────────────────────────
|
|
6
|
+
//
|
|
7
|
+
// The Camera is mounted inside a plain, BORDERLESS wrapper, and that wrapper is
|
|
8
|
+
// the fix for a preview that did not reach the right-hand edge of the circle
|
|
9
|
+
// on Android (Galaxy S24, 2026-09-07: a vertical line one border-width in from
|
|
10
|
+
// the ring, the clip box's ground showing through).
|
|
11
|
+
//
|
|
12
|
+
// VisionCamera installs a "hierarchy fitter" on its Android PreviewView: the
|
|
13
|
+
// moment CameraX adds the SurfaceView, it re-runs `layout(0, 0, w, h)` on the
|
|
14
|
+
// PreviewView so the new child gets measured (React Native never lays native
|
|
15
|
+
// children out on its own). That call keeps the SIZE React Native gave the view
|
|
16
|
+
// and throws away its POSITION — so a Camera that is the direct child of a
|
|
17
|
+
// bordered box snaps from (border, border) back to the box's outer corner. The
|
|
18
|
+
// 3:4 preview buffer overflows the circle vertically and hides the shift on
|
|
19
|
+
// that axis; horizontally it fits exactly, and the missing border-width is the
|
|
20
|
+
// line. iOS has no fitter, which is why only Android showed it.
|
|
21
|
+
//
|
|
22
|
+
// A wrapper with no border sits at (border, border) itself, laid out by React
|
|
23
|
+
// Native, so the fitter's (0, 0) is now the right answer. The wrapper must be
|
|
24
|
+
// `collapsable={false}`: a View that carries nothing but layout is FLATTENED on
|
|
25
|
+
// Android (it never exists natively, its child is re-parented to the
|
|
26
|
+
// grandparent), which put the Camera straight back inside the bordered box
|
|
27
|
+
// and reproduced the line on the first attempt. Never mount VisionCamera's
|
|
28
|
+
// Camera as the direct child of a view with `borderWidth`.
|
|
29
|
+
// Pinned by __tests__/livenessCameraWrapper.test.ts.
|
|
30
|
+
|
|
31
|
+
interface LivenessCameraProps {
|
|
32
|
+
device: CameraDevice | undefined;
|
|
33
|
+
active: boolean;
|
|
34
|
+
outputs: NonNullable<CameraViewProps['outputs']>;
|
|
35
|
+
/** Diameter of the circle, for the Camera's own rounded clip. */
|
|
36
|
+
circle: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function LivenessCamera({ device, active, outputs, circle }: LivenessCameraProps): React.ReactElement {
|
|
40
|
+
return (
|
|
41
|
+
<View style={{ flex: 1 }} collapsable={false}>
|
|
42
|
+
{device ? (
|
|
43
|
+
<Camera
|
|
44
|
+
// borderRadius repeated on the Camera — iOS does not reliably clip
|
|
45
|
+
// a native child to a rounded parent, so the preview's square
|
|
46
|
+
// corners showed through the circle. Same fix the selfie preview
|
|
47
|
+
// and the header brand bar already carry.
|
|
48
|
+
style={{ flex: 1, borderRadius: circle / 2, overflow: 'hidden' }}
|
|
49
|
+
device={device}
|
|
50
|
+
isActive={active}
|
|
51
|
+
outputs={outputs}
|
|
52
|
+
// Mirror the front-camera preview + capture so it reads like a
|
|
53
|
+
// mirror (matches the web SDK's scaleX(-1) video).
|
|
54
|
+
mirrorMode="on"
|
|
55
|
+
/>
|
|
56
|
+
) : (
|
|
57
|
+
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
|
|
58
|
+
<ActivityIndicator color="#FFFFFF" />
|
|
59
|
+
</View>
|
|
60
|
+
)}
|
|
61
|
+
</View>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import React, { useEffect, useRef } from 'react';
|
|
2
|
+
|
|
3
|
+
import { useKycConfig } from '../../components/runtime';
|
|
4
|
+
import { configScope } from '../../lib/scope';
|
|
5
|
+
import { describeWaiting } from '../../lib/result-copy';
|
|
6
|
+
import { waitsForResult } from '../../config/biometricOptions';
|
|
7
|
+
import { biometricCopyFor } from '../../lib/biometric-copy';
|
|
8
|
+
import { SubmittedWaiting } from '../SubmittedWaiting';
|
|
9
|
+
|
|
10
|
+
// ─── Handing over without the review ────────────────────────────────────────
|
|
11
|
+
//
|
|
12
|
+
// On the biometric scopes the selfie review (Retake / Continue) is OFF by
|
|
13
|
+
// default (config/biometricOptions.ts): a re-authentication is a few-second
|
|
14
|
+
// check and a review screen is a stop in the middle of it. The step hands
|
|
15
|
+
// over the moment the ring has closed, WITHOUT waiting for the upload: the
|
|
16
|
+
// upload keeps running and reports to the store, and the submitted step waits
|
|
17
|
+
// on that record (lib/selfie-upload-wait.ts). So the person sees one loading
|
|
18
|
+
// screen from the shutter to the verdict, not one per step. The view below
|
|
19
|
+
// exists for the single render between "ready" and the step change, and it
|
|
20
|
+
// is the same screen the submitted step shows, so nothing visibly changes.
|
|
21
|
+
// An upload that fails BEFORE the ring closes still gets the review, whose
|
|
22
|
+
// Try Again is the recovery.
|
|
23
|
+
|
|
24
|
+
/** Advance exactly once, the first render on which `ready` holds. */
|
|
25
|
+
export function useSelfieAutoAdvance(opts: { enabled: boolean; ready: boolean; onAdvance: () => void }): void {
|
|
26
|
+
const advanced = useRef(false);
|
|
27
|
+
const { enabled, ready, onAdvance } = opts;
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
if (!enabled || !ready || advanced.current) return;
|
|
30
|
+
advanced.current = true;
|
|
31
|
+
onAdvance();
|
|
32
|
+
}, [enabled, ready, onAdvance]);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function LivenessHandover(): React.ReactElement {
|
|
36
|
+
const config = useKycConfig();
|
|
37
|
+
const copy = describeWaiting({
|
|
38
|
+
scope: configScope(config),
|
|
39
|
+
waitsForResult: waitsForResult(config),
|
|
40
|
+
override: biometricCopyFor(config).waiting,
|
|
41
|
+
});
|
|
42
|
+
return <SubmittedWaiting title={copy.title} description={copy.description} />;
|
|
43
|
+
}
|
|
@@ -23,11 +23,15 @@ import type { LivenessFailureReason } from '../../liveness/types';
|
|
|
23
23
|
|
|
24
24
|
export function LivenessComplete({
|
|
25
25
|
selfieUri,
|
|
26
|
+
soft = false,
|
|
26
27
|
upload,
|
|
27
28
|
onRetake,
|
|
28
29
|
onContinue,
|
|
29
30
|
}: {
|
|
30
|
-
|
|
31
|
+
/** Null on a restored session: the mediaId survived, the local file did not. */
|
|
32
|
+
selfieUri: string | null;
|
|
33
|
+
/** The still reads out of focus. A notice on the preview, never a gate. */
|
|
34
|
+
soft?: boolean;
|
|
31
35
|
upload: SelfieUpload;
|
|
32
36
|
onRetake: () => void;
|
|
33
37
|
onContinue: () => void;
|
|
@@ -37,10 +41,10 @@ export function LivenessComplete({
|
|
|
37
41
|
upload;
|
|
38
42
|
return (
|
|
39
43
|
<View>
|
|
40
|
-
<SelfiePreview uri={selfieUri} uploading={uploading && !uploadError} />
|
|
44
|
+
<SelfiePreview uri={selfieUri} uploading={uploading && !uploadError} soft={soft} />
|
|
41
45
|
{retryInfo && uploading ? (
|
|
42
46
|
<MyazaText variant="bodySmall" color={colors.warning} style={{ textAlign: 'center', marginTop: spacing.sm }}>
|
|
43
|
-
{`Upload failed
|
|
47
|
+
{`Upload failed, retrying (${retryInfo.attempt}/${retryInfo.total})`}
|
|
44
48
|
</MyazaText>
|
|
45
49
|
) : null}
|
|
46
50
|
<View style={{ height: spacing.md }} />
|
|
@@ -51,7 +55,7 @@ export function LivenessComplete({
|
|
|
51
55
|
// Disabled while the retry is in flight, not spinning — the progress
|
|
52
56
|
// indicator belongs in the selfie frame, where the work is.
|
|
53
57
|
disabled={uploading}
|
|
54
|
-
onPress={() => void uploadSelfieAndVideo(selfieUri, videoPathRef.current)}
|
|
58
|
+
onPress={() => selfieUri && void uploadSelfieAndVideo(selfieUri, videoPathRef.current)}
|
|
55
59
|
/>
|
|
56
60
|
) : (
|
|
57
61
|
<View style={{ flexDirection: 'row', gap: spacing.md }}>
|
|
@@ -11,15 +11,33 @@ import { StyleAbsFill } from './constants';
|
|
|
11
11
|
// The captured selfie, shown back while it uploads.
|
|
12
12
|
// ---------------------------------------------------------------------------
|
|
13
13
|
|
|
14
|
-
export function SelfiePreview({
|
|
14
|
+
export function SelfiePreview({
|
|
15
|
+
uri,
|
|
16
|
+
uploading,
|
|
17
|
+
soft = false,
|
|
18
|
+
}: {
|
|
19
|
+
uri: string | null;
|
|
20
|
+
uploading?: boolean;
|
|
21
|
+
/** The still reads out of focus (lib/selfie-sharpness). */
|
|
22
|
+
soft?: boolean;
|
|
23
|
+
}): React.ReactElement {
|
|
15
24
|
const { colors } = useTheme();
|
|
16
25
|
const S = 200;
|
|
17
26
|
return (
|
|
18
27
|
<View style={{ alignItems: 'center', justifyContent: 'center', alignSelf: 'stretch' }}>
|
|
19
|
-
<View style={{ width: S, height: S, borderRadius: S / 2, borderWidth: 4, backgroundColor: '#111111'
|
|
28
|
+
<View style={{ width: S, height: S, borderRadius: S / 2, borderWidth: 4, backgroundColor: uri ? '#111111' : `${colors.primary}14`, borderColor: `${colors.primary}33`, overflow: 'hidden', alignItems: 'center', justifyContent: 'center' }}>
|
|
20
29
|
{/* borderRadius repeated on the Image — iOS doesn't reliably clip an Image
|
|
21
|
-
child to a rounded parent (same fix as the header brand bar).
|
|
22
|
-
|
|
30
|
+
child to a rounded parent (same fix as the header brand bar). A null
|
|
31
|
+
uri is a restored session whose local file is gone: the uploaded
|
|
32
|
+
selfie is the durable record, so show a completed state rather than
|
|
33
|
+
a broken image. */}
|
|
34
|
+
{uri ? (
|
|
35
|
+
<Image source={{ uri }} style={{ width: '100%', height: '100%', borderRadius: S / 2 }} resizeMode="cover" />
|
|
36
|
+
) : (
|
|
37
|
+
<MyazaText variant="bodySmall" color={colors.textSecondary} style={{ textAlign: 'center', paddingHorizontal: spacing.lg }}>
|
|
38
|
+
Selfie already captured
|
|
39
|
+
</MyazaText>
|
|
40
|
+
)}
|
|
23
41
|
{/* Standard upload loader inside the preview circle (mirrors web/Flutter). */}
|
|
24
42
|
{uploading ? (
|
|
25
43
|
<View style={[StyleAbsFill, { backgroundColor: 'rgba(0,0,0,0.45)', alignItems: 'center', justifyContent: 'center' }]}>
|
|
@@ -28,11 +46,17 @@ export function SelfiePreview({ uri, uploading }: { uri: string; uploading?: boo
|
|
|
28
46
|
) : null}
|
|
29
47
|
</View>
|
|
30
48
|
<View style={{ height: spacing.md }} />
|
|
31
|
-
|
|
32
|
-
|
|
49
|
+
{/* The heading is the review's verdict on the photo, so a soft still must
|
|
50
|
+
not read "Looking good!". Continue stays available either way: the
|
|
51
|
+
floor is uncalibrated, and a wrong one should cost a sentence, never
|
|
52
|
+
a retake loop. */}
|
|
53
|
+
<MyazaText variant="heading3" color={soft ? colors.warning : undefined} style={{ textAlign: 'center' }}>
|
|
54
|
+
{soft ? 'This photo looks blurry' : 'Looking good!'}
|
|
33
55
|
</MyazaText>
|
|
34
56
|
<MyazaText variant="bodySmall" color={colors.textSecondary} style={{ textAlign: 'center' }}>
|
|
35
|
-
|
|
57
|
+
{soft
|
|
58
|
+
? 'For the best chance of a match, retake it holding the phone steady until your face is sharp.'
|
|
59
|
+
: 'Tap Continue to submit, or Retake to try again.'}
|
|
36
60
|
</MyazaText>
|
|
37
61
|
</View>
|
|
38
62
|
);
|