@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,26 +1,34 @@
1
+ /**
2
+ * ResultStep — no emoji, Unicode geometric icons, detail rows matching web v2.
3
+ */
1
4
  import React from 'react'
2
5
  import { View, Text, TouchableOpacity, StyleSheet } from 'react-native'
3
6
  import type { ResolvedTheme } from '../theme'
4
7
  import type { VerifyLocale, VerifyResult, VerifyVerdict } from '../types'
5
8
 
6
9
  interface Props {
7
- theme: ResolvedTheme
8
- locale: VerifyLocale
9
- result: VerifyResult | null
10
- error: string | null
10
+ theme: ResolvedTheme
11
+ locale: VerifyLocale
12
+ result: VerifyResult | null
13
+ error: string | null
11
14
  onRetry: () => void
12
15
  }
13
16
 
14
- const ICONS: Record<VerifyVerdict, string> = {
15
- pass: '✅',
16
- fail: '❌',
17
- review: '⏳',
17
+ // Unicode block characters — no emoji, render cleanly on all platforms
18
+ const ICON: Record<VerifyVerdict, string> = {
19
+ pass: '\u2714', // ✔ heavy check mark
20
+ fail: '\u2718', // ✘ heavy ballot X
21
+ review: '\u29D6', // ⧖ hourglass
18
22
  }
19
-
20
23
  const ICON_BG: Record<VerifyVerdict, string> = {
21
- pass: '#d1fae5',
24
+ pass: '#dcfce7',
22
25
  fail: '#fee2e2',
23
- review: '#fef3c7',
26
+ review: '#fef9c3',
27
+ }
28
+ const ICON_COLOR: Record<VerifyVerdict, string> = {
29
+ pass: '#16a34a',
30
+ fail: '#dc2626',
31
+ review: '#ca8a04',
24
32
  }
25
33
 
26
34
  export function ResultStep({ theme, locale, result, error, onRetry }: Props) {
@@ -29,18 +37,19 @@ export function ResultStep({ theme, locale, result, error, onRetry }: Props) {
29
37
  if (!result) {
30
38
  return (
31
39
  <View style={s.container}>
32
- <View style={[s.iconWrap, { backgroundColor: '#fee2e2' }]}>
33
- <Text style={s.icon}>❌</Text>
40
+ <View style={[s.iconBox, { backgroundColor: ICON_BG.fail }]}>
41
+ <Text style={[s.icon, { color: ICON_COLOR.fail }]}>{ICON.fail}</Text>
34
42
  </View>
35
43
  <Text style={s.title}>{locale.result_fail_title}</Text>
36
44
  <Text style={s.body}>{error ?? locale.result_fail_body}</Text>
37
45
  <TouchableOpacity style={s.btn} onPress={onRetry} activeOpacity={0.85}>
38
- <Text style={s.btnText}>{locale.result_retry_btn}</Text>
46
+ <Text style={s.btnText}>Try again</Text>
39
47
  </TouchableOpacity>
40
48
  </View>
41
49
  )
42
50
  }
43
51
 
52
+ const v = result.verdict
44
53
  const titles: Record<VerifyVerdict, string> = {
45
54
  pass: locale.result_pass_title,
46
55
  fail: locale.result_fail_title,
@@ -52,16 +61,37 @@ export function ResultStep({ theme, locale, result, error, onRetry }: Props) {
52
61
  review: locale.result_review_body,
53
62
  }
54
63
 
64
+ const rows: Array<{ label: string; value: string; ok?: boolean; bad?: boolean }> = [
65
+ { label: 'Verdict', value: v.charAt(0).toUpperCase() + v.slice(1), ok: v === 'pass', bad: v === 'fail' },
66
+ { label: 'Risk score', value: String(result.score ?? '—') },
67
+ { label: 'Face match', value: result.faceMatch ? 'Confirmed' : 'Not confirmed', ok: result.faceMatch, bad: !result.faceMatch },
68
+ { label: 'Liveness', value: result.isLive ? 'Confirmed' : 'Not confirmed', ok: result.isLive, bad: !result.isLive },
69
+ ]
70
+
55
71
  return (
56
72
  <View style={s.container}>
57
- <View style={[s.iconWrap, { backgroundColor: ICON_BG[result.verdict] }]}>
58
- <Text style={s.icon}>{ICONS[result.verdict]}</Text>
73
+ <View style={[s.iconBox, { backgroundColor: ICON_BG[v] }]}>
74
+ <Text style={[s.icon, { color: ICON_COLOR[v] }]}>{ICON[v]}</Text>
75
+ </View>
76
+ <Text style={s.title}>{titles[v]}</Text>
77
+ <Text style={s.body}>{bodies[v]}</Text>
78
+
79
+ <View style={s.details}>
80
+ {rows.map((row) => (
81
+ <View key={row.label} style={s.row}>
82
+ <Text style={s.rowLabel}>{row.label}</Text>
83
+ <Text style={[
84
+ s.rowVal,
85
+ row.ok && s.rowOk,
86
+ row.bad && s.rowBad,
87
+ ]}>{row.value}</Text>
88
+ </View>
89
+ ))}
59
90
  </View>
60
- <Text style={s.title}>{titles[result.verdict]}</Text>
61
- <Text style={s.body}>{bodies[result.verdict]}</Text>
62
- {result.verdict === 'fail' && (
91
+
92
+ {v === 'fail' && (
63
93
  <TouchableOpacity style={s.btn} onPress={onRetry} activeOpacity={0.85}>
64
- <Text style={s.btnText}>{locale.result_retry_btn}</Text>
94
+ <Text style={s.btnText}>Try again</Text>
65
95
  </TouchableOpacity>
66
96
  )}
67
97
  </View>
@@ -71,38 +101,72 @@ export function ResultStep({ theme, locale, result, error, onRetry }: Props) {
71
101
  const styles = (t: ResolvedTheme) =>
72
102
  StyleSheet.create({
73
103
  container: {
74
- padding: 40,
104
+ flex: 1,
105
+ padding: 28,
75
106
  alignItems: 'center',
76
- gap: 12,
107
+ gap: 14,
108
+ justifyContent: 'center',
77
109
  },
78
- iconWrap: {
79
- width: 76,
80
- height: 76,
81
- borderRadius: 38,
110
+ iconBox: {
111
+ width: 52,
112
+ height: 52,
113
+ borderRadius: 14,
82
114
  alignItems: 'center',
83
115
  justifyContent: 'center',
84
- marginBottom: 8,
116
+ marginBottom: 4,
117
+ },
118
+ icon: {
119
+ fontSize: 22,
120
+ fontWeight: '700',
85
121
  },
86
- icon: { fontSize: 34 },
87
122
  title: {
88
123
  fontSize: 20,
89
124
  fontWeight: '700',
90
125
  color: t.text,
91
126
  textAlign: 'center',
127
+ letterSpacing: -0.3,
92
128
  },
93
129
  body: {
94
- fontSize: 14,
130
+ fontSize: 13.5,
95
131
  color: t.subtext,
96
132
  textAlign: 'center',
97
- lineHeight: 21,
133
+ lineHeight: 19,
98
134
  },
135
+ details: {
136
+ width: '100%',
137
+ gap: 6,
138
+ },
139
+ row: {
140
+ flexDirection: 'row',
141
+ justifyContent: 'space-between',
142
+ alignItems: 'center',
143
+ paddingVertical: 9,
144
+ paddingHorizontal: 12,
145
+ backgroundColor: t.surface,
146
+ borderRadius: 8,
147
+ },
148
+ rowLabel: {
149
+ fontSize: 12.5,
150
+ color: t.subtext,
151
+ },
152
+ rowVal: {
153
+ fontSize: 12.5,
154
+ fontWeight: '600',
155
+ color: t.text,
156
+ },
157
+ rowOk: { color: '#16a34a' },
158
+ rowBad: { color: '#dc2626' },
99
159
  btn: {
100
- marginTop: 8,
160
+ width: '100%',
101
161
  backgroundColor: t.accent,
102
- borderRadius: t.radius,
103
- paddingVertical: 14,
104
- paddingHorizontal: 32,
162
+ borderRadius: t.radius - 4,
163
+ paddingVertical: 12,
105
164
  alignItems: 'center',
165
+ marginTop: 4,
166
+ },
167
+ btnText: {
168
+ color: t.accentInv,
169
+ fontSize: 14,
170
+ fontWeight: '600',
106
171
  },
107
- btnText: { color: '#fff', fontSize: 15, fontWeight: '600' },
108
172
  })
@@ -1,82 +1,136 @@
1
- import React from 'react'
1
+ /**
2
+ * SelfieStep — auto-captures when face quality passes.
3
+ *
4
+ * Every 120ms:
5
+ * 1. takeSnapshot (quality 20) — tiny file, fast
6
+ * 2. analyseSelfieFrame() — brightness, blur, glare checks (mirrors web SDK)
7
+ * 3. Shows live guidance pill with the first failing check message
8
+ * 4. After HOLD_FRAMES consecutive passing frames (~1.5s) → takePhoto (full quality)
9
+ */
10
+ import React, { useEffect, useRef, useState } from 'react'
2
11
  import {
3
12
  View,
4
13
  Text,
5
- TouchableOpacity,
6
- Image,
7
14
  StyleSheet,
8
15
  ActivityIndicator,
16
+ Animated,
9
17
  } from 'react-native'
10
- import { Camera } from 'react-native-vision-camera'
11
- import { useCamera } from '../hooks/useCamera'
18
+ import { Camera, useCameraDevice } from 'react-native-vision-camera'
19
+ import { analyseSelfieFrame } from '../quality'
12
20
  import type { ResolvedTheme } from '../theme'
13
21
  import type { VerifyLocale } from '../types'
14
22
 
23
+ const HOLD_FRAMES = 12 // ~1.5s at 120ms per frame
24
+
15
25
  interface Props {
16
- theme: ResolvedTheme
17
- locale: VerifyLocale
26
+ theme: ResolvedTheme
27
+ locale: VerifyLocale
18
28
  onCapture: (uri: string) => void
19
- onBack: () => void
20
29
  }
21
30
 
22
- export function SelfieStep({ theme, locale, onCapture, onBack }: Props) {
23
- const { cameraRef, device, capturePhoto } = useCamera('front')
24
- const [preview, setPreview] = React.useState<string | null>(null)
25
- const [capturing, setCapturing] = React.useState(false)
31
+ export function SelfieStep({ theme, locale, onCapture }: Props) {
32
+ const cameraRef = useRef<Camera>(null)
33
+ const device = useCameraDevice('front')
34
+ const [pill, setPill] = useState('Align your face')
35
+ const [pillOk, setPillOk] = useState(false)
36
+ const [capturing, setCapturing] = useState(false)
37
+ const readyFrames = useRef(0)
38
+ const timerRef = useRef<ReturnType<typeof setInterval> | null>(null)
39
+ const borderAnim = useRef(new Animated.Value(0)).current // 0 = white, 1 = green
26
40
  const s = styles(theme)
27
41
 
28
- const handleCapture = async () => {
29
- setCapturing(true)
30
- const result = await capturePhoto()
31
- setCapturing(false)
32
- if (result) setPreview(result.uri)
33
- }
42
+ // Animate oval border colour
43
+ useEffect(() => {
44
+ Animated.timing(borderAnim, {
45
+ toValue: pillOk ? 1 : 0,
46
+ duration: 200,
47
+ useNativeDriver: false,
48
+ }).start()
49
+ }, [pillOk, borderAnim])
50
+
51
+ const ovalBorder = borderAnim.interpolate({
52
+ inputRange: [0, 1],
53
+ outputRange: ['rgba(255,255,255,0.75)', '#4ade80'],
54
+ })
55
+
56
+ useEffect(() => {
57
+ if (!device || capturing) return
58
+
59
+ timerRef.current = setInterval(async () => {
60
+ if (!cameraRef.current || capturing) return
61
+ try {
62
+ // Fast low-quality snapshot for analysis only
63
+ const snap = await cameraRef.current.takeSnapshot({ quality: 20 })
64
+ const uri = `file://${snap.path}`
65
+
66
+ const { passing, message } = await analyseSelfieFrame(uri, {
67
+ minBlurScore: 22,
68
+ minBrightness: 40,
69
+ maxBrightness: 220,
70
+ })
71
+
72
+ setPill(passing ? 'Hold still\u2026' : message)
73
+ setPillOk(passing)
74
+
75
+ if (passing) {
76
+ readyFrames.current++
77
+ } else {
78
+ readyFrames.current = 0
79
+ }
80
+
81
+ if (readyFrames.current >= HOLD_FRAMES) {
82
+ // Stop loop, take full-quality photo
83
+ clearInterval(timerRef.current!)
84
+ timerRef.current = null
85
+ setCapturing(true)
86
+ const full = await cameraRef.current.takePhoto({ qualityPrioritization: 'quality' })
87
+ onCapture(`file://${full.path}`)
88
+ }
89
+ } catch {
90
+ readyFrames.current = 0
91
+ }
92
+ }, 120)
93
+
94
+ return () => {
95
+ if (timerRef.current) clearInterval(timerRef.current)
96
+ }
97
+ }, [device, capturing, onCapture])
34
98
 
35
99
  if (!device) return <ActivityIndicator color={theme.accent} style={{ margin: 40 }} />
36
100
 
37
101
  return (
38
102
  <View style={s.container}>
39
- <Text style={s.title}>{locale.selfie_title}</Text>
40
- <Text style={s.subtitle}>{locale.selfie_instruction}</Text>
41
-
42
- {preview ? (
43
- <>
44
- <Image source={{ uri: preview }} style={s.preview} resizeMode="cover" />
45
- <TouchableOpacity style={s.btnPrimary} onPress={() => onCapture(preview)} activeOpacity={0.85}>
46
- <Text style={s.btnText}>{locale.selfie_next_btn}</Text>
47
- </TouchableOpacity>
48
- <TouchableOpacity style={s.btnSecondary} onPress={() => setPreview(null)} activeOpacity={0.75}>
49
- <Text style={s.btnSecondaryText}>{locale.selfie_retake_btn}</Text>
50
- </TouchableOpacity>
51
- </>
52
- ) : (
53
- <>
54
- <View style={s.viewport}>
55
- <Camera
56
- ref={cameraRef}
57
- style={StyleSheet.absoluteFill}
58
- device={device}
59
- isActive={true}
60
- photo={true}
61
- />
62
- {/* Oval face guide */}
63
- <View style={s.ovalGuide} pointerEvents="none" />
64
- </View>
65
- <TouchableOpacity
66
- style={[s.btnPrimary, capturing && s.btnDisabled]}
67
- onPress={handleCapture}
68
- disabled={capturing}
69
- activeOpacity={0.85}
70
- >
71
- {capturing
72
- ? <ActivityIndicator color="#fff" />
73
- : <Text style={s.btnText}>{locale.selfie_capture_btn}</Text>
74
- }
75
- </TouchableOpacity>
76
- <TouchableOpacity style={s.btnSecondary} onPress={onBack} activeOpacity={0.75}>
77
- <Text style={s.btnSecondaryText}>{locale.doc_retake_btn}</Text>
78
- </TouchableOpacity>
79
- </>
103
+ <View style={s.heading}>
104
+ <Text style={s.title}>{locale.selfie_title}</Text>
105
+ <Text style={s.subtitle}>{locale.selfie_instruction}</Text>
106
+ </View>
107
+
108
+ <View style={s.viewport}>
109
+ <Camera
110
+ ref={cameraRef}
111
+ style={StyleSheet.absoluteFill}
112
+ device={device}
113
+ isActive={!capturing}
114
+ photo
115
+ />
116
+
117
+ {/* Oval guide with animated border */}
118
+ <Animated.View
119
+ style={[s.oval, { borderColor: ovalBorder }]}
120
+ pointerEvents="none"
121
+ />
122
+
123
+ {/* Live guidance pill */}
124
+ <View style={[s.pill, pillOk && s.pillOk]} pointerEvents="none">
125
+ <Text style={s.pillText}>{pill}</Text>
126
+ </View>
127
+ </View>
128
+
129
+ {capturing && (
130
+ <View style={s.capturingRow}>
131
+ <ActivityIndicator color={theme.accent} size="small" />
132
+ <Text style={s.capturingText}>Capturing…</Text>
133
+ </View>
80
134
  )}
81
135
  </View>
82
136
  )
@@ -84,55 +138,70 @@ export function SelfieStep({ theme, locale, onCapture, onBack }: Props) {
84
138
 
85
139
  const styles = (t: ResolvedTheme) =>
86
140
  StyleSheet.create({
87
- container: { padding: 24 },
88
- title: { fontSize: 20, fontWeight: '700', color: t.text, marginBottom: 8 },
89
- subtitle: { fontSize: 14, color: t.subtext, lineHeight: 20, marginBottom: 16 },
141
+ container: {
142
+ flex: 1,
143
+ padding: 20,
144
+ gap: 12,
145
+ },
146
+ heading: { gap: 4, alignItems: 'center' },
147
+ title: {
148
+ fontSize: 20,
149
+ fontWeight: '700',
150
+ color: t.text,
151
+ textAlign: 'center',
152
+ letterSpacing: -0.3,
153
+ },
154
+ subtitle: {
155
+ fontSize: 13,
156
+ color: t.subtext,
157
+ lineHeight: 19,
158
+ textAlign: 'center',
159
+ },
90
160
  viewport: {
91
- width: '100%',
92
- aspectRatio: 3 / 4,
93
- borderRadius: t.radius,
161
+ flex: 1,
162
+ borderRadius: t.radius - 4,
94
163
  overflow: 'hidden',
95
164
  backgroundColor: '#000',
96
- marginBottom: 16,
97
165
  position: 'relative',
98
- alignSelf: 'center',
99
- maxWidth: 280,
100
166
  },
101
- ovalGuide: {
167
+ oval: {
102
168
  position: 'absolute',
103
- top: '8%',
104
- left: '12%',
105
- right: '12%',
106
- bottom: '8%',
169
+ left: '21%',
170
+ right: '21%',
171
+ top: '6%',
172
+ bottom: '6%',
107
173
  borderWidth: 2,
108
- borderColor: 'rgba(255,255,255,0.7)',
109
174
  borderRadius: 999,
110
175
  },
111
- preview: {
112
- width: 240,
113
- height: 320,
114
- borderRadius: t.radius,
115
- marginBottom: 16,
176
+ pill: {
177
+ position: 'absolute',
178
+ bottom: 14,
116
179
  alignSelf: 'center',
117
- borderWidth: 1,
118
- borderColor: t.border,
180
+ backgroundColor: 'rgba(0,0,0,0.65)',
181
+ borderRadius: 999,
182
+ paddingHorizontal: 16,
183
+ paddingVertical: 7,
184
+ borderWidth: 1.5,
185
+ borderColor: 'rgba(255,255,255,0.15)',
119
186
  },
120
- btnPrimary: {
121
- backgroundColor: t.accent,
122
- borderRadius: t.radius,
123
- paddingVertical: 14,
124
- alignItems: 'center',
125
- marginBottom: 10,
187
+ pillOk: {
188
+ backgroundColor: 'rgba(22,163,74,0.8)',
189
+ borderColor: 'rgba(134,239,172,0.5)',
126
190
  },
127
- btnSecondary: {
128
- backgroundColor: t.card,
129
- borderRadius: t.radius,
130
- paddingVertical: 14,
191
+ pillText: {
192
+ color: '#fff',
193
+ fontSize: 13,
194
+ fontWeight: '600',
195
+ },
196
+ capturingRow: {
197
+ flexDirection: 'row',
131
198
  alignItems: 'center',
132
- borderWidth: 1,
133
- borderColor: t.border,
199
+ justifyContent: 'center',
200
+ gap: 8,
201
+ paddingVertical: 8,
202
+ },
203
+ capturingText: {
204
+ fontSize: 13,
205
+ color: t.subtext,
134
206
  },
135
- btnDisabled: { opacity: 0.6 },
136
- btnText: { color: '#fff', fontSize: 15, fontWeight: '600' },
137
- btnSecondaryText: { color: t.text, fontSize: 15, fontWeight: '600' },
138
207
  })
package/src/theme.ts CHANGED
@@ -1,24 +1,32 @@
1
1
  import type { VerifyTheme } from './types'
2
2
 
3
3
  export interface ResolvedTheme {
4
- accent: string
5
- bg: string
6
- text: string
7
- subtext: string
8
- border: string
9
- card: string
10
- radius: number
4
+ accent: string
5
+ accentInv: string
6
+ bg: string
7
+ surface: string
8
+ surface2: string
9
+ text: string
10
+ subtext: string
11
+ border: string
12
+ errorBg: string
13
+ errorText: string
14
+ radius: number
11
15
  }
12
16
 
13
17
  export function resolveTheme(theme?: VerifyTheme): ResolvedTheme {
14
18
  const dark = theme?.mode === 'dark'
15
19
  return {
16
- accent: theme?.accentColor ?? '#111827',
17
- bg: theme?.backgroundColor ?? (dark ? '#1a1a2e' : '#ffffff'),
18
- text: theme?.textColor ?? (dark ? '#f9fafb' : '#111827'),
19
- subtext: theme?.subtextColor ?? (dark ? '#9ca3af' : '#6b7280'),
20
- border: theme?.borderColor ?? (dark ? '#374151' : '#e5e7eb'),
21
- card: theme?.cardColor ?? (dark ? '#111827' : '#f9fafb'),
22
- radius: theme?.borderRadius ?? 12,
20
+ accent: theme?.accentColor ?? (dark ? '#e5e7eb' : '#111827'),
21
+ accentInv: dark ? '#111827' : '#ffffff',
22
+ bg: theme?.backgroundColor ?? (dark ? '#0f0f0f' : '#ffffff'),
23
+ surface: theme?.cardColor ?? (dark ? '#1a1a1a' : '#f7f7f7'),
24
+ surface2: dark ? '#242424' : '#efefef',
25
+ text: theme?.textColor ?? (dark ? '#f5f5f5' : '#0f0f0f'),
26
+ subtext: theme?.subtextColor ?? (dark ? '#888888' : '#6b7280'),
27
+ border: theme?.borderColor ?? (dark ? '#2a2a2a' : '#e5e5e5'),
28
+ errorBg: dark ? 'rgba(239,68,68,0.1)' : '#fef2f2',
29
+ errorText: dark ? '#f87171' : '#b91c1c',
30
+ radius: theme?.borderRadius ?? 14,
23
31
  }
24
32
  }