@mleonard9/vin-scanner 1.5.0 → 1.5.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.
Files changed (64) hide show
  1. package/README.md +19 -2
  2. package/ios/VisionCameraBarcodeScanner.m +2 -23
  3. package/ios/VisionCameraTextRecognition.m +2 -21
  4. package/lib/commonjs/ManualVinInput.js +25 -15
  5. package/lib/commonjs/ManualVinInput.js.map +1 -1
  6. package/lib/commonjs/PendingVinBanner.js +44 -21
  7. package/lib/commonjs/PendingVinBanner.js.map +1 -1
  8. package/lib/commonjs/ScannerChromeOverlay.js +185 -0
  9. package/lib/commonjs/ScannerChromeOverlay.js.map +1 -0
  10. package/lib/commonjs/TextVinPrompt.js +33 -18
  11. package/lib/commonjs/TextVinPrompt.js.map +1 -1
  12. package/lib/commonjs/index.js +11 -3
  13. package/lib/commonjs/index.js.map +1 -1
  14. package/lib/commonjs/scanBarcodes.js +25 -24
  15. package/lib/commonjs/scanBarcodes.js.map +1 -1
  16. package/lib/commonjs/scanText.js +22 -22
  17. package/lib/commonjs/scanText.js.map +1 -1
  18. package/lib/commonjs/useVinScanner.js +15 -4
  19. package/lib/commonjs/useVinScanner.js.map +1 -1
  20. package/lib/commonjs/vinUtils.js +6 -4
  21. package/lib/commonjs/vinUtils.js.map +1 -1
  22. package/lib/module/ManualVinInput.js +25 -15
  23. package/lib/module/ManualVinInput.js.map +1 -1
  24. package/lib/module/PendingVinBanner.js +45 -22
  25. package/lib/module/PendingVinBanner.js.map +1 -1
  26. package/lib/module/ScannerChromeOverlay.js +177 -0
  27. package/lib/module/ScannerChromeOverlay.js.map +1 -0
  28. package/lib/module/TextVinPrompt.js +33 -18
  29. package/lib/module/TextVinPrompt.js.map +1 -1
  30. package/lib/module/index.js +5 -3
  31. package/lib/module/index.js.map +1 -1
  32. package/lib/module/scanBarcodes.js +25 -24
  33. package/lib/module/scanBarcodes.js.map +1 -1
  34. package/lib/module/scanText.js +22 -22
  35. package/lib/module/scanText.js.map +1 -1
  36. package/lib/module/useVinScanner.js +15 -4
  37. package/lib/module/useVinScanner.js.map +1 -1
  38. package/lib/module/vinUtils.js +6 -4
  39. package/lib/module/vinUtils.js.map +1 -1
  40. package/lib/typescript/src/ManualVinInput.d.ts.map +1 -1
  41. package/lib/typescript/src/PendingVinBanner.d.ts +2 -1
  42. package/lib/typescript/src/PendingVinBanner.d.ts.map +1 -1
  43. package/lib/typescript/src/ScannerChromeOverlay.d.ts +19 -0
  44. package/lib/typescript/src/ScannerChromeOverlay.d.ts.map +1 -0
  45. package/lib/typescript/src/TextVinPrompt.d.ts.map +1 -1
  46. package/lib/typescript/src/index.d.ts +1 -0
  47. package/lib/typescript/src/index.d.ts.map +1 -1
  48. package/lib/typescript/src/scanBarcodes.d.ts.map +1 -1
  49. package/lib/typescript/src/scanText.d.ts.map +1 -1
  50. package/lib/typescript/src/types.d.ts +8 -2
  51. package/lib/typescript/src/types.d.ts.map +1 -1
  52. package/lib/typescript/src/useVinScanner.d.ts.map +1 -1
  53. package/lib/typescript/src/vinUtils.d.ts.map +1 -1
  54. package/package.json +1 -1
  55. package/src/ManualVinInput.tsx +22 -15
  56. package/src/PendingVinBanner.tsx +66 -27
  57. package/src/ScannerChromeOverlay.tsx +214 -0
  58. package/src/TextVinPrompt.tsx +31 -16
  59. package/src/index.tsx +6 -2
  60. package/src/scanBarcodes.ts +46 -40
  61. package/src/scanText.ts +34 -45
  62. package/src/types.ts +8 -2
  63. package/src/useVinScanner.ts +20 -4
  64. package/src/vinUtils.ts +5 -1
@@ -0,0 +1,214 @@
1
+ import React from 'react';
2
+ import {
3
+ View,
4
+ Pressable,
5
+ Text,
6
+ StyleSheet,
7
+ type StyleProp,
8
+ type ViewStyle,
9
+ } from 'react-native';
10
+
11
+ type ScannerChromeOverlayProps = {
12
+ onBackPress?: () => void;
13
+ onManualEntryPress?: () => void;
14
+ onFlashPress?: () => void;
15
+ isTorchOn?: boolean;
16
+ backContent?: React.ReactNode;
17
+ manualEntryContent?: React.ReactNode;
18
+ flashOnContent?: React.ReactNode;
19
+ flashOffContent?: React.ReactNode;
20
+ centerContent?: React.ReactNode;
21
+ frameSize?: number;
22
+ cornerColor?: string;
23
+ topOffset?: number;
24
+ bottomOffset?: number;
25
+ };
26
+
27
+ const ActionButton = ({
28
+ onPress,
29
+ children,
30
+ style,
31
+ }: {
32
+ onPress?: () => void;
33
+ children: React.ReactNode;
34
+ style?: StyleProp<ViewStyle>;
35
+ }) => {
36
+ if (!onPress) {
37
+ return null;
38
+ }
39
+
40
+ return (
41
+ <Pressable onPress={onPress} style={[styles.actionButton, style]}>
42
+ {children}
43
+ </Pressable>
44
+ );
45
+ };
46
+
47
+ export function ScannerChromeOverlay({
48
+ onBackPress,
49
+ onManualEntryPress,
50
+ onFlashPress,
51
+ isTorchOn = false,
52
+ backContent,
53
+ manualEntryContent,
54
+ flashOnContent,
55
+ flashOffContent,
56
+ centerContent,
57
+ frameSize = 240,
58
+ cornerColor = 'white',
59
+ topOffset = 64,
60
+ bottomOffset = 64,
61
+ }: ScannerChromeOverlayProps) {
62
+ const halfFrame = frameSize / 2;
63
+
64
+ return (
65
+ <>
66
+ <View pointerEvents="box-none" style={styles.actionContainer}>
67
+ <ActionButton
68
+ onPress={onBackPress}
69
+ style={[styles.backButton, { top: topOffset }]}
70
+ >
71
+ {backContent ?? <Text style={styles.actionText}>Back</Text>}
72
+ </ActionButton>
73
+
74
+ <ActionButton
75
+ onPress={onManualEntryPress}
76
+ style={[styles.manualButton, { top: topOffset }]}
77
+ >
78
+ {manualEntryContent ?? <Text style={styles.actionText}>VIN</Text>}
79
+ </ActionButton>
80
+
81
+ <ActionButton
82
+ onPress={onFlashPress}
83
+ style={[styles.flashButton, { bottom: bottomOffset }]}
84
+ >
85
+ {isTorchOn
86
+ ? (flashOnContent ?? (
87
+ <Text style={styles.actionText}>Torch Off</Text>
88
+ ))
89
+ : (flashOffContent ?? <Text style={styles.actionText}>Torch</Text>)}
90
+ </ActionButton>
91
+
92
+ {centerContent ? (
93
+ <View style={styles.centerContent}>{centerContent}</View>
94
+ ) : null}
95
+ </View>
96
+
97
+ <View
98
+ pointerEvents="none"
99
+ style={[
100
+ styles.viewFinderOverlay,
101
+ {
102
+ width: frameSize,
103
+ height: frameSize,
104
+ marginLeft: -halfFrame,
105
+ marginTop: -halfFrame,
106
+ },
107
+ ]}
108
+ >
109
+ <Corner position="topLeft" color={cornerColor} />
110
+ <Corner position="topRight" color={cornerColor} />
111
+ <Corner position="bottomLeft" color={cornerColor} />
112
+ <Corner position="bottomRight" color={cornerColor} />
113
+ </View>
114
+ </>
115
+ );
116
+ }
117
+
118
+ function Corner({
119
+ position,
120
+ color,
121
+ }: {
122
+ position: 'topLeft' | 'topRight' | 'bottomLeft' | 'bottomRight';
123
+ color: string;
124
+ }) {
125
+ const styleMap = {
126
+ topLeft: styles.cornerTopLeft,
127
+ topRight: styles.cornerTopRight,
128
+ bottomLeft: styles.cornerBottomLeft,
129
+ bottomRight: styles.cornerBottomRight,
130
+ };
131
+
132
+ return <View style={[styleMap[position], { borderColor: color }]} />;
133
+ }
134
+
135
+ const styles = StyleSheet.create({
136
+ actionContainer: {
137
+ ...StyleSheet.absoluteFillObject,
138
+ zIndex: 100,
139
+ },
140
+ actionButton: {
141
+ position: 'absolute',
142
+ zIndex: 101,
143
+ paddingHorizontal: 12,
144
+ paddingVertical: 8,
145
+ borderRadius: 999,
146
+ backgroundColor: 'rgba(0, 0, 0, 0.45)',
147
+ },
148
+ backButton: {
149
+ left: 24,
150
+ },
151
+ manualButton: {
152
+ right: 24,
153
+ },
154
+ flashButton: {
155
+ left: 24,
156
+ },
157
+ actionText: {
158
+ color: 'white',
159
+ fontSize: 14,
160
+ fontWeight: '700',
161
+ },
162
+ centerContent: {
163
+ position: 'absolute',
164
+ top: 40,
165
+ left: 0,
166
+ right: 0,
167
+ alignItems: 'center',
168
+ zIndex: 101,
169
+ },
170
+ viewFinderOverlay: {
171
+ position: 'absolute',
172
+ top: '50%',
173
+ left: '50%',
174
+ borderColor: 'transparent',
175
+ },
176
+ cornerTopLeft: {
177
+ position: 'absolute',
178
+ top: 0,
179
+ left: 0,
180
+ width: 20,
181
+ height: 20,
182
+ borderTopWidth: 2,
183
+ borderLeftWidth: 2,
184
+ },
185
+ cornerTopRight: {
186
+ position: 'absolute',
187
+ top: 0,
188
+ right: 0,
189
+ width: 20,
190
+ height: 20,
191
+ borderTopWidth: 2,
192
+ borderRightWidth: 2,
193
+ },
194
+ cornerBottomLeft: {
195
+ position: 'absolute',
196
+ bottom: 0,
197
+ left: 0,
198
+ width: 20,
199
+ height: 20,
200
+ borderBottomWidth: 2,
201
+ borderLeftWidth: 2,
202
+ },
203
+ cornerBottomRight: {
204
+ position: 'absolute',
205
+ bottom: 0,
206
+ right: 0,
207
+ width: 20,
208
+ height: 20,
209
+ borderBottomWidth: 2,
210
+ borderRightWidth: 2,
211
+ },
212
+ });
213
+
214
+ export default ScannerChromeOverlay;
@@ -32,7 +32,7 @@ export function TextVinPrompt({
32
32
  onDismiss,
33
33
  title = 'VIN detected',
34
34
  subtitle,
35
- buttonLabel = 'Use VIN',
35
+ buttonLabel = 'Book It',
36
36
  buttonColor = '#0A84FF',
37
37
  }: TextVinPromptProps) {
38
38
  if (!visible) return null;
@@ -52,8 +52,11 @@ export function TextVinPrompt({
52
52
  style={styles.list}
53
53
  contentContainerStyle={styles.listContent}
54
54
  >
55
- {candidates.map((candidate) => (
56
- <View key={candidate.value} style={styles.row}>
55
+ {candidates.map((candidate, index) => (
56
+ <View
57
+ key={`${candidate.value}-${index}`}
58
+ style={[styles.row, index > 0 && styles.rowBorder]}
59
+ >
57
60
  <Text style={styles.vin} numberOfLines={1}>
58
61
  {candidate.value}
59
62
  </Text>
@@ -81,7 +84,7 @@ export function TextVinPrompt({
81
84
  const styles = StyleSheet.create({
82
85
  backdrop: {
83
86
  flex: 1,
84
- backgroundColor: 'rgba(0,0,0,0.55)',
87
+ backgroundColor: 'rgba(0,0,0,0.4)',
85
88
  alignItems: 'center',
86
89
  justifyContent: 'center',
87
90
  padding: 16,
@@ -89,38 +92,50 @@ const styles = StyleSheet.create({
89
92
  sheet: {
90
93
  width: '100%',
91
94
  maxWidth: 420,
92
- backgroundColor: '#0D0D0D',
93
- borderRadius: 16,
94
- padding: 16,
95
+ backgroundColor: 'white',
96
+ borderRadius: 20,
97
+ paddingVertical: 24,
98
+ paddingHorizontal: 16,
99
+ shadowColor: '#000',
100
+ shadowOffset: { width: 0, height: 2 },
101
+ shadowOpacity: 0.25,
102
+ shadowRadius: 4,
103
+ elevation: 5,
95
104
  },
96
105
  title: {
97
- color: 'white',
106
+ color: '#111827',
98
107
  fontSize: 18,
99
108
  fontWeight: '700',
100
- marginBottom: 12,
109
+ marginBottom: 8,
101
110
  },
102
111
  subtitle: {
103
- color: '#D1D5DB',
112
+ color: '#4B5563',
104
113
  fontSize: 14,
105
- marginBottom: 8,
114
+ marginBottom: 12,
106
115
  },
107
116
  list: {
108
117
  maxHeight: 220,
109
118
  },
110
119
  listContent: {
111
- gap: 12,
120
+ paddingBottom: 4,
112
121
  },
113
122
  row: {
114
123
  flexDirection: 'row',
115
124
  alignItems: 'center',
116
125
  justifyContent: 'space-between',
117
- gap: 12,
126
+ paddingVertical: 10,
127
+ },
128
+ rowBorder: {
129
+ borderTopWidth: StyleSheet.hairlineWidth,
130
+ borderTopColor: '#E5E7EB',
118
131
  },
119
132
  vin: {
120
133
  flex: 1,
121
- color: 'white',
134
+ color: '#111827',
122
135
  fontSize: 16,
136
+ fontWeight: '700',
123
137
  letterSpacing: 1,
138
+ marginRight: 12,
124
139
  },
125
140
  button: {
126
141
  paddingHorizontal: 14,
@@ -133,13 +148,13 @@ const styles = StyleSheet.create({
133
148
  fontWeight: '700',
134
149
  },
135
150
  dismiss: {
136
- marginTop: 12,
151
+ marginTop: 8,
137
152
  alignSelf: 'flex-end',
138
153
  paddingHorizontal: 8,
139
154
  paddingVertical: 6,
140
155
  },
141
156
  dismissText: {
142
- color: '#9CA3AF',
157
+ color: '#6B7280',
143
158
  fontSize: 14,
144
159
  },
145
160
  });
package/src/index.tsx CHANGED
@@ -45,7 +45,9 @@ export const Camera = forwardRef(function Camera(
45
45
 
46
46
  const handleDetections = useRunOnJS(
47
47
  (payload: WorkletPayload) => {
48
- const candidates = buildVinCandidates(payload, resolvedOptions);
48
+ const candidates = buildVinCandidates(payload, resolvedOptions).filter(
49
+ (c) => c.confidence >= resolvedOptions.detection.minConfidence
50
+ );
49
51
  const barcodeCandidates = candidates.filter(
50
52
  (c) => c.source === 'barcode'
51
53
  );
@@ -306,8 +308,9 @@ function useTextScanner(
306
308
  }
307
309
  return createTextRecognitionPlugin({
308
310
  language: options.language,
311
+ validationPattern: options.validationPattern,
309
312
  });
310
- }, [options.enabled, options.language]);
313
+ }, [options.enabled, options.language, options.validationPattern]);
311
314
  }
312
315
 
313
316
  export type {
@@ -334,3 +337,4 @@ export { isValidVin } from './vinUtils';
334
337
  export { TextVinPrompt } from './TextVinPrompt';
335
338
  export { PendingVinBanner } from './PendingVinBanner';
336
339
  export { ManualVinInput } from './ManualVinInput';
340
+ export { ScannerChromeOverlay } from './ScannerChromeOverlay';
@@ -40,15 +40,6 @@ const KNOWN_FORMATS: BarcodeFormat[] = [
40
40
  ];
41
41
 
42
42
  type NativeBarcodeOptionMap = Partial<Record<BarcodeFormat, boolean>>;
43
- type SerializableValue =
44
- | string
45
- | number
46
- | boolean
47
- | null
48
- | undefined
49
- | ArrayBuffer
50
- | SerializableValue[]
51
- | { [key: string]: SerializableValue };
52
43
 
53
44
  const toNativeBarcodeOptions = (
54
45
  formats?: ScanBarcodeOptions
@@ -73,36 +64,47 @@ const unwrapPluginFrame = (frame: Frame): Frame => {
73
64
  return candidate.__frame ?? frame;
74
65
  };
75
66
 
76
- const toSerializableValue = (value: unknown): SerializableValue => {
67
+ const toSerializableArgs = (args: BarcodePluginArgs): BarcodePluginArgs => {
77
68
  'worklet';
78
- if (
79
- value == null ||
80
- typeof value === 'string' ||
81
- typeof value === 'number' ||
82
- typeof value === 'boolean'
83
- ) {
84
- return value;
85
- }
86
- if (value instanceof ArrayBuffer) {
87
- return value;
88
- }
89
- if (Array.isArray(value)) {
90
- return value.map((item) => toSerializableValue(item));
69
+ const serialized: BarcodePluginArgs = {};
70
+ const formats: BarcodeFormat[] = [
71
+ 'all',
72
+ 'aztec',
73
+ 'code-128',
74
+ 'code-39',
75
+ 'code-93',
76
+ 'codabar',
77
+ 'data-matrix',
78
+ 'ean-13',
79
+ 'ean-8',
80
+ 'itf',
81
+ 'pdf-417',
82
+ 'qr',
83
+ 'upc-a',
84
+ 'upc-e',
85
+ ];
86
+
87
+ formats.forEach((format) => {
88
+ const value = args[format];
89
+ if (typeof value === 'boolean') {
90
+ serialized[format] = value;
91
+ }
92
+ });
93
+
94
+ if (args.orientation) {
95
+ serialized.orientation = args.orientation;
91
96
  }
92
- if (typeof value === 'object') {
93
- const record = value as Record<string, unknown>;
94
- const output: { [key: string]: SerializableValue } = {};
95
- Object.keys(record).forEach((key) => {
96
- output[key] = toSerializableValue(record[key]);
97
- });
98
- return output;
97
+
98
+ if (args.scanRegion) {
99
+ serialized.scanRegion = {
100
+ x: args.scanRegion.x,
101
+ y: args.scanRegion.y,
102
+ width: args.scanRegion.width,
103
+ height: args.scanRegion.height,
104
+ };
99
105
  }
100
- return undefined;
101
- };
102
106
 
103
- const toSerializableArgs = (args: BarcodePluginArgs): BarcodePluginArgs => {
104
- 'worklet';
105
- return toSerializableValue(args) as BarcodePluginArgs;
107
+ return serialized;
106
108
  };
107
109
 
108
110
  const toFloat32Array = (input: unknown): Float32Array | undefined => {
@@ -214,11 +216,15 @@ export function createBarcodeScannerPlugin(
214
216
  overrides || orientation
215
217
  ? mergeArgs(baseOptions, overrides, orientation ?? null)
216
218
  : baseOptions;
217
- const result = plugin.call(
218
- unwrapPluginFrame(frame),
219
- toSerializableArgs(args)
220
- );
221
- return normalizeBarcodeDetections(result);
219
+ try {
220
+ const result = plugin.call(
221
+ unwrapPluginFrame(frame),
222
+ toSerializableArgs(args)
223
+ );
224
+ return normalizeBarcodeDetections(result);
225
+ } catch {
226
+ return [];
227
+ }
222
228
  },
223
229
  };
224
230
  }
package/src/scanText.ts CHANGED
@@ -12,16 +12,6 @@ import type {
12
12
  TextRecognitionPlugin,
13
13
  } from './types';
14
14
 
15
- type SerializableValue =
16
- | string
17
- | number
18
- | boolean
19
- | null
20
- | undefined
21
- | ArrayBuffer
22
- | SerializableValue[]
23
- | { [key: string]: SerializableValue };
24
-
25
15
  const LINKING_ERROR: string =
26
16
  `The package '@mleonard9/vin-scanner' doesn't seem to be linked. Make sure: \n\n` +
27
17
  Platform.select({ ios: "- You have run 'pod install'\n", default: '' }) +
@@ -36,42 +26,37 @@ const unwrapPluginFrame = (frame: Frame): Frame => {
36
26
  return candidate.__frame ?? frame;
37
27
  };
38
28
 
39
- const toSerializableValue = (value: unknown): SerializableValue => {
40
- 'worklet';
41
- if (
42
- value == null ||
43
- typeof value === 'string' ||
44
- typeof value === 'number' ||
45
- typeof value === 'boolean'
46
- ) {
47
- return value;
48
- }
49
- if (value instanceof ArrayBuffer) {
50
- return value;
51
- }
52
- if (Array.isArray(value)) {
53
- return value.map((item) => toSerializableValue(item));
54
- }
55
- if (typeof value === 'object') {
56
- const record = value as Record<string, unknown>;
57
- const output: { [key: string]: SerializableValue } = {};
58
- Object.keys(record).forEach((key) => {
59
- output[key] = toSerializableValue(record[key]);
60
- });
61
- return output;
62
- }
63
- return undefined;
64
- };
65
-
66
29
  const toSerializableArgs = (
67
30
  args: TextRecognitionOptions & {
68
31
  orientation?: FrameOrientation;
69
32
  }
70
- ) => {
33
+ ): TextRecognitionOptions & {
34
+ orientation?: FrameOrientation;
35
+ } => {
71
36
  'worklet';
72
- return toSerializableValue(args) as TextRecognitionOptions & {
37
+ const serialized: TextRecognitionOptions & {
73
38
  orientation?: FrameOrientation;
74
- };
39
+ } = {};
40
+
41
+ if (args.language) {
42
+ serialized.language = args.language;
43
+ }
44
+ if (args.validationPattern) {
45
+ serialized.validationPattern = args.validationPattern;
46
+ }
47
+ if (args.orientation) {
48
+ serialized.orientation = args.orientation;
49
+ }
50
+ if (args.scanRegion) {
51
+ serialized.scanRegion = {
52
+ x: args.scanRegion.x,
53
+ y: args.scanRegion.y,
54
+ width: args.scanRegion.width,
55
+ height: args.scanRegion.height,
56
+ };
57
+ }
58
+
59
+ return serialized;
75
60
  };
76
61
 
77
62
  const toFloat32Array = (input: unknown): Float32Array | undefined => {
@@ -194,11 +179,15 @@ export function createTextRecognitionPlugin(
194
179
  overrides || orientation
195
180
  ? mergeTextArgs(options, overrides, orientation ?? null)
196
181
  : options;
197
- const result = plugin.call(
198
- unwrapPluginFrame(frame),
199
- toSerializableArgs(args)
200
- );
201
- return normalizeTextDetections(result);
182
+ try {
183
+ const result = plugin.call(
184
+ unwrapPluginFrame(frame),
185
+ toSerializableArgs(args)
186
+ );
187
+ return normalizeTextDetections(result);
188
+ } catch {
189
+ return [];
190
+ }
202
191
  },
203
192
  };
204
193
  }
package/src/types.ts CHANGED
@@ -152,6 +152,12 @@ export type BarcodeScannerOptions = {
152
152
  export type TextScannerOptions = {
153
153
  enabled?: boolean;
154
154
  language?: TextRecognitionLanguage;
155
+ /**
156
+ * Optional regex pattern forwarded to the native text recognizer to reduce
157
+ * bridge traffic before VIN validation runs in JS.
158
+ * Default: `[A-Z0-9]{10,}`
159
+ */
160
+ validationPattern?: string;
155
161
  /**
156
162
  * When true, text VINs are surfaced via `onTextPending` and must be
157
163
  * confirmed manually; barcode VINs still emit immediately.
@@ -212,7 +218,7 @@ export type DetectionOptions = {
212
218
  /**
213
219
  * Enable intelligent frame quality checks to skip blurry or dark frames.
214
220
  * This improves accuracy by only processing high-quality frames.
215
- * Note: currently unused; retained for backward compatibility.
221
+ * Set to `false` to disable the luma/sharpness gates entirely.
216
222
  */
217
223
  enableFrameQualityCheck?: boolean;
218
224
  /**
@@ -227,7 +233,7 @@ export type DetectionOptions = {
227
233
  minSharpness?: number;
228
234
  /**
229
235
  * Minimum candidate confidence required before emitting.
230
- * Default: 0.6
236
+ * Default: 0 (checksum validity is the primary acceptance gate).
231
237
  */
232
238
  minConfidence?: number;
233
239
  /**
@@ -23,6 +23,7 @@ export function useVinScanner(options?: VinScannerOptions) {
23
23
  >([]);
24
24
  const pendingTextTimestampRef = useRef<number | null>(null);
25
25
  const pendingTextRef = useRef<VinCandidate[]>([]);
26
+ const pendingTextKeyRef = useRef('');
26
27
  const pendingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
27
28
  const hapticsEnabled = options?.haptics ?? true;
28
29
  const sessionSeen = useRef<Set<string>>(new Set());
@@ -42,7 +43,7 @@ export function useVinScanner(options?: VinScannerOptions) {
42
43
  }
43
44
  return createTextRecognitionPlugin({
44
45
  language: resolvedOptions.text.language,
45
- validationPattern: '[A-HJ-NPR-Z0-9]{10,}',
46
+ validationPattern: resolvedOptions.text.validationPattern,
46
47
  });
47
48
  }, [resolvedOptions.text]);
48
49
 
@@ -57,6 +58,16 @@ export function useVinScanner(options?: VinScannerOptions) {
57
58
 
58
59
  const emitTextPending = useRunOnJS(
59
60
  (pending: VinCandidate[], ts: number) => {
61
+ const pendingKey = pending
62
+ .map((candidate) => candidate.value)
63
+ .sort()
64
+ .join('|');
65
+
66
+ if (pendingKey.length === 0 || pendingKey === pendingTextKeyRef.current) {
67
+ return;
68
+ }
69
+
70
+ pendingTextKeyRef.current = pendingKey;
60
71
  pendingTextTimestampRef.current = ts;
61
72
  pendingTextRef.current = pending;
62
73
  setPendingTextCandidates(pending);
@@ -66,6 +77,7 @@ export function useVinScanner(options?: VinScannerOptions) {
66
77
  const ttl = resolvedOptions.text.pendingTtlMs;
67
78
  if (ttl > 0) {
68
79
  pendingTimerRef.current = setTimeout(() => {
80
+ pendingTextKeyRef.current = '';
69
81
  pendingTextTimestampRef.current = null;
70
82
  pendingTextRef.current = [];
71
83
  setPendingTextCandidates([]);
@@ -227,7 +239,7 @@ export function useVinScanner(options?: VinScannerOptions) {
227
239
  noBarcodeFrameCount.current = 0;
228
240
  useBarcodeFallback.current = false;
229
241
  }
230
- const firstCandidate = pickFirstCandidate(candidates, resolvedOptions);
242
+ const firstCandidate = pickFirstCandidate(filtered, resolvedOptions);
231
243
 
232
244
  const t3 = Date.now();
233
245
 
@@ -236,7 +248,7 @@ export function useVinScanner(options?: VinScannerOptions) {
236
248
  const event: VinScannerEvent = {
237
249
  timestamp: payload.timestamp,
238
250
  duration,
239
- candidates,
251
+ candidates: filtered,
240
252
  firstCandidate,
241
253
  raw: {
242
254
  barcodes: payload.barcodes,
@@ -300,7 +312,7 @@ export function useVinScanner(options?: VinScannerOptions) {
300
312
  lastEmittedVin.current = firstCandidate.value;
301
313
  lastEmitTimestamp.current = now;
302
314
  sessionSeen.current.add(firstCandidate.value);
303
- emitResult(candidates, event);
315
+ emitResult(filtered, event);
304
316
  }
305
317
  }
306
318
  },
@@ -321,6 +333,7 @@ export function useVinScanner(options?: VinScannerOptions) {
321
333
  clearTimeout(pendingTimerRef.current);
322
334
  pendingTimerRef.current = null;
323
335
  }
336
+ pendingTextKeyRef.current = '';
324
337
  pendingTextTimestampRef.current = null;
325
338
  pendingTextRef.current = [];
326
339
  setPendingTextCandidates([]);
@@ -334,6 +347,9 @@ export function useVinScanner(options?: VinScannerOptions) {
334
347
  };
335
348
 
336
349
  emitResult(pending, event);
350
+ if (selected?.value) {
351
+ sessionSeen.current.add(selected.value);
352
+ }
337
353
  lastEmittedVin.current = selected?.value ?? null;
338
354
  lastEmitTimestamp.current = Date.now();
339
355
  },