@switchlabs/verify-ai-react-native 0.1.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.
@@ -0,0 +1,346 @@
1
+ import React, { useRef, useState, useCallback } from 'react';
2
+ import {
3
+ View,
4
+ Text,
5
+ TouchableOpacity,
6
+ StyleSheet,
7
+ ActivityIndicator,
8
+ type ViewStyle,
9
+ } from 'react-native';
10
+ import {
11
+ CameraView,
12
+ useCameraPermissions,
13
+ type CameraCapturedPicture,
14
+ } from 'expo-camera';
15
+ import type {
16
+ VerificationResult,
17
+ ScannerStatus,
18
+ ScannerOverlayConfig,
19
+ } from '../types';
20
+
21
+ export interface VerifyAIScannerProps {
22
+ /** Called with the base64 image data when the user captures a photo. */
23
+ onCapture: (base64Image: string) => Promise<VerificationResult | null>;
24
+ /** Called when verification completes successfully. */
25
+ onResult?: (result: VerificationResult) => void;
26
+ /** Called when an error occurs. */
27
+ onError?: (error: Error) => void;
28
+ /** Overlay configuration for the camera view. */
29
+ overlay?: ScannerOverlayConfig;
30
+ /** Custom style for the container. */
31
+ style?: ViewStyle;
32
+ /** Whether to show the default capture button. Set false to use your own. */
33
+ showCaptureButton?: boolean;
34
+ /** Ref to imperatively trigger capture from parent. */
35
+ captureRef?: React.MutableRefObject<(() => void) | null>;
36
+ }
37
+
38
+ /**
39
+ * Camera scanner component for capturing verification photos.
40
+ * Uses expo-camera for the camera view and provides a simple capture UI.
41
+ *
42
+ * @example
43
+ * ```tsx
44
+ * const { verify } = useVerifyAI({ apiKey: 'vai_...' });
45
+ *
46
+ * <VerifyAIScanner
47
+ * onCapture={(base64) => verify({ image: base64, policy: 'scooter_parking' })}
48
+ * onResult={(result) => {
49
+ * if (result.is_compliant) navigation.navigate('Success');
50
+ * }}
51
+ * overlay={{
52
+ * title: 'Photo Verification',
53
+ * instructions: 'Center the scooter in the frame',
54
+ * showGuideFrame: true,
55
+ * }}
56
+ * />
57
+ * ```
58
+ */
59
+ export function VerifyAIScanner({
60
+ onCapture,
61
+ onResult,
62
+ onError,
63
+ overlay,
64
+ style,
65
+ showCaptureButton = true,
66
+ captureRef,
67
+ }: VerifyAIScannerProps) {
68
+ const cameraRef = useRef<CameraView>(null);
69
+ const [status, setStatus] = useState<ScannerStatus>('idle');
70
+ const [result, setResult] = useState<VerificationResult | null>(null);
71
+ const [permission, requestPermission] = useCameraPermissions();
72
+
73
+ const handleCapture = useCallback(async () => {
74
+ if (!cameraRef.current || status === 'capturing' || status === 'processing') return;
75
+
76
+ setStatus('capturing');
77
+ setResult(null);
78
+
79
+ try {
80
+ const photo = await cameraRef.current.takePictureAsync({
81
+ base64: true,
82
+ quality: 0.8,
83
+ exif: false,
84
+ });
85
+
86
+ if (!photo?.base64) {
87
+ throw new Error('Failed to capture photo');
88
+ }
89
+
90
+ setStatus('processing');
91
+ const verificationResult = await onCapture(photo.base64);
92
+
93
+ if (verificationResult) {
94
+ setResult(verificationResult);
95
+ setStatus('success');
96
+ onResult?.(verificationResult);
97
+ } else {
98
+ // null result means queued for offline
99
+ setStatus('idle');
100
+ }
101
+ } catch (err) {
102
+ const error = err instanceof Error ? err : new Error(String(err));
103
+ setStatus('error');
104
+ onError?.(error);
105
+ // Reset after a brief pause
106
+ setTimeout(() => setStatus('idle'), 2000);
107
+ }
108
+ }, [status, onCapture, onResult, onError]);
109
+
110
+ // Expose capture to parent via ref
111
+ if (captureRef) {
112
+ captureRef.current = handleCapture;
113
+ }
114
+
115
+ if (!permission) {
116
+ return <View style={[styles.container, style]} />;
117
+ }
118
+
119
+ if (!permission.granted) {
120
+ return (
121
+ <View style={[styles.container, styles.permissionContainer, style]}>
122
+ <Text style={styles.permissionText}>Camera access is required for photo verification</Text>
123
+ <TouchableOpacity style={styles.permissionButton} onPress={requestPermission}>
124
+ <Text style={styles.permissionButtonText}>Grant Camera Access</Text>
125
+ </TouchableOpacity>
126
+ </View>
127
+ );
128
+ }
129
+
130
+ return (
131
+ <View style={[styles.container, style]}>
132
+ <CameraView ref={cameraRef} style={styles.camera} facing="back">
133
+ {/* Overlay */}
134
+ <View style={styles.overlay}>
135
+ {overlay?.title && (
136
+ <View style={styles.topBar}>
137
+ <Text style={styles.titleText}>{overlay.title}</Text>
138
+ </View>
139
+ )}
140
+
141
+ {overlay?.showGuideFrame && (
142
+ <View style={styles.guideContainer}>
143
+ <View
144
+ style={[
145
+ styles.guideFrame,
146
+ overlay.guideFrameAspectRatio
147
+ ? { aspectRatio: overlay.guideFrameAspectRatio }
148
+ : undefined,
149
+ ]}
150
+ />
151
+ </View>
152
+ )}
153
+
154
+ {overlay?.instructions && status === 'idle' && (
155
+ <View style={styles.instructionsContainer}>
156
+ <Text style={styles.instructionsText}>{overlay.instructions}</Text>
157
+ </View>
158
+ )}
159
+
160
+ {/* Status indicators */}
161
+ {status === 'processing' && (
162
+ <View style={styles.statusOverlay}>
163
+ <ActivityIndicator size="large" color="#fff" />
164
+ <Text style={styles.statusText}>Analyzing photo...</Text>
165
+ </View>
166
+ )}
167
+
168
+ {status === 'success' && result && (
169
+ <View style={styles.statusOverlay}>
170
+ <View
171
+ style={[
172
+ styles.resultBadge,
173
+ result.is_compliant ? styles.resultPass : styles.resultFail,
174
+ ]}
175
+ >
176
+ <Text style={styles.resultText}>
177
+ {result.is_compliant ? 'PASS' : 'FAIL'}
178
+ </Text>
179
+ </View>
180
+ <Text style={styles.feedbackText}>{result.feedback}</Text>
181
+ </View>
182
+ )}
183
+
184
+ {status === 'error' && (
185
+ <View style={styles.statusOverlay}>
186
+ <Text style={styles.errorText}>Verification failed. Try again.</Text>
187
+ </View>
188
+ )}
189
+ </View>
190
+
191
+ {/* Capture button */}
192
+ {showCaptureButton && (
193
+ <View style={styles.bottomBar}>
194
+ <TouchableOpacity
195
+ style={[
196
+ styles.captureButton,
197
+ (status === 'capturing' || status === 'processing') && styles.captureButtonDisabled,
198
+ ]}
199
+ onPress={handleCapture}
200
+ disabled={status === 'capturing' || status === 'processing'}
201
+ activeOpacity={0.7}
202
+ >
203
+ <View style={styles.captureButtonInner} />
204
+ </TouchableOpacity>
205
+ </View>
206
+ )}
207
+ </CameraView>
208
+ </View>
209
+ );
210
+ }
211
+
212
+ const styles = StyleSheet.create({
213
+ container: {
214
+ flex: 1,
215
+ backgroundColor: '#000',
216
+ },
217
+ camera: {
218
+ flex: 1,
219
+ },
220
+ overlay: {
221
+ ...StyleSheet.absoluteFillObject,
222
+ justifyContent: 'space-between',
223
+ },
224
+ topBar: {
225
+ paddingTop: 60,
226
+ paddingHorizontal: 20,
227
+ alignItems: 'center',
228
+ },
229
+ titleText: {
230
+ color: '#fff',
231
+ fontSize: 18,
232
+ fontWeight: '600',
233
+ },
234
+ guideContainer: {
235
+ flex: 1,
236
+ justifyContent: 'center',
237
+ alignItems: 'center',
238
+ paddingHorizontal: 40,
239
+ },
240
+ guideFrame: {
241
+ width: '100%',
242
+ aspectRatio: 4 / 3,
243
+ borderWidth: 2,
244
+ borderColor: 'rgba(255, 255, 255, 0.5)',
245
+ borderRadius: 12,
246
+ borderStyle: 'dashed',
247
+ },
248
+ instructionsContainer: {
249
+ paddingHorizontal: 20,
250
+ paddingBottom: 20,
251
+ alignItems: 'center',
252
+ },
253
+ instructionsText: {
254
+ color: 'rgba(255, 255, 255, 0.8)',
255
+ fontSize: 14,
256
+ textAlign: 'center',
257
+ },
258
+ statusOverlay: {
259
+ ...StyleSheet.absoluteFillObject,
260
+ backgroundColor: 'rgba(0, 0, 0, 0.6)',
261
+ justifyContent: 'center',
262
+ alignItems: 'center',
263
+ gap: 16,
264
+ },
265
+ statusText: {
266
+ color: '#fff',
267
+ fontSize: 16,
268
+ fontWeight: '500',
269
+ },
270
+ resultBadge: {
271
+ paddingHorizontal: 24,
272
+ paddingVertical: 12,
273
+ borderRadius: 8,
274
+ },
275
+ resultPass: {
276
+ backgroundColor: 'rgba(34, 197, 94, 0.9)',
277
+ },
278
+ resultFail: {
279
+ backgroundColor: 'rgba(239, 68, 68, 0.9)',
280
+ },
281
+ resultText: {
282
+ color: '#fff',
283
+ fontSize: 24,
284
+ fontWeight: '700',
285
+ },
286
+ feedbackText: {
287
+ color: '#fff',
288
+ fontSize: 14,
289
+ textAlign: 'center',
290
+ paddingHorizontal: 40,
291
+ lineHeight: 20,
292
+ },
293
+ errorText: {
294
+ color: '#ef4444',
295
+ fontSize: 16,
296
+ fontWeight: '500',
297
+ },
298
+ bottomBar: {
299
+ position: 'absolute',
300
+ bottom: 40,
301
+ left: 0,
302
+ right: 0,
303
+ alignItems: 'center',
304
+ },
305
+ captureButton: {
306
+ width: 72,
307
+ height: 72,
308
+ borderRadius: 36,
309
+ borderWidth: 4,
310
+ borderColor: '#fff',
311
+ justifyContent: 'center',
312
+ alignItems: 'center',
313
+ },
314
+ captureButtonDisabled: {
315
+ opacity: 0.4,
316
+ },
317
+ captureButtonInner: {
318
+ width: 58,
319
+ height: 58,
320
+ borderRadius: 29,
321
+ backgroundColor: '#fff',
322
+ },
323
+ permissionContainer: {
324
+ justifyContent: 'center',
325
+ alignItems: 'center',
326
+ paddingHorizontal: 40,
327
+ gap: 20,
328
+ },
329
+ permissionText: {
330
+ color: '#fff',
331
+ fontSize: 16,
332
+ textAlign: 'center',
333
+ lineHeight: 24,
334
+ },
335
+ permissionButton: {
336
+ backgroundColor: '#3b82f6',
337
+ paddingHorizontal: 24,
338
+ paddingVertical: 12,
339
+ borderRadius: 8,
340
+ },
341
+ permissionButtonText: {
342
+ color: '#fff',
343
+ fontSize: 16,
344
+ fontWeight: '600',
345
+ },
346
+ });
@@ -0,0 +1,150 @@
1
+ import { useRef, useMemo, useCallback, useState, useEffect } from 'react';
2
+ import { AppState, type AppStateStatus } from 'react-native';
3
+ import { VerifyAIClient } from '../client';
4
+ import { OfflineQueue } from '../storage/offlineQueue';
5
+ import type {
6
+ VerifyAIConfig,
7
+ VerificationRequest,
8
+ VerificationResult,
9
+ VerificationListParams,
10
+ VerificationListResponse,
11
+ } from '../types';
12
+
13
+ export interface UseVerifyAIReturn {
14
+ /** Submit a verification. Uses offline queue if offlineMode is enabled and request fails. */
15
+ verify: (request: VerificationRequest) => Promise<VerificationResult | null>;
16
+ /** List past verifications. */
17
+ listVerifications: (params?: VerificationListParams) => Promise<VerificationListResponse>;
18
+ /** Get a single verification by ID. */
19
+ getVerification: (id: string) => Promise<VerificationResult>;
20
+ /** Whether a verification is currently in progress. */
21
+ loading: boolean;
22
+ /** The most recent error, if any. */
23
+ error: Error | null;
24
+ /** The most recent verification result. */
25
+ lastResult: VerificationResult | null;
26
+ /** Number of items in the offline queue. */
27
+ queueSize: number;
28
+ /** Manually process the offline queue. */
29
+ processQueue: () => Promise<void>;
30
+ /** The underlying client instance. */
31
+ client: VerifyAIClient;
32
+ /** The offline queue instance (null if offlineMode is disabled). */
33
+ offlineQueue: OfflineQueue | null;
34
+ }
35
+
36
+ /**
37
+ * React hook for Verify AI. Provides verification methods,
38
+ * loading/error state, and optional offline queue management.
39
+ *
40
+ * @example
41
+ * ```tsx
42
+ * const { verify, loading, lastResult, error } = useVerifyAI({
43
+ * apiKey: 'vai_your_api_key',
44
+ * offlineMode: true,
45
+ * });
46
+ *
47
+ * const handleCapture = async (base64Image: string) => {
48
+ * const result = await verify({
49
+ * image: base64Image,
50
+ * policy: 'scooter_parking',
51
+ * });
52
+ * if (result?.is_compliant) {
53
+ * // Success
54
+ * }
55
+ * };
56
+ * ```
57
+ */
58
+ export function useVerifyAI(config: VerifyAIConfig): UseVerifyAIReturn {
59
+ const client = useMemo(() => new VerifyAIClient(config), [config.apiKey, config.baseUrl, config.timeout]);
60
+ const offlineQueue = useMemo(
61
+ () => (config.offlineMode ? new OfflineQueue(client) : null),
62
+ [client, config.offlineMode]
63
+ );
64
+
65
+ const [loading, setLoading] = useState(false);
66
+ const [error, setError] = useState<Error | null>(null);
67
+ const [lastResult, setLastResult] = useState<VerificationResult | null>(null);
68
+ const [queueSize, setQueueSize] = useState(0);
69
+
70
+ // Refresh queue size
71
+ const refreshQueueSize = useCallback(async () => {
72
+ if (offlineQueue) {
73
+ const size = await offlineQueue.getQueueSize();
74
+ setQueueSize(size);
75
+ }
76
+ }, [offlineQueue]);
77
+
78
+ // Process queue on app foreground
79
+ useEffect(() => {
80
+ if (!offlineQueue) return;
81
+
82
+ refreshQueueSize();
83
+
84
+ const handleAppState = (state: AppStateStatus) => {
85
+ if (state === 'active') {
86
+ offlineQueue.processQueue().then(() => refreshQueueSize());
87
+ }
88
+ };
89
+
90
+ const subscription = AppState.addEventListener('change', handleAppState);
91
+ return () => subscription.remove();
92
+ }, [offlineQueue, refreshQueueSize]);
93
+
94
+ const verify = useCallback(
95
+ async (request: VerificationRequest): Promise<VerificationResult | null> => {
96
+ setLoading(true);
97
+ setError(null);
98
+
99
+ try {
100
+ const result = await client.verify(request);
101
+ setLastResult(result);
102
+ return result;
103
+ } catch (err) {
104
+ const error = err instanceof Error ? err : new Error(String(err));
105
+ setError(error);
106
+
107
+ // If offline mode, queue the request
108
+ if (offlineQueue) {
109
+ await offlineQueue.enqueue(request);
110
+ await refreshQueueSize();
111
+ return null;
112
+ }
113
+
114
+ throw error;
115
+ } finally {
116
+ setLoading(false);
117
+ }
118
+ },
119
+ [client, offlineQueue, refreshQueueSize]
120
+ );
121
+
122
+ const listVerifications = useCallback(
123
+ (params?: VerificationListParams) => client.listVerifications(params),
124
+ [client]
125
+ );
126
+
127
+ const getVerification = useCallback(
128
+ (id: string) => client.getVerification(id),
129
+ [client]
130
+ );
131
+
132
+ const processQueue = useCallback(async () => {
133
+ if (!offlineQueue) return;
134
+ await offlineQueue.processQueue((_, result) => setLastResult(result));
135
+ await refreshQueueSize();
136
+ }, [offlineQueue, refreshQueueSize]);
137
+
138
+ return {
139
+ verify,
140
+ listVerifications,
141
+ getVerification,
142
+ loading,
143
+ error,
144
+ lastResult,
145
+ queueSize,
146
+ processQueue,
147
+ client,
148
+ offlineQueue,
149
+ };
150
+ }
package/src/index.ts ADDED
@@ -0,0 +1,26 @@
1
+ // Client
2
+ export { VerifyAIClient, VerifyAIRequestError } from './client';
3
+
4
+ // Hooks
5
+ export { useVerifyAI } from './hooks/useVerifyAI';
6
+ export type { UseVerifyAIReturn } from './hooks/useVerifyAI';
7
+
8
+ // Components
9
+ export { VerifyAIScanner } from './components/VerifyAIScanner';
10
+ export type { VerifyAIScannerProps } from './components/VerifyAIScanner';
11
+
12
+ // Offline Queue
13
+ export { OfflineQueue } from './storage/offlineQueue';
14
+
15
+ // Types
16
+ export type {
17
+ VerifyAIConfig,
18
+ VerificationRequest,
19
+ VerificationResult,
20
+ VerificationListResponse,
21
+ VerificationListParams,
22
+ QueueItem,
23
+ VerifyAIError,
24
+ ScannerStatus,
25
+ ScannerOverlayConfig,
26
+ } from './types';