@myazahq/kyc-sdk-react-native 2.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.
Files changed (87) hide show
  1. package/KycSdkReactNative.podspec +32 -0
  2. package/LICENSE +21 -0
  3. package/README.md +164 -0
  4. package/android/CMakeLists.txt +29 -0
  5. package/android/build.gradle +110 -0
  6. package/android/src/main/AndroidManifest.xml +12 -0
  7. package/android/src/main/cpp/cpp-adapter.cpp +12 -0
  8. package/android/src/main/java/co/myazahq/kyc/rn/MyazaFaceDetectorPackage.kt +36 -0
  9. package/android/src/main/java/co/myazahq/kyc/rn/MyazaStatusBarModule.kt +64 -0
  10. package/android/src/main/java/com/margelo/nitro/myazakyc/HybridMyazaFaceDetector.kt +134 -0
  11. package/app.plugin.js +55 -0
  12. package/expo-module.config.json +6 -0
  13. package/ios/HybridMyazaFaceDetector.swift +233 -0
  14. package/package.json +84 -0
  15. package/react-native.config.js +23 -0
  16. package/src/MyazaKYC.tsx +235 -0
  17. package/src/__tests__/cardCrop.test.ts +39 -0
  18. package/src/__tests__/deviceMetadata.test.ts +34 -0
  19. package/src/__tests__/errors.test.ts +34 -0
  20. package/src/__tests__/flow.test.ts +61 -0
  21. package/src/__tests__/gestureDetector.test.ts +37 -0
  22. package/src/__tests__/liveness.test.ts +112 -0
  23. package/src/__tests__/resolveUrl.test.ts +64 -0
  24. package/src/__tests__/validators.test.ts +38 -0
  25. package/src/assets/liveness/Blink.gif +0 -0
  26. package/src/assets/liveness/Nod.gif +0 -0
  27. package/src/assets/liveness/Smile.gif +0 -0
  28. package/src/assets/liveness/Turn.gif +0 -0
  29. package/src/components/CameraPermissionView.tsx +116 -0
  30. package/src/components/CameraViewfinder.tsx +156 -0
  31. package/src/components/CountryFlag.tsx +52 -0
  32. package/src/components/DocumentCropper.tsx +325 -0
  33. package/src/components/GlassIconButton.tsx +92 -0
  34. package/src/components/Icon.tsx +125 -0
  35. package/src/components/KycFlow.tsx +205 -0
  36. package/src/components/KycSheet.tsx +224 -0
  37. package/src/components/MyazaAlert.tsx +57 -0
  38. package/src/components/MyazaButton.tsx +101 -0
  39. package/src/components/MyazaCard.tsx +48 -0
  40. package/src/components/MyazaInput.tsx +112 -0
  41. package/src/components/MyazaPulseLoader.tsx +71 -0
  42. package/src/components/StatusBarController.tsx +42 -0
  43. package/src/components/StepHeader.tsx +56 -0
  44. package/src/components/StepIndicator.tsx +74 -0
  45. package/src/components/Typography.tsx +69 -0
  46. package/src/components/fonts.ts +49 -0
  47. package/src/components/glass/GlassGroup.tsx +34 -0
  48. package/src/components/glass/GlassSurface.tsx +64 -0
  49. package/src/components/runtime.tsx +93 -0
  50. package/src/components/toast.tsx +154 -0
  51. package/src/components/useBranding.ts +27 -0
  52. package/src/components/useVideoRecorder.ts +122 -0
  53. package/src/config/captureSettings.ts +67 -0
  54. package/src/config/idTypes.ts +79 -0
  55. package/src/config/theme.ts +186 -0
  56. package/src/index.ts +54 -0
  57. package/src/liveness/challengeManager.ts +130 -0
  58. package/src/liveness/faceDetector.ts +79 -0
  59. package/src/liveness/gestureDetector.ts +80 -0
  60. package/src/liveness/speech.ts +66 -0
  61. package/src/liveness/types.ts +99 -0
  62. package/src/liveness/useLiveness.ts +484 -0
  63. package/src/liveness/visionCameraFaceDetector.ts +118 -0
  64. package/src/screens/ConsentStep.tsx +164 -0
  65. package/src/screens/DocumentCaptureStep.tsx +500 -0
  66. package/src/screens/IdInputStep.tsx +79 -0
  67. package/src/screens/IdTypeStep.tsx +142 -0
  68. package/src/screens/LivenessAvatar.tsx +69 -0
  69. package/src/screens/LivenessStep.tsx +615 -0
  70. package/src/screens/SubmittedStep.tsx +177 -0
  71. package/src/services/api.ts +291 -0
  72. package/src/services/cardCrop.ts +52 -0
  73. package/src/services/deviceMetadata.ts +185 -0
  74. package/src/services/errors.ts +92 -0
  75. package/src/services/mediaCompress.ts +129 -0
  76. package/src/services/resolveUrl.ts +98 -0
  77. package/src/services/retry.ts +70 -0
  78. package/src/services/validators.ts +103 -0
  79. package/src/specs/MyazaFaceDetector.nitro.ts +44 -0
  80. package/src/store/kycStore.ts +288 -0
  81. package/src/store/serverConfig.ts +78 -0
  82. package/src/types/config.ts +239 -0
  83. package/src/types/country-flag-icons.d.ts +6 -0
  84. package/src/types/verification.ts +79 -0
  85. package/src/utils/platform.ts +23 -0
  86. package/src/utils/tokens.ts +10 -0
  87. package/src/utils/uuid.ts +31 -0
@@ -0,0 +1,52 @@
1
+ import React from 'react';
2
+ import { View } from 'react-native';
3
+ import { SvgXml } from 'react-native-svg';
4
+ // Raw flag SVG strings — the SAME source the web SDK uses (`country-flag-icons`),
5
+ // so the flags match. Only the supported countries are imported.
6
+ import NG from 'country-flag-icons/string/3x2/NG';
7
+ import GH from 'country-flag-icons/string/3x2/GH';
8
+ import KE from 'country-flag-icons/string/3x2/KE';
9
+ import ZA from 'country-flag-icons/string/3x2/ZA';
10
+ import CI from 'country-flag-icons/string/3x2/CI';
11
+
12
+ import { radius } from '../config/theme';
13
+ import type { SupportedCountry } from '../types/config';
14
+
15
+ // Circular country flag beside the step title — the RN mirror of the web SDK's
16
+ // `CountryFlag` and Flutter's `MyazaCountryFlag`. Renders the country-flag-icons
17
+ // 3:2 SVG via react-native-svg, cropped to a circle (cover-fit, like the web
18
+ // SDK's `object-cover` + `rounded-full`). Works identically on iOS and Android
19
+ // (no emoji-flag dependency, which Android doesn't render).
20
+
21
+ const FLAGS: Record<SupportedCountry, string> = { NG, GH, KE, ZA, CI };
22
+
23
+ export interface CountryFlagProps {
24
+ country?: SupportedCountry | null;
25
+ size?: number;
26
+ }
27
+
28
+ export function CountryFlag({ country, size = 20 }: CountryFlagProps): React.ReactElement | null {
29
+ if (!country) return null;
30
+ const xml = FLAGS[country];
31
+ if (!xml) return null;
32
+
33
+ // The flag is 3:2; to cover a square circle we render it wider than the circle
34
+ // and let the circular clip crop the sides (matches the web's object-cover).
35
+ const flagWidth = size * 1.5;
36
+
37
+ return (
38
+ <View
39
+ style={{
40
+ width: size,
41
+ height: size,
42
+ borderRadius: radius.full,
43
+ overflow: 'hidden',
44
+ backgroundColor: 'rgba(0,0,0,0.04)',
45
+ alignItems: 'center',
46
+ justifyContent: 'center',
47
+ }}
48
+ >
49
+ <SvgXml xml={xml} width={flagWidth} height={size} preserveAspectRatio="xMidYMid slice" />
50
+ </View>
51
+ );
52
+ }
@@ -0,0 +1,325 @@
1
+ import React, { useEffect, useMemo, useRef, useState } from 'react';
2
+ import { ActivityIndicator, LayoutChangeEvent, Modal, PanResponder, Platform, Pressable, StatusBar, StyleSheet, View } from 'react-native';
3
+ import Svg, { Line, Rect } from 'react-native-svg';
4
+
5
+ import { spacing } from '../config/theme';
6
+ import { cropImage, imageSize } from '../services/mediaCompress';
7
+ import { MyazaText } from './Typography';
8
+ import { MyazaButton } from './MyazaButton';
9
+ import { Icon } from './Icon';
10
+ import { Image } from 'react-native';
11
+
12
+ // Interactive ID-card cropper — a 1:1 RN port of the Flutter SDK's
13
+ // _DocumentCropperScreen. Shown after picking a document photo from the gallery:
14
+ // a draggable, fixed-aspect (ISO ID-1) crop box over the image, with a dimmed
15
+ // surround, white border, rule-of-thirds grid and L-shaped corner brackets.
16
+ // Geometry, hit-testing and the confirm math all mirror the Flutter widget.
17
+
18
+ // ISO/IEC 7810 ID-1: 85.6 mm × 53.98 mm → AR ≈ 1.5858 (fixed, like Flutter).
19
+ const ID_AR = 85.6 / 53.98;
20
+ const HANDLE_RADIUS = 28; // touch radius for the corner handles
21
+ const ARM = 22; // corner-bracket arm length
22
+ const APPBAR_BG = '#1A1A2E';
23
+ // Keep the title clear of the status bar / notch on the full-screen cropper Modal.
24
+ const TOP_INSET = Platform.OS === 'ios' ? 56 : (StatusBar.currentHeight ?? 24) + 8;
25
+
26
+ interface Rectangle {
27
+ x: number;
28
+ y: number;
29
+ w: number;
30
+ h: number;
31
+ }
32
+
33
+ type Handle = 'none' | 'move' | 'tl' | 'tr' | 'bl' | 'br';
34
+
35
+ export interface DocumentCropperProps {
36
+ uri: string;
37
+ onCancel: () => void;
38
+ onConfirm: (croppedUri: string) => void;
39
+ }
40
+
41
+ function computeImgRect(natW: number, natH: number, cw: number, ch: number): Rectangle {
42
+ // object-contain rect of the image within the container.
43
+ const natAR = natW / natH;
44
+ const conAR = cw / ch;
45
+ let rw: number;
46
+ let rh: number;
47
+ if (natAR > conAR) {
48
+ rw = cw;
49
+ rh = cw / natAR;
50
+ } else {
51
+ rh = ch;
52
+ rw = ch * natAR;
53
+ }
54
+ return { x: (cw - rw) / 2, y: (ch - rh) / 2, w: rw, h: rh };
55
+ }
56
+
57
+ function initCropRect(img: Rectangle): Rectangle {
58
+ // 85% of image width, centered, respecting the ID aspect; capped at 90% height.
59
+ let w = img.w * 0.85;
60
+ let h = w / ID_AR;
61
+ if (h > img.h * 0.9) {
62
+ h = img.h * 0.9;
63
+ w = h * ID_AR;
64
+ }
65
+ return { x: img.x + (img.w - w) / 2, y: img.y + (img.h - h) / 2, w, h };
66
+ }
67
+
68
+ function clampCrop(crop: Rectangle, b: Rectangle): Rectangle {
69
+ let w = Math.min(Math.max(crop.w, 60), b.w);
70
+ let h = w / ID_AR;
71
+ if (h > b.h) {
72
+ h = b.h;
73
+ w = h * ID_AR;
74
+ if (w > b.w) {
75
+ w = b.w;
76
+ h = w / ID_AR;
77
+ }
78
+ }
79
+ const maxLeft = Math.max(b.x, b.x + b.w - w);
80
+ const maxTop = Math.max(b.y, b.y + b.h - h);
81
+ return {
82
+ x: Math.min(Math.max(crop.x, b.x), maxLeft),
83
+ y: Math.min(Math.max(crop.y, b.y), maxTop),
84
+ w,
85
+ h,
86
+ };
87
+ }
88
+
89
+ function dist(ax: number, ay: number, bx: number, by: number): number {
90
+ return Math.hypot(ax - bx, ay - by);
91
+ }
92
+
93
+ export function DocumentCropper({ uri, onCancel, onConfirm }: DocumentCropperProps): React.ReactElement {
94
+ const [natural, setNatural] = useState<{ w: number; h: number } | null>(null);
95
+ const [container, setContainer] = useState<{ w: number; h: number } | null>(null);
96
+ const [crop, setCrop] = useState<Rectangle | null>(null);
97
+ const [processing, setProcessing] = useState(false);
98
+
99
+ // Pan state held in refs so the PanResponder callbacks stay stable.
100
+ const handleRef = useRef<Handle>('none');
101
+ const cropAtStart = useRef<Rectangle | null>(null);
102
+ const cropRef = useRef<Rectangle | null>(null);
103
+ cropRef.current = crop;
104
+
105
+ useEffect(() => {
106
+ let alive = true;
107
+ imageSize(uri)
108
+ .then(({ width, height }) => {
109
+ if (alive) setNatural({ w: width, h: height });
110
+ })
111
+ .catch(() => {
112
+ /* fall through — confirm is gated on `natural` */
113
+ });
114
+ return () => {
115
+ alive = false;
116
+ };
117
+ }, [uri]);
118
+
119
+ const imgRect = useMemo<Rectangle | null>(() => {
120
+ if (!natural || !container) return null;
121
+ return computeImgRect(natural.w, natural.h, container.w, container.h);
122
+ }, [natural, container]);
123
+
124
+ // Initialise the crop box once the image rect is known.
125
+ useEffect(() => {
126
+ if (imgRect && !crop) setCrop(initCropRect(imgRect));
127
+ }, [imgRect, crop]);
128
+
129
+ const panResponder = useMemo(
130
+ () =>
131
+ PanResponder.create({
132
+ onStartShouldSetPanResponder: () => true,
133
+ onMoveShouldSetPanResponder: () => true,
134
+ onPanResponderGrant: (evt) => {
135
+ const c = cropRef.current;
136
+ if (!c) return;
137
+ const px = evt.nativeEvent.locationX;
138
+ const py = evt.nativeEvent.locationY;
139
+ let h: Handle = 'none';
140
+ if (dist(px, py, c.x, c.y) < HANDLE_RADIUS) h = 'tl';
141
+ else if (dist(px, py, c.x + c.w, c.y) < HANDLE_RADIUS) h = 'tr';
142
+ else if (dist(px, py, c.x, c.y + c.h) < HANDLE_RADIUS) h = 'bl';
143
+ else if (dist(px, py, c.x + c.w, c.y + c.h) < HANDLE_RADIUS) h = 'br';
144
+ else if (px >= c.x - 8 && px <= c.x + c.w + 8 && py >= c.y - 8 && py <= c.y + c.h + 8) h = 'move';
145
+ handleRef.current = h;
146
+ cropAtStart.current = c;
147
+ },
148
+ onPanResponderMove: (_evt, g) => {
149
+ const start = cropAtStart.current;
150
+ const bounds = imgRect;
151
+ const h = handleRef.current;
152
+ if (!start || !bounds || h === 'none') return;
153
+ const dx = g.dx;
154
+ const dy = g.dy;
155
+ const bottom = start.y + start.h;
156
+ let next: Rectangle;
157
+ switch (h) {
158
+ case 'move':
159
+ next = { x: start.x + dx, y: start.y + dy, w: start.w, h: start.h };
160
+ break;
161
+ case 'br': {
162
+ const w = start.w + dx;
163
+ next = { x: start.x, y: start.y, w, h: w / ID_AR };
164
+ break;
165
+ }
166
+ case 'bl': {
167
+ const w = start.w - dx;
168
+ next = { x: start.x + dx, y: start.y, w, h: w / ID_AR };
169
+ break;
170
+ }
171
+ case 'tr': {
172
+ const w = start.w + dx;
173
+ const hh = w / ID_AR;
174
+ next = { x: start.x, y: bottom - hh, w, h: hh };
175
+ break;
176
+ }
177
+ case 'tl': {
178
+ const w = start.w - dx;
179
+ const hh = w / ID_AR;
180
+ next = { x: start.x + dx, y: bottom - hh, w, h: hh };
181
+ break;
182
+ }
183
+ default:
184
+ return;
185
+ }
186
+ setCrop(clampCrop(next, bounds));
187
+ },
188
+ onPanResponderRelease: () => {
189
+ handleRef.current = 'none';
190
+ },
191
+ onPanResponderTerminate: () => {
192
+ handleRef.current = 'none';
193
+ },
194
+ }),
195
+ [imgRect],
196
+ );
197
+
198
+ const onContainerLayout = (e: LayoutChangeEvent) => {
199
+ const { width, height } = e.nativeEvent.layout;
200
+ setContainer((prev) => (prev && prev.w === width && prev.h === height ? prev : { w: width, h: height }));
201
+ };
202
+
203
+ const confirm = async () => {
204
+ if (!natural || !imgRect || !crop || processing) return;
205
+ setProcessing(true);
206
+ try {
207
+ const scaleX = natural.w / imgRect.w;
208
+ const scaleY = natural.h / imgRect.h;
209
+ const originX = Math.min(Math.max((crop.x - imgRect.x) * scaleX, 0), natural.w - 1);
210
+ const originY = Math.min(Math.max((crop.y - imgRect.y) * scaleY, 0), natural.h - 1);
211
+ const width = Math.min(crop.w * scaleX, natural.w - originX);
212
+ const height = Math.min(crop.h * scaleY, natural.h - originY);
213
+ const out = await cropImage(uri, { originX, originY, width, height });
214
+ onConfirm(out);
215
+ } catch {
216
+ setProcessing(false);
217
+ }
218
+ };
219
+
220
+ return (
221
+ <Modal visible animationType="slide" presentationStyle="fullScreen" onRequestClose={onCancel}>
222
+ <View style={{ flex: 1, backgroundColor: '#000' }}>
223
+ {/* App bar */}
224
+ <View style={styles.appBar}>
225
+ <Pressable onPress={onCancel} accessibilityRole="button" accessibilityLabel="Cancel" hitSlop={8} style={{ padding: 4, marginRight: 4 }}>
226
+ <Icon name="close" size={20} color="#FFFFFF" />
227
+ </Pressable>
228
+ <View>
229
+ <MyazaText variant="bodyMedium" color="#FFFFFF" style={{ fontWeight: '600' }}>
230
+ Crop to ID Card
231
+ </MyazaText>
232
+ <MyazaText variant="bodySmall" color="rgba(255,255,255,0.6)">
233
+ Drag to reposition · handles to resize
234
+ </MyazaText>
235
+ </View>
236
+ </View>
237
+
238
+ {/* Image + crop overlay */}
239
+ <View style={{ flex: 1 }} onLayout={onContainerLayout}>
240
+ {!natural ? (
241
+ <View style={StyleSheet.absoluteFill}>
242
+ <ActivityIndicator color="#FFFFFF" style={{ flex: 1 }} />
243
+ </View>
244
+ ) : (
245
+ <>
246
+ <Image source={{ uri }} resizeMode="contain" style={StyleSheet.absoluteFill} />
247
+ {container && crop ? (
248
+ <Svg style={StyleSheet.absoluteFill} width={container.w} height={container.h}>
249
+ {/* Dim surround (4 rects) */}
250
+ <Rect x={0} y={0} width={container.w} height={crop.y} fill="rgba(0,0,0,0.6)" />
251
+ <Rect x={0} y={crop.y + crop.h} width={container.w} height={Math.max(0, container.h - crop.y - crop.h)} fill="rgba(0,0,0,0.6)" />
252
+ <Rect x={0} y={crop.y} width={crop.x} height={crop.h} fill="rgba(0,0,0,0.6)" />
253
+ <Rect x={crop.x + crop.w} y={crop.y} width={Math.max(0, container.w - crop.x - crop.w)} height={crop.h} fill="rgba(0,0,0,0.6)" />
254
+
255
+ {/* White border */}
256
+ <Rect x={crop.x} y={crop.y} width={crop.w} height={crop.h} fill="none" stroke="#FFFFFF" strokeWidth={2} />
257
+
258
+ {/* Rule-of-thirds grid */}
259
+ <Line x1={crop.x + crop.w / 3} y1={crop.y} x2={crop.x + crop.w / 3} y2={crop.y + crop.h} stroke="rgba(255,255,255,0.25)" strokeWidth={1} />
260
+ <Line x1={crop.x + (crop.w * 2) / 3} y1={crop.y} x2={crop.x + (crop.w * 2) / 3} y2={crop.y + crop.h} stroke="rgba(255,255,255,0.25)" strokeWidth={1} />
261
+ <Line x1={crop.x} y1={crop.y + crop.h / 3} x2={crop.x + crop.w} y2={crop.y + crop.h / 3} stroke="rgba(255,255,255,0.25)" strokeWidth={1} />
262
+ <Line x1={crop.x} y1={crop.y + (crop.h * 2) / 3} x2={crop.x + crop.w} y2={crop.y + (crop.h * 2) / 3} stroke="rgba(255,255,255,0.25)" strokeWidth={1} />
263
+
264
+ {/* L-shaped corner brackets */}
265
+ {cornerLines(crop).map((l, i) => (
266
+ <Line key={i} x1={l[0]} y1={l[1]} x2={l[2]} y2={l[3]} stroke="#FFFFFF" strokeWidth={3.5} strokeLinecap="round" />
267
+ ))}
268
+ </Svg>
269
+ ) : null}
270
+ {/* Gesture layer on top */}
271
+ <View style={StyleSheet.absoluteFill} {...panResponder.panHandlers} />
272
+ </>
273
+ )}
274
+ </View>
275
+
276
+ {/* Action bar */}
277
+ <View style={styles.actionBar}>
278
+ {processing ? (
279
+ <View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'center', height: 48 }}>
280
+ <ActivityIndicator color="#FFFFFF" />
281
+ <View style={{ width: 10 }} />
282
+ <MyazaText variant="bodyMedium" color="rgba(255,255,255,0.8)">
283
+ Processing…
284
+ </MyazaText>
285
+ </View>
286
+ ) : (
287
+ <MyazaButton label="Crop & Use" leadingIcon="check" onPress={confirm} disabled={!crop} />
288
+ )}
289
+ </View>
290
+ </View>
291
+ </Modal>
292
+ );
293
+ }
294
+
295
+ function cornerLines(c: Rectangle): Array<[number, number, number, number]> {
296
+ const r = c.x + c.w;
297
+ const b = c.y + c.h;
298
+ return [
299
+ [c.x, c.y, c.x + ARM, c.y],
300
+ [c.x, c.y, c.x, c.y + ARM],
301
+ [r, c.y, r - ARM, c.y],
302
+ [r, c.y, r, c.y + ARM],
303
+ [c.x, b, c.x + ARM, b],
304
+ [c.x, b, c.x, b - ARM],
305
+ [r, b, r - ARM, b],
306
+ [r, b, r, b - ARM],
307
+ ];
308
+ }
309
+
310
+ const styles = StyleSheet.create({
311
+ appBar: {
312
+ flexDirection: 'row',
313
+ alignItems: 'center',
314
+ backgroundColor: APPBAR_BG,
315
+ paddingTop: TOP_INSET,
316
+ paddingBottom: spacing.sm,
317
+ paddingHorizontal: spacing.sm,
318
+ },
319
+ actionBar: {
320
+ backgroundColor: APPBAR_BG,
321
+ paddingHorizontal: spacing.md,
322
+ paddingTop: spacing.sm,
323
+ paddingBottom: spacing.lg,
324
+ },
325
+ });
@@ -0,0 +1,92 @@
1
+ import React from 'react';
2
+ import { Pressable, View } from 'react-native';
3
+
4
+ import { radius } from '../config/theme';
5
+ import { useTheme } from './runtime';
6
+ import { Icon, type IconName } from './Icon';
7
+ import { GlassSurface, supportsLiquidGlass } from './glass/GlassSurface';
8
+
9
+ // Circular icon button for the header chrome (back / theme toggle / close).
10
+ // On iOS 26 it renders as an interactive Liquid Glass circle; elsewhere it's a
11
+ // borderless ghost icon with a generous tap target. This is where the user
12
+ // asked for Liquid Glass — the theme/close/back chrome icons.
13
+
14
+ export interface GlassIconButtonProps {
15
+ icon: IconName;
16
+ onPress?: () => void;
17
+ size?: number;
18
+ iconSize?: number;
19
+ disabled?: boolean;
20
+ /** Render the glyph solid/filled (e.g. the theme toggle). */
21
+ solid?: boolean;
22
+ /**
23
+ * Skip this button's own glass surface — used when it sits inside a
24
+ * {@link GlassGroup} that already provides one shared glass capsule, so the
25
+ * group reads as a single piece of glass rather than two overlapping ones.
26
+ */
27
+ plain?: boolean;
28
+ /** Accessible label for screen readers. */
29
+ accessibilityLabel?: string;
30
+ }
31
+
32
+ export function GlassIconButton({
33
+ icon,
34
+ onPress,
35
+ size = 40,
36
+ iconSize = 20,
37
+ disabled = false,
38
+ solid = false,
39
+ plain = false,
40
+ accessibilityLabel,
41
+ }: GlassIconButtonProps): React.ReactElement {
42
+ const { colors } = useTheme();
43
+ const enabled = !disabled && !!onPress;
44
+ const iconColor = enabled ? `${colors.textDark}CC` : colors.textMuted; // ~0.8 alpha
45
+
46
+ const glyph = (
47
+ <View
48
+ style={{
49
+ width: size,
50
+ height: size,
51
+ borderRadius: radius.full,
52
+ alignItems: 'center',
53
+ justifyContent: 'center',
54
+ overflow: 'hidden',
55
+ }}
56
+ >
57
+ <Icon name={icon} size={iconSize} color={iconColor} solid={solid} />
58
+ </View>
59
+ );
60
+
61
+ const content =
62
+ !plain && supportsLiquidGlass() && enabled ? (
63
+ <GlassSurface
64
+ glassStyle="regular"
65
+ interactive
66
+ style={{
67
+ width: size,
68
+ height: size,
69
+ borderRadius: radius.full,
70
+ alignItems: 'center',
71
+ justifyContent: 'center',
72
+ }}
73
+ >
74
+ <Icon name={icon} size={iconSize} color={iconColor} solid={solid} />
75
+ </GlassSurface>
76
+ ) : (
77
+ glyph
78
+ );
79
+
80
+ return (
81
+ <Pressable
82
+ onPress={enabled ? onPress : undefined}
83
+ disabled={!enabled}
84
+ hitSlop={6}
85
+ accessibilityRole="button"
86
+ accessibilityLabel={accessibilityLabel}
87
+ style={({ pressed }) => ({ opacity: pressed ? 0.6 : 1, transform: [{ scale: pressed ? 0.92 : 1 }] })}
88
+ >
89
+ {content}
90
+ </Pressable>
91
+ );
92
+ }
@@ -0,0 +1,125 @@
1
+ import React from 'react';
2
+ import Ionicons from '@expo/vector-icons/Ionicons';
3
+ import {
4
+ BadgeCheck,
5
+ BookUser,
6
+ Camera,
7
+ CameraOff,
8
+ Car,
9
+ Check,
10
+ CircleAlert,
11
+ Contact,
12
+ CreditCard,
13
+ Fingerprint,
14
+ FileText,
15
+ IdCard,
16
+ Landmark,
17
+ Lightbulb,
18
+ Globe,
19
+ Lock,
20
+ Moon,
21
+ MoveLeft,
22
+ RefreshCw,
23
+ ScanFace,
24
+ ScanLine,
25
+ ShieldCheck,
26
+ Sun,
27
+ Upload,
28
+ UserRound,
29
+ VideoOff,
30
+ Vote,
31
+ X,
32
+ type LucideIcon,
33
+ } from 'lucide-react-native';
34
+
35
+ // Centralised icon set — the SAME Lucide icons the Flutter SDK uses
36
+ // (`lucide_icons_flutter`) and the web SDK uses (`lucide-react`). Rendered via
37
+ // lucide-react-native (react-native-svg), so the glyphs are pixel-identical
38
+ // across all three platforms.
39
+
40
+ export type IconName =
41
+ // chrome
42
+ | 'close'
43
+ | 'back'
44
+ | 'moon'
45
+ | 'sun'
46
+ | 'check'
47
+ | 'lock'
48
+ | 'alert'
49
+ | 'refresh'
50
+ // consent hero + process steps
51
+ | 'shield'
52
+ | 'badge-check'
53
+ | 'user'
54
+ | 'scan-line'
55
+ | 'scan-face'
56
+ // id-type glyphs
57
+ | 'fingerprint'
58
+ | 'credit-card'
59
+ | 'globe'
60
+ | 'car'
61
+ | 'vote'
62
+ | 'file-text'
63
+ | 'landmark'
64
+ | 'id-card'
65
+ | 'contact'
66
+ | 'passport'
67
+ | 'camera'
68
+ | 'camera-off'
69
+ | 'video-off'
70
+ | 'upload'
71
+ | 'lightbulb';
72
+
73
+ // Maps each name to its Lucide component. Names mirror the Flutter SDK exactly:
74
+ // shieldCheck · badgeCheck · userRound · scanLine · scanFace · fingerprint ·
75
+ // creditCard · globe · car · vote · fileText (+ chrome: X / ArrowLeft /
76
+ // Moon / Sun / Check / Lock / CircleAlert / RefreshCw / Camera).
77
+ const ICONS: Record<IconName, LucideIcon> = {
78
+ close: X,
79
+ back: MoveLeft, // longer-shaft left arrow, matching Flutter's keyboard_backspace
80
+ moon: Moon,
81
+ sun: Sun,
82
+ check: Check,
83
+ lock: Lock,
84
+ alert: CircleAlert,
85
+ refresh: RefreshCw,
86
+ shield: ShieldCheck,
87
+ 'badge-check': BadgeCheck,
88
+ user: UserRound,
89
+ 'scan-line': ScanLine,
90
+ 'scan-face': ScanFace,
91
+ fingerprint: Fingerprint,
92
+ 'credit-card': CreditCard,
93
+ globe: Globe,
94
+ car: Car,
95
+ vote: Vote,
96
+ 'file-text': FileText,
97
+ landmark: Landmark,
98
+ 'id-card': IdCard,
99
+ contact: Contact,
100
+ passport: BookUser,
101
+ camera: Camera,
102
+ 'camera-off': CameraOff,
103
+ 'video-off': VideoOff,
104
+ upload: Upload,
105
+ lightbulb: Lightbulb,
106
+ };
107
+
108
+ export interface IconProps {
109
+ name: IconName;
110
+ size?: number;
111
+ color: string;
112
+ strokeWidth?: number;
113
+ /** Render a solid/filled glyph (used for the theme toggle, matching Flutter's filled moon/sun). */
114
+ solid?: boolean;
115
+ }
116
+
117
+ export function Icon({ name, size = 20, color, strokeWidth = 2, solid = false }: IconProps): React.ReactElement {
118
+ // Solid moon/sun for the theme toggle — Lucide has no filled variants, so use
119
+ // Ionicons' purpose-built solid glyphs (matches Flutter's dark_mode/light_mode).
120
+ if (solid && (name === 'moon' || name === 'sun')) {
121
+ return <Ionicons name={name === 'moon' ? 'moon' : 'sunny'} size={size} color={color} />;
122
+ }
123
+ const Glyph = ICONS[name];
124
+ return <Glyph size={size} color={color} strokeWidth={strokeWidth} />;
125
+ }