@kernaq/verify-react-native 2.0.0 → 2.0.1

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.
@@ -1,4 +1,9 @@
1
- import React, { useState, useEffect, useRef } from 'react'
1
+ /**
2
+ * LivenessStep — randomised action challenges shown as an overlay on the camera feed.
3
+ * Tasks: blink twice, turn left, turn right, nod, open mouth.
4
+ * Instruction is overlaid at the bottom of the viewport (mirrors web v2).
5
+ */
6
+ import React, { useState, useEffect, useRef, useMemo } from 'react'
2
7
  import {
3
8
  View,
4
9
  Text,
@@ -7,114 +12,209 @@ import {
7
12
  ActivityIndicator,
8
13
  Animated,
9
14
  } from 'react-native'
10
- import { Camera } from 'react-native-vision-camera'
15
+ import { Camera, useCameraDevice } from 'react-native-vision-camera'
11
16
  import { useCamera } from '../hooks/useCamera'
12
17
  import type { ResolvedTheme } from '../theme'
13
- import type { VerifyLocale } from '../types'
18
+ import type { VerifyLocale, LivenessTask } from '../types'
19
+
20
+ interface TaskDef {
21
+ id: LivenessTask
22
+ label: string
23
+ duration: number
24
+ }
25
+
26
+ const TASK_POOL: TaskDef[] = [
27
+ { id: 'blink', label: 'Blink twice', duration: 3000 },
28
+ { id: 'turn-left', label: 'Turn your head left', duration: 3000 },
29
+ { id: 'turn-right', label: 'Turn your head right', duration: 3000 },
30
+ { id: 'nod', label: 'Nod your head', duration: 3000 },
31
+ { id: 'open-mouth', label: 'Open your mouth', duration: 3000 },
32
+ ]
33
+
34
+ function shuffle<T>(arr: T[]): T[] {
35
+ const a = [...arr]
36
+ for (let i = a.length - 1; i > 0; i--) {
37
+ const j = Math.floor(Math.random() * (i + 1))
38
+ ;[a[i], a[j]] = [a[j]!, a[i]!]
39
+ }
40
+ return a
41
+ }
14
42
 
15
43
  interface Props {
16
- theme: ResolvedTheme
17
- locale: VerifyLocale
18
- duration: number // ms
19
- onCapture: (uri: string) => void
20
- onBack: () => void
44
+ theme: ResolvedTheme
45
+ locale: VerifyLocale
46
+ duration: number
47
+ taskCount?: number
48
+ onCapture: (uri: string) => void
21
49
  }
22
50
 
23
- export function LivenessStep({ theme, locale, duration, onCapture, onBack }: Props) {
24
- const { cameraRef, device, startRecording, stopRecording } = useCamera('front')
25
- const [recording, setRecording] = useState(false)
26
- const [countdown, setCountdown] = useState(Math.ceil(duration / 1000))
27
- const timerRef = useRef<ReturnType<typeof setInterval> | null>(null)
28
- const pulseAnim = useRef(new Animated.Value(1)).current
29
- const s = styles(theme)
51
+ export function LivenessStep({ theme, locale, duration, taskCount = 2, onCapture }: Props) {
52
+ const { cameraRef, startRecording, stopRecording } = useCamera('front')
53
+ const device = useCameraDevice('front')
54
+
55
+ const tasks = useMemo(() => shuffle(TASK_POOL).slice(0, Math.min(taskCount, TASK_POOL.length)), [taskCount])
56
+
57
+ const [taskIdx, setTaskIdx] = useState(0)
58
+ const [started, setStarted] = useState(false) // task in progress
59
+ const [taskDone, setTaskDone] = useState(0) // completed tasks count
60
+ const [progress, setProgress] = useState(0) // 0–100 for current task bar
61
+ const [complete, setComplete] = useState(false)
62
+ const [recording, setRecording] = useState(false)
30
63
 
31
- // Pulse animation for recording indicator
64
+ const progressTimer = useRef<ReturnType<typeof setInterval> | null>(null)
65
+ const taskTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
66
+ const pulseAnim = useRef(new Animated.Value(1)).current
67
+
68
+ // Pulse REC dot
32
69
  useEffect(() => {
33
70
  if (recording) {
34
71
  Animated.loop(
35
72
  Animated.sequence([
36
- Animated.timing(pulseAnim, { toValue: 0.3, duration: 600, useNativeDriver: true }),
37
- Animated.timing(pulseAnim, { toValue: 1, duration: 600, useNativeDriver: true }),
73
+ Animated.timing(pulseAnim, { toValue: 0.2, duration: 600, useNativeDriver: true }),
74
+ Animated.timing(pulseAnim, { toValue: 1, duration: 600, useNativeDriver: true }),
38
75
  ])
39
76
  ).start()
40
77
  } else {
78
+ pulseAnim.stopAnimation()
41
79
  pulseAnim.setValue(1)
42
80
  }
43
81
  }, [recording, pulseAnim])
44
82
 
45
- const handleStart = () => {
46
- if (!cameraRef.current) return
47
- setRecording(true)
48
- setCountdown(Math.ceil(duration / 1000))
49
-
50
- startRecording(
51
- (video) => {
52
- setRecording(false)
53
- onCapture(video.uri)
54
- },
55
- () => setRecording(false)
56
- )
57
-
58
- // Countdown tick
59
- let remaining = duration / 1000
60
- timerRef.current = setInterval(() => {
61
- remaining -= 0.1
62
- setCountdown(Math.max(0, Math.ceil(remaining)))
63
- if (remaining <= 0) {
64
- if (timerRef.current) clearInterval(timerRef.current)
65
- stopRecording()
83
+ const startTask = () => {
84
+ if (!recording) {
85
+ // Begin recording when first task starts
86
+ startRecording(
87
+ (video) => { onCapture(video.uri) },
88
+ () => {}
89
+ )
90
+ setRecording(true)
91
+ }
92
+
93
+ const task = tasks[taskIdx]
94
+ if (!task) return
95
+ setStarted(true)
96
+ setProgress(0)
97
+
98
+ const start = Date.now()
99
+ progressTimer.current = setInterval(() => {
100
+ const elapsed = Date.now() - start
101
+ const pct = Math.min(100, (elapsed / task.duration) * 100)
102
+ setProgress(pct)
103
+ if (elapsed >= task.duration) {
104
+ clearInterval(progressTimer.current!)
105
+ progressTimer.current = null
106
+ nextTask()
66
107
  }
67
- }, 100)
108
+ }, 80)
109
+ }
110
+
111
+ const nextTask = () => {
112
+ const next = taskIdx + 1
113
+ setTaskDone(prev => prev + 1)
114
+ if (next >= tasks.length) {
115
+ finishLiveness()
116
+ return
117
+ }
118
+ setTaskIdx(next)
119
+ setStarted(false)
120
+ setProgress(0)
121
+ // Short pause then auto-start next task
122
+ taskTimer.current = setTimeout(() => startTask(), 500)
123
+ }
124
+
125
+ const finishLiveness = () => {
126
+ setComplete(true)
127
+ taskTimer.current = setTimeout(() => {
128
+ stopRecording()
129
+ }, 1000)
68
130
  }
69
131
 
70
132
  useEffect(() => {
71
133
  return () => {
72
- if (timerRef.current) clearInterval(timerRef.current)
134
+ if (progressTimer.current) clearInterval(progressTimer.current)
135
+ if (taskTimer.current) clearTimeout(taskTimer.current)
73
136
  }
74
137
  }, [])
75
138
 
76
139
  if (!device) return <ActivityIndicator color={theme.accent} style={{ margin: 40 }} />
77
140
 
141
+ const currentTask = tasks[taskIdx]
142
+ const s = styles(theme)
143
+
78
144
  return (
79
145
  <View style={s.container}>
80
- <Text style={s.title}>{locale.liveness_title}</Text>
81
- <Text style={s.subtitle}>
82
- {recording ? locale.liveness_recording : locale.liveness_instruction}
83
- </Text>
146
+ <View style={s.heading}>
147
+ <Text style={s.title}>{complete ? locale.liveness_complete : locale.liveness_title}</Text>
148
+ {!complete && <Text style={s.subtitle}>{locale.liveness_ready}</Text>}
149
+ </View>
150
+
151
+ {/* Task progress dots */}
152
+ {!complete && (
153
+ <View style={s.taskDots}>
154
+ {tasks.map((_, i) => (
155
+ <View
156
+ key={i}
157
+ style={[
158
+ s.dot,
159
+ i < taskDone && s.dotDone,
160
+ i === taskIdx && s.dotActive,
161
+ ]}
162
+ />
163
+ ))}
164
+ </View>
165
+ )}
84
166
 
167
+ {/* Camera with in-viewport overlay */}
85
168
  <View style={s.viewport}>
86
169
  <Camera
87
170
  ref={cameraRef}
88
171
  style={StyleSheet.absoluteFill}
89
172
  device={device}
90
- isActive={true}
91
- video={true}
92
- audio={true}
173
+ isActive
174
+ video
175
+ audio={false}
93
176
  />
177
+
94
178
  <View style={s.ovalGuide} pointerEvents="none" />
95
179
 
180
+ {/* Recording badge */}
96
181
  {recording && (
97
- <>
98
- <View style={s.recBadge}>
99
- <Animated.View style={[s.recDot, { opacity: pulseAnim }]} />
100
- <Text style={s.recText}>REC</Text>
182
+ <View style={s.recBadge}>
183
+ <Animated.View style={[s.recDot, { opacity: pulseAnim }]} />
184
+ <Text style={s.recText}>REC</Text>
185
+ </View>
186
+ )}
187
+
188
+ {/* Instruction overlay — always visible on video */}
189
+ {currentTask && !complete && (
190
+ <View style={s.overlay}>
191
+ <View style={s.overlayPill}>
192
+ <Text style={s.overlayNum}>{taskIdx + 1}/{tasks.length}</Text>
193
+ <Text style={s.overlayLabel}>{currentTask.label}</Text>
101
194
  </View>
102
- <View style={s.countdownBadge}>
103
- <Text style={s.countdownText}>{countdown}</Text>
195
+ {started && (
196
+ <View style={s.barWrap}>
197
+ <View style={[s.barFill, { width: `${progress}%` }]} />
198
+ </View>
199
+ )}
200
+ </View>
201
+ )}
202
+
203
+ {complete && (
204
+ <View style={s.overlay}>
205
+ <View style={s.overlayPill}>
206
+ <Text style={s.overlayLabel}>{locale.liveness_complete}</Text>
104
207
  </View>
105
- </>
208
+ </View>
106
209
  )}
107
210
  </View>
108
211
 
109
- {!recording && (
110
- <>
111
- <TouchableOpacity style={s.btnPrimary} onPress={handleStart} activeOpacity={0.85}>
112
- <Text style={s.btnText}>{locale.liveness_start_btn}</Text>
113
- </TouchableOpacity>
114
- <TouchableOpacity style={s.btnSecondary} onPress={onBack} activeOpacity={0.75}>
115
- <Text style={s.btnSecondaryText}>{locale.selfie_retake_btn}</Text>
116
- </TouchableOpacity>
117
- </>
212
+ {/* Begin button — only shown before first task starts */}
213
+ {!started && !complete && currentTask && taskDone === 0 && (
214
+ <TouchableOpacity style={s.btn} onPress={startTask} activeOpacity={0.85}>
215
+ <Text style={s.btnText}>Begin</Text>
216
+ <Text style={s.btnArrow}> →</Text>
217
+ </TouchableOpacity>
118
218
  )}
119
219
  </View>
120
220
  )
@@ -122,76 +222,69 @@ export function LivenessStep({ theme, locale, duration, onCapture, onBack }: Pro
122
222
 
123
223
  const styles = (t: ResolvedTheme) =>
124
224
  StyleSheet.create({
125
- container: { padding: 24 },
126
- title: { fontSize: 20, fontWeight: '700', color: t.text, marginBottom: 8 },
127
- subtitle: { fontSize: 14, color: t.subtext, lineHeight: 20, marginBottom: 16 },
225
+ container: { flex: 1, padding: 20, gap: 12 },
226
+ heading: { gap: 4, alignItems: 'center' },
227
+ title: {
228
+ fontSize: 20, fontWeight: '700', color: t.text,
229
+ textAlign: 'center', letterSpacing: -0.3,
230
+ },
231
+ subtitle: {
232
+ fontSize: 13, color: t.subtext, lineHeight: 19, textAlign: 'center',
233
+ },
234
+ taskDots: { flexDirection: 'row', gap: 6 },
235
+ dot: {
236
+ flex: 1, height: 2.5, borderRadius: 999, backgroundColor: t.border,
237
+ },
238
+ dotDone: { backgroundColor: t.accent, opacity: 0.35 },
239
+ dotActive: { backgroundColor: t.accent },
128
240
  viewport: {
129
- width: '100%',
130
- aspectRatio: 3 / 4,
131
- borderRadius: t.radius,
132
- overflow: 'hidden',
133
- backgroundColor: '#000',
134
- marginBottom: 16,
135
- alignSelf: 'center',
136
- maxWidth: 280,
137
- position: 'relative',
241
+ flex: 1, borderRadius: t.radius - 4, overflow: 'hidden',
242
+ backgroundColor: '#000', position: 'relative',
138
243
  },
139
244
  ovalGuide: {
140
- position: 'absolute',
141
- top: '8%',
142
- left: '12%',
143
- right: '12%',
144
- bottom: '8%',
145
- borderWidth: 2,
146
- borderColor: 'rgba(255,255,255,0.7)',
147
- borderRadius: 999,
245
+ position: 'absolute', left: '21%', right: '21%', top: '6%', bottom: '6%',
246
+ borderWidth: 2, borderColor: 'rgba(255,255,255,0.75)', borderRadius: 999,
148
247
  },
149
248
  recBadge: {
150
- position: 'absolute',
151
- top: 10,
152
- left: 10,
153
- flexDirection: 'row',
154
- alignItems: 'center',
155
- backgroundColor: 'rgba(220,38,38,0.85)',
156
- borderRadius: 999,
157
- paddingHorizontal: 10,
158
- paddingVertical: 4,
159
- gap: 6,
249
+ position: 'absolute', top: 10, left: 10,
250
+ flexDirection: 'row', alignItems: 'center', gap: 5,
251
+ backgroundColor: 'rgba(0,0,0,0.65)',
252
+ borderWidth: 1, borderColor: 'rgba(239,68,68,0.5)',
253
+ borderRadius: 5, paddingHorizontal: 8, paddingVertical: 4,
160
254
  },
161
255
  recDot: {
162
- width: 8,
163
- height: 8,
164
- borderRadius: 4,
165
- backgroundColor: '#fff',
256
+ width: 7, height: 7, borderRadius: 3.5, backgroundColor: '#ef4444',
257
+ },
258
+ recText: { color: '#ef4444', fontSize: 11, fontWeight: '700', letterSpacing: 0.5 },
259
+ overlay: {
260
+ position: 'absolute', bottom: 0, left: 0, right: 0,
261
+ paddingHorizontal: 12, paddingTop: 10, paddingBottom: 12,
262
+ gap: 8,
263
+ backgroundColor: 'transparent',
264
+ // Gradient-like: darker at bottom via background trick
265
+ },
266
+ overlayPill: {
267
+ flexDirection: 'row', alignItems: 'center', gap: 8,
268
+ },
269
+ overlayNum: {
270
+ fontSize: 11, fontWeight: '700', color: 'rgba(255,255,255,0.55)',
271
+ textTransform: 'uppercase', letterSpacing: 0.5,
272
+ },
273
+ overlayLabel: {
274
+ fontSize: 15, fontWeight: '700', color: '#fff',
166
275
  },
167
- recText: { color: '#fff', fontSize: 12, fontWeight: '700' },
168
- countdownBadge: {
169
- position: 'absolute',
170
- bottom: 12,
171
- alignSelf: 'center',
172
- width: 44,
173
- height: 44,
174
- borderRadius: 22,
175
- backgroundColor: 'rgba(0,0,0,0.5)',
176
- alignItems: 'center',
177
- justifyContent: 'center',
276
+ barWrap: {
277
+ height: 3, backgroundColor: 'rgba(255,255,255,0.2)',
278
+ borderRadius: 999, overflow: 'hidden',
178
279
  },
179
- countdownText: { color: '#fff', fontSize: 18, fontWeight: '700' },
180
- btnPrimary: {
181
- backgroundColor: t.accent,
182
- borderRadius: t.radius,
183
- paddingVertical: 14,
184
- alignItems: 'center',
185
- marginBottom: 10,
280
+ barFill: {
281
+ height: '100%', backgroundColor: '#fff', borderRadius: 999,
186
282
  },
187
- btnSecondary: {
188
- backgroundColor: t.card,
189
- borderRadius: t.radius,
190
- paddingVertical: 14,
191
- alignItems: 'center',
192
- borderWidth: 1,
193
- borderColor: t.border,
283
+ btn: {
284
+ backgroundColor: t.accent, borderRadius: t.radius - 4,
285
+ paddingVertical: 12, flexDirection: 'row',
286
+ alignItems: 'center', justifyContent: 'center',
194
287
  },
195
- btnText: { color: '#fff', fontSize: 15, fontWeight: '600' },
196
- btnSecondaryText: { color: t.text, fontSize: 15, fontWeight: '600' },
288
+ btnText: { color: t.accentInv, fontSize: 14, fontWeight: '600' },
289
+ btnArrow: { color: t.accentInv, fontSize: 14, fontWeight: '600' },
197
290
  })
@@ -1,37 +1,20 @@
1
- import React, { useEffect, useRef } from 'react'
2
- import { View, Text, StyleSheet, Animated } from 'react-native'
1
+ import React from 'react'
2
+ import { View, Text, ActivityIndicator, StyleSheet } from 'react-native'
3
3
  import type { ResolvedTheme } from '../theme'
4
4
  import type { VerifyLocale } from '../types'
5
5
 
6
6
  interface Props {
7
- theme: ResolvedTheme
7
+ theme: ResolvedTheme
8
8
  locale: VerifyLocale
9
9
  }
10
10
 
11
11
  export function ProcessingStep({ theme, locale }: Props) {
12
- const spinAnim = useRef(new Animated.Value(0)).current
13
12
  const s = styles(theme)
14
-
15
- useEffect(() => {
16
- Animated.loop(
17
- Animated.timing(spinAnim, {
18
- toValue: 1,
19
- duration: 800,
20
- useNativeDriver: true,
21
- })
22
- ).start()
23
- }, [spinAnim])
24
-
25
- const spin = spinAnim.interpolate({
26
- inputRange: [0, 1],
27
- outputRange: ['0deg', '360deg'],
28
- })
29
-
30
13
  return (
31
14
  <View style={s.container}>
32
- <Animated.View style={[s.spinner, { transform: [{ rotate: spin }] }]} />
15
+ <ActivityIndicator color={theme.accent} size="large" />
33
16
  <Text style={s.title}>{locale.processing_title}</Text>
34
- <Text style={s.subtitle}>{locale.processing_subtitle}</Text>
17
+ <Text style={s.subtitle}>This usually takes under 10 seconds.</Text>
35
18
  </View>
36
19
  )
37
20
  }
@@ -39,27 +22,20 @@ export function ProcessingStep({ theme, locale }: Props) {
39
22
  const styles = (t: ResolvedTheme) =>
40
23
  StyleSheet.create({
41
24
  container: {
42
- padding: 48,
25
+ flex: 1,
43
26
  alignItems: 'center',
27
+ justifyContent: 'center',
44
28
  gap: 16,
45
- },
46
- spinner: {
47
- width: 52,
48
- height: 52,
49
- borderRadius: 26,
50
- borderWidth: 4,
51
- borderColor: t.border,
52
- borderTopColor: t.accent,
53
- marginBottom: 8,
29
+ padding: 32,
54
30
  },
55
31
  title: {
56
- fontSize: 18,
32
+ fontSize: 16,
57
33
  fontWeight: '700',
58
34
  color: t.text,
59
35
  textAlign: 'center',
60
36
  },
61
37
  subtitle: {
62
- fontSize: 14,
38
+ fontSize: 13,
63
39
  color: t.subtext,
64
40
  textAlign: 'center',
65
41
  },