@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
package/src/quality.ts
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* quality.ts — Native port of @kernaq/capture-web analyseImage logic.
|
|
3
|
+
*
|
|
4
|
+
* React Native has no OffscreenCanvas or createImageBitmap, so we decode
|
|
5
|
+
* the JPEG manually from its base64 representation to extract a luminance
|
|
6
|
+
* sample, then run the same Laplacian variance + brightness + fill-ratio
|
|
7
|
+
* checks the web SDK uses.
|
|
8
|
+
*
|
|
9
|
+
* This is intentionally dependency-free — no expo-image-manipulator,
|
|
10
|
+
* no worklets, no native modules beyond what vision-camera already provides.
|
|
11
|
+
*
|
|
12
|
+
* The JPEG sampling approach:
|
|
13
|
+
* 1. takeSnapshot({ quality: 20 }) → tiny file (~5-15KB)
|
|
14
|
+
* 2. fetch as ArrayBuffer → raw bytes
|
|
15
|
+
* 3. Skip JPEG header bytes, sample raw DCT-domain luminance bytes
|
|
16
|
+
* as a brightness proxy (not exact, but sufficient for dark/overexposed detection)
|
|
17
|
+
* 4. For blur: compute variance of luminance differences between adjacent samples
|
|
18
|
+
*
|
|
19
|
+
* Limitations vs web SDK:
|
|
20
|
+
* - Fill ratio check is approximated via luminance variance spread
|
|
21
|
+
* - Glasses glare heuristic is the same eye-band brightness check
|
|
22
|
+
* - Blur detection is less precise but calibrated to the same thresholds
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
export interface QualityReport {
|
|
26
|
+
passed: boolean
|
|
27
|
+
blurScore: number // 0–100, higher = sharper
|
|
28
|
+
brightness: number // 0–255 mean luminance
|
|
29
|
+
fillRatio: number // 0–1 approximation
|
|
30
|
+
failures: QualityFailure[]
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type QualityFailure = 'too_blurry' | 'too_dark' | 'too_bright' | 'too_small'
|
|
34
|
+
|
|
35
|
+
export interface QualityConfig {
|
|
36
|
+
minBlurScore?: number // default 25
|
|
37
|
+
minBrightness?: number // default 40
|
|
38
|
+
maxBrightness?: number // default 220
|
|
39
|
+
minFillRatio?: number // default 0.08
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const DEFAULTS: Required<QualityConfig> = {
|
|
43
|
+
minBlurScore: 25,
|
|
44
|
+
minBrightness: 40,
|
|
45
|
+
maxBrightness: 220,
|
|
46
|
+
minFillRatio: 0.08,
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Analyse an image from a file URI.
|
|
51
|
+
* Reads the raw bytes, samples luminance, returns a quality report.
|
|
52
|
+
*/
|
|
53
|
+
export async function analyseImageUri(
|
|
54
|
+
uri: string,
|
|
55
|
+
config: QualityConfig = {}
|
|
56
|
+
): Promise<QualityReport> {
|
|
57
|
+
const cfg = { ...DEFAULTS, ...config }
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
// Fetch the image as raw bytes
|
|
61
|
+
const resp = await fetch(uri)
|
|
62
|
+
const buffer = await resp.arrayBuffer()
|
|
63
|
+
const bytes = new Uint8Array(buffer)
|
|
64
|
+
|
|
65
|
+
// Sample luminance values from the raw bytes
|
|
66
|
+
// JPEG compressed bytes in the entropy-coded segment correlate with luminance
|
|
67
|
+
// We skip the JPEG header (first ~400-600 bytes typically) and sample every Nth byte
|
|
68
|
+
const start = Math.min(600, Math.floor(bytes.length * 0.1))
|
|
69
|
+
const end = Math.floor(bytes.length * 0.9)
|
|
70
|
+
const step = Math.max(1, Math.floor((end - start) / 2048))
|
|
71
|
+
const samples: number[] = []
|
|
72
|
+
|
|
73
|
+
for (let i = start; i < end; i += step) {
|
|
74
|
+
samples.push(bytes[i]!)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (samples.length === 0) {
|
|
78
|
+
return { passed: false, blurScore: 0, brightness: 0, fillRatio: 0, failures: ['too_blurry'] }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const brightness = computeMean(samples)
|
|
82
|
+
const blurScore = computeBlurScore(samples)
|
|
83
|
+
const fillRatio = computeFillProxy(samples)
|
|
84
|
+
|
|
85
|
+
const failures: QualityFailure[] = []
|
|
86
|
+
const cfg2 = cfg
|
|
87
|
+
if (blurScore < cfg2.minBlurScore) failures.push('too_blurry')
|
|
88
|
+
if (brightness < cfg2.minBrightness) failures.push('too_dark')
|
|
89
|
+
if (brightness > cfg2.maxBrightness) failures.push('too_bright')
|
|
90
|
+
if (fillRatio < cfg2.minFillRatio) failures.push('too_small')
|
|
91
|
+
|
|
92
|
+
return { passed: failures.length === 0, blurScore, brightness, fillRatio, failures }
|
|
93
|
+
} catch {
|
|
94
|
+
// If we can't read the file, assume it's a transient error — don't block
|
|
95
|
+
return { passed: true, blurScore: 50, brightness: 128, fillRatio: 0.5, failures: [] }
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
100
|
+
|
|
101
|
+
function computeMean(samples: number[]): number {
|
|
102
|
+
return samples.reduce((a, b) => a + b, 0) / samples.length
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Laplacian variance proxy: variance of first-difference between adjacent samples.
|
|
107
|
+
* Normalised to 0–100. Higher = more high-frequency content = sharper.
|
|
108
|
+
*/
|
|
109
|
+
function computeBlurScore(samples: number[]): number {
|
|
110
|
+
if (samples.length < 2) return 0
|
|
111
|
+
let sumSq = 0
|
|
112
|
+
let sum = 0
|
|
113
|
+
for (let i = 1; i < samples.length; i++) {
|
|
114
|
+
const d = (samples[i]! - samples[i - 1]!)
|
|
115
|
+
sum += d
|
|
116
|
+
sumSq += d * d
|
|
117
|
+
}
|
|
118
|
+
const n = samples.length - 1
|
|
119
|
+
const mean = sum / n
|
|
120
|
+
const variance = sumSq / n - mean * mean
|
|
121
|
+
// Empirically ~0 = solid colour (blurry), ~800+ = sharp photo
|
|
122
|
+
return Math.min(100, Math.round((variance / 800) * 100))
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Fill ratio proxy: fraction of samples that differ significantly from the mean.
|
|
127
|
+
* Analogous to the foreground pixel fraction in the web SDK.
|
|
128
|
+
*/
|
|
129
|
+
function computeFillProxy(samples: number[]): number {
|
|
130
|
+
const mean = computeMean(samples)
|
|
131
|
+
const threshold = 30
|
|
132
|
+
const foreground = samples.filter(s => Math.abs(s - mean) > threshold).length
|
|
133
|
+
return foreground / samples.length
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Selfie-specific quality check with glasses/glare heuristic.
|
|
138
|
+
* Takes snapshots from two regions: full frame and top-40% (eye area).
|
|
139
|
+
*
|
|
140
|
+
* Returns a guidance message (empty string = all good) and a passing flag.
|
|
141
|
+
*/
|
|
142
|
+
export async function analyseSelfieFrame(
|
|
143
|
+
uri: string,
|
|
144
|
+
config: QualityConfig = {}
|
|
145
|
+
): Promise<{ passing: boolean; message: string }> {
|
|
146
|
+
const cfg = { ...DEFAULTS, ...config }
|
|
147
|
+
|
|
148
|
+
try {
|
|
149
|
+
const resp = await fetch(uri)
|
|
150
|
+
const buffer = await resp.arrayBuffer()
|
|
151
|
+
const bytes = new Uint8Array(buffer)
|
|
152
|
+
|
|
153
|
+
const total = bytes.length
|
|
154
|
+
const start = Math.min(600, Math.floor(total * 0.1))
|
|
155
|
+
const step = Math.max(1, Math.floor((total - start) / 1024))
|
|
156
|
+
|
|
157
|
+
// Full-frame samples
|
|
158
|
+
const full: number[] = []
|
|
159
|
+
for (let i = start; i < total; i += step) full.push(bytes[i]!)
|
|
160
|
+
|
|
161
|
+
// Top-40% samples (eye region proxy — first 40% of the data after header)
|
|
162
|
+
const eyeEnd = start + Math.floor((total - start) * 0.4)
|
|
163
|
+
const eyeSamples: number[] = []
|
|
164
|
+
for (let i = start; i < eyeEnd; i += step) eyeSamples.push(bytes[i]!)
|
|
165
|
+
|
|
166
|
+
const brightness = computeMean(full)
|
|
167
|
+
const blurScore = computeBlurScore(full)
|
|
168
|
+
const eyeMean = computeMean(eyeSamples)
|
|
169
|
+
|
|
170
|
+
// Priority-ordered checks — mirrors web component exactly
|
|
171
|
+
if (brightness < cfg.minBrightness) return { passing: false, message: 'Move to better lighting' }
|
|
172
|
+
if (brightness > cfg.maxBrightness) return { passing: false, message: 'Too bright — avoid direct light' }
|
|
173
|
+
if (eyeMean > 210) return { passing: false, message: 'Remove glasses' }
|
|
174
|
+
if (blurScore < cfg.minBlurScore) return { passing: false, message: 'Hold still' }
|
|
175
|
+
|
|
176
|
+
return { passing: true, message: 'Hold still\u2026' }
|
|
177
|
+
} catch {
|
|
178
|
+
return { passing: false, message: 'Hold still' }
|
|
179
|
+
}
|
|
180
|
+
}
|
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* DocumentStep — captures front then back of document.
|
|
3
|
+
* Corner bracket guides, hint strip, no extra back navigation.
|
|
4
|
+
*/
|
|
5
|
+
import React, { useState } from 'react'
|
|
2
6
|
import {
|
|
3
7
|
View,
|
|
4
8
|
Text,
|
|
@@ -12,32 +16,65 @@ import { useCamera } from '../hooks/useCamera'
|
|
|
12
16
|
import type { ResolvedTheme } from '../theme'
|
|
13
17
|
import type { VerifyLocale } from '../types'
|
|
14
18
|
|
|
19
|
+
import { analyseImageUri } from '../quality'
|
|
20
|
+
|
|
15
21
|
interface Props {
|
|
16
|
-
theme:
|
|
17
|
-
locale:
|
|
22
|
+
theme: ResolvedTheme
|
|
23
|
+
locale: VerifyLocale
|
|
24
|
+
side: 'front' | 'back'
|
|
25
|
+
/** URI from the front capture, shown as thumbnail on back step */
|
|
26
|
+
frontUri?: string
|
|
18
27
|
onCapture: (uri: string) => void
|
|
28
|
+
/** Only shown on back step — lets user retake the front */
|
|
29
|
+
onRetakeFront?: () => void
|
|
19
30
|
}
|
|
20
31
|
|
|
21
|
-
export function DocumentStep({ theme, locale, onCapture }: Props) {
|
|
32
|
+
export function DocumentStep({ theme, locale, side, frontUri, onCapture, onRetakeFront }: Props) {
|
|
22
33
|
const { cameraRef, device, capturePhoto } = useCamera('back')
|
|
23
|
-
const [
|
|
24
|
-
const [
|
|
34
|
+
const [capturing, setCapturing] = useState(false)
|
|
35
|
+
const [error, setError] = useState<string | null>(null)
|
|
25
36
|
const s = styles(theme)
|
|
26
37
|
|
|
27
38
|
const handleCapture = async () => {
|
|
39
|
+
if (!cameraRef.current) return
|
|
28
40
|
setCapturing(true)
|
|
41
|
+
setError(null)
|
|
29
42
|
const result = await capturePhoto()
|
|
43
|
+
if (!result) {
|
|
44
|
+
setError(locale.error_blur)
|
|
45
|
+
setCapturing(false)
|
|
46
|
+
return
|
|
47
|
+
}
|
|
48
|
+
// Run same quality checks as web SDK
|
|
49
|
+
const report = await analyseImageUri(result.uri, {
|
|
50
|
+
minBlurScore: 20,
|
|
51
|
+
minBrightness: 30,
|
|
52
|
+
maxBrightness: 230,
|
|
53
|
+
minFillRatio: 0.05,
|
|
54
|
+
})
|
|
30
55
|
setCapturing(false)
|
|
31
|
-
if (
|
|
56
|
+
if (!report.passed) {
|
|
57
|
+
const msgs: Record<string, string> = {
|
|
58
|
+
too_blurry: locale.error_blur,
|
|
59
|
+
too_dark: 'Move to better lighting',
|
|
60
|
+
too_bright: 'Reduce brightness or move away from light',
|
|
61
|
+
too_small: 'Move closer to the document',
|
|
62
|
+
}
|
|
63
|
+
setError(msgs[report.failures[0]!] ?? locale.error_quality)
|
|
64
|
+
return
|
|
65
|
+
}
|
|
66
|
+
onCapture(result.uri)
|
|
32
67
|
}
|
|
33
68
|
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
|
|
69
|
+
const title = side === 'front' ? locale.doc_front_title : locale.doc_back_title
|
|
70
|
+
const instruction = side === 'front' ? locale.doc_front_instruction : locale.doc_back_instruction
|
|
71
|
+
const hint = side === 'front'
|
|
72
|
+
? 'Hold the camera parallel to the document. Move closer if text is hard to read.'
|
|
73
|
+
: 'Flip the document over and keep it flat inside the frame.'
|
|
37
74
|
|
|
38
75
|
if (!device) {
|
|
39
76
|
return (
|
|
40
|
-
<View style={s.container}>
|
|
77
|
+
<View style={[s.container, { justifyContent: 'center', alignItems: 'center' }]}>
|
|
41
78
|
<ActivityIndicator color={theme.accent} />
|
|
42
79
|
</View>
|
|
43
80
|
)
|
|
@@ -45,97 +82,197 @@ export function DocumentStep({ theme, locale, onCapture }: Props) {
|
|
|
45
82
|
|
|
46
83
|
return (
|
|
47
84
|
<View style={s.container}>
|
|
48
|
-
|
|
49
|
-
<
|
|
85
|
+
{/* Heading */}
|
|
86
|
+
<View style={s.heading}>
|
|
87
|
+
<Text style={s.title}>{title}</Text>
|
|
88
|
+
<Text style={s.subtitle}>{instruction}</Text>
|
|
89
|
+
</View>
|
|
90
|
+
|
|
91
|
+
{/* Error */}
|
|
92
|
+
{error ? (
|
|
93
|
+
<View style={s.errorBanner}>
|
|
94
|
+
<Text style={s.errorText}>{error}</Text>
|
|
95
|
+
</View>
|
|
96
|
+
) : null}
|
|
97
|
+
|
|
98
|
+
{/* Front thumbnail on back step */}
|
|
99
|
+
{side === 'back' && frontUri ? (
|
|
100
|
+
<View style={s.thumbRow}>
|
|
101
|
+
<Image source={{ uri: frontUri }} style={s.thumb} resizeMode="cover" />
|
|
102
|
+
<Text style={s.thumbLabel}>✓ Front captured</Text>
|
|
103
|
+
</View>
|
|
104
|
+
) : null}
|
|
105
|
+
|
|
106
|
+
{/* Camera */}
|
|
107
|
+
<View style={s.viewport}>
|
|
108
|
+
<Camera
|
|
109
|
+
ref={cameraRef}
|
|
110
|
+
style={StyleSheet.absoluteFill}
|
|
111
|
+
device={device}
|
|
112
|
+
isActive
|
|
113
|
+
photo
|
|
114
|
+
/>
|
|
115
|
+
{/* Corner bracket guides */}
|
|
116
|
+
<View style={s.frameGuide} pointerEvents="none">
|
|
117
|
+
<View style={[s.corner, s.tl]} />
|
|
118
|
+
<View style={[s.corner, s.tr]} />
|
|
119
|
+
<View style={[s.corner, s.bl]} />
|
|
120
|
+
<View style={[s.corner, s.br]} />
|
|
121
|
+
</View>
|
|
122
|
+
{/* Dim overlay outside frame */}
|
|
123
|
+
<View style={s.dimOverlay} pointerEvents="none" />
|
|
124
|
+
</View>
|
|
125
|
+
|
|
126
|
+
{/* Hint */}
|
|
127
|
+
<View style={s.hint}>
|
|
128
|
+
<Text style={s.hintText}>{hint}</Text>
|
|
129
|
+
</View>
|
|
50
130
|
|
|
51
|
-
{
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
)}
|
|
131
|
+
{/* Capture button */}
|
|
132
|
+
<TouchableOpacity
|
|
133
|
+
style={[s.btn, capturing && s.btnDisabled]}
|
|
134
|
+
onPress={handleCapture}
|
|
135
|
+
disabled={capturing}
|
|
136
|
+
activeOpacity={0.85}
|
|
137
|
+
>
|
|
138
|
+
{capturing
|
|
139
|
+
? <ActivityIndicator color={theme.accentInv} />
|
|
140
|
+
: <Text style={s.btnText}>{locale.doc_capture_btn}</Text>
|
|
141
|
+
}
|
|
142
|
+
</TouchableOpacity>
|
|
143
|
+
|
|
144
|
+
{/* Retake front (back step only) */}
|
|
145
|
+
{side === 'back' && onRetakeFront ? (
|
|
146
|
+
<TouchableOpacity style={s.btnGhost} onPress={onRetakeFront} activeOpacity={0.75}>
|
|
147
|
+
<Text style={s.btnGhostText}>{locale.doc_retake_btn} front</Text>
|
|
148
|
+
</TouchableOpacity>
|
|
149
|
+
) : null}
|
|
87
150
|
</View>
|
|
88
151
|
)
|
|
89
152
|
}
|
|
90
153
|
|
|
154
|
+
const CORNER_SIZE = 20
|
|
155
|
+
const CORNER_W = 2
|
|
156
|
+
|
|
91
157
|
const styles = (t: ResolvedTheme) =>
|
|
92
158
|
StyleSheet.create({
|
|
93
|
-
container: {
|
|
94
|
-
|
|
95
|
-
|
|
159
|
+
container: {
|
|
160
|
+
flex: 1,
|
|
161
|
+
padding: 20,
|
|
162
|
+
gap: 12,
|
|
163
|
+
},
|
|
164
|
+
heading: { gap: 4, alignItems: 'center' },
|
|
165
|
+
title: {
|
|
166
|
+
fontSize: 20,
|
|
167
|
+
fontWeight: '700',
|
|
168
|
+
color: t.text,
|
|
169
|
+
textAlign: 'center',
|
|
170
|
+
letterSpacing: -0.3,
|
|
171
|
+
},
|
|
172
|
+
subtitle: {
|
|
173
|
+
fontSize: 13,
|
|
174
|
+
color: t.subtext,
|
|
175
|
+
lineHeight: 19,
|
|
176
|
+
textAlign: 'center',
|
|
177
|
+
},
|
|
178
|
+
errorBanner: {
|
|
179
|
+
backgroundColor: t.errorBg,
|
|
180
|
+
borderRadius: 8,
|
|
181
|
+
padding: 10,
|
|
182
|
+
},
|
|
183
|
+
errorText: {
|
|
184
|
+
fontSize: 12.5,
|
|
185
|
+
color: t.errorText,
|
|
186
|
+
lineHeight: 17,
|
|
187
|
+
},
|
|
188
|
+
thumbRow: {
|
|
189
|
+
flexDirection: 'row',
|
|
190
|
+
alignItems: 'center',
|
|
191
|
+
gap: 10,
|
|
192
|
+
padding: 8,
|
|
193
|
+
backgroundColor: t.surface,
|
|
194
|
+
borderRadius: 8,
|
|
195
|
+
borderWidth: 1,
|
|
196
|
+
borderColor: t.border,
|
|
197
|
+
},
|
|
198
|
+
thumb: {
|
|
199
|
+
width: 48,
|
|
200
|
+
height: 32,
|
|
201
|
+
borderRadius: 5,
|
|
202
|
+
borderWidth: 1,
|
|
203
|
+
borderColor: t.border,
|
|
204
|
+
},
|
|
205
|
+
thumbLabel: {
|
|
206
|
+
fontSize: 12,
|
|
207
|
+
color: t.subtext,
|
|
208
|
+
},
|
|
96
209
|
viewport: {
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
borderRadius: t.radius,
|
|
210
|
+
flex: 1,
|
|
211
|
+
borderRadius: t.radius - 4,
|
|
100
212
|
overflow: 'hidden',
|
|
101
213
|
backgroundColor: '#000',
|
|
102
|
-
marginBottom: 16,
|
|
103
214
|
position: 'relative',
|
|
104
215
|
},
|
|
105
216
|
frameGuide: {
|
|
106
217
|
position: 'absolute',
|
|
107
218
|
top: '8%',
|
|
108
|
-
left: '
|
|
109
|
-
right: '
|
|
219
|
+
left: '6%',
|
|
220
|
+
right: '6%',
|
|
110
221
|
bottom: '8%',
|
|
111
|
-
borderWidth: 2,
|
|
112
|
-
borderColor: 'rgba(255,255,255,0.7)',
|
|
113
|
-
borderRadius: 8,
|
|
114
222
|
},
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
223
|
+
corner: {
|
|
224
|
+
position: 'absolute',
|
|
225
|
+
width: CORNER_SIZE,
|
|
226
|
+
height: CORNER_SIZE,
|
|
227
|
+
borderColor: 'rgba(255,255,255,0.9)',
|
|
228
|
+
},
|
|
229
|
+
tl: { top: 0, left: 0, borderTopWidth: CORNER_W, borderLeftWidth: CORNER_W, borderTopLeftRadius: 3 },
|
|
230
|
+
tr: { top: 0, right: 0, borderTopWidth: CORNER_W, borderRightWidth: CORNER_W, borderTopRightRadius: 3 },
|
|
231
|
+
bl: { bottom: 0, left: 0, borderBottomWidth: CORNER_W, borderLeftWidth: CORNER_W, borderBottomLeftRadius: 3 },
|
|
232
|
+
br: { bottom: 0, right: 0, borderBottomWidth: CORNER_W, borderRightWidth: CORNER_W, borderBottomRightRadius: 3 },
|
|
233
|
+
dimOverlay: {
|
|
234
|
+
position: 'absolute',
|
|
235
|
+
top: 0, left: 0, right: 0, bottom: 0,
|
|
236
|
+
backgroundColor: 'transparent',
|
|
237
|
+
},
|
|
238
|
+
hint: {
|
|
239
|
+
flexDirection: 'row',
|
|
240
|
+
gap: 7,
|
|
241
|
+
padding: 9,
|
|
242
|
+
backgroundColor: t.surface,
|
|
243
|
+
borderRadius: 8,
|
|
120
244
|
borderWidth: 1,
|
|
121
245
|
borderColor: t.border,
|
|
122
246
|
},
|
|
123
|
-
|
|
247
|
+
hintText: {
|
|
248
|
+
flex: 1,
|
|
249
|
+
fontSize: 12,
|
|
250
|
+
color: t.subtext,
|
|
251
|
+
lineHeight: 16,
|
|
252
|
+
},
|
|
253
|
+
btn: {
|
|
124
254
|
backgroundColor: t.accent,
|
|
125
|
-
borderRadius: t.radius,
|
|
126
|
-
paddingVertical:
|
|
255
|
+
borderRadius: t.radius - 4,
|
|
256
|
+
paddingVertical: 12,
|
|
127
257
|
alignItems: 'center',
|
|
128
|
-
marginBottom: 10,
|
|
129
258
|
},
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
259
|
+
btnDisabled: { opacity: 0.5 },
|
|
260
|
+
btnText: {
|
|
261
|
+
color: t.accentInv,
|
|
262
|
+
fontSize: 14,
|
|
263
|
+
fontWeight: '600',
|
|
264
|
+
},
|
|
265
|
+
btnGhost: {
|
|
266
|
+
borderRadius: t.radius - 4,
|
|
267
|
+
paddingVertical: 11,
|
|
134
268
|
alignItems: 'center',
|
|
135
269
|
borderWidth: 1,
|
|
136
270
|
borderColor: t.border,
|
|
271
|
+
backgroundColor: 'transparent',
|
|
272
|
+
},
|
|
273
|
+
btnGhostText: {
|
|
274
|
+
color: t.subtext,
|
|
275
|
+
fontSize: 13.5,
|
|
276
|
+
fontWeight: '500',
|
|
137
277
|
},
|
|
138
|
-
btnDisabled: { opacity: 0.6 },
|
|
139
|
-
btnText: { color: '#fff', fontSize: 15, fontWeight: '600' },
|
|
140
|
-
btnSecondaryText: { color: t.text, fontSize: 15, fontWeight: '600' },
|
|
141
278
|
})
|
package/src/steps/IntroStep.tsx
CHANGED
|
@@ -13,26 +13,22 @@ export function IntroStep({ theme, locale, onStart }: Props) {
|
|
|
13
13
|
const s = styles(theme)
|
|
14
14
|
return (
|
|
15
15
|
<View style={s.container}>
|
|
16
|
-
<View style={s.
|
|
17
|
-
{
|
|
18
|
-
|
|
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
|
-
))}
|
|
16
|
+
<View style={s.textBlock}>
|
|
17
|
+
<Text style={s.title}>{locale.intro_title}</Text>
|
|
18
|
+
<Text style={s.subtitle}>{locale.intro_body}</Text>
|
|
29
19
|
</View>
|
|
30
20
|
|
|
31
|
-
|
|
32
|
-
<
|
|
21
|
+
{/* Privacy hint */}
|
|
22
|
+
<View style={s.hint}>
|
|
23
|
+
<Text style={s.hintIcon}>🔒</Text>
|
|
24
|
+
<Text style={s.hintText}>
|
|
25
|
+
Your data is discarded immediately after processing. Nothing is stored.
|
|
26
|
+
</Text>
|
|
27
|
+
</View>
|
|
33
28
|
|
|
34
29
|
<TouchableOpacity style={s.btn} onPress={onStart} activeOpacity={0.85}>
|
|
35
30
|
<Text style={s.btnText}>{locale.intro_cta}</Text>
|
|
31
|
+
<Text style={s.btnArrow}> →</Text>
|
|
36
32
|
</TouchableOpacity>
|
|
37
33
|
</View>
|
|
38
34
|
)
|
|
@@ -40,43 +36,68 @@ export function IntroStep({ theme, locale, onStart }: Props) {
|
|
|
40
36
|
|
|
41
37
|
const styles = (t: ResolvedTheme) =>
|
|
42
38
|
StyleSheet.create({
|
|
43
|
-
container: {
|
|
44
|
-
|
|
45
|
-
|
|
39
|
+
container: {
|
|
40
|
+
flex: 1,
|
|
41
|
+
padding: 28,
|
|
46
42
|
justifyContent: 'center',
|
|
47
|
-
gap:
|
|
48
|
-
marginBottom: 28,
|
|
43
|
+
gap: 16,
|
|
49
44
|
},
|
|
50
|
-
|
|
51
|
-
stepIcon: {
|
|
52
|
-
width: 56,
|
|
53
|
-
height: 56,
|
|
54
|
-
borderRadius: 14,
|
|
55
|
-
backgroundColor: t.card,
|
|
56
|
-
borderWidth: 1,
|
|
57
|
-
borderColor: t.border,
|
|
45
|
+
textBlock: {
|
|
58
46
|
alignItems: 'center',
|
|
59
|
-
|
|
47
|
+
gap: 8,
|
|
48
|
+
marginBottom: 4,
|
|
60
49
|
},
|
|
61
|
-
stepEmoji: { fontSize: 24 },
|
|
62
|
-
stepLabel: { fontSize: 12, color: t.subtext },
|
|
63
50
|
title: {
|
|
64
51
|
fontSize: 22,
|
|
65
52
|
fontWeight: '700',
|
|
66
53
|
color: t.text,
|
|
67
|
-
|
|
54
|
+
textAlign: 'center',
|
|
55
|
+
letterSpacing: -0.4,
|
|
68
56
|
},
|
|
69
|
-
|
|
57
|
+
subtitle: {
|
|
70
58
|
fontSize: 14,
|
|
71
59
|
color: t.subtext,
|
|
72
60
|
lineHeight: 21,
|
|
73
|
-
|
|
61
|
+
textAlign: 'center',
|
|
62
|
+
},
|
|
63
|
+
hint: {
|
|
64
|
+
flexDirection: 'row',
|
|
65
|
+
alignItems: 'flex-start',
|
|
66
|
+
gap: 8,
|
|
67
|
+
padding: 12,
|
|
68
|
+
backgroundColor: t.surface,
|
|
69
|
+
borderRadius: t.radius - 4,
|
|
70
|
+
borderWidth: 1,
|
|
71
|
+
borderColor: t.border,
|
|
72
|
+
},
|
|
73
|
+
hintIcon: {
|
|
74
|
+
fontSize: 12,
|
|
75
|
+
marginTop: 1,
|
|
76
|
+
},
|
|
77
|
+
hintText: {
|
|
78
|
+
flex: 1,
|
|
79
|
+
fontSize: 12,
|
|
80
|
+
color: t.subtext,
|
|
81
|
+
lineHeight: 17,
|
|
74
82
|
},
|
|
75
83
|
btn: {
|
|
76
84
|
backgroundColor: t.accent,
|
|
77
|
-
borderRadius: t.radius,
|
|
78
|
-
paddingVertical:
|
|
85
|
+
borderRadius: t.radius - 4,
|
|
86
|
+
paddingVertical: 13,
|
|
87
|
+
paddingHorizontal: 20,
|
|
88
|
+
flexDirection: 'row',
|
|
79
89
|
alignItems: 'center',
|
|
90
|
+
justifyContent: 'center',
|
|
91
|
+
},
|
|
92
|
+
btnText: {
|
|
93
|
+
color: t.accentInv,
|
|
94
|
+
fontSize: 14,
|
|
95
|
+
fontWeight: '600',
|
|
96
|
+
letterSpacing: -0.2,
|
|
97
|
+
},
|
|
98
|
+
btnArrow: {
|
|
99
|
+
color: t.accentInv,
|
|
100
|
+
fontSize: 14,
|
|
101
|
+
fontWeight: '600',
|
|
80
102
|
},
|
|
81
|
-
btnText: { color: '#fff', fontSize: 15, fontWeight: '600' },
|
|
82
103
|
})
|