@myazahq/kyc-sdk-react-native 2.6.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 +128 -4
- 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 -72
- 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 +57 -2
- 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 +17 -8
- 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/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/Icon.tsx +8 -7
- package/src/components/KycFlow.tsx +9 -2
- package/src/components/MyazaButton.tsx +9 -0
- package/src/components/RequiredDocumentPill.tsx +114 -0
- package/src/components/StepHeader.tsx +23 -3
- package/src/components/documentReviewCopy.ts +39 -0
- package/src/components/fonts.ts +14 -17
- package/src/components/stepHeaderMeta.tsx +6 -1
- package/src/config/documentCaptureMethods.ts +31 -0
- package/src/config/workflowMerge.ts +2 -0
- package/src/index.ts +5 -10
- package/src/lib/documentCaptureCheck.ts +136 -0
- package/src/lib/model-ready.ts +82 -0
- package/src/lib/prime-models.ts +50 -0
- package/src/lib/resubmit.ts +36 -7
- package/src/lib/selfie-sharpness.ts +211 -0
- package/src/liveness/avatarSource.ts +59 -0
- 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/screens/DocumentCaptureStep.tsx +177 -93
- package/src/screens/LivenessAvatar.tsx +18 -13
- package/src/screens/LivenessStep.tsx +37 -0
- package/src/screens/MrzScanView.tsx +33 -0
- 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/LivenessOutcome.tsx +4 -1
- package/src/screens/liveness/SelfiePreview.tsx +19 -4
- package/src/services/api-types.ts +28 -2
- package/src/services/api.ts +19 -3
- package/src/services/deviceMetadata.ts +1 -1
- package/src/services/mediaCompress.ts +23 -5
- package/src/specs/MyazaTextRecognizer.nitro.ts +29 -0
- package/src/store/kycStore.ts +8 -2
- package/src/types/config.ts +15 -0
- package/src/MyazaBiometricAuth.tsx +0 -119
- 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
- package/src/lib/biometric-auth.ts +0 -52
- package/src/screens/biometric/BiometricAuthFlow.tsx +0 -170
- package/src/services/api-biometric.ts +0 -37
- package/src/services/api-types-biometric.ts +0 -41
|
@@ -2,11 +2,12 @@ import React, { useEffect, useMemo, useRef, useState } from 'react';
|
|
|
2
2
|
import { ActivityIndicator, LayoutChangeEvent, Modal, PanResponder, Platform, Pressable, StatusBar, StyleSheet, View } from 'react-native';
|
|
3
3
|
import Svg, { Line, Rect } from 'react-native-svg';
|
|
4
4
|
|
|
5
|
-
import { spacing } from '../config/theme';
|
|
5
|
+
import { headerSurface, spacing } from '../config/theme';
|
|
6
6
|
import { cropImage, imageSize } from '../services/mediaCompress';
|
|
7
7
|
import { MyazaText } from './Typography';
|
|
8
8
|
import { MyazaButton } from './MyazaButton';
|
|
9
9
|
import { Icon } from './Icon';
|
|
10
|
+
import { useTheme } from './runtime';
|
|
10
11
|
import { Image } from 'react-native';
|
|
11
12
|
|
|
12
13
|
// Interactive ID-card cropper — a 1:1 RN port of the Flutter SDK's
|
|
@@ -19,7 +20,11 @@ import { Image } from 'react-native';
|
|
|
19
20
|
const ID_AR = 85.6 / 53.98;
|
|
20
21
|
const HANDLE_RADIUS = 28; // touch radius for the corner handles
|
|
21
22
|
const ARM = 22; // corner-bracket arm length
|
|
22
|
-
|
|
23
|
+
// The photo sits on a neutral dark stage in both themes (the web SDK's
|
|
24
|
+
// neutral-900): it is the backdrop a document photo reads well against, and it
|
|
25
|
+
// keeps the white crop strokes legible. Everything around the photo takes the
|
|
26
|
+
// workflow's own colours.
|
|
27
|
+
export const CROPPER_STAGE = '#171717';
|
|
23
28
|
// Keep the title clear of the status bar / notch on the full-screen cropper Modal.
|
|
24
29
|
const TOP_INSET = Platform.OS === 'ios' ? 56 : (StatusBar.currentHeight ?? 24) + 8;
|
|
25
30
|
|
|
@@ -95,6 +100,7 @@ export function DocumentCropper({ uri, onCancel, onConfirm }: DocumentCropperPro
|
|
|
95
100
|
const [container, setContainer] = useState<{ w: number; h: number } | null>(null);
|
|
96
101
|
const [crop, setCrop] = useState<Rectangle | null>(null);
|
|
97
102
|
const [processing, setProcessing] = useState(false);
|
|
103
|
+
const { colors, mode } = useTheme();
|
|
98
104
|
|
|
99
105
|
// Pan state held in refs so the PanResponder callbacks stay stable.
|
|
100
106
|
const handleRef = useRef<Handle>('none');
|
|
@@ -219,17 +225,18 @@ export function DocumentCropper({ uri, onCancel, onConfirm }: DocumentCropperPro
|
|
|
219
225
|
|
|
220
226
|
return (
|
|
221
227
|
<Modal visible animationType="slide" presentationStyle="fullScreen" onRequestClose={onCancel}>
|
|
222
|
-
<
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
228
|
+
<StatusBar barStyle={mode === 'dark' ? 'light-content' : 'dark-content'} />
|
|
229
|
+
<View style={{ flex: 1, backgroundColor: CROPPER_STAGE }}>
|
|
230
|
+
{/* App bar: the band the flow's steps sit under */}
|
|
231
|
+
<View style={[styles.appBar, { backgroundColor: headerSurface(colors, mode), borderBottomColor: colors.border }]}>
|
|
232
|
+
<Pressable onPress={onCancel} accessibilityRole="button" accessibilityLabel="Cancel" hitSlop={8} style={styles.close}>
|
|
233
|
+
<Icon name="close" size={20} color={colors.textDark} />
|
|
227
234
|
</Pressable>
|
|
228
|
-
<View>
|
|
229
|
-
<MyazaText variant="bodyMedium" color=
|
|
235
|
+
<View style={{ flexShrink: 1 }}>
|
|
236
|
+
<MyazaText variant="bodyMedium" color={colors.textDark} style={{ fontWeight: '600' }}>
|
|
230
237
|
Crop to ID Card
|
|
231
238
|
</MyazaText>
|
|
232
|
-
<MyazaText variant="bodySmall" color=
|
|
239
|
+
<MyazaText variant="bodySmall" color={colors.textMuted}>
|
|
233
240
|
Drag to reposition · handles to resize
|
|
234
241
|
</MyazaText>
|
|
235
242
|
</View>
|
|
@@ -274,18 +281,9 @@ export function DocumentCropper({ uri, onCancel, onConfirm }: DocumentCropperPro
|
|
|
274
281
|
</View>
|
|
275
282
|
|
|
276
283
|
{/* Action bar */}
|
|
277
|
-
<View style={styles.actionBar}>
|
|
278
|
-
{
|
|
279
|
-
|
|
280
|
-
<ActivityIndicator color="#FFFFFF" />
|
|
281
|
-
<View style={{ width: 10 }} />
|
|
282
|
-
<MyazaText variant="bodyMedium" color="rgba(255,255,255,0.8)" style={{ flexShrink: 1 }}>
|
|
283
|
-
Processing…
|
|
284
|
-
</MyazaText>
|
|
285
|
-
</View>
|
|
286
|
-
) : (
|
|
287
|
-
<MyazaButton label="Crop & Use" leadingIcon="check" onPress={confirm} disabled={!crop} />
|
|
288
|
-
)}
|
|
284
|
+
<View style={[styles.actionBar, { backgroundColor: colors.background, borderTopColor: colors.border }]}>
|
|
285
|
+
{/* The flow's own button: primary from the workflow, its loading state while the crop runs. */}
|
|
286
|
+
<MyazaButton label="Crop & Use" leadingIcon="check" onPress={confirm} disabled={!crop} loading={processing} />
|
|
289
287
|
</View>
|
|
290
288
|
</View>
|
|
291
289
|
</Modal>
|
|
@@ -311,15 +309,23 @@ const styles = StyleSheet.create({
|
|
|
311
309
|
appBar: {
|
|
312
310
|
flexDirection: 'row',
|
|
313
311
|
alignItems: 'center',
|
|
314
|
-
backgroundColor: APPBAR_BG,
|
|
315
312
|
paddingTop: TOP_INSET,
|
|
316
313
|
paddingBottom: spacing.sm,
|
|
317
314
|
paddingHorizontal: spacing.sm,
|
|
315
|
+
borderBottomWidth: StyleSheet.hairlineWidth,
|
|
316
|
+
},
|
|
317
|
+
// 44pt: the tap target the platforms ask for, around a 20pt glyph.
|
|
318
|
+
close: {
|
|
319
|
+
width: 44,
|
|
320
|
+
height: 44,
|
|
321
|
+
alignItems: 'center',
|
|
322
|
+
justifyContent: 'center',
|
|
323
|
+
marginRight: 4,
|
|
318
324
|
},
|
|
319
325
|
actionBar: {
|
|
320
|
-
backgroundColor: APPBAR_BG,
|
|
321
326
|
paddingHorizontal: spacing.md,
|
|
322
327
|
paddingTop: spacing.sm,
|
|
323
328
|
paddingBottom: spacing.lg,
|
|
329
|
+
borderTopWidth: StyleSheet.hairlineWidth,
|
|
324
330
|
},
|
|
325
331
|
});
|
|
@@ -6,6 +6,7 @@ import { Icon } from './Icon';
|
|
|
6
6
|
import { MyazaText } from './Typography';
|
|
7
7
|
import { DocumentReviewThumb, type ReviewSide } from './DocumentReviewSide';
|
|
8
8
|
import { DocumentReviewZoom } from './DocumentReviewZoom';
|
|
9
|
+
import { documentReviewCopy, type DocumentCaptureMode } from './documentReviewCopy';
|
|
9
10
|
import { radius, spacing } from '../config/theme';
|
|
10
11
|
|
|
11
12
|
// ─── Document review ──────────────────────────────────────────────────────────
|
|
@@ -33,6 +34,9 @@ import { radius, spacing } from '../config/theme';
|
|
|
33
34
|
// Unlike Flutter, the step here sits inside the sheet's own ScrollView (which
|
|
34
35
|
// already carries the bottom safe-area inset), so the footer is pushed down by
|
|
35
36
|
// a flexible spacer rather than pinned with a hand-computed inset.
|
|
37
|
+
//
|
|
38
|
+
// The words follow `mode` (see documentReviewCopy): an upload-only workflow
|
|
39
|
+
// never showed a camera, so it says "Replace" and "added".
|
|
36
40
|
|
|
37
41
|
export function DocumentReview({
|
|
38
42
|
frontUri,
|
|
@@ -43,6 +47,7 @@ export function DocumentReview({
|
|
|
43
47
|
footer,
|
|
44
48
|
onRetakeFront,
|
|
45
49
|
onRetakeBack,
|
|
50
|
+
mode = 'scan',
|
|
46
51
|
}: {
|
|
47
52
|
frontUri: string;
|
|
48
53
|
backUri?: string | null;
|
|
@@ -56,6 +61,8 @@ export function DocumentReview({
|
|
|
56
61
|
footer: React.ReactNode;
|
|
57
62
|
onRetakeFront?: () => void;
|
|
58
63
|
onRetakeBack?: () => void;
|
|
64
|
+
/** How the photos got in: the camera (default) or chosen from the device. */
|
|
65
|
+
mode?: DocumentCaptureMode;
|
|
59
66
|
}): React.ReactElement {
|
|
60
67
|
const { colors } = useTheme();
|
|
61
68
|
const [zoomed, setZoomed] = useState<ReviewSide | null>(null);
|
|
@@ -65,6 +72,7 @@ export function DocumentReview({
|
|
|
65
72
|
...(backUri ? [{ id: 'back' as const, label: 'Back', uri: backUri, onRetake: onRetakeBack }] : []),
|
|
66
73
|
];
|
|
67
74
|
const twoSided = sides.length > 1;
|
|
75
|
+
const copy = documentReviewCopy(mode, twoSided);
|
|
68
76
|
|
|
69
77
|
return (
|
|
70
78
|
<View style={{ flex: 1 }}>
|
|
@@ -85,7 +93,7 @@ export function DocumentReview({
|
|
|
85
93
|
</View>
|
|
86
94
|
<View style={{ width: spacing.xs }} />
|
|
87
95
|
<MyazaText variant="bodySmall" style={{ fontWeight: '600' }}>
|
|
88
|
-
{
|
|
96
|
+
{copy.status}
|
|
89
97
|
</MyazaText>
|
|
90
98
|
</View>
|
|
91
99
|
|
|
@@ -108,6 +116,7 @@ export function DocumentReview({
|
|
|
108
116
|
// the screen read as permanently stuck.
|
|
109
117
|
busyOverlay={isBusy ? busyOverlay : null}
|
|
110
118
|
onZoom={() => setZoomed(side)}
|
|
119
|
+
copy={copy}
|
|
111
120
|
/>
|
|
112
121
|
</View>
|
|
113
122
|
</React.Fragment>
|
|
@@ -129,6 +138,7 @@ export function DocumentReview({
|
|
|
129
138
|
<DocumentReviewZoom
|
|
130
139
|
side={zoomed}
|
|
131
140
|
isBusy={isBusy}
|
|
141
|
+
copy={copy}
|
|
132
142
|
onClose={() => setZoomed(null)}
|
|
133
143
|
onRetake={() => {
|
|
134
144
|
const retake = zoomed.onRetake;
|
|
@@ -6,6 +6,7 @@ import { Icon } from './Icon';
|
|
|
6
6
|
import { MyazaText } from './Typography';
|
|
7
7
|
import { radius, spacing } from '../config/theme';
|
|
8
8
|
import { CHROME_SCRIM, ChromeGlass } from './glass/ChromeGlass';
|
|
9
|
+
import type { DocumentReviewCopy } from './documentReviewCopy';
|
|
9
10
|
|
|
10
11
|
/** The chip's flat backing, and still the fallback off iOS 26. */
|
|
11
12
|
const CHIP_SCRIM = CHROME_SCRIM;
|
|
@@ -34,12 +35,15 @@ export function DocumentReviewThumb({
|
|
|
34
35
|
isBusy,
|
|
35
36
|
busyOverlay,
|
|
36
37
|
onZoom,
|
|
38
|
+
copy,
|
|
37
39
|
}: {
|
|
38
40
|
side: ReviewSide;
|
|
39
41
|
aspect: number;
|
|
40
42
|
isBusy: boolean;
|
|
41
43
|
busyOverlay?: React.ReactNode;
|
|
42
44
|
onZoom: () => void;
|
|
45
|
+
/** The review's words for the capture mode (documentReviewCopy). */
|
|
46
|
+
copy: DocumentReviewCopy;
|
|
43
47
|
}): React.ReactElement {
|
|
44
48
|
const { colors } = useTheme();
|
|
45
49
|
return (
|
|
@@ -90,7 +94,7 @@ export function DocumentReviewThumb({
|
|
|
90
94
|
onPress={isBusy ? undefined : side.onRetake}
|
|
91
95
|
disabled={isBusy}
|
|
92
96
|
accessibilityRole="button"
|
|
93
|
-
accessibilityLabel={
|
|
97
|
+
accessibilityLabel={copy.redoAccessibility(side.label)}
|
|
94
98
|
style={{
|
|
95
99
|
flexDirection: 'row',
|
|
96
100
|
alignItems: 'center',
|
|
@@ -103,7 +107,7 @@ export function DocumentReviewThumb({
|
|
|
103
107
|
<Icon name="refresh" size={14} color={colors.primary} />
|
|
104
108
|
<View style={{ width: 6 }} />
|
|
105
109
|
<MyazaText variant="bodySmall" color={colors.primary} style={{ flexShrink: 1 }}>
|
|
106
|
-
|
|
110
|
+
{copy.redo}
|
|
107
111
|
</MyazaText>
|
|
108
112
|
</Pressable>
|
|
109
113
|
</View>
|
|
@@ -7,6 +7,7 @@ import { Icon } from './Icon';
|
|
|
7
7
|
import { MyazaText } from './Typography';
|
|
8
8
|
import { MyazaButton } from './MyazaButton';
|
|
9
9
|
import { Chip, CHIP_TEXT, type ReviewSide } from './DocumentReviewSide';
|
|
10
|
+
import type { DocumentReviewCopy } from './documentReviewCopy';
|
|
10
11
|
|
|
11
12
|
/**
|
|
12
13
|
* Full-bleed view of one side, with retake to hand — checking a capture and
|
|
@@ -43,11 +44,14 @@ function bottomInset(): number {
|
|
|
43
44
|
export function DocumentReviewZoom({
|
|
44
45
|
side,
|
|
45
46
|
isBusy,
|
|
47
|
+
copy,
|
|
46
48
|
onClose,
|
|
47
49
|
onRetake,
|
|
48
50
|
}: {
|
|
49
51
|
side: ReviewSide;
|
|
50
52
|
isBusy: boolean;
|
|
53
|
+
/** The review's words for the capture mode (documentReviewCopy). */
|
|
54
|
+
copy: DocumentReviewCopy;
|
|
51
55
|
onClose: () => void;
|
|
52
56
|
onRetake: () => void;
|
|
53
57
|
}): React.ReactElement {
|
|
@@ -107,7 +111,7 @@ export function DocumentReviewZoom({
|
|
|
107
111
|
|
|
108
112
|
<View style={{ height: spacing.sm }} />
|
|
109
113
|
<MyazaButton
|
|
110
|
-
label={
|
|
114
|
+
label={copy.redoSide(side.label)}
|
|
111
115
|
leadingIcon="refresh"
|
|
112
116
|
variant="outline"
|
|
113
117
|
disabled={isBusy}
|
package/src/components/Icon.tsx
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
|
-
import Ionicons from '@expo/vector-icons/Ionicons';
|
|
3
2
|
|
|
4
3
|
import { ICONS, type IconName } from './icon-map';
|
|
5
4
|
|
|
@@ -15,11 +14,13 @@ export interface IconProps {
|
|
|
15
14
|
}
|
|
16
15
|
|
|
17
16
|
export function Icon({ name, size = 20, color, strokeWidth = 2, solid = false }: IconProps): React.ReactElement {
|
|
18
|
-
// Solid moon/sun for the theme toggle — Lucide has no filled variants, so use
|
|
19
|
-
// Ionicons' purpose-built solid glyphs (matches Flutter's dark_mode/light_mode).
|
|
20
|
-
if (solid && (name === 'moon' || name === 'sun')) {
|
|
21
|
-
return <Ionicons name={name === 'moon' ? 'moon' : 'sunny'} size={size} color={color} />;
|
|
22
|
-
}
|
|
23
17
|
const Glyph = ICONS[name];
|
|
24
|
-
|
|
18
|
+
// The theme toggle's filled moon/sun (Flutter's dark_mode/light_mode). Lucide
|
|
19
|
+
// ships no filled variants, so the glyph's own body takes the colour: the
|
|
20
|
+
// moon's crescent and the sun's disc fill, the sun's rays stay strokes. This
|
|
21
|
+
// replaced an Ionicons import that was the SDK's ONLY use of
|
|
22
|
+
// @expo/vector-icons, a package whose icon fonts still reached every
|
|
23
|
+
// integrator's app for those two glyphs (1.35 MB on a real release APK).
|
|
24
|
+
const fill = solid && (name === 'moon' || name === 'sun') ? color : 'none';
|
|
25
|
+
return <Glyph size={size} color={color} strokeWidth={strokeWidth} fill={fill} />;
|
|
25
26
|
}
|
|
@@ -134,9 +134,16 @@ export function KycFlow({
|
|
|
134
134
|
return { progress: (idx + 1) / steps.length, stepCount: steps.length };
|
|
135
135
|
}, [currentStep, order]);
|
|
136
136
|
|
|
137
|
-
// The flag beside the title names the country whose IDs are on screen.
|
|
137
|
+
// The flag beside the title names the country whose IDs are on screen. The
|
|
138
|
+
// document steps ask for a country's document just as directly as the ID
|
|
139
|
+
// ones do, so they carry the same flag rather than a second treatment.
|
|
138
140
|
const headerCountry =
|
|
139
|
-
currentStep === 'id-type' ||
|
|
141
|
+
currentStep === 'id-type' ||
|
|
142
|
+
currentStep === 'id-input' ||
|
|
143
|
+
currentStep === 'document-capture' ||
|
|
144
|
+
currentStep === 'nfc'
|
|
145
|
+
? country
|
|
146
|
+
: null;
|
|
140
147
|
// Back is hidden on the flow's OPENING step (consent, or the first real step
|
|
141
148
|
// when the workflow switched the consent screen off), not on consent by
|
|
142
149
|
// name. A multi-ID run returns to the ID picker for its next check, where
|
|
@@ -25,6 +25,10 @@ export interface MyazaButtonProps {
|
|
|
25
25
|
leadingIcon?: IconName;
|
|
26
26
|
style?: StyleProp<ViewStyle>;
|
|
27
27
|
fullWidth?: boolean;
|
|
28
|
+
/** Screen reader label, when the visible label alone is ambiguous. */
|
|
29
|
+
accessibilityLabel?: string;
|
|
30
|
+
/** Screen reader hint describing what the press does. */
|
|
31
|
+
accessibilityHint?: string;
|
|
28
32
|
}
|
|
29
33
|
|
|
30
34
|
export function MyazaButton({
|
|
@@ -36,6 +40,8 @@ export function MyazaButton({
|
|
|
36
40
|
leadingIcon,
|
|
37
41
|
style,
|
|
38
42
|
fullWidth = true,
|
|
43
|
+
accessibilityLabel,
|
|
44
|
+
accessibilityHint,
|
|
39
45
|
}: MyazaButtonProps): React.ReactElement {
|
|
40
46
|
const { colors } = useTheme();
|
|
41
47
|
const isDisabled = disabled || loading || !onPress;
|
|
@@ -59,6 +65,9 @@ export function MyazaButton({
|
|
|
59
65
|
<Pressable
|
|
60
66
|
onPress={isDisabled ? undefined : onPress}
|
|
61
67
|
disabled={isDisabled}
|
|
68
|
+
accessibilityRole="button"
|
|
69
|
+
accessibilityLabel={accessibilityLabel}
|
|
70
|
+
accessibilityHint={accessibilityHint}
|
|
62
71
|
style={({ pressed }) => [
|
|
63
72
|
{
|
|
64
73
|
height: sizing.buttonHeight,
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { View } from 'react-native';
|
|
3
|
+
|
|
4
|
+
import { radius, spacing } from '../config/theme';
|
|
5
|
+
import { Icon } from './Icon';
|
|
6
|
+
import { useTheme } from './runtime';
|
|
7
|
+
import { MyazaText } from './Typography';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* "Required: <document>" — which document this step expects, on the screens
|
|
11
|
+
* either side of the camera (the ready primer and the review).
|
|
12
|
+
*
|
|
13
|
+
* The header names the document too, but this is the last thing in view when
|
|
14
|
+
* the applicant is deciding whether they have the right one in hand, and on a
|
|
15
|
+
* two-sided ID it is the only place that says which SIDE is being asked for
|
|
16
|
+
* and how far through they are.
|
|
17
|
+
*
|
|
18
|
+
* Mirrors the web SDK's badge in DocumentCaptureStep and Flutter's
|
|
19
|
+
* `_RequiredPill`: same Lucide glyph, same primary tint, same wording.
|
|
20
|
+
*/
|
|
21
|
+
export function RequiredDocumentPill({
|
|
22
|
+
idTypeLabel,
|
|
23
|
+
sideBadge,
|
|
24
|
+
stepLabel,
|
|
25
|
+
}: {
|
|
26
|
+
idTypeLabel: string;
|
|
27
|
+
/** "Front Side" / "Back Side" — two-sided documents only. */
|
|
28
|
+
sideBadge?: string | null;
|
|
29
|
+
/** "Step 1 of 2" — two-sided documents only. */
|
|
30
|
+
stepLabel?: string | null;
|
|
31
|
+
}): React.ReactElement {
|
|
32
|
+
const { colors } = useTheme();
|
|
33
|
+
const hasMeta = Boolean(sideBadge || stepLabel);
|
|
34
|
+
return (
|
|
35
|
+
<View
|
|
36
|
+
style={{
|
|
37
|
+
flexDirection: 'row',
|
|
38
|
+
alignItems: 'center',
|
|
39
|
+
flexWrap: 'wrap',
|
|
40
|
+
// Column and row gaps are set separately: when this wraps, the two
|
|
41
|
+
// groups need breathing room vertically too, and a single `gap` gives
|
|
42
|
+
// the wrapped row the same spacing as the inline one by accident.
|
|
43
|
+
columnGap: spacing.sm,
|
|
44
|
+
rowGap: spacing.xs,
|
|
45
|
+
paddingHorizontal: spacing.md,
|
|
46
|
+
paddingVertical: spacing.sm,
|
|
47
|
+
borderRadius: radius.md,
|
|
48
|
+
borderWidth: 1,
|
|
49
|
+
borderColor: colors.primary200,
|
|
50
|
+
backgroundColor: colors.primary50,
|
|
51
|
+
}}
|
|
52
|
+
// One statement to a screen reader; the parts read as a sentence
|
|
53
|
+
// rather than three disconnected fragments.
|
|
54
|
+
accessible
|
|
55
|
+
accessibilityLabel={[`Required: ${idTypeLabel}`, sideBadge, stepLabel]
|
|
56
|
+
.filter(Boolean)
|
|
57
|
+
.join(', ')}
|
|
58
|
+
>
|
|
59
|
+
{/* What the applicant has to be holding. Shrinks and wraps within its
|
|
60
|
+
own box before it pushes anything else off the line, because the
|
|
61
|
+
document name is the one part of this that cannot be abbreviated —
|
|
62
|
+
"Permanent Voter's Card" truncated is a different document. */}
|
|
63
|
+
<View
|
|
64
|
+
style={{
|
|
65
|
+
flexDirection: 'row',
|
|
66
|
+
alignItems: 'center',
|
|
67
|
+
gap: spacing.xs,
|
|
68
|
+
flexShrink: 1,
|
|
69
|
+
minWidth: 0,
|
|
70
|
+
}}
|
|
71
|
+
>
|
|
72
|
+
<Icon name="credit-card" size={16} color={colors.primary} />
|
|
73
|
+
<MyazaText variant="bodySmall" color={colors.primary} style={{ fontWeight: '600' }}>
|
|
74
|
+
Required:
|
|
75
|
+
</MyazaText>
|
|
76
|
+
<MyazaText variant="bodySmall" color={colors.primary} style={{ flexShrink: 1 }}>
|
|
77
|
+
{idTypeLabel}
|
|
78
|
+
</MyazaText>
|
|
79
|
+
</View>
|
|
80
|
+
{/* Side and progress travel TOGETHER. They are one thought — which half
|
|
81
|
+
of a two-sided document this is — and on a narrow screen they drop to
|
|
82
|
+
a second line as a pair.
|
|
83
|
+
The step label used to carry `marginLeft: 'auto'`, which fights
|
|
84
|
+
`flexWrap`: auto-margin assumes spare space on the line, wrapping
|
|
85
|
+
means there is none. So on a long document name it wrapped alone and
|
|
86
|
+
was then flung to the right edge, leaving an orphaned "Step 1 of 2"
|
|
87
|
+
under an empty gap. Seen on a Galaxy S24 with "Permanent Voter's
|
|
88
|
+
Card". Grouping them makes the wrap look deliberate instead. */}
|
|
89
|
+
{hasMeta ? (
|
|
90
|
+
<View style={{ flexDirection: 'row', alignItems: 'center', gap: spacing.xs }}>
|
|
91
|
+
{sideBadge ? (
|
|
92
|
+
<View
|
|
93
|
+
style={{
|
|
94
|
+
paddingHorizontal: spacing.sm,
|
|
95
|
+
paddingVertical: 2,
|
|
96
|
+
borderRadius: radius.full,
|
|
97
|
+
backgroundColor: colors.primary100,
|
|
98
|
+
}}
|
|
99
|
+
>
|
|
100
|
+
<MyazaText variant="bodySmall" color={colors.primary} style={{ fontWeight: '600' }}>
|
|
101
|
+
{sideBadge}
|
|
102
|
+
</MyazaText>
|
|
103
|
+
</View>
|
|
104
|
+
) : null}
|
|
105
|
+
{stepLabel ? (
|
|
106
|
+
<MyazaText variant="bodySmall" color={colors.textSecondary}>
|
|
107
|
+
{stepLabel}
|
|
108
|
+
</MyazaText>
|
|
109
|
+
) : null}
|
|
110
|
+
</View>
|
|
111
|
+
) : null}
|
|
112
|
+
</View>
|
|
113
|
+
);
|
|
114
|
+
}
|
|
@@ -17,6 +17,10 @@ import { CountryFlag } from './CountryFlag';
|
|
|
17
17
|
const BACK_SIZE = 40;
|
|
18
18
|
const BACK_FOOTPRINT = BACK_SIZE + spacing.sm;
|
|
19
19
|
|
|
20
|
+
// One line of heading2 (fontSize 20). The flag is centred inside a box of this
|
|
21
|
+
// height so it lands on the first line whether the title wraps or not.
|
|
22
|
+
const FLAG_LINE_HEIGHT = 26;
|
|
23
|
+
|
|
20
24
|
export interface StepHeaderProps {
|
|
21
25
|
title: string;
|
|
22
26
|
description?: string | null;
|
|
@@ -44,13 +48,29 @@ export function StepHeader({ title, description, onBack, country }: StepHeaderPr
|
|
|
44
48
|
/>
|
|
45
49
|
</View>
|
|
46
50
|
) : null}
|
|
47
|
-
|
|
51
|
+
{/* `flex-start`, not `center`. A long document name wraps to two lines
|
|
52
|
+
on a small screen ("Capture Your Permanent Voter's Card" does at
|
|
53
|
+
360dp), and centring the flag against the whole block floated it in
|
|
54
|
+
the gap between the lines, attached to neither. Anchored to the top
|
|
55
|
+
it reads as a marker on the title, wherever the text wraps.
|
|
56
|
+
Seen on a Galaxy S24. */}
|
|
57
|
+
<View style={{ flex: 1, flexDirection: 'row', alignItems: 'flex-start' }}>
|
|
48
58
|
<MyazaText variant="heading2" numberOfLines={2} style={{ flexShrink: 1 }}>
|
|
49
59
|
{title}
|
|
50
60
|
</MyazaText>
|
|
51
61
|
{country ? (
|
|
52
|
-
|
|
53
|
-
|
|
62
|
+
// The flag box is the height of one line of heading2, with the flag
|
|
63
|
+
// centred in it — so it sits on the first line's optical centre
|
|
64
|
+
// rather than its top edge, and does not have to be re-nudged if
|
|
65
|
+
// the type scale moves.
|
|
66
|
+
<View
|
|
67
|
+
style={{
|
|
68
|
+
marginLeft: spacing.sm,
|
|
69
|
+
height: FLAG_LINE_HEIGHT,
|
|
70
|
+
justifyContent: 'center',
|
|
71
|
+
}}
|
|
72
|
+
>
|
|
73
|
+
<CountryFlag country={country} size={18} />
|
|
54
74
|
</View>
|
|
55
75
|
) : null}
|
|
56
76
|
</View>
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// ─── The review screen's words, per capture mode ──────────────────────────────
|
|
2
|
+
//
|
|
3
|
+
// The document review is shared by both ways a document gets in. "Retake" and
|
|
4
|
+
// "captured" describe a camera, which an upload-only workflow
|
|
5
|
+
// (`allowDocumentScan: false`) never shows the applicant, so that mode says
|
|
6
|
+
// "Replace" and "added" instead, matching the web SDK. Scan mode (scan-only and
|
|
7
|
+
// both-on) keeps its words exactly.
|
|
8
|
+
//
|
|
9
|
+
// Pure, so the wording is pinned by a test without mounting React Native.
|
|
10
|
+
|
|
11
|
+
export type DocumentCaptureMode = 'scan' | 'upload';
|
|
12
|
+
|
|
13
|
+
export interface DocumentReviewCopy {
|
|
14
|
+
/** The line above the thumbnails saying the capture is complete. */
|
|
15
|
+
status: string;
|
|
16
|
+
/** The labelled action under each thumbnail. */
|
|
17
|
+
redo: string;
|
|
18
|
+
/** The screen reader label for that action, naming the side. */
|
|
19
|
+
redoAccessibility: (sideLabel: string) => string;
|
|
20
|
+
/** The enlarged view's button, naming the side. */
|
|
21
|
+
redoSide: (sideLabel: string) => string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function documentReviewCopy(mode: DocumentCaptureMode, twoSided: boolean): DocumentReviewCopy {
|
|
25
|
+
if (mode === 'upload') {
|
|
26
|
+
return {
|
|
27
|
+
status: twoSided ? 'Both sides added' : 'Photo added',
|
|
28
|
+
redo: 'Replace',
|
|
29
|
+
redoAccessibility: (side) => `Replace the ${side.toLowerCase()} photo`,
|
|
30
|
+
redoSide: (side) => `Replace ${side.toLowerCase()}`,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
return {
|
|
34
|
+
status: twoSided ? 'Both sides captured' : 'Photo captured',
|
|
35
|
+
redo: 'Retake',
|
|
36
|
+
redoAccessibility: (side) => `Retake ${side.toLowerCase()}`,
|
|
37
|
+
redoSide: (side) => `Retake ${side.toLowerCase()}`,
|
|
38
|
+
};
|
|
39
|
+
}
|
package/src/components/fonts.ts
CHANGED
|
@@ -1,26 +1,29 @@
|
|
|
1
1
|
import type { TextStyle } from 'react-native';
|
|
2
2
|
import { useFonts } from 'expo-font';
|
|
3
|
-
import { SpaceGrotesk_500Medium, SpaceGrotesk_600SemiBold, SpaceGrotesk_700Bold } from '@expo-google-fonts/space-grotesk';
|
|
4
|
-
import { Karla_400Regular, Karla_500Medium, Karla_600SemiBold, Karla_700Bold } from '@expo-google-fonts/karla';
|
|
5
3
|
import { fontFamilyFor } from '../config/font-resolve';
|
|
6
4
|
import { useTheme } from './theme-provider';
|
|
7
5
|
|
|
8
6
|
export { fontFamilyFor, markFamilyName, brandFamilyName, BRAND_WEIGHTS } from '../config/font-resolve';
|
|
9
7
|
|
|
10
|
-
|
|
11
8
|
// Typography fonts — the SAME families the Flutter SDK uses via google_fonts:
|
|
12
9
|
// Space Grotesk for headings, Karla for body. Loaded at runtime through
|
|
13
10
|
// expo-font (already natively linked), so no native rebuild is needed. Each
|
|
14
11
|
// weight is its own RN font family, so we resolve family-by-weight below.
|
|
15
|
-
|
|
12
|
+
//
|
|
13
|
+
// The files are VENDORED under src/assets/fonts and required one by one (see
|
|
14
|
+
// the README there). They used to be named imports from the
|
|
15
|
+
// @expo-google-fonts packages, whose index requires EVERY weight the family
|
|
16
|
+
// ships — and Metro bundles every `require` it can see, used or not — so 19
|
|
17
|
+
// font files reached every integrator's app for the seven faces the SDK draws
|
|
18
|
+
// with. Measured at 1.85 MB on a real integrator's release APK.
|
|
16
19
|
export const MYAZA_FONTS = {
|
|
17
|
-
SpaceGrotesk_500Medium,
|
|
18
|
-
SpaceGrotesk_600SemiBold,
|
|
19
|
-
SpaceGrotesk_700Bold,
|
|
20
|
-
Karla_400Regular,
|
|
21
|
-
Karla_500Medium,
|
|
22
|
-
Karla_600SemiBold,
|
|
23
|
-
Karla_700Bold,
|
|
20
|
+
SpaceGrotesk_500Medium: require('../assets/fonts/SpaceGrotesk_500Medium.ttf'),
|
|
21
|
+
SpaceGrotesk_600SemiBold: require('../assets/fonts/SpaceGrotesk_600SemiBold.ttf'),
|
|
22
|
+
SpaceGrotesk_700Bold: require('../assets/fonts/SpaceGrotesk_700Bold.ttf'),
|
|
23
|
+
Karla_400Regular: require('../assets/fonts/Karla_400Regular.ttf'),
|
|
24
|
+
Karla_500Medium: require('../assets/fonts/Karla_500Medium.ttf'),
|
|
25
|
+
Karla_600SemiBold: require('../assets/fonts/Karla_600SemiBold.ttf'),
|
|
26
|
+
Karla_700Bold: require('../assets/fonts/Karla_700Bold.ttf'),
|
|
24
27
|
};
|
|
25
28
|
|
|
26
29
|
/** Loads the Myaza fonts. Returns true once they're ready (system font until then). */
|
|
@@ -29,12 +32,6 @@ export function useMyazaFonts(): boolean {
|
|
|
29
32
|
return loaded;
|
|
30
33
|
}
|
|
31
34
|
|
|
32
|
-
/**
|
|
33
|
-
* Resolves the concrete RN font family for a (heading vs body, weight) pair.
|
|
34
|
-
* Returns `undefined` until fonts are loaded so text falls back to the system
|
|
35
|
-
* font + `fontWeight` without triggering an "unrecognized font" warning.
|
|
36
|
-
*/
|
|
37
|
-
|
|
38
35
|
/**
|
|
39
36
|
* The body font family for a TEXT INPUT, at the given weight.
|
|
40
37
|
*
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { ID_TYPES } from '../config/idTypes';
|
|
2
2
|
import { poaDocumentTypes, poaMaxAgeDays, poaNamePolicy } from '../config/proofOfAddress';
|
|
3
3
|
import { documentCaptureMeta } from '../screens/DocumentCaptureStep';
|
|
4
|
+
import { documentCaptureMethods } from '../config/documentCaptureMethods';
|
|
4
5
|
import { questionnaireMeta } from '../screens/QuestionnaireStep';
|
|
5
6
|
import { contactCodeLength, contactMeta, type ContactChallenge } from '../config/contact';
|
|
6
7
|
|
|
@@ -77,7 +78,11 @@ export function stepHeaderMeta(
|
|
|
77
78
|
case 'document-capture':
|
|
78
79
|
// Phase-aware title/description live in the header (synced from the
|
|
79
80
|
// capture screen via `documentCapturePhase`) — mirrors Flutter.
|
|
80
|
-
return documentCaptureMeta(
|
|
81
|
+
return documentCaptureMeta(
|
|
82
|
+
documentCapturePhase,
|
|
83
|
+
label,
|
|
84
|
+
documentCaptureMethods(config).scan ? 'scan' : 'upload',
|
|
85
|
+
);
|
|
81
86
|
case 'liveness':
|
|
82
87
|
return { title: 'Face Verification', description: 'Follow the on-screen instructions' };
|
|
83
88
|
case 'questionnaire':
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// How the applicant may put a document in front of us.
|
|
3
|
+
//
|
|
4
|
+
// Two workflow switches, each on unless set to `false`: `allowDocumentScan`
|
|
5
|
+
// (photograph it with the live camera) and `allowDocumentUpload` (choose a
|
|
6
|
+
// photo of it from the device). The server refuses to publish a workflow with
|
|
7
|
+
// both off, but a stored or prop-supplied config can still carry that, and a
|
|
8
|
+
// document step with no way in is a dead end. So the camera comes back on in
|
|
9
|
+
// that case: it is the method the step was built around.
|
|
10
|
+
//
|
|
11
|
+
// Screens read the pair through here and never the raw keys, so the fallback
|
|
12
|
+
// is one rule rather than a check every screen has to remember. Mirrors the
|
|
13
|
+
// web and Flutter SDKs.
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
|
|
16
|
+
export interface DocumentCaptureMethods {
|
|
17
|
+
/** Photograph the document with the live camera. */
|
|
18
|
+
scan: boolean;
|
|
19
|
+
/** Choose a photo of the document from the device. */
|
|
20
|
+
upload: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function documentCaptureMethods(config: {
|
|
24
|
+
allowDocumentScan?: boolean;
|
|
25
|
+
allowDocumentUpload?: boolean;
|
|
26
|
+
}): DocumentCaptureMethods {
|
|
27
|
+
const upload = config.allowDocumentUpload !== false;
|
|
28
|
+
// With upload off the camera is the only way left, whatever its flag says.
|
|
29
|
+
const scan = upload ? config.allowDocumentScan !== false : true;
|
|
30
|
+
return { scan, upload };
|
|
31
|
+
}
|
|
@@ -25,6 +25,7 @@ export const WORKFLOW_KEYS = [
|
|
|
25
25
|
'enableSelfie',
|
|
26
26
|
'enableDocumentCapture',
|
|
27
27
|
'allowDocumentUpload',
|
|
28
|
+
'allowDocumentScan',
|
|
28
29
|
'enableLiveness',
|
|
29
30
|
'livenessMode',
|
|
30
31
|
'flashSequenceLength',
|
|
@@ -110,6 +111,7 @@ const APPLICANT_LEG_KEYS = [
|
|
|
110
111
|
'enableSelfie',
|
|
111
112
|
'enableDocumentCapture',
|
|
112
113
|
'allowDocumentUpload',
|
|
114
|
+
'allowDocumentScan',
|
|
113
115
|
'enableLiveness',
|
|
114
116
|
'livenessMode',
|
|
115
117
|
'flashSequenceLength',
|