@myazahq/kyc-sdk-react-native 2.2.0 → 2.4.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/package.json +1 -2
- package/src/components/BrandBar.tsx +137 -0
- package/src/components/DialCodePicker.tsx +11 -17
- package/src/components/DocumentReviewSide.tsx +34 -14
- package/src/components/Icon.tsx +5 -0
- package/src/components/KycSheet.tsx +161 -180
- package/src/components/MediaSourceSheet.tsx +7 -37
- package/src/components/MyazaDateField.tsx +6 -20
- package/src/components/MyazaInput.tsx +6 -1
- package/src/components/MyazaSelect.tsx +20 -39
- package/src/components/PoweredBy.tsx +20 -15
- package/src/components/ProgressBar.tsx +91 -0
- package/src/components/SandboxBanner.tsx +92 -0
- package/src/components/StepHeader.tsx +15 -2
- package/src/components/StepIndicator.tsx +145 -31
- package/src/components/fonts.ts +23 -0
- package/src/components/glass/ChromeGlass.tsx +65 -0
- package/src/components/glass/FloatingSheet.tsx +190 -0
- package/src/components/glass/GlassSheet.tsx +38 -0
- package/src/components/glass/GlassSurface.tsx +31 -3
- package/src/components/viewfinder/ImmersiveBottomBar.tsx +28 -17
- package/src/components/viewfinder/ImmersiveControls.tsx +26 -14
- package/src/components/viewfinder/ViewfinderControls.tsx +29 -7
- package/src/config/questionnaire.ts +45 -4
- package/src/config/workflowMerge.ts +1 -0
- package/src/emrtd/crypto.ts +1 -1
- package/src/emrtd/dg1.ts +55 -0
- package/src/emrtd/ec-curves.ts +142 -0
- package/src/emrtd/ec.ts +196 -0
- package/src/emrtd/index.ts +1 -0
- package/src/emrtd/mrzKey.ts +21 -1
- package/src/emrtd/open.ts +169 -0
- package/src/emrtd/pace-params.ts +169 -0
- package/src/emrtd/pace.ts +295 -0
- package/src/emrtd/secureMessaging.ts +32 -14
- package/src/emrtd/session.ts +124 -20
- package/src/emrtd/suites.ts +99 -0
- package/src/index.ts +1 -0
- package/src/lib/step-log.ts +43 -0
- package/src/lib/step-window.ts +96 -0
- package/src/liveness/useLiveness.ts +1 -1
- package/src/screens/IdTypeStep.tsx +15 -2
- package/src/screens/NfcStep.tsx +12 -0
- package/src/screens/QuestionnaireField.tsx +30 -0
- package/src/screens/QuestionnaireStep.tsx +3 -1
- package/src/screens/nfc/NfcSuccessPanel.tsx +13 -6
- package/src/services/deviceMetadata.ts +9 -1
- package/src/store/derive.ts +7 -0
- package/src/store/kycStore.ts +25 -1
- package/src/types/config.ts +22 -0
- package/src/types/workflow.ts +10 -0
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import React, { useState } from 'react';
|
|
2
|
-
import {
|
|
2
|
+
import { Pressable, ScrollView, TextInput, useWindowDimensions, View } from 'react-native';
|
|
3
3
|
|
|
4
4
|
import { radius, spacing } from '../config/theme';
|
|
5
5
|
import { useTheme } from './runtime';
|
|
6
|
+
import { useInputFontFamily } from './fonts';
|
|
6
7
|
import { MyazaText } from './Typography';
|
|
7
8
|
import { Icon } from './Icon';
|
|
9
|
+
import { FloatingSheet } from './glass/FloatingSheet';
|
|
8
10
|
|
|
9
11
|
// ---------------------------------------------------------------------------
|
|
10
12
|
// The select field — a collapsed value that opens a sheet of options.
|
|
@@ -72,6 +74,9 @@ export function MyazaSelect<T extends string | number>({
|
|
|
72
74
|
searchable?: boolean;
|
|
73
75
|
}): React.ReactElement {
|
|
74
76
|
const { colors } = useTheme();
|
|
77
|
+
// TextInput does not inherit fontFamily in RN — set it or the search field
|
|
78
|
+
// (and its placeholder) renders in the system face.
|
|
79
|
+
const fontFamily = useInputFontFamily();
|
|
75
80
|
const { height: windowHeight } = useWindowDimensions();
|
|
76
81
|
const [open, setOpen] = useState(false);
|
|
77
82
|
const [query, setQuery] = useState('');
|
|
@@ -127,45 +132,16 @@ export function MyazaSelect<T extends string | number>({
|
|
|
127
132
|
<Icon name="chevron-down" size={18} color={colors.textSecondary} />
|
|
128
133
|
</Pressable>
|
|
129
134
|
|
|
130
|
-
<
|
|
135
|
+
<FloatingSheet
|
|
131
136
|
visible={open}
|
|
132
|
-
|
|
133
|
-
animationType="slide"
|
|
134
|
-
onRequestClose={() => {
|
|
137
|
+
onClose={() => {
|
|
135
138
|
setOpen(false);
|
|
136
139
|
setQuery('');
|
|
137
140
|
}}
|
|
141
|
+
// Long option lists stay inside the sheet and scroll.
|
|
142
|
+
maxHeight={windowHeight * 0.6}
|
|
143
|
+
closeLabel={`Close ${sheetTitle ?? hint}`}
|
|
138
144
|
>
|
|
139
|
-
{/* Tapping the backdrop dismisses, matching the platform sheet. */}
|
|
140
|
-
<Pressable
|
|
141
|
-
onPress={() => {
|
|
142
|
-
setOpen(false);
|
|
143
|
-
setQuery('');
|
|
144
|
-
}}
|
|
145
|
-
style={{ flex: 1, backgroundColor: 'rgba(0,0,0,0.45)', justifyContent: 'flex-end' }}
|
|
146
|
-
>
|
|
147
|
-
{/* Stops a tap inside the sheet from closing it. */}
|
|
148
|
-
<Pressable
|
|
149
|
-
onPress={() => {}}
|
|
150
|
-
style={{
|
|
151
|
-
backgroundColor: colors.background,
|
|
152
|
-
borderTopLeftRadius: radius.lg,
|
|
153
|
-
borderTopRightRadius: radius.lg,
|
|
154
|
-
paddingBottom: spacing.lg,
|
|
155
|
-
// Long option lists stay inside the sheet and scroll.
|
|
156
|
-
maxHeight: windowHeight * 0.6,
|
|
157
|
-
}}
|
|
158
|
-
>
|
|
159
|
-
<View
|
|
160
|
-
style={{
|
|
161
|
-
alignSelf: 'center',
|
|
162
|
-
width: 36,
|
|
163
|
-
height: 4,
|
|
164
|
-
borderRadius: radius.full,
|
|
165
|
-
backgroundColor: colors.border,
|
|
166
|
-
marginTop: spacing.sm,
|
|
167
|
-
}}
|
|
168
|
-
/>
|
|
169
145
|
<MyazaText
|
|
170
146
|
variant="body"
|
|
171
147
|
style={{
|
|
@@ -207,6 +183,7 @@ export function MyazaSelect<T extends string | number>({
|
|
|
207
183
|
paddingHorizontal: spacing.sm,
|
|
208
184
|
color: colors.textDark,
|
|
209
185
|
fontSize: 15,
|
|
186
|
+
fontFamily,
|
|
210
187
|
}}
|
|
211
188
|
/>
|
|
212
189
|
{query !== '' ? (
|
|
@@ -217,7 +194,13 @@ export function MyazaSelect<T extends string | number>({
|
|
|
217
194
|
</View>
|
|
218
195
|
) : null}
|
|
219
196
|
|
|
220
|
-
<ScrollView
|
|
197
|
+
<ScrollView
|
|
198
|
+
bounces={false}
|
|
199
|
+
keyboardShouldPersistTaps="handled"
|
|
200
|
+
// The floating shell has no bottom padding of its own, so the
|
|
201
|
+
// last option must not sit flush against the rounded corner.
|
|
202
|
+
contentContainerStyle={{ paddingBottom: spacing.lg }}
|
|
203
|
+
>
|
|
221
204
|
{visible.length === 0 ? (
|
|
222
205
|
<MyazaText
|
|
223
206
|
variant="bodySmall"
|
|
@@ -275,9 +258,7 @@ export function MyazaSelect<T extends string | number>({
|
|
|
275
258
|
);
|
|
276
259
|
})}
|
|
277
260
|
</ScrollView>
|
|
278
|
-
|
|
279
|
-
</Pressable>
|
|
280
|
-
</Modal>
|
|
261
|
+
</FloatingSheet>
|
|
281
262
|
</>
|
|
282
263
|
);
|
|
283
264
|
}
|
|
@@ -45,17 +45,9 @@ export function PoweredBy({ bottomInset = 0 }: { bottomInset?: number }): React.
|
|
|
45
45
|
alignItems: 'center',
|
|
46
46
|
}}
|
|
47
47
|
>
|
|
48
|
-
{/*
|
|
49
|
-
|
|
50
|
-
<
|
|
51
|
-
onPress={() => {
|
|
52
|
-
void Linking.openURL(PRODUCT_URL).catch(() => undefined);
|
|
53
|
-
}}
|
|
54
|
-
accessibilityRole="link"
|
|
55
|
-
accessibilityLabel="Powered by Myaza Trust"
|
|
56
|
-
hitSlop={8}
|
|
57
|
-
style={{ flexDirection: 'row', alignItems: 'center', gap: 10, opacity: 0.9 }}
|
|
58
|
-
>
|
|
48
|
+
{/* The ROW is not the link — only the mark is. "Powered by" is a label,
|
|
49
|
+
not a destination, so it does not carry the tap. */}
|
|
50
|
+
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 10, opacity: 0.9 }}>
|
|
59
51
|
{/* Small and muted on purpose — "Powered by" is connective tissue, not
|
|
60
52
|
the message. The BRAND carries the weight. */}
|
|
61
53
|
<MyazaText variant="body" color={markColor} style={{ flexShrink: 1, fontSize: 12 }}>
|
|
@@ -63,8 +55,21 @@ export function PoweredBy({ bottomInset = 0 }: { bottomInset?: number }): React.
|
|
|
63
55
|
</MyazaText>
|
|
64
56
|
|
|
65
57
|
{/* The lockup, spaced TIGHTER than the gap before it so it reads as one
|
|
66
|
-
mark rather than three evenly-spaced items.
|
|
67
|
-
|
|
58
|
+
mark rather than three evenly-spaced items. This is the link:
|
|
59
|
+
wordmark, rule and TRUST are one brand, so the whole lockup is the
|
|
60
|
+
target — but nothing beyond it is.
|
|
61
|
+
|
|
62
|
+
openURL can reject (no browser / a locked-down device). The mark
|
|
63
|
+
itself is the point, so a failed open is swallowed. */}
|
|
64
|
+
<Pressable
|
|
65
|
+
onPress={() => {
|
|
66
|
+
void Linking.openURL(PRODUCT_URL).catch(() => undefined);
|
|
67
|
+
}}
|
|
68
|
+
accessibilityRole="link"
|
|
69
|
+
accessibilityLabel="Myaza Trust"
|
|
70
|
+
hitSlop={8}
|
|
71
|
+
style={{ flexDirection: 'row', alignItems: 'center', gap: spacing.sm }}
|
|
72
|
+
>
|
|
68
73
|
<MyazaWordmark height={28} wordmark={markColor} />
|
|
69
74
|
|
|
70
75
|
<View style={{ width: 1, height: 24, backgroundColor: markColor, opacity: 0.35 }} />
|
|
@@ -82,8 +87,8 @@ export function PoweredBy({ bottomInset = 0 }: { bottomInset?: number }): React.
|
|
|
82
87
|
>
|
|
83
88
|
TRUST
|
|
84
89
|
</MyazaText>
|
|
85
|
-
</
|
|
86
|
-
</
|
|
90
|
+
</Pressable>
|
|
91
|
+
</View>
|
|
87
92
|
</View>
|
|
88
93
|
);
|
|
89
94
|
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import React, { useEffect, useRef } from 'react';
|
|
2
|
+
import { Animated, Easing, useAnimatedValue, View } from 'react-native';
|
|
3
|
+
|
|
4
|
+
import { useTheme } from './runtime';
|
|
5
|
+
|
|
6
|
+
// The quiet alternative to StepIndicator: a single thin bar sitting ON the
|
|
7
|
+
// header's bottom edge, replacing its border rather than adding a row beneath
|
|
8
|
+
// it — so choosing it costs the header no height at all.
|
|
9
|
+
//
|
|
10
|
+
// Unlike the step circles it does not say WHICH step you are on or how many
|
|
11
|
+
// there are, which is the trade: it is unaffected by step count, so a 14-step
|
|
12
|
+
// KYB flow draws exactly like a 4-step one. Hosts who would rather the chrome
|
|
13
|
+
// said less opt in with `progressStyle: 'bar'`.
|
|
14
|
+
|
|
15
|
+
export interface ProgressBarProps {
|
|
16
|
+
/** 0.0–1.0 progress fraction. */
|
|
17
|
+
progress: number;
|
|
18
|
+
/** Steps in the flow — announced, not drawn. */
|
|
19
|
+
stepCount: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Thickness of the bar.
|
|
24
|
+
*
|
|
25
|
+
* 5, not the 1px border it replaces: at hairline weight it read as a rendering
|
|
26
|
+
* artefact rather than a deliberate indicator, and the filled portion needs
|
|
27
|
+
* enough body for its colour to register against the track at a glance.
|
|
28
|
+
*/
|
|
29
|
+
const HEIGHT = 5;
|
|
30
|
+
|
|
31
|
+
export function ProgressBar({ progress, stepCount }: ProgressBarProps): React.ReactElement {
|
|
32
|
+
const { colors } = useTheme();
|
|
33
|
+
const fraction = Math.min(Math.max(progress, 0), 1);
|
|
34
|
+
const step = Math.min(Math.max(Math.round(fraction * stepCount), 1), stepCount);
|
|
35
|
+
|
|
36
|
+
// Animated so advancing a step reads as movement rather than a jump — the
|
|
37
|
+
// motion IS the feedback that the step was accepted. 250ms sits inside the
|
|
38
|
+
// 150–300ms micro-interaction band; ease-out because it is entering.
|
|
39
|
+
const width = useAnimatedValue(fraction);
|
|
40
|
+
const first = useRef(true);
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
if (first.current) {
|
|
43
|
+
// Don't animate the initial mount from 0 — the flow may be resumed
|
|
44
|
+
// mid-way, and a bar sweeping in from empty would misreport where the
|
|
45
|
+
// user actually is.
|
|
46
|
+
first.current = false;
|
|
47
|
+
width.setValue(fraction);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
Animated.timing(width, {
|
|
51
|
+
toValue: fraction,
|
|
52
|
+
duration: 250,
|
|
53
|
+
easing: Easing.out(Easing.cubic),
|
|
54
|
+
// Width cannot be driven natively, and a scaleX transform would squash
|
|
55
|
+
// the rounded cap. The bar is one small view, so the JS driver is fine.
|
|
56
|
+
useNativeDriver: false,
|
|
57
|
+
}).start();
|
|
58
|
+
}, [fraction, width]);
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<View
|
|
62
|
+
style={{
|
|
63
|
+
position: 'absolute',
|
|
64
|
+
left: 0,
|
|
65
|
+
right: 0,
|
|
66
|
+
bottom: 0,
|
|
67
|
+
height: HEIGHT,
|
|
68
|
+
// The track doubles as the header's bottom border, which is why the
|
|
69
|
+
// header drops its own when this is shown.
|
|
70
|
+
backgroundColor: colors.border,
|
|
71
|
+
}}
|
|
72
|
+
accessible
|
|
73
|
+
accessibilityRole="progressbar"
|
|
74
|
+
accessibilityLabel={`Step ${step} of ${stepCount}`}
|
|
75
|
+
accessibilityValue={{ min: 1, max: stepCount, now: step }}
|
|
76
|
+
>
|
|
77
|
+
<Animated.View
|
|
78
|
+
style={{
|
|
79
|
+
height: HEIGHT,
|
|
80
|
+
backgroundColor: colors.primary,
|
|
81
|
+
borderTopRightRadius: HEIGHT / 2,
|
|
82
|
+
borderBottomRightRadius: HEIGHT / 2,
|
|
83
|
+
width: width.interpolate({
|
|
84
|
+
inputRange: [0, 1],
|
|
85
|
+
outputRange: ['0%', '100%'],
|
|
86
|
+
}),
|
|
87
|
+
}}
|
|
88
|
+
/>
|
|
89
|
+
</View>
|
|
90
|
+
);
|
|
91
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { View } from 'react-native';
|
|
3
|
+
|
|
4
|
+
import { spacing } from '../config/theme';
|
|
5
|
+
import type { KycState } from '../store/state';
|
|
6
|
+
import { useKyc } from './runtime';
|
|
7
|
+
import { Icon } from './Icon';
|
|
8
|
+
import { MyazaText } from './Typography';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* "You are not in production" strip, shown above the sheet's header.
|
|
12
|
+
*
|
|
13
|
+
* A sandbox flow is pixel-identical to a live one, which is the point — you are
|
|
14
|
+
* testing the real thing — and also the hazard: screenshots get mistaken for
|
|
15
|
+
* production incidents, testers wonder why a real passport was rejected, and a
|
|
16
|
+
* `pk_test_` key shipped to production looks like it works right up until
|
|
17
|
+
* nobody is actually verified.
|
|
18
|
+
*
|
|
19
|
+
* Environment comes from the SERVER (`/config`), not the API key prefix. Hosted
|
|
20
|
+
* sessions authenticate with an `hs_` handoff token that carries no environment
|
|
21
|
+
* slot, so key-sniffing would leave exactly the surface an end user sees
|
|
22
|
+
* unlabelled.
|
|
23
|
+
*
|
|
24
|
+
* DEVELOPMENT is labelled too, and differently: it runs the real pipeline
|
|
25
|
+
* against staging provider credentials, so "test data only" would be a lie
|
|
26
|
+
* there. 1:1 with the web and Flutter SDKs.
|
|
27
|
+
*/
|
|
28
|
+
/**
|
|
29
|
+
* Whether the banner will render. The sheet needs this to decide who owns the
|
|
30
|
+
* safe-area top inset: the banner sits ABOVE the header, so when it shows it is
|
|
31
|
+
* the topmost element and must clear the status bar itself — otherwise the
|
|
32
|
+
* header pads for an inset the banner already crossed, and the strip is drawn
|
|
33
|
+
* under the clock on Android's edge-to-edge modals.
|
|
34
|
+
*/
|
|
35
|
+
export function useSandboxBannerVisible(): boolean {
|
|
36
|
+
const environment = useKyc((s: KycState) => s.serverConfig.environment);
|
|
37
|
+
return environment === 'SANDBOX' || environment === 'DEVELOPMENT';
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function SandboxBanner({
|
|
41
|
+
topInset = 0,
|
|
42
|
+
}: {
|
|
43
|
+
/** Safe-area top inset to absorb, when the banner is the topmost element. */
|
|
44
|
+
topInset?: number;
|
|
45
|
+
}): React.ReactElement | null {
|
|
46
|
+
const environment = useKyc((s: KycState) => s.serverConfig.environment);
|
|
47
|
+
if (environment !== 'SANDBOX' && environment !== 'DEVELOPMENT') return null;
|
|
48
|
+
|
|
49
|
+
const sandbox = environment === 'SANDBOX';
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<View
|
|
53
|
+
accessibilityRole="alert"
|
|
54
|
+
style={{
|
|
55
|
+
flexDirection: 'row',
|
|
56
|
+
alignItems: 'center',
|
|
57
|
+
justifyContent: 'center',
|
|
58
|
+
gap: spacing.xs + 2,
|
|
59
|
+
// Amber, not the brand colour: this is an out-of-band status about the
|
|
60
|
+
// session, and the org's palette would read as part of their flow.
|
|
61
|
+
backgroundColor: AMBER_TINT,
|
|
62
|
+
paddingHorizontal: spacing.md,
|
|
63
|
+
// The tint extends up through the status bar so the strip reads as one
|
|
64
|
+
// band rather than floating below a bare gap.
|
|
65
|
+
paddingTop: topInset + spacing.xs + 2,
|
|
66
|
+
paddingBottom: spacing.xs + 2,
|
|
67
|
+
}}
|
|
68
|
+
>
|
|
69
|
+
{/* Flask, not an alert mark: nothing is wrong — this is a statement about
|
|
70
|
+
WHICH environment you are in ("test environment", "test data only").
|
|
71
|
+
An alert glyph reads as a fault and sends testers hunting for one.
|
|
72
|
+
Same lucide icon the web SDK uses; Flutter mirrors it with
|
|
73
|
+
Icons.science_outlined. */}
|
|
74
|
+
<Icon name="flask" size={13} color={AMBER_INK} />
|
|
75
|
+
<MyazaText
|
|
76
|
+
variant="bodySmall"
|
|
77
|
+
color={AMBER_INK}
|
|
78
|
+
style={{ fontSize: 11, fontWeight: '700', letterSpacing: 1, textTransform: 'uppercase' }}
|
|
79
|
+
>
|
|
80
|
+
{sandbox ? 'Sandbox' : 'Development'}
|
|
81
|
+
</MyazaText>
|
|
82
|
+
<MyazaText variant="bodySmall" color={AMBER_INK} style={{ fontSize: 11, flexShrink: 1 }}>
|
|
83
|
+
{sandbox ? 'Test data only, no real checks run' : 'Test environment, results are not live'}
|
|
84
|
+
</MyazaText>
|
|
85
|
+
</View>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Fixed rather than themed: the strip must read identically in light and dark,
|
|
90
|
+
// and it deliberately does not belong to the org's palette.
|
|
91
|
+
const AMBER_TINT = 'rgba(245, 158, 11, 0.18)';
|
|
92
|
+
const AMBER_INK = '#B45309';
|
|
@@ -12,7 +12,10 @@ import { CountryFlag } from './CountryFlag';
|
|
|
12
12
|
// title (heading2) + optional country flag, then an optional description below
|
|
13
13
|
// (indented to align under the title when a back button is present).
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
// Back button width + gap. Kept in step with BACK_SIZE below: it indents the
|
|
16
|
+
// description so it starts under the title rather than under the button.
|
|
17
|
+
const BACK_SIZE = 40;
|
|
18
|
+
const BACK_FOOTPRINT = BACK_SIZE + spacing.sm;
|
|
16
19
|
|
|
17
20
|
export interface StepHeaderProps {
|
|
18
21
|
title: string;
|
|
@@ -28,7 +31,17 @@ export function StepHeader({ title, description, onBack, country }: StepHeaderPr
|
|
|
28
31
|
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
|
|
29
32
|
{onBack ? (
|
|
30
33
|
<View style={{ marginRight: spacing.sm }}>
|
|
31
|
-
|
|
34
|
+
{/* Same 40pt footprint as the theme/close chrome above it — a
|
|
35
|
+
smaller glass circle here read as a different class of control,
|
|
36
|
+
and 28pt was under the 44pt minimum touch target even with
|
|
37
|
+
hitSlop. */}
|
|
38
|
+
<GlassIconButton
|
|
39
|
+
icon="back"
|
|
40
|
+
onPress={onBack}
|
|
41
|
+
size={BACK_SIZE}
|
|
42
|
+
iconSize={22}
|
|
43
|
+
accessibilityLabel="Back"
|
|
44
|
+
/>
|
|
32
45
|
</View>
|
|
33
46
|
) : null}
|
|
34
47
|
<View style={{ flex: 1, flexDirection: 'row', alignItems: 'center' }}>
|
|
@@ -1,17 +1,23 @@
|
|
|
1
|
-
import React from 'react';
|
|
2
|
-
import { View } from 'react-native';
|
|
1
|
+
import React, { useState } from 'react';
|
|
2
|
+
import { PixelRatio, useWindowDimensions, View, type LayoutChangeEvent } from 'react-native';
|
|
3
3
|
|
|
4
4
|
import { spacing } from '../config/theme';
|
|
5
|
+
import { fitStepCircles, windowedSteps, type StepSlot } from '../lib/step-window';
|
|
5
6
|
import { useTheme } from './runtime';
|
|
6
7
|
import { MyazaText } from './Typography';
|
|
7
8
|
import { Icon } from './Icon';
|
|
8
9
|
|
|
9
10
|
// Segmented numbered step indicator — 1:1 with the Flutter SDK's _StepIndicator.
|
|
10
|
-
// N circles connected by thin lines:
|
|
11
|
-
// • completed → filled primary +
|
|
12
|
-
// • active → filled primary +
|
|
11
|
+
// N numbered circles connected by thin lines:
|
|
12
|
+
// • completed → filled primary + number + a check BADGE
|
|
13
|
+
// • active → filled primary + number
|
|
13
14
|
// • upcoming → outlined (primary200) + muted number
|
|
14
15
|
// activeIndex = round(progress * stepCount) - 1.
|
|
16
|
+
//
|
|
17
|
+
// Long flows COLLAPSE rather than overflow, but only as a LAST RESORT: the row
|
|
18
|
+
// measures itself and shows as many real circles as the width allows, windowing
|
|
19
|
+
// around the current step (first and last always shown) only once the connectors
|
|
20
|
+
// would stop reading as a chain. See lib/step-window.
|
|
15
21
|
|
|
16
22
|
export interface StepIndicatorProps {
|
|
17
23
|
/** 0.0–1.0 progress fraction. */
|
|
@@ -19,47 +25,155 @@ export interface StepIndicatorProps {
|
|
|
19
25
|
stepCount: number;
|
|
20
26
|
}
|
|
21
27
|
|
|
28
|
+
/** Base circle size, before text scaling. */
|
|
29
|
+
const CIRCLE = 26;
|
|
30
|
+
|
|
22
31
|
export function StepIndicator({ progress, stepCount }: StepIndicatorProps): React.ReactElement {
|
|
23
32
|
const { colors } = useTheme();
|
|
24
33
|
const active = Math.round(progress * stepCount) - 1;
|
|
25
34
|
|
|
35
|
+
// Grow the circle with the system text size, or the number inside it clips
|
|
36
|
+
// the moment a user turns Dynamic Type up — the circle was a hard 26px while
|
|
37
|
+
// the label scaled freely. Capped at 1.4: past that the row matters less than
|
|
38
|
+
// the step content below it, and an indicator that pushes the title off screen
|
|
39
|
+
// helps nobody.
|
|
40
|
+
const scale = Math.min(PixelRatio.getFontScale(), 1.4);
|
|
41
|
+
const size = Math.round(CIRCLE * scale);
|
|
42
|
+
const badge = Math.round(13 * scale);
|
|
43
|
+
|
|
44
|
+
// How many circles actually fit. Measured rather than assumed, so a flow
|
|
45
|
+
// collapses on a narrow phone and stays whole on a wide one instead of both
|
|
46
|
+
// obeying the same hardcoded cap. The window width is the first estimate — it
|
|
47
|
+
// is right on the first frame, which avoids rendering a collapsed row and then
|
|
48
|
+
// snapping to a full one — and onLayout corrects it if the row is inset by
|
|
49
|
+
// anything this does not know about.
|
|
50
|
+
const { width: screenWidth } = useWindowDimensions();
|
|
51
|
+
const [measured, setMeasured] = useState(0);
|
|
52
|
+
const rowWidth = measured || Math.max(0, screenWidth - spacing.md * 2);
|
|
53
|
+
const slots = windowedSteps(stepCount, active, fitStepCircles(rowWidth, size));
|
|
54
|
+
|
|
55
|
+
const onLayout = (e: LayoutChangeEvent): void => {
|
|
56
|
+
const w = e.nativeEvent.layout.width - spacing.md * 2;
|
|
57
|
+
// Only react to real changes (rotation, split screen). Setting state on
|
|
58
|
+
// every layout pass would re-render the row for nothing.
|
|
59
|
+
setMeasured((prev) => (Math.abs(prev - w) > 1 ? w : prev));
|
|
60
|
+
};
|
|
61
|
+
|
|
26
62
|
return (
|
|
27
|
-
<View
|
|
28
|
-
{
|
|
29
|
-
|
|
30
|
-
|
|
63
|
+
<View
|
|
64
|
+
style={{ flexDirection: 'row', alignItems: 'center', paddingHorizontal: spacing.md }}
|
|
65
|
+
onLayout={onLayout}
|
|
66
|
+
// ONE label for the whole row. Previously a screen reader walked ten
|
|
67
|
+
// unlabelled circles and announced a bare "6", which says nothing about
|
|
68
|
+
// how far through the flow that is.
|
|
69
|
+
accessible
|
|
70
|
+
accessibilityRole="progressbar"
|
|
71
|
+
accessibilityLabel={`Step ${Math.min(Math.max(active + 1, 1), stepCount)} of ${stepCount}`}
|
|
72
|
+
accessibilityValue={{
|
|
73
|
+
min: 1,
|
|
74
|
+
max: stepCount,
|
|
75
|
+
now: Math.min(Math.max(active + 1, 1), stepCount),
|
|
76
|
+
}}
|
|
77
|
+
>
|
|
78
|
+
{slots.map((slot: StepSlot, position) => {
|
|
79
|
+
const isEllipsis = slot === 'ellipsis';
|
|
80
|
+
const completed = !isEllipsis && slot < active;
|
|
81
|
+
const isActive = !isEllipsis && slot === active;
|
|
31
82
|
const filled = completed || isActive;
|
|
32
83
|
return (
|
|
33
|
-
<React.Fragment key={
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
borderWidth: 1.5,
|
|
43
|
-
borderColor: filled ? colors.primary : colors.primary200,
|
|
44
|
-
}}
|
|
45
|
-
>
|
|
46
|
-
{completed ? (
|
|
47
|
-
<Icon name="check" size={14} color="#FFFFFF" />
|
|
48
|
-
) : (
|
|
84
|
+
<React.Fragment key={isEllipsis ? `gap-${position}` : `step-${slot}`}>
|
|
85
|
+
{isEllipsis ? (
|
|
86
|
+
// Collapsed run. Sized to the circle's height so the connectors on
|
|
87
|
+
// either side stay on the same centre line and the chain reads as
|
|
88
|
+
// continuous rather than broken in two.
|
|
89
|
+
<View
|
|
90
|
+
style={{ height: size, justifyContent: 'center', paddingHorizontal: 2 }}
|
|
91
|
+
importantForAccessibility="no"
|
|
92
|
+
>
|
|
49
93
|
<MyazaText
|
|
50
94
|
variant="bodySmall"
|
|
51
|
-
color={
|
|
52
|
-
style={{ fontSize:
|
|
95
|
+
color={colors.textMuted}
|
|
96
|
+
style={{ fontSize: 13, letterSpacing: 1 }}
|
|
97
|
+
allowFontScaling={false}
|
|
53
98
|
>
|
|
54
|
-
|
|
99
|
+
···
|
|
55
100
|
</MyazaText>
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
101
|
+
</View>
|
|
102
|
+
) : (
|
|
103
|
+
// Positioning context for the badge, which straddles the circle's
|
|
104
|
+
// edge. Nothing here clips, so the overhang renders.
|
|
105
|
+
<View style={{ width: size, height: size }} importantForAccessibility="no">
|
|
106
|
+
<View
|
|
107
|
+
style={{
|
|
108
|
+
width: size,
|
|
109
|
+
height: size,
|
|
110
|
+
borderRadius: size / 2,
|
|
111
|
+
alignItems: 'center',
|
|
112
|
+
justifyContent: 'center',
|
|
113
|
+
backgroundColor: filled ? colors.primary : 'transparent',
|
|
114
|
+
borderWidth: 1.5,
|
|
115
|
+
borderColor: filled ? colors.primary : colors.primary200,
|
|
116
|
+
}}
|
|
117
|
+
>
|
|
118
|
+
{/* The NUMBER stays, completed or not. A check alone says a
|
|
119
|
+
step is done but not WHICH step — and once the row is
|
|
120
|
+
windowed ("1 ··· 5 6 7 ··· 10") that is precisely what the
|
|
121
|
+
numbers are there to answer. */}
|
|
122
|
+
<MyazaText
|
|
123
|
+
variant="bodySmall"
|
|
124
|
+
color={filled ? '#FFFFFF' : colors.textMuted}
|
|
125
|
+
style={{ fontSize: 12, fontWeight: '700' }}
|
|
126
|
+
// The circle already scales with the system text size, so
|
|
127
|
+
// scaling the glyph again would overflow it.
|
|
128
|
+
allowFontScaling={false}
|
|
129
|
+
>
|
|
130
|
+
{slot + 1}
|
|
131
|
+
</MyazaText>
|
|
132
|
+
</View>
|
|
133
|
+
|
|
134
|
+
{/* Completion rides as a badge tucked onto the circle's corner.
|
|
135
|
+
The ring is WHITE — the same colour as the number inside the
|
|
136
|
+
circle — because the badge sits on the circle, not on the
|
|
137
|
+
page. Ringing it in the page background (which the chip
|
|
138
|
+
portrait's CheckBadge does, correctly, because it sits on a
|
|
139
|
+
photo) painted a near-black blob around the check on the dark
|
|
140
|
+
theme, where background is #040218. */}
|
|
141
|
+
{completed ? (
|
|
142
|
+
<View
|
|
143
|
+
style={{
|
|
144
|
+
position: 'absolute',
|
|
145
|
+
top: -3,
|
|
146
|
+
right: -2,
|
|
147
|
+
width: badge,
|
|
148
|
+
height: badge,
|
|
149
|
+
borderRadius: badge / 2,
|
|
150
|
+
backgroundColor: colors.success,
|
|
151
|
+
// 1px, not 1.5: on a 13px badge a 1.5px ring eats nearly a
|
|
152
|
+
// quarter of the diameter and reads as a white outline
|
|
153
|
+
// rather than a hairline separating badge from circle.
|
|
154
|
+
borderWidth: 1,
|
|
155
|
+
borderColor: '#FFFFFF',
|
|
156
|
+
alignItems: 'center',
|
|
157
|
+
justifyContent: 'center',
|
|
158
|
+
}}
|
|
159
|
+
>
|
|
160
|
+
<Icon name="check" size={Math.round(badge * 0.6)} color="#FFFFFF" />
|
|
161
|
+
</View>
|
|
162
|
+
) : null}
|
|
163
|
+
</View>
|
|
164
|
+
)}
|
|
165
|
+
{position < slots.length - 1 ? (
|
|
59
166
|
<View
|
|
60
167
|
style={{
|
|
61
168
|
flex: 1,
|
|
169
|
+
// Floor, so an unforeseen overflow degrades to a visibly short
|
|
170
|
+
// connector rather than a row of circles with no chain at all.
|
|
171
|
+
minWidth: 6,
|
|
62
172
|
height: 2,
|
|
173
|
+
// UNIFORM, deliberately: fitStepCircles budgets 3+3 per
|
|
174
|
+
// connector, so widening this for completed steps made the row
|
|
175
|
+
// need more space than was reserved. The badge is tucked in
|
|
176
|
+
// far enough (right: -2) not to need the extra.
|
|
63
177
|
marginHorizontal: 3,
|
|
64
178
|
borderRadius: 1,
|
|
65
179
|
backgroundColor: completed ? colors.primary : colors.primary200,
|
package/src/components/fonts.ts
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
|
+
import type { TextStyle } from 'react-native';
|
|
1
2
|
import { useFonts } from 'expo-font';
|
|
2
3
|
import { SpaceGrotesk_500Medium, SpaceGrotesk_600SemiBold, SpaceGrotesk_700Bold } from '@expo-google-fonts/space-grotesk';
|
|
3
4
|
import { Karla_400Regular, Karla_500Medium, Karla_600SemiBold, Karla_700Bold } from '@expo-google-fonts/karla';
|
|
5
|
+
import { fontFamilyFor } from '../config/font-resolve';
|
|
6
|
+
import { useTheme } from './theme-provider';
|
|
7
|
+
|
|
4
8
|
export { fontFamilyFor, markFamilyName, brandFamilyName, BRAND_WEIGHTS } from '../config/font-resolve';
|
|
5
9
|
|
|
6
10
|
|
|
@@ -30,3 +34,22 @@ export function useMyazaFonts(): boolean {
|
|
|
30
34
|
* Returns `undefined` until fonts are loaded so text falls back to the system
|
|
31
35
|
* font + `fontWeight` without triggering an "unrecognized font" warning.
|
|
32
36
|
*/
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The body font family for a TEXT INPUT, at the given weight.
|
|
40
|
+
*
|
|
41
|
+
* `TextInput` does not inherit `fontFamily` from any ancestor the way web
|
|
42
|
+
* inputs inherit from a stylesheet — RN resolves it per-element — so an input
|
|
43
|
+
* that never sets one renders in the system face while every `MyazaText`
|
|
44
|
+
* beside it renders in the brand's. The placeholder follows the input's own
|
|
45
|
+
* family too, which is why it looked wrong as well.
|
|
46
|
+
*
|
|
47
|
+
* Goes through the same resolver as MyazaText so an org's uploaded brand font
|
|
48
|
+
* reaches inputs, not just text. Returns undefined until fonts load, which is
|
|
49
|
+
* the caller's cue to leave `fontFamily` unset rather than name a family the
|
|
50
|
+
* platform has not registered.
|
|
51
|
+
*/
|
|
52
|
+
export function useInputFontFamily(weight: TextStyle['fontWeight'] = '400'): string | undefined {
|
|
53
|
+
const { fontsLoaded, brandFonts } = useTheme();
|
|
54
|
+
return fontFamilyFor(false, weight, fontsLoaded, brandFonts);
|
|
55
|
+
}
|