@hexar/biometric-identity-sdk-react-native 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +68 -0
- package/dist/components/BiometricIdentityFlow.d.ts +17 -0
- package/dist/components/BiometricIdentityFlow.d.ts.map +1 -0
- package/dist/components/BiometricIdentityFlow.js +366 -0
- package/dist/components/CameraCapture.d.ts +15 -0
- package/dist/components/CameraCapture.d.ts.map +1 -0
- package/dist/components/CameraCapture.js +238 -0
- package/dist/components/ErrorScreen.d.ts +15 -0
- package/dist/components/ErrorScreen.d.ts.map +1 -0
- package/dist/components/ErrorScreen.js +142 -0
- package/dist/components/InstructionsScreen.d.ts +14 -0
- package/dist/components/InstructionsScreen.d.ts.map +1 -0
- package/dist/components/InstructionsScreen.js +181 -0
- package/dist/components/ResultScreen.d.ts +15 -0
- package/dist/components/ResultScreen.d.ts.map +1 -0
- package/dist/components/ResultScreen.js +182 -0
- package/dist/components/ValidationProgress.d.ts +14 -0
- package/dist/components/ValidationProgress.d.ts.map +1 -0
- package/dist/components/ValidationProgress.js +143 -0
- package/dist/components/VideoRecorder.d.ts +43 -0
- package/dist/components/VideoRecorder.d.ts.map +1 -0
- package/dist/components/VideoRecorder.js +631 -0
- package/dist/hooks/useBiometricSDK.d.ts +25 -0
- package/dist/hooks/useBiometricSDK.d.ts.map +1 -0
- package/dist/hooks/useBiometricSDK.js +173 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +47 -0
- package/package.json +27 -0
- package/src/components/BiometricIdentityFlow.tsx +557 -0
- package/src/components/CameraCapture.tsx +262 -0
- package/src/components/ErrorScreen.tsx +201 -0
- package/src/components/InstructionsScreen.tsx +269 -0
- package/src/components/ResultScreen.tsx +301 -0
- package/src/components/ValidationProgress.tsx +223 -0
- package/src/components/VideoRecorder.tsx +794 -0
- package/src/hooks/useBiometricSDK.ts +230 -0
- package/src/index.ts +24 -0
- package/tsconfig.json +20 -0
|
@@ -0,0 +1,794 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Smart Video Recorder Component for Liveness Detection
|
|
3
|
+
* Features challenge-response flow with guided head movements
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
|
7
|
+
import {
|
|
8
|
+
View,
|
|
9
|
+
Text,
|
|
10
|
+
StyleSheet,
|
|
11
|
+
TouchableOpacity,
|
|
12
|
+
Animated,
|
|
13
|
+
Easing,
|
|
14
|
+
} from 'react-native';
|
|
15
|
+
import { ThemeConfig, LivenessInstruction } from '@hexar/biometric-identity-sdk-core';
|
|
16
|
+
|
|
17
|
+
// Challenge action configuration (matches backend response)
|
|
18
|
+
export interface ChallengeAction {
|
|
19
|
+
action: string;
|
|
20
|
+
instruction: string;
|
|
21
|
+
duration_ms: number;
|
|
22
|
+
order: number;
|
|
23
|
+
icon?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface VideoRecorderProps {
|
|
27
|
+
theme?: ThemeConfig;
|
|
28
|
+
/** Total recording duration in ms (default: 8000ms for smart mode) */
|
|
29
|
+
duration?: number;
|
|
30
|
+
/** Instructions for user to follow */
|
|
31
|
+
instructions?: LivenessInstruction[];
|
|
32
|
+
/** Pre-fetched challenges from backend */
|
|
33
|
+
challenges?: ChallengeAction[];
|
|
34
|
+
/** Session ID from backend challenge */
|
|
35
|
+
sessionId?: string;
|
|
36
|
+
/** Enable smart challenge mode (default: true) */
|
|
37
|
+
smartMode?: boolean;
|
|
38
|
+
/** Callback when recording completes */
|
|
39
|
+
onComplete: (videoData: VideoRecordingResult) => void;
|
|
40
|
+
/** Callback when user cancels */
|
|
41
|
+
onCancel: () => void;
|
|
42
|
+
/** Callback to fetch challenges from backend */
|
|
43
|
+
onFetchChallenges?: () => Promise<ChallengeAction[]>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface VideoRecordingResult {
|
|
47
|
+
frames: string[];
|
|
48
|
+
duration: number;
|
|
49
|
+
instructionsFollowed: boolean;
|
|
50
|
+
qualityScore: number;
|
|
51
|
+
challengesCompleted: string[];
|
|
52
|
+
sessionId?: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Default challenge set (used if backend not available)
|
|
56
|
+
const DEFAULT_CHALLENGES: ChallengeAction[] = [
|
|
57
|
+
{
|
|
58
|
+
action: 'look_left',
|
|
59
|
+
instruction: 'Slowly turn your head to the LEFT',
|
|
60
|
+
duration_ms: 2500,
|
|
61
|
+
order: 1,
|
|
62
|
+
icon: '←',
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
action: 'look_right',
|
|
66
|
+
instruction: 'Slowly turn your head to the RIGHT',
|
|
67
|
+
duration_ms: 2500,
|
|
68
|
+
order: 2,
|
|
69
|
+
icon: '→',
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
action: 'blink',
|
|
73
|
+
instruction: 'Blink your eyes naturally',
|
|
74
|
+
duration_ms: 2000,
|
|
75
|
+
order: 3,
|
|
76
|
+
icon: '👁',
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
action: 'smile',
|
|
80
|
+
instruction: 'Smile 😊',
|
|
81
|
+
duration_ms: 2000,
|
|
82
|
+
order: 4,
|
|
83
|
+
icon: '😊',
|
|
84
|
+
},
|
|
85
|
+
];
|
|
86
|
+
|
|
87
|
+
// Instruction text mapping
|
|
88
|
+
const INSTRUCTION_MAP: Record<string, { text: string; icon: string }> = {
|
|
89
|
+
look_left: { text: 'Slowly turn your head LEFT', icon: '←' },
|
|
90
|
+
look_right: { text: 'Slowly turn your head RIGHT', icon: '→' },
|
|
91
|
+
look_up: { text: 'Look UP', icon: '↑' },
|
|
92
|
+
look_down: { text: 'Look DOWN', icon: '↓' },
|
|
93
|
+
turn_head_left: { text: 'Turn your head LEFT', icon: '←' },
|
|
94
|
+
turn_head_right: { text: 'Turn your head RIGHT', icon: '→' },
|
|
95
|
+
smile: { text: 'Smile 😊', icon: '😊' },
|
|
96
|
+
blink: { text: 'Blink your eyes naturally', icon: '👁' },
|
|
97
|
+
open_mouth: { text: 'Open your mouth slightly', icon: '😮' },
|
|
98
|
+
stay_still: { text: 'Look at the camera and stay still', icon: '📷' },
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
export const VideoRecorder: React.FC<VideoRecorderProps> = ({
|
|
102
|
+
theme,
|
|
103
|
+
duration,
|
|
104
|
+
instructions,
|
|
105
|
+
challenges: propChallenges,
|
|
106
|
+
sessionId,
|
|
107
|
+
smartMode = true,
|
|
108
|
+
onComplete,
|
|
109
|
+
onCancel,
|
|
110
|
+
onFetchChallenges,
|
|
111
|
+
}) => {
|
|
112
|
+
// State
|
|
113
|
+
const [phase, setPhase] = useState<'loading' | 'countdown' | 'recording' | 'processing'>('loading');
|
|
114
|
+
const [countdown, setCountdown] = useState(3);
|
|
115
|
+
const [challenges, setChallenges] = useState<ChallengeAction[]>([]);
|
|
116
|
+
const [currentChallengeIndex, setCurrentChallengeIndex] = useState(0);
|
|
117
|
+
const [challengeProgress, setChallengeProgress] = useState(0);
|
|
118
|
+
const [overallProgress, setOverallProgress] = useState(0);
|
|
119
|
+
const [completedChallenges, setCompletedChallenges] = useState<string[]>([]);
|
|
120
|
+
const [frames, setFrames] = useState<string[]>([]);
|
|
121
|
+
|
|
122
|
+
// Animations
|
|
123
|
+
const fadeAnim = useRef(new Animated.Value(0)).current;
|
|
124
|
+
const scaleAnim = useRef(new Animated.Value(1)).current;
|
|
125
|
+
const pulseAnim = useRef(new Animated.Value(1)).current;
|
|
126
|
+
const arrowAnim = useRef(new Animated.Value(0)).current;
|
|
127
|
+
const progressAnim = useRef(new Animated.Value(0)).current;
|
|
128
|
+
|
|
129
|
+
// Refs
|
|
130
|
+
const recordingStartTime = useRef<number>(0);
|
|
131
|
+
const frameInterval = useRef<NodeJS.Timeout | null>(null);
|
|
132
|
+
|
|
133
|
+
// Calculate total duration from challenges
|
|
134
|
+
const totalDuration = duration || challenges.reduce((sum, c) => sum + c.duration_ms, 0) + 2000;
|
|
135
|
+
|
|
136
|
+
// Initialize challenges
|
|
137
|
+
useEffect(() => {
|
|
138
|
+
const initChallenges = async () => {
|
|
139
|
+
try {
|
|
140
|
+
let challengeList: ChallengeAction[];
|
|
141
|
+
|
|
142
|
+
if (propChallenges && propChallenges.length > 0) {
|
|
143
|
+
// Use provided challenges
|
|
144
|
+
challengeList = propChallenges;
|
|
145
|
+
} else if (onFetchChallenges) {
|
|
146
|
+
// Fetch from backend
|
|
147
|
+
challengeList = await onFetchChallenges();
|
|
148
|
+
} else if (instructions && instructions.length > 0) {
|
|
149
|
+
// Convert instructions to challenges
|
|
150
|
+
challengeList = instructions.map((inst, idx) => ({
|
|
151
|
+
action: inst,
|
|
152
|
+
instruction: INSTRUCTION_MAP[inst]?.text || inst,
|
|
153
|
+
duration_ms: 2000,
|
|
154
|
+
order: idx + 1,
|
|
155
|
+
icon: INSTRUCTION_MAP[inst]?.icon,
|
|
156
|
+
}));
|
|
157
|
+
} else {
|
|
158
|
+
// Use default challenges
|
|
159
|
+
challengeList = smartMode ? DEFAULT_CHALLENGES : [
|
|
160
|
+
{
|
|
161
|
+
action: 'stay_still',
|
|
162
|
+
instruction: 'Look at the camera and stay still',
|
|
163
|
+
duration_ms: duration || 5000,
|
|
164
|
+
order: 1,
|
|
165
|
+
icon: '📷',
|
|
166
|
+
},
|
|
167
|
+
];
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
setChallenges(challengeList);
|
|
171
|
+
setPhase('countdown');
|
|
172
|
+
} catch (error) {
|
|
173
|
+
console.error('Failed to fetch challenges:', error);
|
|
174
|
+
// Fallback to default
|
|
175
|
+
setChallenges(DEFAULT_CHALLENGES);
|
|
176
|
+
setPhase('countdown');
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
initChallenges();
|
|
181
|
+
}, [propChallenges, instructions, onFetchChallenges, smartMode, duration]);
|
|
182
|
+
|
|
183
|
+
// Countdown phase
|
|
184
|
+
useEffect(() => {
|
|
185
|
+
if (phase !== 'countdown') return;
|
|
186
|
+
|
|
187
|
+
if (countdown > 0) {
|
|
188
|
+
// Animate countdown number
|
|
189
|
+
Animated.sequence([
|
|
190
|
+
Animated.timing(scaleAnim, {
|
|
191
|
+
toValue: 1.3,
|
|
192
|
+
duration: 200,
|
|
193
|
+
useNativeDriver: true,
|
|
194
|
+
}),
|
|
195
|
+
Animated.timing(scaleAnim, {
|
|
196
|
+
toValue: 1,
|
|
197
|
+
duration: 200,
|
|
198
|
+
useNativeDriver: true,
|
|
199
|
+
}),
|
|
200
|
+
]).start();
|
|
201
|
+
|
|
202
|
+
const timer = setTimeout(() => setCountdown(countdown - 1), 1000);
|
|
203
|
+
return () => clearTimeout(timer);
|
|
204
|
+
} else {
|
|
205
|
+
startRecording();
|
|
206
|
+
}
|
|
207
|
+
}, [countdown, phase]);
|
|
208
|
+
|
|
209
|
+
// Start pulse animation for recording indicator
|
|
210
|
+
useEffect(() => {
|
|
211
|
+
if (phase === 'recording') {
|
|
212
|
+
Animated.loop(
|
|
213
|
+
Animated.sequence([
|
|
214
|
+
Animated.timing(pulseAnim, {
|
|
215
|
+
toValue: 1.2,
|
|
216
|
+
duration: 500,
|
|
217
|
+
easing: Easing.inOut(Easing.ease),
|
|
218
|
+
useNativeDriver: true,
|
|
219
|
+
}),
|
|
220
|
+
Animated.timing(pulseAnim, {
|
|
221
|
+
toValue: 1,
|
|
222
|
+
duration: 500,
|
|
223
|
+
easing: Easing.inOut(Easing.ease),
|
|
224
|
+
useNativeDriver: true,
|
|
225
|
+
}),
|
|
226
|
+
])
|
|
227
|
+
).start();
|
|
228
|
+
}
|
|
229
|
+
}, [phase]);
|
|
230
|
+
|
|
231
|
+
// Animate arrow for directional challenges
|
|
232
|
+
const animateArrow = useCallback((direction: string) => {
|
|
233
|
+
const toValue = direction.includes('left') ? -20 : direction.includes('right') ? 20 : 0;
|
|
234
|
+
|
|
235
|
+
Animated.loop(
|
|
236
|
+
Animated.sequence([
|
|
237
|
+
Animated.timing(arrowAnim, {
|
|
238
|
+
toValue,
|
|
239
|
+
duration: 500,
|
|
240
|
+
easing: Easing.inOut(Easing.ease),
|
|
241
|
+
useNativeDriver: true,
|
|
242
|
+
}),
|
|
243
|
+
Animated.timing(arrowAnim, {
|
|
244
|
+
toValue: 0,
|
|
245
|
+
duration: 500,
|
|
246
|
+
easing: Easing.inOut(Easing.ease),
|
|
247
|
+
useNativeDriver: true,
|
|
248
|
+
}),
|
|
249
|
+
])
|
|
250
|
+
).start();
|
|
251
|
+
}, [arrowAnim]);
|
|
252
|
+
|
|
253
|
+
// Start recording
|
|
254
|
+
const startRecording = useCallback(() => {
|
|
255
|
+
setPhase('recording');
|
|
256
|
+
recordingStartTime.current = Date.now();
|
|
257
|
+
|
|
258
|
+
// Simulate frame capture (in real implementation, this would capture actual camera frames)
|
|
259
|
+
frameInterval.current = setInterval(() => {
|
|
260
|
+
setFrames(prev => [...prev, `frame_${Date.now()}`]);
|
|
261
|
+
}, 100); // Capture ~10 FPS
|
|
262
|
+
|
|
263
|
+
// Start first challenge
|
|
264
|
+
runChallenge(0);
|
|
265
|
+
}, []);
|
|
266
|
+
|
|
267
|
+
// Run a specific challenge
|
|
268
|
+
const runChallenge = useCallback((index: number) => {
|
|
269
|
+
if (index >= challenges.length) {
|
|
270
|
+
completeRecording();
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const challenge = challenges[index];
|
|
275
|
+
setCurrentChallengeIndex(index);
|
|
276
|
+
setChallengeProgress(0);
|
|
277
|
+
|
|
278
|
+
// Fade in instruction
|
|
279
|
+
Animated.timing(fadeAnim, {
|
|
280
|
+
toValue: 1,
|
|
281
|
+
duration: 300,
|
|
282
|
+
useNativeDriver: true,
|
|
283
|
+
}).start();
|
|
284
|
+
|
|
285
|
+
// Animate arrow for directional challenges
|
|
286
|
+
if (challenge.action.includes('left') || challenge.action.includes('right')) {
|
|
287
|
+
animateArrow(challenge.action);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Progress animation for this challenge
|
|
291
|
+
Animated.timing(progressAnim, {
|
|
292
|
+
toValue: 100,
|
|
293
|
+
duration: challenge.duration_ms,
|
|
294
|
+
useNativeDriver: false,
|
|
295
|
+
}).start();
|
|
296
|
+
|
|
297
|
+
// Update progress in real-time
|
|
298
|
+
const progressInterval = setInterval(() => {
|
|
299
|
+
setChallengeProgress(prev => {
|
|
300
|
+
const newProgress = prev + (100 / (challenge.duration_ms / 100));
|
|
301
|
+
return Math.min(100, newProgress);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
// Update overall progress
|
|
305
|
+
const elapsed = Date.now() - recordingStartTime.current;
|
|
306
|
+
const totalTime = challenges.reduce((sum, c) => sum + c.duration_ms, 0);
|
|
307
|
+
setOverallProgress(Math.min(100, (elapsed / totalTime) * 100));
|
|
308
|
+
}, 100);
|
|
309
|
+
|
|
310
|
+
// Move to next challenge after duration
|
|
311
|
+
setTimeout(() => {
|
|
312
|
+
clearInterval(progressInterval);
|
|
313
|
+
|
|
314
|
+
// Mark challenge as completed
|
|
315
|
+
setCompletedChallenges(prev => [...prev, challenge.action]);
|
|
316
|
+
|
|
317
|
+
// Fade out current instruction
|
|
318
|
+
Animated.timing(fadeAnim, {
|
|
319
|
+
toValue: 0,
|
|
320
|
+
duration: 200,
|
|
321
|
+
useNativeDriver: true,
|
|
322
|
+
}).start(() => {
|
|
323
|
+
// Reset and start next challenge
|
|
324
|
+
progressAnim.setValue(0);
|
|
325
|
+
runChallenge(index + 1);
|
|
326
|
+
});
|
|
327
|
+
}, challenge.duration_ms);
|
|
328
|
+
}, [challenges, fadeAnim, progressAnim, animateArrow]);
|
|
329
|
+
|
|
330
|
+
// Complete recording
|
|
331
|
+
const completeRecording = useCallback(() => {
|
|
332
|
+
if (frameInterval.current) {
|
|
333
|
+
clearInterval(frameInterval.current);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
setPhase('processing');
|
|
337
|
+
setOverallProgress(100);
|
|
338
|
+
|
|
339
|
+
// Simulate processing delay
|
|
340
|
+
setTimeout(() => {
|
|
341
|
+
const result: VideoRecordingResult = {
|
|
342
|
+
frames,
|
|
343
|
+
duration: Date.now() - recordingStartTime.current,
|
|
344
|
+
instructionsFollowed: completedChallenges.length === challenges.length,
|
|
345
|
+
qualityScore: 85 + Math.random() * 10, // Simulated quality score
|
|
346
|
+
challengesCompleted: completedChallenges,
|
|
347
|
+
sessionId,
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
onComplete(result);
|
|
351
|
+
}, 500);
|
|
352
|
+
}, [frames, completedChallenges, challenges, sessionId, onComplete]);
|
|
353
|
+
|
|
354
|
+
// Current challenge
|
|
355
|
+
const currentChallenge = challenges[currentChallengeIndex];
|
|
356
|
+
|
|
357
|
+
// Get direction arrow for the current challenge
|
|
358
|
+
const getDirectionIndicator = () => {
|
|
359
|
+
if (!currentChallenge) return null;
|
|
360
|
+
|
|
361
|
+
const action = currentChallenge.action;
|
|
362
|
+
|
|
363
|
+
if (action.includes('left')) {
|
|
364
|
+
return (
|
|
365
|
+
<Animated.View
|
|
366
|
+
style={[
|
|
367
|
+
styles.directionArrow,
|
|
368
|
+
styles.leftArrow,
|
|
369
|
+
{ transform: [{ translateX: arrowAnim }] }
|
|
370
|
+
]}
|
|
371
|
+
>
|
|
372
|
+
<Text style={styles.arrowText}>◀</Text>
|
|
373
|
+
</Animated.View>
|
|
374
|
+
);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
if (action.includes('right')) {
|
|
378
|
+
return (
|
|
379
|
+
<Animated.View
|
|
380
|
+
style={[
|
|
381
|
+
styles.directionArrow,
|
|
382
|
+
styles.rightArrow,
|
|
383
|
+
{ transform: [{ translateX: arrowAnim }] }
|
|
384
|
+
]}
|
|
385
|
+
>
|
|
386
|
+
<Text style={styles.arrowText}>▶</Text>
|
|
387
|
+
</Animated.View>
|
|
388
|
+
);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
if (action === 'look_up') {
|
|
392
|
+
return (
|
|
393
|
+
<View style={[styles.directionArrow, styles.topArrow]}>
|
|
394
|
+
<Text style={styles.arrowText}>▲</Text>
|
|
395
|
+
</View>
|
|
396
|
+
);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
if (action === 'look_down') {
|
|
400
|
+
return (
|
|
401
|
+
<View style={[styles.directionArrow, styles.bottomArrow]}>
|
|
402
|
+
<Text style={styles.arrowText}>▼</Text>
|
|
403
|
+
</View>
|
|
404
|
+
);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
return null;
|
|
408
|
+
};
|
|
409
|
+
|
|
410
|
+
return (
|
|
411
|
+
<View style={styles.container}>
|
|
412
|
+
{/* Camera View */}
|
|
413
|
+
<View style={styles.cameraContainer}>
|
|
414
|
+
<View style={styles.mockCamera}>
|
|
415
|
+
<Text style={styles.mockCameraText}>Front Camera</Text>
|
|
416
|
+
</View>
|
|
417
|
+
|
|
418
|
+
{/* Face Oval Overlay */}
|
|
419
|
+
<View style={styles.overlay}>
|
|
420
|
+
<View
|
|
421
|
+
style={[
|
|
422
|
+
styles.faceOval,
|
|
423
|
+
phase === 'recording' && {
|
|
424
|
+
borderColor: theme?.successColor || '#10B981',
|
|
425
|
+
borderStyle: 'solid',
|
|
426
|
+
}
|
|
427
|
+
]}
|
|
428
|
+
/>
|
|
429
|
+
|
|
430
|
+
{/* Direction indicators */}
|
|
431
|
+
{phase === 'recording' && getDirectionIndicator()}
|
|
432
|
+
</View>
|
|
433
|
+
|
|
434
|
+
{/* Loading Phase */}
|
|
435
|
+
{phase === 'loading' && (
|
|
436
|
+
<View style={styles.centeredOverlay}>
|
|
437
|
+
<Text style={styles.loadingText}>Preparing challenges...</Text>
|
|
438
|
+
</View>
|
|
439
|
+
)}
|
|
440
|
+
|
|
441
|
+
{/* Countdown */}
|
|
442
|
+
{phase === 'countdown' && (
|
|
443
|
+
<View style={styles.countdownContainer}>
|
|
444
|
+
<Text style={styles.getReadyText}>Get Ready!</Text>
|
|
445
|
+
<Animated.Text
|
|
446
|
+
style={[
|
|
447
|
+
styles.countdownText,
|
|
448
|
+
{ transform: [{ scale: scaleAnim }] }
|
|
449
|
+
]}
|
|
450
|
+
>
|
|
451
|
+
{countdown}
|
|
452
|
+
</Animated.Text>
|
|
453
|
+
</View>
|
|
454
|
+
)}
|
|
455
|
+
|
|
456
|
+
{/* Recording Indicator */}
|
|
457
|
+
{phase === 'recording' && (
|
|
458
|
+
<View style={styles.recordingIndicator}>
|
|
459
|
+
<Animated.View
|
|
460
|
+
style={[
|
|
461
|
+
styles.recordingDot,
|
|
462
|
+
{ transform: [{ scale: pulseAnim }] }
|
|
463
|
+
]}
|
|
464
|
+
/>
|
|
465
|
+
<Text style={styles.recordingText}>Recording</Text>
|
|
466
|
+
</View>
|
|
467
|
+
)}
|
|
468
|
+
|
|
469
|
+
{/* Overall Progress Bar */}
|
|
470
|
+
{(phase === 'recording' || phase === 'processing') && (
|
|
471
|
+
<View style={styles.progressContainer}>
|
|
472
|
+
<View style={styles.progressBar}>
|
|
473
|
+
<View
|
|
474
|
+
style={[
|
|
475
|
+
styles.progressFill,
|
|
476
|
+
{
|
|
477
|
+
width: `${overallProgress}%`,
|
|
478
|
+
backgroundColor: theme?.primaryColor || '#6366F1'
|
|
479
|
+
}
|
|
480
|
+
]}
|
|
481
|
+
/>
|
|
482
|
+
</View>
|
|
483
|
+
<Text style={styles.progressText}>
|
|
484
|
+
{Math.round(overallProgress)}%
|
|
485
|
+
</Text>
|
|
486
|
+
</View>
|
|
487
|
+
)}
|
|
488
|
+
|
|
489
|
+
{/* Challenge Instructions */}
|
|
490
|
+
{phase === 'recording' && currentChallenge && (
|
|
491
|
+
<Animated.View
|
|
492
|
+
style={[
|
|
493
|
+
styles.instructionContainer,
|
|
494
|
+
{ opacity: fadeAnim }
|
|
495
|
+
]}
|
|
496
|
+
>
|
|
497
|
+
<View style={styles.instructionBox}>
|
|
498
|
+
{currentChallenge.icon && (
|
|
499
|
+
<Text style={styles.instructionIcon}>{currentChallenge.icon}</Text>
|
|
500
|
+
)}
|
|
501
|
+
<Text style={styles.instructionText}>
|
|
502
|
+
{currentChallenge.instruction}
|
|
503
|
+
</Text>
|
|
504
|
+
|
|
505
|
+
{/* Challenge progress bar */}
|
|
506
|
+
<View style={styles.challengeProgressBar}>
|
|
507
|
+
<View
|
|
508
|
+
style={[
|
|
509
|
+
styles.challengeProgressFill,
|
|
510
|
+
{
|
|
511
|
+
width: `${challengeProgress}%`,
|
|
512
|
+
backgroundColor: theme?.successColor || '#10B981'
|
|
513
|
+
}
|
|
514
|
+
]}
|
|
515
|
+
/>
|
|
516
|
+
</View>
|
|
517
|
+
</View>
|
|
518
|
+
</Animated.View>
|
|
519
|
+
)}
|
|
520
|
+
|
|
521
|
+
{/* Challenge Counter */}
|
|
522
|
+
{phase === 'recording' && challenges.length > 1 && (
|
|
523
|
+
<View style={styles.challengeCounter}>
|
|
524
|
+
<Text style={styles.challengeCounterText}>
|
|
525
|
+
{currentChallengeIndex + 1} / {challenges.length}
|
|
526
|
+
</Text>
|
|
527
|
+
</View>
|
|
528
|
+
)}
|
|
529
|
+
|
|
530
|
+
{/* Processing Overlay */}
|
|
531
|
+
{phase === 'processing' && (
|
|
532
|
+
<View style={styles.processingOverlay}>
|
|
533
|
+
<Text style={styles.processingText}>Processing video...</Text>
|
|
534
|
+
</View>
|
|
535
|
+
)}
|
|
536
|
+
</View>
|
|
537
|
+
|
|
538
|
+
{/* Bottom Container */}
|
|
539
|
+
<View style={styles.bottomContainer}>
|
|
540
|
+
{phase === 'countdown' && (
|
|
541
|
+
<>
|
|
542
|
+
<Text style={styles.bottomText}>
|
|
543
|
+
You'll perform {challenges.length} action{challenges.length > 1 ? 's' : ''}.{'\n'}
|
|
544
|
+
Follow the on-screen instructions.
|
|
545
|
+
</Text>
|
|
546
|
+
<TouchableOpacity
|
|
547
|
+
style={[styles.cancelButton, { borderColor: theme?.errorColor || '#EF4444' }]}
|
|
548
|
+
onPress={onCancel}
|
|
549
|
+
>
|
|
550
|
+
<Text style={[styles.cancelButtonText, { color: theme?.errorColor || '#EF4444' }]}>
|
|
551
|
+
Cancel
|
|
552
|
+
</Text>
|
|
553
|
+
</TouchableOpacity>
|
|
554
|
+
</>
|
|
555
|
+
)}
|
|
556
|
+
|
|
557
|
+
{phase === 'recording' && (
|
|
558
|
+
<Text style={styles.bottomText}>
|
|
559
|
+
Keep your face visible and follow the instructions
|
|
560
|
+
</Text>
|
|
561
|
+
)}
|
|
562
|
+
|
|
563
|
+
{phase === 'processing' && (
|
|
564
|
+
<Text style={styles.bottomText}>
|
|
565
|
+
Almost done...
|
|
566
|
+
</Text>
|
|
567
|
+
)}
|
|
568
|
+
</View>
|
|
569
|
+
</View>
|
|
570
|
+
);
|
|
571
|
+
};
|
|
572
|
+
|
|
573
|
+
const styles = StyleSheet.create({
|
|
574
|
+
container: {
|
|
575
|
+
flex: 1,
|
|
576
|
+
backgroundColor: '#000000',
|
|
577
|
+
},
|
|
578
|
+
cameraContainer: {
|
|
579
|
+
flex: 1,
|
|
580
|
+
position: 'relative',
|
|
581
|
+
},
|
|
582
|
+
mockCamera: {
|
|
583
|
+
flex: 1,
|
|
584
|
+
backgroundColor: '#1F2937',
|
|
585
|
+
justifyContent: 'center',
|
|
586
|
+
alignItems: 'center',
|
|
587
|
+
},
|
|
588
|
+
mockCameraText: {
|
|
589
|
+
color: '#FFFFFF',
|
|
590
|
+
fontSize: 20,
|
|
591
|
+
fontWeight: 'bold',
|
|
592
|
+
},
|
|
593
|
+
overlay: {
|
|
594
|
+
...StyleSheet.absoluteFillObject,
|
|
595
|
+
justifyContent: 'center',
|
|
596
|
+
alignItems: 'center',
|
|
597
|
+
},
|
|
598
|
+
faceOval: {
|
|
599
|
+
width: 250,
|
|
600
|
+
height: 320,
|
|
601
|
+
borderRadius: 125,
|
|
602
|
+
borderWidth: 4,
|
|
603
|
+
borderColor: '#FFFFFF',
|
|
604
|
+
borderStyle: 'dashed',
|
|
605
|
+
},
|
|
606
|
+
centeredOverlay: {
|
|
607
|
+
...StyleSheet.absoluteFillObject,
|
|
608
|
+
justifyContent: 'center',
|
|
609
|
+
alignItems: 'center',
|
|
610
|
+
backgroundColor: 'rgba(0, 0, 0, 0.7)',
|
|
611
|
+
},
|
|
612
|
+
loadingText: {
|
|
613
|
+
color: '#FFFFFF',
|
|
614
|
+
fontSize: 18,
|
|
615
|
+
},
|
|
616
|
+
countdownContainer: {
|
|
617
|
+
...StyleSheet.absoluteFillObject,
|
|
618
|
+
justifyContent: 'center',
|
|
619
|
+
alignItems: 'center',
|
|
620
|
+
backgroundColor: 'rgba(0, 0, 0, 0.6)',
|
|
621
|
+
},
|
|
622
|
+
getReadyText: {
|
|
623
|
+
color: '#FFFFFF',
|
|
624
|
+
fontSize: 24,
|
|
625
|
+
fontWeight: '600',
|
|
626
|
+
marginBottom: 20,
|
|
627
|
+
},
|
|
628
|
+
countdownText: {
|
|
629
|
+
fontSize: 96,
|
|
630
|
+
fontWeight: 'bold',
|
|
631
|
+
color: '#FFFFFF',
|
|
632
|
+
},
|
|
633
|
+
recordingIndicator: {
|
|
634
|
+
position: 'absolute',
|
|
635
|
+
top: 50,
|
|
636
|
+
right: 20,
|
|
637
|
+
flexDirection: 'row',
|
|
638
|
+
alignItems: 'center',
|
|
639
|
+
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
|
640
|
+
paddingHorizontal: 12,
|
|
641
|
+
paddingVertical: 6,
|
|
642
|
+
borderRadius: 20,
|
|
643
|
+
},
|
|
644
|
+
recordingDot: {
|
|
645
|
+
width: 12,
|
|
646
|
+
height: 12,
|
|
647
|
+
borderRadius: 6,
|
|
648
|
+
backgroundColor: '#EF4444',
|
|
649
|
+
marginRight: 8,
|
|
650
|
+
},
|
|
651
|
+
recordingText: {
|
|
652
|
+
color: '#FFFFFF',
|
|
653
|
+
fontSize: 14,
|
|
654
|
+
fontWeight: '600',
|
|
655
|
+
},
|
|
656
|
+
progressContainer: {
|
|
657
|
+
position: 'absolute',
|
|
658
|
+
top: 100,
|
|
659
|
+
left: 20,
|
|
660
|
+
right: 20,
|
|
661
|
+
flexDirection: 'row',
|
|
662
|
+
alignItems: 'center',
|
|
663
|
+
},
|
|
664
|
+
progressBar: {
|
|
665
|
+
flex: 1,
|
|
666
|
+
height: 8,
|
|
667
|
+
backgroundColor: 'rgba(255, 255, 255, 0.3)',
|
|
668
|
+
borderRadius: 4,
|
|
669
|
+
overflow: 'hidden',
|
|
670
|
+
marginRight: 10,
|
|
671
|
+
},
|
|
672
|
+
progressFill: {
|
|
673
|
+
height: '100%',
|
|
674
|
+
borderRadius: 4,
|
|
675
|
+
},
|
|
676
|
+
progressText: {
|
|
677
|
+
color: '#FFFFFF',
|
|
678
|
+
fontSize: 12,
|
|
679
|
+
fontWeight: '600',
|
|
680
|
+
width: 40,
|
|
681
|
+
textAlign: 'right',
|
|
682
|
+
},
|
|
683
|
+
instructionContainer: {
|
|
684
|
+
position: 'absolute',
|
|
685
|
+
top: 140,
|
|
686
|
+
left: 20,
|
|
687
|
+
right: 20,
|
|
688
|
+
alignItems: 'center',
|
|
689
|
+
},
|
|
690
|
+
instructionBox: {
|
|
691
|
+
backgroundColor: 'rgba(0, 0, 0, 0.8)',
|
|
692
|
+
paddingHorizontal: 24,
|
|
693
|
+
paddingVertical: 16,
|
|
694
|
+
borderRadius: 16,
|
|
695
|
+
alignItems: 'center',
|
|
696
|
+
minWidth: 280,
|
|
697
|
+
},
|
|
698
|
+
instructionIcon: {
|
|
699
|
+
fontSize: 36,
|
|
700
|
+
marginBottom: 8,
|
|
701
|
+
},
|
|
702
|
+
instructionText: {
|
|
703
|
+
fontSize: 20,
|
|
704
|
+
fontWeight: 'bold',
|
|
705
|
+
color: '#FFFFFF',
|
|
706
|
+
textAlign: 'center',
|
|
707
|
+
marginBottom: 12,
|
|
708
|
+
},
|
|
709
|
+
challengeProgressBar: {
|
|
710
|
+
width: '100%',
|
|
711
|
+
height: 4,
|
|
712
|
+
backgroundColor: 'rgba(255, 255, 255, 0.3)',
|
|
713
|
+
borderRadius: 2,
|
|
714
|
+
overflow: 'hidden',
|
|
715
|
+
},
|
|
716
|
+
challengeProgressFill: {
|
|
717
|
+
height: '100%',
|
|
718
|
+
borderRadius: 2,
|
|
719
|
+
},
|
|
720
|
+
challengeCounter: {
|
|
721
|
+
position: 'absolute',
|
|
722
|
+
top: 50,
|
|
723
|
+
left: 20,
|
|
724
|
+
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
|
725
|
+
paddingHorizontal: 12,
|
|
726
|
+
paddingVertical: 6,
|
|
727
|
+
borderRadius: 20,
|
|
728
|
+
},
|
|
729
|
+
challengeCounterText: {
|
|
730
|
+
color: '#FFFFFF',
|
|
731
|
+
fontSize: 14,
|
|
732
|
+
fontWeight: '600',
|
|
733
|
+
},
|
|
734
|
+
directionArrow: {
|
|
735
|
+
position: 'absolute',
|
|
736
|
+
justifyContent: 'center',
|
|
737
|
+
alignItems: 'center',
|
|
738
|
+
},
|
|
739
|
+
leftArrow: {
|
|
740
|
+
left: 20,
|
|
741
|
+
},
|
|
742
|
+
rightArrow: {
|
|
743
|
+
right: 20,
|
|
744
|
+
},
|
|
745
|
+
topArrow: {
|
|
746
|
+
top: 100,
|
|
747
|
+
},
|
|
748
|
+
bottomArrow: {
|
|
749
|
+
bottom: 150,
|
|
750
|
+
},
|
|
751
|
+
arrowText: {
|
|
752
|
+
fontSize: 48,
|
|
753
|
+
color: '#FFFFFF',
|
|
754
|
+
textShadowColor: 'rgba(0, 0, 0, 0.5)',
|
|
755
|
+
textShadowOffset: { width: 2, height: 2 },
|
|
756
|
+
textShadowRadius: 4,
|
|
757
|
+
},
|
|
758
|
+
processingOverlay: {
|
|
759
|
+
...StyleSheet.absoluteFillObject,
|
|
760
|
+
justifyContent: 'center',
|
|
761
|
+
alignItems: 'center',
|
|
762
|
+
backgroundColor: 'rgba(0, 0, 0, 0.7)',
|
|
763
|
+
},
|
|
764
|
+
processingText: {
|
|
765
|
+
color: '#FFFFFF',
|
|
766
|
+
fontSize: 20,
|
|
767
|
+
fontWeight: '600',
|
|
768
|
+
},
|
|
769
|
+
bottomContainer: {
|
|
770
|
+
paddingVertical: 32,
|
|
771
|
+
paddingHorizontal: 24,
|
|
772
|
+
backgroundColor: 'rgba(0, 0, 0, 0.9)',
|
|
773
|
+
alignItems: 'center',
|
|
774
|
+
},
|
|
775
|
+
bottomText: {
|
|
776
|
+
color: '#FFFFFF',
|
|
777
|
+
fontSize: 16,
|
|
778
|
+
textAlign: 'center',
|
|
779
|
+
marginBottom: 16,
|
|
780
|
+
lineHeight: 24,
|
|
781
|
+
},
|
|
782
|
+
cancelButton: {
|
|
783
|
+
paddingVertical: 12,
|
|
784
|
+
paddingHorizontal: 32,
|
|
785
|
+
borderRadius: 8,
|
|
786
|
+
borderWidth: 2,
|
|
787
|
+
},
|
|
788
|
+
cancelButtonText: {
|
|
789
|
+
fontSize: 16,
|
|
790
|
+
fontWeight: '600',
|
|
791
|
+
},
|
|
792
|
+
});
|
|
793
|
+
|
|
794
|
+
export default VideoRecorder;
|