@kernaq/verify-react-native 1.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.
- package/dist/index.d.ts +125 -0
- package/dist/index.esm.js +1198 -0
- package/dist/index.esm.js.map +1 -0
- package/dist/index.js +1200 -0
- package/dist/index.js.map +1 -0
- package/package.json +51 -0
- package/src/KernaqVerify.tsx +329 -0
- package/src/hooks/useCamera.ts +73 -0
- package/src/hooks/usePermissions.ts +67 -0
- package/src/index.ts +32 -0
- package/src/locale.ts +33 -0
- package/src/steps/DocumentStep.tsx +141 -0
- package/src/steps/IntroStep.tsx +82 -0
- package/src/steps/LivenessStep.tsx +197 -0
- package/src/steps/ProcessingStep.tsx +66 -0
- package/src/steps/ResultStep.tsx +108 -0
- package/src/steps/SelfieStep.tsx +138 -0
- package/src/theme.ts +24 -0
- package/src/types.ts +124 -0
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { useState, useCallback } from 'react'
|
|
2
|
+
import { Platform, Linking, Alert } from 'react-native'
|
|
3
|
+
import {
|
|
4
|
+
check,
|
|
5
|
+
request,
|
|
6
|
+
PERMISSIONS,
|
|
7
|
+
RESULTS,
|
|
8
|
+
type Permission,
|
|
9
|
+
} from 'react-native-permissions'
|
|
10
|
+
|
|
11
|
+
export type PermissionStatus = 'unknown' | 'granted' | 'denied' | 'blocked'
|
|
12
|
+
|
|
13
|
+
export function usePermissions() {
|
|
14
|
+
const [status, setStatus] = useState<PermissionStatus>('unknown')
|
|
15
|
+
|
|
16
|
+
const cameraPermission: Permission =
|
|
17
|
+
Platform.OS === 'ios'
|
|
18
|
+
? PERMISSIONS.IOS.CAMERA
|
|
19
|
+
: PERMISSIONS.ANDROID.CAMERA
|
|
20
|
+
|
|
21
|
+
const micPermission: Permission =
|
|
22
|
+
Platform.OS === 'ios'
|
|
23
|
+
? PERMISSIONS.IOS.MICROPHONE
|
|
24
|
+
: PERMISSIONS.ANDROID.RECORD_AUDIO
|
|
25
|
+
|
|
26
|
+
const requestPermissions = useCallback(async (): Promise<boolean> => {
|
|
27
|
+
try {
|
|
28
|
+
const camResult = await check(cameraPermission)
|
|
29
|
+
|
|
30
|
+
if (camResult === RESULTS.GRANTED) {
|
|
31
|
+
// Also request mic for liveness video
|
|
32
|
+
await request(micPermission)
|
|
33
|
+
setStatus('granted')
|
|
34
|
+
return true
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (camResult === RESULTS.BLOCKED) {
|
|
38
|
+
setStatus('blocked')
|
|
39
|
+
Alert.alert(
|
|
40
|
+
'Camera Permission Required',
|
|
41
|
+
'Camera access is blocked. Please enable it in your device settings.',
|
|
42
|
+
[
|
|
43
|
+
{ text: 'Cancel', style: 'cancel' },
|
|
44
|
+
{ text: 'Open Settings', onPress: () => Linking.openSettings() },
|
|
45
|
+
]
|
|
46
|
+
)
|
|
47
|
+
return false
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Request camera
|
|
51
|
+
const result = await request(cameraPermission)
|
|
52
|
+
if (result === RESULTS.GRANTED) {
|
|
53
|
+
await request(micPermission)
|
|
54
|
+
setStatus('granted')
|
|
55
|
+
return true
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
setStatus('denied')
|
|
59
|
+
return false
|
|
60
|
+
} catch {
|
|
61
|
+
setStatus('denied')
|
|
62
|
+
return false
|
|
63
|
+
}
|
|
64
|
+
}, [cameraPermission, micPermission])
|
|
65
|
+
|
|
66
|
+
return { status, requestPermissions }
|
|
67
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @kernaq/verify-react-native
|
|
3
|
+
*
|
|
4
|
+
* Drop-in KYC verification UI for React Native.
|
|
5
|
+
* Works with bare React Native and Expo (requires expo prebuild).
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* import { KernaqVerify } from '@kernaq/verify-react-native'
|
|
9
|
+
*
|
|
10
|
+
* <KernaqVerify
|
|
11
|
+
* visible={showVerify}
|
|
12
|
+
* apiKey="k_test_..."
|
|
13
|
+
* country="KEN"
|
|
14
|
+
* onComplete={(result) => console.log(result.verdict)}
|
|
15
|
+
* onCancel={() => setShowVerify(false)}
|
|
16
|
+
* />
|
|
17
|
+
*
|
|
18
|
+
* Peer dependencies:
|
|
19
|
+
* react-native-vision-camera >= 4
|
|
20
|
+
* react-native-permissions >= 4
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
export { KernaqVerify } from './KernaqVerify'
|
|
24
|
+
export type {
|
|
25
|
+
VerifyConfig,
|
|
26
|
+
VerifyResult,
|
|
27
|
+
VerifyStep,
|
|
28
|
+
VerifyVerdict,
|
|
29
|
+
VerifyTheme,
|
|
30
|
+
VerifyLocale,
|
|
31
|
+
DocumentType,
|
|
32
|
+
} from './types'
|
package/src/locale.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { VerifyLocale } from './types'
|
|
2
|
+
|
|
3
|
+
export const DEFAULT_LOCALE: VerifyLocale = {
|
|
4
|
+
intro_title: 'Verify your identity',
|
|
5
|
+
intro_body: "This takes about 60 seconds. You'll need your ID document and a clear view of your face.",
|
|
6
|
+
intro_cta: 'Get started',
|
|
7
|
+
doc_title: 'Scan your document',
|
|
8
|
+
doc_instruction: 'Hold your ID flat inside the frame. Make sure it\'s well-lit and all four corners are visible.',
|
|
9
|
+
doc_capture_btn: 'Capture',
|
|
10
|
+
doc_retake_btn: 'Retake',
|
|
11
|
+
doc_next_btn: 'Looks good',
|
|
12
|
+
selfie_title: 'Take a selfie',
|
|
13
|
+
selfie_instruction: 'Look straight at the camera. Keep your face inside the oval.',
|
|
14
|
+
selfie_capture_btn: 'Take selfie',
|
|
15
|
+
selfie_retake_btn: 'Retake',
|
|
16
|
+
selfie_next_btn: 'Looks good',
|
|
17
|
+
liveness_title: 'Liveness check',
|
|
18
|
+
liveness_instruction: 'Press start, then slowly turn your head left and right.',
|
|
19
|
+
liveness_start_btn: 'Start recording',
|
|
20
|
+
liveness_recording: 'Recording…',
|
|
21
|
+
processing_title: 'Verifying your identity',
|
|
22
|
+
processing_subtitle: 'This usually takes a few seconds.',
|
|
23
|
+
result_pass_title: 'Verification passed',
|
|
24
|
+
result_pass_body: 'Your identity has been verified successfully.',
|
|
25
|
+
result_fail_title: 'Verification failed',
|
|
26
|
+
result_fail_body: 'We were unable to verify your identity. Please try again.',
|
|
27
|
+
result_review_title: 'Under review',
|
|
28
|
+
result_review_body: 'Your submission is being reviewed. This usually takes a few minutes.',
|
|
29
|
+
result_retry_btn: 'Try again',
|
|
30
|
+
error_camera_denied: 'Camera access denied. Please allow camera permissions in settings.',
|
|
31
|
+
error_quality: 'Image quality too low. Try better lighting or hold the camera steady.',
|
|
32
|
+
error_network: 'Network error. Please check your connection and try again.',
|
|
33
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import {
|
|
3
|
+
View,
|
|
4
|
+
Text,
|
|
5
|
+
TouchableOpacity,
|
|
6
|
+
Image,
|
|
7
|
+
StyleSheet,
|
|
8
|
+
ActivityIndicator,
|
|
9
|
+
} from 'react-native'
|
|
10
|
+
import { Camera } from 'react-native-vision-camera'
|
|
11
|
+
import { useCamera } from '../hooks/useCamera'
|
|
12
|
+
import type { ResolvedTheme } from '../theme'
|
|
13
|
+
import type { VerifyLocale } from '../types'
|
|
14
|
+
|
|
15
|
+
interface Props {
|
|
16
|
+
theme: ResolvedTheme
|
|
17
|
+
locale: VerifyLocale
|
|
18
|
+
onCapture: (uri: string) => void
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function DocumentStep({ theme, locale, onCapture }: Props) {
|
|
22
|
+
const { cameraRef, device, capturePhoto } = useCamera('back')
|
|
23
|
+
const [preview, setPreview] = React.useState<string | null>(null)
|
|
24
|
+
const [capturing, setCapturing] = React.useState(false)
|
|
25
|
+
const s = styles(theme)
|
|
26
|
+
|
|
27
|
+
const handleCapture = async () => {
|
|
28
|
+
setCapturing(true)
|
|
29
|
+
const result = await capturePhoto()
|
|
30
|
+
setCapturing(false)
|
|
31
|
+
if (result) setPreview(result.uri)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const handleConfirm = () => {
|
|
35
|
+
if (preview) onCapture(preview)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (!device) {
|
|
39
|
+
return (
|
|
40
|
+
<View style={s.container}>
|
|
41
|
+
<ActivityIndicator color={theme.accent} />
|
|
42
|
+
</View>
|
|
43
|
+
)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
<View style={s.container}>
|
|
48
|
+
<Text style={s.title}>{locale.doc_title}</Text>
|
|
49
|
+
<Text style={s.subtitle}>{locale.doc_instruction}</Text>
|
|
50
|
+
|
|
51
|
+
{preview ? (
|
|
52
|
+
<>
|
|
53
|
+
<Image source={{ uri: preview }} style={s.preview} resizeMode="cover" />
|
|
54
|
+
<TouchableOpacity style={s.btnPrimary} onPress={handleConfirm} activeOpacity={0.85}>
|
|
55
|
+
<Text style={s.btnText}>{locale.doc_next_btn}</Text>
|
|
56
|
+
</TouchableOpacity>
|
|
57
|
+
<TouchableOpacity style={s.btnSecondary} onPress={() => setPreview(null)} activeOpacity={0.75}>
|
|
58
|
+
<Text style={s.btnSecondaryText}>{locale.doc_retake_btn}</Text>
|
|
59
|
+
</TouchableOpacity>
|
|
60
|
+
</>
|
|
61
|
+
) : (
|
|
62
|
+
<>
|
|
63
|
+
<View style={s.viewport}>
|
|
64
|
+
<Camera
|
|
65
|
+
ref={cameraRef}
|
|
66
|
+
style={StyleSheet.absoluteFill}
|
|
67
|
+
device={device}
|
|
68
|
+
isActive={true}
|
|
69
|
+
photo={true}
|
|
70
|
+
/>
|
|
71
|
+
{/* Document frame guide */}
|
|
72
|
+
<View style={s.frameGuide} pointerEvents="none" />
|
|
73
|
+
</View>
|
|
74
|
+
<TouchableOpacity
|
|
75
|
+
style={[s.btnPrimary, capturing && s.btnDisabled]}
|
|
76
|
+
onPress={handleCapture}
|
|
77
|
+
disabled={capturing}
|
|
78
|
+
activeOpacity={0.85}
|
|
79
|
+
>
|
|
80
|
+
{capturing
|
|
81
|
+
? <ActivityIndicator color="#fff" />
|
|
82
|
+
: <Text style={s.btnText}>{locale.doc_capture_btn}</Text>
|
|
83
|
+
}
|
|
84
|
+
</TouchableOpacity>
|
|
85
|
+
</>
|
|
86
|
+
)}
|
|
87
|
+
</View>
|
|
88
|
+
)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const styles = (t: ResolvedTheme) =>
|
|
92
|
+
StyleSheet.create({
|
|
93
|
+
container: { padding: 24 },
|
|
94
|
+
title: { fontSize: 20, fontWeight: '700', color: t.text, marginBottom: 8 },
|
|
95
|
+
subtitle: { fontSize: 14, color: t.subtext, lineHeight: 20, marginBottom: 16 },
|
|
96
|
+
viewport: {
|
|
97
|
+
width: '100%',
|
|
98
|
+
aspectRatio: 4 / 3,
|
|
99
|
+
borderRadius: t.radius,
|
|
100
|
+
overflow: 'hidden',
|
|
101
|
+
backgroundColor: '#000',
|
|
102
|
+
marginBottom: 16,
|
|
103
|
+
position: 'relative',
|
|
104
|
+
},
|
|
105
|
+
frameGuide: {
|
|
106
|
+
position: 'absolute',
|
|
107
|
+
top: '8%',
|
|
108
|
+
left: '5%',
|
|
109
|
+
right: '5%',
|
|
110
|
+
bottom: '8%',
|
|
111
|
+
borderWidth: 2,
|
|
112
|
+
borderColor: 'rgba(255,255,255,0.7)',
|
|
113
|
+
borderRadius: 8,
|
|
114
|
+
},
|
|
115
|
+
preview: {
|
|
116
|
+
width: '100%',
|
|
117
|
+
aspectRatio: 4 / 3,
|
|
118
|
+
borderRadius: t.radius,
|
|
119
|
+
marginBottom: 16,
|
|
120
|
+
borderWidth: 1,
|
|
121
|
+
borderColor: t.border,
|
|
122
|
+
},
|
|
123
|
+
btnPrimary: {
|
|
124
|
+
backgroundColor: t.accent,
|
|
125
|
+
borderRadius: t.radius,
|
|
126
|
+
paddingVertical: 14,
|
|
127
|
+
alignItems: 'center',
|
|
128
|
+
marginBottom: 10,
|
|
129
|
+
},
|
|
130
|
+
btnSecondary: {
|
|
131
|
+
backgroundColor: t.card,
|
|
132
|
+
borderRadius: t.radius,
|
|
133
|
+
paddingVertical: 14,
|
|
134
|
+
alignItems: 'center',
|
|
135
|
+
borderWidth: 1,
|
|
136
|
+
borderColor: t.border,
|
|
137
|
+
},
|
|
138
|
+
btnDisabled: { opacity: 0.6 },
|
|
139
|
+
btnText: { color: '#fff', fontSize: 15, fontWeight: '600' },
|
|
140
|
+
btnSecondaryText: { color: t.text, fontSize: 15, fontWeight: '600' },
|
|
141
|
+
})
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { View, Text, TouchableOpacity, StyleSheet } from 'react-native'
|
|
3
|
+
import type { ResolvedTheme } from '../theme'
|
|
4
|
+
import type { VerifyLocale } from '../types'
|
|
5
|
+
|
|
6
|
+
interface Props {
|
|
7
|
+
theme: ResolvedTheme
|
|
8
|
+
locale: VerifyLocale
|
|
9
|
+
onStart: () => void
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function IntroStep({ theme, locale, onStart }: Props) {
|
|
13
|
+
const s = styles(theme)
|
|
14
|
+
return (
|
|
15
|
+
<View style={s.container}>
|
|
16
|
+
<View style={s.steps}>
|
|
17
|
+
{[
|
|
18
|
+
{ icon: '🪪', label: 'Document' },
|
|
19
|
+
{ icon: '🤳', label: 'Selfie' },
|
|
20
|
+
{ icon: '👁️', label: 'Liveness' },
|
|
21
|
+
].map((item) => (
|
|
22
|
+
<View key={item.label} style={s.step}>
|
|
23
|
+
<View style={s.stepIcon}>
|
|
24
|
+
<Text style={s.stepEmoji}>{item.icon}</Text>
|
|
25
|
+
</View>
|
|
26
|
+
<Text style={s.stepLabel}>{item.label}</Text>
|
|
27
|
+
</View>
|
|
28
|
+
))}
|
|
29
|
+
</View>
|
|
30
|
+
|
|
31
|
+
<Text style={s.title}>{locale.intro_title}</Text>
|
|
32
|
+
<Text style={s.body}>{locale.intro_body}</Text>
|
|
33
|
+
|
|
34
|
+
<TouchableOpacity style={s.btn} onPress={onStart} activeOpacity={0.85}>
|
|
35
|
+
<Text style={s.btnText}>{locale.intro_cta}</Text>
|
|
36
|
+
</TouchableOpacity>
|
|
37
|
+
</View>
|
|
38
|
+
)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const styles = (t: ResolvedTheme) =>
|
|
42
|
+
StyleSheet.create({
|
|
43
|
+
container: { padding: 24 },
|
|
44
|
+
steps: {
|
|
45
|
+
flexDirection: 'row',
|
|
46
|
+
justifyContent: 'center',
|
|
47
|
+
gap: 20,
|
|
48
|
+
marginBottom: 28,
|
|
49
|
+
},
|
|
50
|
+
step: { alignItems: 'center', gap: 8 },
|
|
51
|
+
stepIcon: {
|
|
52
|
+
width: 56,
|
|
53
|
+
height: 56,
|
|
54
|
+
borderRadius: 14,
|
|
55
|
+
backgroundColor: t.card,
|
|
56
|
+
borderWidth: 1,
|
|
57
|
+
borderColor: t.border,
|
|
58
|
+
alignItems: 'center',
|
|
59
|
+
justifyContent: 'center',
|
|
60
|
+
},
|
|
61
|
+
stepEmoji: { fontSize: 24 },
|
|
62
|
+
stepLabel: { fontSize: 12, color: t.subtext },
|
|
63
|
+
title: {
|
|
64
|
+
fontSize: 22,
|
|
65
|
+
fontWeight: '700',
|
|
66
|
+
color: t.text,
|
|
67
|
+
marginBottom: 10,
|
|
68
|
+
},
|
|
69
|
+
body: {
|
|
70
|
+
fontSize: 14,
|
|
71
|
+
color: t.subtext,
|
|
72
|
+
lineHeight: 21,
|
|
73
|
+
marginBottom: 28,
|
|
74
|
+
},
|
|
75
|
+
btn: {
|
|
76
|
+
backgroundColor: t.accent,
|
|
77
|
+
borderRadius: t.radius,
|
|
78
|
+
paddingVertical: 14,
|
|
79
|
+
alignItems: 'center',
|
|
80
|
+
},
|
|
81
|
+
btnText: { color: '#fff', fontSize: 15, fontWeight: '600' },
|
|
82
|
+
})
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import React, { useState, useEffect, useRef } from 'react'
|
|
2
|
+
import {
|
|
3
|
+
View,
|
|
4
|
+
Text,
|
|
5
|
+
TouchableOpacity,
|
|
6
|
+
StyleSheet,
|
|
7
|
+
ActivityIndicator,
|
|
8
|
+
Animated,
|
|
9
|
+
} from 'react-native'
|
|
10
|
+
import { Camera } from 'react-native-vision-camera'
|
|
11
|
+
import { useCamera } from '../hooks/useCamera'
|
|
12
|
+
import type { ResolvedTheme } from '../theme'
|
|
13
|
+
import type { VerifyLocale } from '../types'
|
|
14
|
+
|
|
15
|
+
interface Props {
|
|
16
|
+
theme: ResolvedTheme
|
|
17
|
+
locale: VerifyLocale
|
|
18
|
+
duration: number // ms
|
|
19
|
+
onCapture: (uri: string) => void
|
|
20
|
+
onBack: () => void
|
|
21
|
+
}
|
|
22
|
+
|
|
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)
|
|
30
|
+
|
|
31
|
+
// Pulse animation for recording indicator
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
if (recording) {
|
|
34
|
+
Animated.loop(
|
|
35
|
+
Animated.sequence([
|
|
36
|
+
Animated.timing(pulseAnim, { toValue: 0.3, duration: 600, useNativeDriver: true }),
|
|
37
|
+
Animated.timing(pulseAnim, { toValue: 1, duration: 600, useNativeDriver: true }),
|
|
38
|
+
])
|
|
39
|
+
).start()
|
|
40
|
+
} else {
|
|
41
|
+
pulseAnim.setValue(1)
|
|
42
|
+
}
|
|
43
|
+
}, [recording, pulseAnim])
|
|
44
|
+
|
|
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()
|
|
66
|
+
}
|
|
67
|
+
}, 100)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
return () => {
|
|
72
|
+
if (timerRef.current) clearInterval(timerRef.current)
|
|
73
|
+
}
|
|
74
|
+
}, [])
|
|
75
|
+
|
|
76
|
+
if (!device) return <ActivityIndicator color={theme.accent} style={{ margin: 40 }} />
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
<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>
|
|
84
|
+
|
|
85
|
+
<View style={s.viewport}>
|
|
86
|
+
<Camera
|
|
87
|
+
ref={cameraRef}
|
|
88
|
+
style={StyleSheet.absoluteFill}
|
|
89
|
+
device={device}
|
|
90
|
+
isActive={true}
|
|
91
|
+
video={true}
|
|
92
|
+
audio={true}
|
|
93
|
+
/>
|
|
94
|
+
<View style={s.ovalGuide} pointerEvents="none" />
|
|
95
|
+
|
|
96
|
+
{recording && (
|
|
97
|
+
<>
|
|
98
|
+
<View style={s.recBadge}>
|
|
99
|
+
<Animated.View style={[s.recDot, { opacity: pulseAnim }]} />
|
|
100
|
+
<Text style={s.recText}>REC</Text>
|
|
101
|
+
</View>
|
|
102
|
+
<View style={s.countdownBadge}>
|
|
103
|
+
<Text style={s.countdownText}>{countdown}</Text>
|
|
104
|
+
</View>
|
|
105
|
+
</>
|
|
106
|
+
)}
|
|
107
|
+
</View>
|
|
108
|
+
|
|
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
|
+
</>
|
|
118
|
+
)}
|
|
119
|
+
</View>
|
|
120
|
+
)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const styles = (t: ResolvedTheme) =>
|
|
124
|
+
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 },
|
|
128
|
+
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',
|
|
138
|
+
},
|
|
139
|
+
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,
|
|
148
|
+
},
|
|
149
|
+
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,
|
|
160
|
+
},
|
|
161
|
+
recDot: {
|
|
162
|
+
width: 8,
|
|
163
|
+
height: 8,
|
|
164
|
+
borderRadius: 4,
|
|
165
|
+
backgroundColor: '#fff',
|
|
166
|
+
},
|
|
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',
|
|
178
|
+
},
|
|
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,
|
|
186
|
+
},
|
|
187
|
+
btnSecondary: {
|
|
188
|
+
backgroundColor: t.card,
|
|
189
|
+
borderRadius: t.radius,
|
|
190
|
+
paddingVertical: 14,
|
|
191
|
+
alignItems: 'center',
|
|
192
|
+
borderWidth: 1,
|
|
193
|
+
borderColor: t.border,
|
|
194
|
+
},
|
|
195
|
+
btnText: { color: '#fff', fontSize: 15, fontWeight: '600' },
|
|
196
|
+
btnSecondaryText: { color: t.text, fontSize: 15, fontWeight: '600' },
|
|
197
|
+
})
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import React, { useEffect, useRef } from 'react'
|
|
2
|
+
import { View, Text, StyleSheet, Animated } from 'react-native'
|
|
3
|
+
import type { ResolvedTheme } from '../theme'
|
|
4
|
+
import type { VerifyLocale } from '../types'
|
|
5
|
+
|
|
6
|
+
interface Props {
|
|
7
|
+
theme: ResolvedTheme
|
|
8
|
+
locale: VerifyLocale
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function ProcessingStep({ theme, locale }: Props) {
|
|
12
|
+
const spinAnim = useRef(new Animated.Value(0)).current
|
|
13
|
+
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
|
+
return (
|
|
31
|
+
<View style={s.container}>
|
|
32
|
+
<Animated.View style={[s.spinner, { transform: [{ rotate: spin }] }]} />
|
|
33
|
+
<Text style={s.title}>{locale.processing_title}</Text>
|
|
34
|
+
<Text style={s.subtitle}>{locale.processing_subtitle}</Text>
|
|
35
|
+
</View>
|
|
36
|
+
)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const styles = (t: ResolvedTheme) =>
|
|
40
|
+
StyleSheet.create({
|
|
41
|
+
container: {
|
|
42
|
+
padding: 48,
|
|
43
|
+
alignItems: 'center',
|
|
44
|
+
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,
|
|
54
|
+
},
|
|
55
|
+
title: {
|
|
56
|
+
fontSize: 18,
|
|
57
|
+
fontWeight: '700',
|
|
58
|
+
color: t.text,
|
|
59
|
+
textAlign: 'center',
|
|
60
|
+
},
|
|
61
|
+
subtitle: {
|
|
62
|
+
fontSize: 14,
|
|
63
|
+
color: t.subtext,
|
|
64
|
+
textAlign: 'center',
|
|
65
|
+
},
|
|
66
|
+
})
|