@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.
Files changed (51) hide show
  1. package/package.json +1 -2
  2. package/src/components/BrandBar.tsx +137 -0
  3. package/src/components/DialCodePicker.tsx +11 -17
  4. package/src/components/DocumentReviewSide.tsx +34 -14
  5. package/src/components/Icon.tsx +5 -0
  6. package/src/components/KycSheet.tsx +161 -180
  7. package/src/components/MediaSourceSheet.tsx +7 -37
  8. package/src/components/MyazaDateField.tsx +6 -20
  9. package/src/components/MyazaInput.tsx +6 -1
  10. package/src/components/MyazaSelect.tsx +20 -39
  11. package/src/components/PoweredBy.tsx +20 -15
  12. package/src/components/ProgressBar.tsx +91 -0
  13. package/src/components/SandboxBanner.tsx +92 -0
  14. package/src/components/StepHeader.tsx +15 -2
  15. package/src/components/StepIndicator.tsx +145 -31
  16. package/src/components/fonts.ts +23 -0
  17. package/src/components/glass/ChromeGlass.tsx +65 -0
  18. package/src/components/glass/FloatingSheet.tsx +190 -0
  19. package/src/components/glass/GlassSheet.tsx +38 -0
  20. package/src/components/glass/GlassSurface.tsx +31 -3
  21. package/src/components/viewfinder/ImmersiveBottomBar.tsx +28 -17
  22. package/src/components/viewfinder/ImmersiveControls.tsx +26 -14
  23. package/src/components/viewfinder/ViewfinderControls.tsx +29 -7
  24. package/src/config/questionnaire.ts +45 -4
  25. package/src/config/workflowMerge.ts +1 -0
  26. package/src/emrtd/crypto.ts +1 -1
  27. package/src/emrtd/dg1.ts +55 -0
  28. package/src/emrtd/ec-curves.ts +142 -0
  29. package/src/emrtd/ec.ts +196 -0
  30. package/src/emrtd/index.ts +1 -0
  31. package/src/emrtd/mrzKey.ts +21 -1
  32. package/src/emrtd/open.ts +169 -0
  33. package/src/emrtd/pace-params.ts +169 -0
  34. package/src/emrtd/pace.ts +295 -0
  35. package/src/emrtd/secureMessaging.ts +32 -14
  36. package/src/emrtd/session.ts +124 -20
  37. package/src/emrtd/suites.ts +99 -0
  38. package/src/index.ts +1 -0
  39. package/src/lib/step-log.ts +43 -0
  40. package/src/lib/step-window.ts +96 -0
  41. package/src/liveness/useLiveness.ts +1 -1
  42. package/src/screens/IdTypeStep.tsx +15 -2
  43. package/src/screens/NfcStep.tsx +12 -0
  44. package/src/screens/QuestionnaireField.tsx +30 -0
  45. package/src/screens/QuestionnaireStep.tsx +3 -1
  46. package/src/screens/nfc/NfcSuccessPanel.tsx +13 -6
  47. package/src/services/deviceMetadata.ts +9 -1
  48. package/src/store/derive.ts +7 -0
  49. package/src/store/kycStore.ts +25 -1
  50. package/src/types/config.ts +22 -0
  51. package/src/types/workflow.ts +10 -0
@@ -0,0 +1,65 @@
1
+ import React from 'react';
2
+ import { type StyleProp, type ViewStyle } from 'react-native';
3
+
4
+ import { GlassSurface } from './GlassSurface';
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // ChromeGlass — the surface behind controls that float over the LIVE CAMERA.
8
+ //
9
+ // This is the strongest case for Liquid Glass in the SDK: the chrome floats
10
+ // above content, it is interactive, and the background is a moving scene rather
11
+ // than a flat tint, which is exactly the condition the material was designed
12
+ // for (it is what the iOS Camera app does).
13
+ //
14
+ // Two properties are load-bearing, and both exist to protect the white glyphs:
15
+ //
16
+ // • `colorScheme: 'dark'` — forced, NOT the app theme. These icons are white
17
+ // against a camera feed, not against the app's background, so a light app
18
+ // theme would put pale glass under white icons.
19
+ //
20
+ // • `tintColor` — a dark bias over the glass. Plain glass is translucent, so
21
+ // a white passport page filling the frame would wash a white glyph out.
22
+ // The flat scrim this replaces guaranteed contrast; the tint is what buys
23
+ // that guarantee back while keeping the material's depth and adaptivity.
24
+ //
25
+ // Off iOS 26 it renders the ORIGINAL flat scrim, so Android and older iOS are
26
+ // pixel-identical to before. Glass stays purely additive.
27
+ // ---------------------------------------------------------------------------
28
+
29
+ /** The flat scrim used before glass, and still the fallback everywhere else. */
30
+ export const CHROME_SCRIM = 'rgba(0,0,0,0.55)';
31
+
32
+ /** Dark bias laid over the glass so white glyphs survive a bright scene. */
33
+ const CHROME_TINT = 'rgba(0,0,0,0.28)';
34
+
35
+ export interface ChromeGlassProps {
36
+ children?: React.ReactNode;
37
+ style?: StyleProp<ViewStyle>;
38
+ /** Whether the glass reacts to touch. True for buttons, false for pills. */
39
+ interactive?: boolean;
40
+ /**
41
+ * Overrides the scrim on the fallback path. Pass the value the call site used
42
+ * before, so non-glass devices keep their exact previous appearance.
43
+ */
44
+ scrim?: string;
45
+ }
46
+
47
+ export function ChromeGlass({
48
+ children,
49
+ style,
50
+ interactive = false,
51
+ scrim = CHROME_SCRIM,
52
+ }: ChromeGlassProps): React.ReactElement {
53
+ return (
54
+ <GlassSurface
55
+ glassStyle="regular"
56
+ colorScheme="dark"
57
+ tintColor={CHROME_TINT}
58
+ interactive={interactive}
59
+ fallbackColor={scrim}
60
+ style={style}
61
+ >
62
+ {children}
63
+ </GlassSurface>
64
+ );
65
+ }
@@ -0,0 +1,190 @@
1
+ import React, { useEffect, useRef } from 'react';
2
+ import {
3
+ Animated,
4
+ Easing,
5
+ Modal,
6
+ PanResponder,
7
+ Pressable,
8
+ View,
9
+ type ViewStyle,
10
+ } from 'react-native';
11
+ import { initialWindowMetrics } from 'react-native-safe-area-context';
12
+
13
+ import { radius, spacing } from '../../config/theme';
14
+ import { useTheme } from '../runtime';
15
+ import { GlassIconButton } from '../GlassIconButton';
16
+ import { GlassSheet } from './GlassSheet';
17
+
18
+ // ---------------------------------------------------------------------------
19
+ // FloatingSheet — the shell every bottom sheet in the SDK sits in.
20
+ //
21
+ // It floats: inset from all three edges and rounded on every corner, rather
22
+ // than welded to the bottom of the screen. That is what the glass is for. A
23
+ // panel flush to the edge reads as part of the screen, so seeing through it
24
+ // says nothing; a detached one reads as a layer that has risen above the flow.
25
+ //
26
+ // It offers three ways out, because a sheet that can only be dismissed one way
27
+ // is a trap for whichever user does not find that way:
28
+ // • swipe it down,
29
+ // • the close button,
30
+ // • tap the backdrop.
31
+ //
32
+ // Shared rather than copied into each sheet: swipe-to-dismiss is the kind of
33
+ // thing that rots when it exists in four places, and the sheets would drift
34
+ // apart on handle size, dismiss threshold and safe-area maths.
35
+ // ---------------------------------------------------------------------------
36
+
37
+ /** How far down a drag must travel before release dismisses instead of springing back. */
38
+ const DISMISS_DISTANCE = 90;
39
+ /** A flick dismisses at any distance — velocity is intent, distance is only evidence of it. */
40
+ const DISMISS_VELOCITY = 0.7;
41
+
42
+ export interface FloatingSheetProps {
43
+ visible: boolean;
44
+ onClose: () => void;
45
+ children?: React.ReactNode;
46
+ /** Extra bottom offset, e.g. a raised keyboard. */
47
+ bottomOffset?: number;
48
+ /** Caps the panel; the body owns its own scrolling. */
49
+ maxHeight?: number;
50
+ /** Accessible label for the close button. */
51
+ closeLabel?: string;
52
+ /**
53
+ * Fires once the modal has finished going away (iOS only, from `Modal`).
54
+ *
55
+ * Needed by callers that must wait for THIS sheet to be gone before opening
56
+ * another one — iOS refuses to present a second modal while the first is
57
+ * still on screen.
58
+ */
59
+ onDismiss?: () => void;
60
+ }
61
+
62
+ export function FloatingSheet({
63
+ visible,
64
+ onClose,
65
+ children,
66
+ bottomOffset = 0,
67
+ maxHeight,
68
+ closeLabel = 'Close',
69
+ onDismiss,
70
+ }: FloatingSheetProps): React.ReactElement {
71
+ const { colors } = useTheme();
72
+ const translateY = useRef(new Animated.Value(0)).current;
73
+
74
+ // The PanResponder is built once, so it would otherwise close over the FIRST
75
+ // onClose forever and stop dismissing after the parent re-renders.
76
+ const onCloseRef = useRef(onClose);
77
+ onCloseRef.current = onClose;
78
+
79
+ // A sheet reopened after being dragged away must not still be off-screen.
80
+ useEffect(() => {
81
+ if (visible) translateY.setValue(0);
82
+ }, [visible, translateY]);
83
+
84
+ const pan = useRef(
85
+ PanResponder.create({
86
+ // Claim on touch START, not just on move.
87
+ //
88
+ // The panel is wrapped in a Pressable to swallow backdrop taps, and a
89
+ // Pressable takes the responder the instant a finger lands. Asking only
90
+ // on move meant that Pressable had already won and the drag never began.
91
+ // `onStartShouldSetResponder` bubbles from the deepest view up, and this
92
+ // header is deeper than that wrapper, so claiming here beats it — while
93
+ // the close button, deeper still, keeps winning its own taps.
94
+ onStartShouldSetPanResponder: () => true,
95
+ // Kept for the case where a touch begins as something else and turns
96
+ // into a drag. Downward only: stealing horizontal or upward gestures
97
+ // would make the scrollable bodies below feel broken.
98
+ onMoveShouldSetPanResponder: (_, g) => g.dy > 4 && Math.abs(g.dy) > Math.abs(g.dx),
99
+ // Once the drag owns the gesture, nothing may take it mid-pull.
100
+ onPanResponderTerminationRequest: () => false,
101
+ onPanResponderMove: (_, g) => {
102
+ // Downward only: dragging up must not detach the sheet from its corner.
103
+ if (g.dy > 0) translateY.setValue(g.dy);
104
+ },
105
+ onPanResponderRelease: (_, g) => {
106
+ if (g.dy > DISMISS_DISTANCE || g.vy > DISMISS_VELOCITY) {
107
+ Animated.timing(translateY, {
108
+ toValue: 700,
109
+ duration: 180,
110
+ easing: Easing.in(Easing.cubic),
111
+ useNativeDriver: true,
112
+ }).start(() => onCloseRef.current());
113
+ return;
114
+ }
115
+ Animated.spring(translateY, {
116
+ toValue: 0,
117
+ useNativeDriver: true,
118
+ bounciness: 0,
119
+ }).start();
120
+ },
121
+ }),
122
+ ).current;
123
+
124
+ // Read once at module init like the rest of the SDK: these sheets are modal,
125
+ // so there is no provider above them to read live insets from.
126
+ const safeBottom = initialWindowMetrics?.insets?.bottom ?? 0;
127
+
128
+ const panel: ViewStyle = {
129
+ marginHorizontal: spacing.md,
130
+ // Clear the home indicator, and never sit flush even where there is none.
131
+ marginBottom: bottomOffset + Math.max(safeBottom, spacing.md),
132
+ };
133
+
134
+ return (
135
+ <Modal
136
+ visible={visible}
137
+ transparent
138
+ animationType="slide"
139
+ onRequestClose={onClose}
140
+ onDismiss={onDismiss}
141
+ >
142
+ <Pressable
143
+ onPress={onClose}
144
+ accessibilityLabel="Dismiss"
145
+ style={{ flex: 1, justifyContent: 'flex-end', backgroundColor: 'rgba(0,0,0,0.5)' }}
146
+ >
147
+ <Animated.View style={[panel, { transform: [{ translateY }] }]}>
148
+ {/* Swallows taps so only the backdrop dismisses. */}
149
+ <Pressable onPress={() => undefined}>
150
+ <GlassSheet style={{ borderRadius: radius.xl, maxHeight, overflow: 'hidden' }}>
151
+ {/* The drag lives on the header, NOT the whole panel: bodies here
152
+ scroll, and a panel-wide responder fights the list for every
153
+ downward swipe. */}
154
+ <View
155
+ {...pan.panHandlers}
156
+ style={{
157
+ flexDirection: 'row',
158
+ alignItems: 'center',
159
+ paddingTop: spacing.sm,
160
+ paddingHorizontal: spacing.sm,
161
+ }}
162
+ >
163
+ <View style={{ flex: 1 }} />
164
+ {/* Grab handle — says "draggable" without a caption. */}
165
+ <View
166
+ style={{
167
+ width: 36,
168
+ height: 4,
169
+ borderRadius: 2,
170
+ backgroundColor: colors.border,
171
+ }}
172
+ />
173
+ <View style={{ flex: 1, alignItems: 'flex-end' }}>
174
+ <GlassIconButton
175
+ icon="close"
176
+ onPress={onClose}
177
+ size={32}
178
+ iconSize={18}
179
+ accessibilityLabel={closeLabel}
180
+ />
181
+ </View>
182
+ </View>
183
+ {children}
184
+ </GlassSheet>
185
+ </Pressable>
186
+ </Animated.View>
187
+ </Pressable>
188
+ </Modal>
189
+ );
190
+ }
@@ -0,0 +1,38 @@
1
+ import React from 'react';
2
+ import { type StyleProp, type ViewStyle } from 'react-native';
3
+
4
+ import { useTheme } from '../runtime';
5
+ import { GlassSurface } from './GlassSurface';
6
+
7
+ // ---------------------------------------------------------------------------
8
+ // GlassSheet — the panel of a bottom sheet that floats above the flow.
9
+ //
10
+ // A sheet is a surface, not a control, which is the one case where glass is
11
+ // right for something non-interactive: it is a layer that has risen above the
12
+ // content, and letting that content show through is what says so.
13
+ //
14
+ // `glassStyle: 'regular'` rather than `'clear'` on purpose. These panels are
15
+ // full of list text that has to stay readable, and `regular` is the frosted
16
+ // variant; `clear` is for chrome over imagery, where there is little to read.
17
+ //
18
+ // The colour scheme follows the APP theme (unlike the camera chrome, which is
19
+ // pinned dark) because a sheet sits over the SDK's own background, so its text
20
+ // is themed text on a themed surface.
21
+ //
22
+ // Off iOS 26 it is the opaque themed panel it has always been.
23
+ // ---------------------------------------------------------------------------
24
+
25
+ export interface GlassSheetProps {
26
+ children?: React.ReactNode;
27
+ /** Panel geometry — corner radii and padding. */
28
+ style?: StyleProp<ViewStyle>;
29
+ }
30
+
31
+ export function GlassSheet({ children, style }: GlassSheetProps): React.ReactElement {
32
+ const { colors } = useTheme();
33
+ return (
34
+ <GlassSurface glassStyle="regular" fallbackColor={colors.background} style={style}>
35
+ {children}
36
+ </GlassSurface>
37
+ );
38
+ }
@@ -1,6 +1,11 @@
1
1
  import React from 'react';
2
2
  import { Platform, View, type StyleProp, type ViewStyle } from 'react-native';
3
- import { GlassView, isLiquidGlassAvailable, type GlassStyle } from 'expo-glass-effect';
3
+ import {
4
+ GlassView,
5
+ isLiquidGlassAvailable,
6
+ type GlassColorScheme,
7
+ type GlassStyle,
8
+ } from 'expo-glass-effect';
4
9
 
5
10
  import { useTheme } from '../runtime';
6
11
 
@@ -10,7 +15,21 @@ import { useTheme } from '../runtime';
10
15
  // On iOS 26+ (where `isLiquidGlassAvailable()` is true) it renders a native
11
16
  // `GlassView`; everywhere else (Android, iOS < 26) it falls back to a plain
12
17
  // token-styled surface. Glass is purely additive — every screen looks correct
13
- // without it. Used for the sheet shell, header, primary buttons, and ID cards.
18
+ // without it.
19
+ //
20
+ // Where it is used, and the rule behind that: glass is for things that FLOAT
21
+ // above content — surfaces (the sheet shell + header, bottom sheets via
22
+ // `GlassSheet`) and interactive chrome (the header icon buttons, the camera
23
+ // controls via `ChromeGlass`).
24
+ //
25
+ // Deliberately NOT used for:
26
+ // • the primary CTA — it needs a solid brand fill to read as the one clear
27
+ // action, and a translucent one competes with everything behind it;
28
+ // • labels and status pills (the brand bar, capture hints, the sandbox
29
+ // strip) — at capsule scale glass reads as a control, so wrapping static
30
+ // text in it promises a tap that does nothing;
31
+ // • any "on" state (torch lit, toggle active) — a material that samples
32
+ // what is behind it cannot state a binary unambiguously.
14
33
  // ---------------------------------------------------------------------------
15
34
 
16
35
  const liquidGlass = Platform.OS === 'ios' && isLiquidGlassAvailable();
@@ -34,6 +53,14 @@ export interface GlassSurfaceProps {
34
53
  * theme's elevated surface (`backgroundSecondary`).
35
54
  */
36
55
  fallbackColor?: string;
56
+ /**
57
+ * Forces the glass to render light or dark, overriding the app theme.
58
+ *
59
+ * Chrome that floats over the CAMERA needs this: its glyphs are white against
60
+ * a live scene, not against the app's background, so following a light app
61
+ * theme would render pale glass under white icons.
62
+ */
63
+ colorScheme?: GlassColorScheme;
37
64
  }
38
65
 
39
66
  export function GlassSurface({
@@ -43,6 +70,7 @@ export function GlassSurface({
43
70
  tintColor,
44
71
  interactive = false,
45
72
  fallbackColor,
73
+ colorScheme,
46
74
  }: GlassSurfaceProps): React.ReactElement {
47
75
  const { colors, mode } = useTheme();
48
76
 
@@ -53,7 +81,7 @@ export function GlassSurface({
53
81
  glassEffectStyle={glassStyle}
54
82
  tintColor={tintColor}
55
83
  isInteractive={interactive}
56
- colorScheme={mode}
84
+ colorScheme={colorScheme ?? mode}
57
85
  >
58
86
  {children}
59
87
  </GlassView>
@@ -1,13 +1,25 @@
1
1
  import React from 'react';
2
- import { ActivityIndicator, Pressable, View } from 'react-native';
2
+ import { ActivityIndicator, Pressable, View, type ViewStyle } from 'react-native';
3
3
  import Svg, { Circle } from 'react-native-svg';
4
4
 
5
5
  import { radius, spacing } from '../../config/theme';
6
6
  import { useTheme } from '../runtime';
7
7
  import { MyazaText } from '../Typography';
8
8
  import { Icon } from '../Icon';
9
+ import { CHROME_SCRIM, ChromeGlass } from '../glass/ChromeGlass';
9
10
 
10
- const SCRIM = 'rgba(0,0,0,0.55)';
11
+ const SCRIM = CHROME_SCRIM;
12
+
13
+ /** The torch's own scrim, kept as-is so non-glass devices look unchanged. */
14
+ const TORCH_SCRIM = 'rgba(0,0,0,0.45)';
15
+
16
+ /** Fills a sized Pressable with a round surface. */
17
+ const ROUND_FILL: ViewStyle = {
18
+ flex: 1,
19
+ borderRadius: radius.full,
20
+ alignItems: 'center',
21
+ justifyContent: 'center',
22
+ };
11
23
 
12
24
  /**
13
25
  * Fixed slots either side of the shutter.
@@ -147,22 +159,21 @@ export function BottomBar({
147
159
  onPress={onToggleTorch}
148
160
  accessibilityRole="button"
149
161
  accessibilityLabel={torch ? 'Turn off the torch' : 'Turn on the torch'}
150
- style={{
151
- width: 36,
152
- height: 36,
153
- borderRadius: radius.full,
154
- // Inverted when on, so "lit" reads at a glance rather than
155
- // needing the icon to be decoded.
156
- backgroundColor: torch ? '#FFFFFF' : 'rgba(0,0,0,0.45)',
157
- alignItems: 'center',
158
- justifyContent: 'center',
159
- }}
162
+ style={{ width: 36, height: 36 }}
160
163
  >
161
- <Icon
162
- name={torch ? 'zap' : 'zap-off'}
163
- size={18}
164
- color={torch ? colors.primary : '#FFFFFF'}
165
- />
164
+ {torch ? (
165
+ // Inverted when on, so "lit" reads at a glance rather than
166
+ // needing the icon to be decoded. Solid rather than glass: a
167
+ // translucent surface samples the scene behind it, so "on"
168
+ // would look different depending on where the camera points.
169
+ <View style={[ROUND_FILL, { backgroundColor: '#FFFFFF' }]}>
170
+ <Icon name="zap" size={18} color={colors.primary} />
171
+ </View>
172
+ ) : (
173
+ <ChromeGlass interactive scrim={TORCH_SCRIM} style={ROUND_FILL}>
174
+ <Icon name="zap-off" size={18} color="#FFFFFF" />
175
+ </ChromeGlass>
176
+ )}
166
177
  </Pressable>
167
178
  ) : null}
168
179
  </View>
@@ -1,5 +1,5 @@
1
1
  import React from 'react';
2
- import { ActivityIndicator, Pressable, View } from 'react-native';
2
+ import { ActivityIndicator, Pressable, View, type ViewStyle } from 'react-native';
3
3
  import Svg, { Circle } from 'react-native-svg';
4
4
 
5
5
  import { radius, spacing } from '../../config/theme';
@@ -7,6 +7,7 @@ import { useTheme } from '../runtime';
7
7
  import { MyazaText } from '../Typography';
8
8
  import { Icon } from '../Icon';
9
9
  import { CountryFlag } from '../CountryFlag';
10
+ import { CHROME_SCRIM, ChromeGlass } from '../glass/ChromeGlass';
10
11
 
11
12
  // ---------------------------------------------------------------------------
12
13
  // Immersive capture chrome — 1:1 with the Flutter SDK's full-screen camera.
@@ -18,9 +19,16 @@ import { CountryFlag } from '../CountryFlag';
18
19
  // • the side badge and document pill sit top, out of the frame's way.
19
20
  // ---------------------------------------------------------------------------
20
21
 
21
- const SCRIM = 'rgba(0,0,0,0.55)';
22
+ const SCRIM = CHROME_SCRIM;
22
23
 
23
- /** Round translucent control — back, torch. */
24
+ /**
25
+ * Round translucent control — back, torch.
26
+ *
27
+ * Liquid Glass when idle; SOLID brand fill when `active`. An "on" state has to
28
+ * be unmistakable, and a translucent material that samples the scene behind it
29
+ * cannot promise that — the torch would look on or off depending on what the
30
+ * camera happened to be pointed at.
31
+ */
24
32
  export function RoundControl({
25
33
  icon,
26
34
  label,
@@ -37,23 +45,27 @@ export function RoundControl({
37
45
  style: object;
38
46
  }): React.ReactElement {
39
47
  const { colors } = useTheme();
48
+ const fill: ViewStyle = {
49
+ flex: 1,
50
+ borderRadius: radius.full,
51
+ alignItems: 'center',
52
+ justifyContent: 'center',
53
+ };
54
+ const glyph = <Icon name={icon} size={20} color="#FFFFFF" />;
40
55
  return (
41
56
  <Pressable
42
57
  onPress={onPress}
43
58
  accessibilityRole="button"
44
59
  accessibilityLabel={label}
45
- style={{
46
- position: 'absolute',
47
- width: size,
48
- height: size,
49
- borderRadius: radius.full,
50
- backgroundColor: active ? colors.primary : SCRIM,
51
- alignItems: 'center',
52
- justifyContent: 'center',
53
- ...style,
54
- }}
60
+ style={{ position: 'absolute', width: size, height: size, ...style }}
55
61
  >
56
- <Icon name={icon} size={20} color="#FFFFFF" />
62
+ {active ? (
63
+ <View style={[fill, { backgroundColor: colors.primary }]}>{glyph}</View>
64
+ ) : (
65
+ <ChromeGlass interactive style={fill}>
66
+ {glyph}
67
+ </ChromeGlass>
68
+ )}
57
69
  </Pressable>
58
70
  );
59
71
  }
@@ -1,11 +1,23 @@
1
1
  import React from 'react';
2
- import { ActivityIndicator, Pressable, View } from 'react-native';
2
+ import { ActivityIndicator, Pressable, View, type ViewStyle } from 'react-native';
3
3
  import Svg, { Circle } from 'react-native-svg';
4
4
 
5
5
  import { radius, spacing } from '../../config/theme';
6
6
  import { useTheme } from '../runtime';
7
7
  import { MyazaText } from '../Typography';
8
8
  import { Icon } from '../Icon';
9
+ import { ChromeGlass } from '../glass/ChromeGlass';
10
+
11
+ /** This overlay's own scrim, kept so non-glass devices look unchanged. */
12
+ const OVERLAY_SCRIM = 'rgba(0,0,0,0.4)';
13
+
14
+ /** Fills a sized Pressable with a round surface. */
15
+ const ROUND_FILL: ViewStyle = {
16
+ flex: 1,
17
+ borderRadius: radius.full,
18
+ alignItems: 'center',
19
+ justifyContent: 'center',
20
+ };
9
21
 
10
22
  /** Top offset that clears the status bar when the camera runs full-bleed. */
11
23
  export function topInset(fill: boolean): number {
@@ -49,7 +61,13 @@ export function HintBanner({
49
61
  );
50
62
  }
51
63
 
52
- /** A round overlay button — the torch and the immersive back control. */
64
+ /**
65
+ * A round overlay button — the torch and the immersive back control.
66
+ *
67
+ * Liquid Glass when idle; SOLID brand fill when `active`. An "on" state has to
68
+ * be unmistakable, and a translucent material that samples the scene behind it
69
+ * cannot promise that.
70
+ */
53
71
  export function OverlayButton({
54
72
  icon,
55
73
  onPress,
@@ -77,13 +95,17 @@ export function OverlayButton({
77
95
  [side]: spacing.md,
78
96
  width: 36,
79
97
  height: 36,
80
- borderRadius: radius.full,
81
- backgroundColor: active ? colors.primary : 'rgba(0,0,0,0.4)',
82
- alignItems: 'center',
83
- justifyContent: 'center',
84
98
  }}
85
99
  >
86
- <Icon name={icon} size={18} color="#FFFFFF" />
100
+ {active ? (
101
+ <View style={[ROUND_FILL, { backgroundColor: colors.primary }]}>
102
+ <Icon name={icon} size={18} color="#FFFFFF" />
103
+ </View>
104
+ ) : (
105
+ <ChromeGlass interactive scrim={OVERLAY_SCRIM} style={ROUND_FILL}>
106
+ <Icon name={icon} size={18} color="#FFFFFF" />
107
+ </ChromeGlass>
108
+ )}
87
109
  </Pressable>
88
110
  );
89
111
  }
@@ -39,17 +39,24 @@ export function currencyKeyFor(field: QuestionnaireField): string {
39
39
  return `${field.key}_currency`;
40
40
  }
41
41
 
42
+ /** The companion key holding what an "Other" choice actually was. */
43
+ export function otherKeyFor(field: QuestionnaireField): string {
44
+ return `${field.key}_other`;
45
+ }
46
+
42
47
  /**
43
- * Every answer key a definition can produce — money questions contribute two.
44
- * The server uses the same expansion to cross-check decision-graph references,
45
- * so anything reasoning about "the keys this questionnaire yields" must use it
46
- * rather than mapping over `fields`.
48
+ * Every answer key a definition can produce — money questions contribute two,
49
+ * and so does a choice question offering an "Other". The server uses the same
50
+ * expansion to cross-check decision-graph references, so anything reasoning
51
+ * about "the keys this questionnaire yields" must use it rather than mapping
52
+ * over `fields`.
47
53
  */
48
54
  export function questionnaireAnswerKeys(questionnaire: QuestionnaireConfig | undefined): string[] {
49
55
  const keys: string[] = [];
50
56
  for (const field of questionnaire?.fields ?? []) {
51
57
  keys.push(field.key);
52
58
  if (field.type === 'money') keys.push(currencyKeyFor(field));
59
+ if ((field.options ?? []).some((o) => o.requiresDetail)) keys.push(otherKeyFor(field));
53
60
  }
54
61
  return keys;
55
62
  }
@@ -78,6 +85,23 @@ export function validateQuestionnaire(
78
85
  continue;
79
86
  }
80
87
 
88
+ // An "Other" choice obliges a description, whether or not the question
89
+ // itself is required: an unexplained "Other" is the answer that most needs
90
+ // explaining. Checked before the per-type rules so it applies to select and
91
+ // multiselect alike.
92
+ const detailOption = (field.options ?? []).find(
93
+ (o) =>
94
+ o.requiresDetail &&
95
+ (Array.isArray(value) ? (value as string[]).includes(o.value) : value === o.value),
96
+ );
97
+ if (detailOption) {
98
+ const detail = answers[`${field.key}_other`];
99
+ if (typeof detail !== 'string' || !detail.trim()) {
100
+ errors[field.key] = `Tell us more about "${detailOption.label}".`;
101
+ continue;
102
+ }
103
+ }
104
+
81
105
  if (field.type === 'number' || field.type === 'money') {
82
106
  const num = Number(value);
83
107
  if (!Number.isFinite(num) || (field.type === 'money' && num < 0)) {
@@ -147,6 +171,23 @@ export function questionnairePayload(
147
171
  }
148
172
 
149
173
  payload[field.key] = value as QuestionnaireAnswerValue;
174
+
175
+ // The description behind an "Other" choice. Carried explicitly for the same
176
+ // reason `_currency` is: this builds the request from the FIELD LIST, so a
177
+ // companion answer that is not named here is silently dropped — the user
178
+ // types it, the client accepts it, and the server then rejects the
179
+ // submission for a missing detail it was never sent.
180
+ const detailOption = (field.options ?? []).find(
181
+ (o) =>
182
+ o.requiresDetail &&
183
+ (Array.isArray(value) ? (value as string[]).includes(o.value) : value === o.value),
184
+ );
185
+ if (detailOption) {
186
+ const detail = answers[otherKeyFor(field)];
187
+ if (typeof detail === 'string' && detail.trim() !== '') {
188
+ payload[otherKeyFor(field)] = detail.trim();
189
+ }
190
+ }
150
191
  }
151
192
 
152
193
  return payload;
@@ -30,6 +30,7 @@ export const WORKFLOW_KEYS = [
30
30
  'requireMobileDevice',
31
31
  'voiceGuidance',
32
32
  'showThemeToggle',
33
+ 'progressStyle',
33
34
  'disableClose',
34
35
  'appearance',
35
36
  'consent',
@@ -99,7 +99,7 @@ export function deriveKey(
99
99
  }
100
100
 
101
101
  // The MRZ-derived key lives in ./mrzKey; re-exported so callers keep one import.
102
- export { keySeed, mrzKeySeedInput, type MrzKeyFields } from './mrzKey';
102
+ export { keySeed, paceKeySeed, mrzKeySeedInput, type MrzKeyFields } from './mrzKey';
103
103
 
104
104
  /** The pair of session keys derived from a seed. */
105
105
  export interface SessionKeys {