@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.
- package/README.md +1 -1
- package/dist/index.d.ts +95 -50
- package/dist/index.esm.js +1171 -580
- package/dist/index.esm.js.map +1 -1
- package/dist/index.js +1169 -576
- package/dist/index.js.map +1 -1
- package/package.json +2 -9
- package/src/KernaqVerify.tsx +172 -176
- package/src/index.ts +9 -4
- package/src/locale.ts +32 -29
- package/src/quality.ts +180 -0
- package/src/steps/DocumentStep.tsx +214 -77
- package/src/steps/IntroStep.tsx +58 -37
- package/src/steps/LivenessStep.tsx +225 -125
- package/src/steps/ProcessingStep.tsx +10 -34
- package/src/steps/ResultStep.tsx +99 -35
- package/src/steps/SelfieStep.tsx +165 -96
- package/src/theme.ts +22 -14
- package/src/types.ts +27 -51
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
|
|
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:
|
|
17
|
-
locale:
|
|
18
|
-
duration:
|
|
19
|
-
|
|
20
|
-
|
|
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,
|
|
24
|
-
const { cameraRef,
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
const
|
|
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
|
-
|
|
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.
|
|
37
|
-
Animated.timing(pulseAnim, { toValue: 1,
|
|
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
|
|
46
|
-
if (!
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
(
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
)
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
},
|
|
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 (
|
|
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
|
-
<
|
|
81
|
-
|
|
82
|
-
{
|
|
83
|
-
</
|
|
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
|
|
91
|
-
video
|
|
92
|
-
audio={
|
|
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.
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
103
|
-
<
|
|
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
|
-
{
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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:
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
130
|
-
|
|
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
|
-
|
|
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
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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:
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
|
|
180
|
-
|
|
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
|
-
|
|
188
|
-
backgroundColor: t.
|
|
189
|
-
|
|
190
|
-
|
|
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:
|
|
196
|
-
|
|
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
|
|
2
|
-
import { View, Text,
|
|
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:
|
|
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
|
-
<
|
|
15
|
+
<ActivityIndicator color={theme.accent} size="large" />
|
|
33
16
|
<Text style={s.title}>{locale.processing_title}</Text>
|
|
34
|
-
<Text style={s.subtitle}>
|
|
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
|
-
|
|
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:
|
|
32
|
+
fontSize: 16,
|
|
57
33
|
fontWeight: '700',
|
|
58
34
|
color: t.text,
|
|
59
35
|
textAlign: 'center',
|
|
60
36
|
},
|
|
61
37
|
subtitle: {
|
|
62
|
-
fontSize:
|
|
38
|
+
fontSize: 13,
|
|
63
39
|
color: t.subtext,
|
|
64
40
|
textAlign: 'center',
|
|
65
41
|
},
|