@kernaq/verify-react-native 2.0.0 → 2.0.2

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,216 @@ 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])
30
56
 
31
- // Pulse animation for recording indicator
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)
63
+
64
+ // Keep a ref in sync with taskIdx so setTimeout/setInterval closures always
65
+ // read the latest value and don't stall on a stale snapshot.
66
+ const taskIdxRef = useRef(0)
67
+ const setTaskIdxSynced = (n: number) => {
68
+ taskIdxRef.current = n
69
+ setTaskIdx(n)
70
+ }
71
+
72
+ const progressTimer = useRef<ReturnType<typeof setInterval> | null>(null)
73
+ const taskTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
74
+ const pulseAnim = useRef(new Animated.Value(1)).current
75
+
76
+ // Pulse REC dot
32
77
  useEffect(() => {
33
78
  if (recording) {
34
79
  Animated.loop(
35
80
  Animated.sequence([
36
- Animated.timing(pulseAnim, { toValue: 0.3, duration: 600, useNativeDriver: true }),
37
- Animated.timing(pulseAnim, { toValue: 1, duration: 600, useNativeDriver: true }),
81
+ Animated.timing(pulseAnim, { toValue: 0.2, duration: 600, useNativeDriver: true }),
82
+ Animated.timing(pulseAnim, { toValue: 1, duration: 600, useNativeDriver: true }),
38
83
  ])
39
84
  ).start()
40
85
  } else {
86
+ pulseAnim.stopAnimation()
41
87
  pulseAnim.setValue(1)
42
88
  }
43
89
  }, [recording, pulseAnim])
44
90
 
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()
91
+ const startTask = () => {
92
+ if (!recording) {
93
+ startRecording(
94
+ (video: import('../hooks/useCamera').VideoResult) => { onCapture(video.uri) },
95
+ () => {}
96
+ )
97
+ setRecording(true)
98
+ }
99
+
100
+ const task = tasks[taskIdxRef.current]
101
+ if (!task) return
102
+ setStarted(true)
103
+ setProgress(0)
104
+
105
+ const start = Date.now()
106
+ progressTimer.current = setInterval(() => {
107
+ const elapsed = Date.now() - start
108
+ const pct = Math.min(100, (elapsed / task.duration) * 100)
109
+ setProgress(pct)
110
+ if (elapsed >= task.duration) {
111
+ clearInterval(progressTimer.current!)
112
+ progressTimer.current = null
113
+ nextTask()
66
114
  }
67
- }, 100)
115
+ }, 80)
116
+ }
117
+
118
+ const nextTask = () => {
119
+ const next = taskIdxRef.current + 1
120
+ setTaskDone((prev: number) => prev + 1)
121
+ if (next >= tasks.length) {
122
+ finishLiveness()
123
+ return
124
+ }
125
+ setTaskIdxSynced(next)
126
+ setStarted(false)
127
+ setProgress(0)
128
+ // Use the ref-based startTask via the timer so it reads the fresh taskIdx
129
+ taskTimer.current = setTimeout(() => startTask(), 500)
130
+ }
131
+
132
+ const finishLiveness = () => {
133
+ setComplete(true)
134
+ taskTimer.current = setTimeout(() => {
135
+ stopRecording()
136
+ }, 1000)
68
137
  }
69
138
 
70
139
  useEffect(() => {
71
140
  return () => {
72
- if (timerRef.current) clearInterval(timerRef.current)
141
+ if (progressTimer.current) clearInterval(progressTimer.current)
142
+ if (taskTimer.current) clearTimeout(taskTimer.current)
73
143
  }
74
144
  }, [])
75
145
 
76
146
  if (!device) return <ActivityIndicator color={theme.accent} style={{ margin: 40 }} />
77
147
 
148
+ const currentTask = tasks[taskIdx]
149
+ const s = styles(theme)
150
+
78
151
  return (
79
152
  <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>
153
+ <View style={s.heading}>
154
+ <Text style={s.title}>{complete ? locale.liveness_complete : locale.liveness_title}</Text>
155
+ {!complete && <Text style={s.subtitle}>{locale.liveness_ready}</Text>}
156
+ </View>
84
157
 
158
+ {/* Task progress dots */}
159
+ {!complete && (
160
+ <View style={s.taskDots}>
161
+ {tasks.map((_: TaskDef, i: number) => (
162
+ <View
163
+ key={i}
164
+ style={[
165
+ s.dot,
166
+ i < taskDone && s.dotDone,
167
+ i === taskIdx && s.dotActive,
168
+ ]}
169
+ />
170
+ ))}
171
+ </View>
172
+ )}
173
+
174
+ {/* Camera with in-viewport overlay */}
85
175
  <View style={s.viewport}>
86
176
  <Camera
87
177
  ref={cameraRef}
88
178
  style={StyleSheet.absoluteFill}
89
179
  device={device}
90
- isActive={true}
91
- video={true}
92
- audio={true}
180
+ isActive
181
+ video
182
+ audio={false}
93
183
  />
184
+
94
185
  <View style={s.ovalGuide} pointerEvents="none" />
95
186
 
187
+ {/* Recording badge */}
96
188
  {recording && (
97
- <>
98
- <View style={s.recBadge}>
99
- <Animated.View style={[s.recDot, { opacity: pulseAnim }]} />
100
- <Text style={s.recText}>REC</Text>
189
+ <View style={s.recBadge}>
190
+ <Animated.View style={[s.recDot, { opacity: pulseAnim }]} />
191
+ <Text style={s.recText}>REC</Text>
192
+ </View>
193
+ )}
194
+
195
+ {/* Instruction overlay — always visible on video */}
196
+ {currentTask && !complete && (
197
+ <View style={s.overlay}>
198
+ <View style={s.overlayPill}>
199
+ <Text style={s.overlayNum}>{taskIdx + 1}/{tasks.length}</Text>
200
+ <Text style={s.overlayLabel}>{currentTask.label}</Text>
101
201
  </View>
102
- <View style={s.countdownBadge}>
103
- <Text style={s.countdownText}>{countdown}</Text>
202
+ {started && (
203
+ <View style={s.barWrap}>
204
+ <View style={[s.barFill, { width: `${progress}%` }]} />
205
+ </View>
206
+ )}
207
+ </View>
208
+ )}
209
+
210
+ {complete && (
211
+ <View style={s.overlay}>
212
+ <View style={s.overlayPill}>
213
+ <Text style={s.overlayLabel}>{locale.liveness_complete}</Text>
104
214
  </View>
105
- </>
215
+ </View>
106
216
  )}
107
217
  </View>
108
218
 
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
- </>
219
+ {/* Begin button — only shown before first task starts */}
220
+ {!started && !complete && currentTask && taskDone === 0 && (
221
+ <TouchableOpacity style={s.btn} onPress={startTask} activeOpacity={0.85}>
222
+ <Text style={s.btnText}>Begin</Text>
223
+ <Text style={s.btnArrow}> →</Text>
224
+ </TouchableOpacity>
118
225
  )}
119
226
  </View>
120
227
  )
@@ -122,76 +229,69 @@ export function LivenessStep({ theme, locale, duration, onCapture, onBack }: Pro
122
229
 
123
230
  const styles = (t: ResolvedTheme) =>
124
231
  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 },
232
+ container: { flex: 1, padding: 20, gap: 12 },
233
+ heading: { gap: 4, alignItems: 'center' },
234
+ title: {
235
+ fontSize: 20, fontWeight: '700', color: t.text,
236
+ textAlign: 'center', letterSpacing: -0.3,
237
+ },
238
+ subtitle: {
239
+ fontSize: 13, color: t.subtext, lineHeight: 19, textAlign: 'center',
240
+ },
241
+ taskDots: { flexDirection: 'row', gap: 6 },
242
+ dot: {
243
+ flex: 1, height: 2.5, borderRadius: 999, backgroundColor: t.border,
244
+ },
245
+ dotDone: { backgroundColor: t.accent, opacity: 0.35 },
246
+ dotActive: { backgroundColor: t.accent },
128
247
  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',
248
+ flex: 1, borderRadius: t.radius - 4, overflow: 'hidden',
249
+ backgroundColor: '#000', position: 'relative',
138
250
  },
139
251
  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,
252
+ position: 'absolute', left: '21%', right: '21%', top: '6%', bottom: '6%',
253
+ borderWidth: 2, borderColor: 'rgba(255,255,255,0.75)', borderRadius: 999,
148
254
  },
149
255
  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,
256
+ position: 'absolute', top: 10, left: 10,
257
+ flexDirection: 'row', alignItems: 'center', gap: 5,
258
+ backgroundColor: 'rgba(0,0,0,0.65)',
259
+ borderWidth: 1, borderColor: 'rgba(239,68,68,0.5)',
260
+ borderRadius: 5, paddingHorizontal: 8, paddingVertical: 4,
160
261
  },
161
262
  recDot: {
162
- width: 8,
163
- height: 8,
164
- borderRadius: 4,
165
- backgroundColor: '#fff',
263
+ width: 7, height: 7, borderRadius: 3.5, backgroundColor: '#ef4444',
264
+ },
265
+ recText: { color: '#ef4444', fontSize: 11, fontWeight: '700', letterSpacing: 0.5 },
266
+ overlay: {
267
+ position: 'absolute', bottom: 0, left: 0, right: 0,
268
+ paddingHorizontal: 12, paddingTop: 10, paddingBottom: 12,
269
+ gap: 8,
270
+ backgroundColor: 'transparent',
271
+ // Gradient-like: darker at bottom via background trick
272
+ },
273
+ overlayPill: {
274
+ flexDirection: 'row', alignItems: 'center', gap: 8,
275
+ },
276
+ overlayNum: {
277
+ fontSize: 11, fontWeight: '700', color: 'rgba(255,255,255,0.55)',
278
+ textTransform: 'uppercase', letterSpacing: 0.5,
279
+ },
280
+ overlayLabel: {
281
+ fontSize: 15, fontWeight: '700', color: '#fff',
166
282
  },
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',
283
+ barWrap: {
284
+ height: 3, backgroundColor: 'rgba(255,255,255,0.2)',
285
+ borderRadius: 999, overflow: 'hidden',
178
286
  },
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,
287
+ barFill: {
288
+ height: '100%', backgroundColor: '#fff', borderRadius: 999,
186
289
  },
187
- btnSecondary: {
188
- backgroundColor: t.card,
189
- borderRadius: t.radius,
190
- paddingVertical: 14,
191
- alignItems: 'center',
192
- borderWidth: 1,
193
- borderColor: t.border,
290
+ btn: {
291
+ backgroundColor: t.accent, borderRadius: t.radius - 4,
292
+ paddingVertical: 12, flexDirection: 'row',
293
+ alignItems: 'center', justifyContent: 'center',
194
294
  },
195
- btnText: { color: '#fff', fontSize: 15, fontWeight: '600' },
196
- btnSecondaryText: { color: t.text, fontSize: 15, fontWeight: '600' },
295
+ btnText: { color: t.accentInv, fontSize: 14, fontWeight: '600' },
296
+ btnArrow: { color: t.accentInv, fontSize: 14, fontWeight: '600' },
197
297
  })
@@ -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
  },