@unif/react-native-hms-scan 0.1.0
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/LICENSE +21 -0
- package/README.md +149 -0
- package/ReactNativeHmsScan.podspec +30 -0
- package/android/build.gradle +79 -0
- package/android/consumer-rules.pro +13 -0
- package/android/src/main/AndroidManifest.xml +23 -0
- package/android/src/main/java/com/unif/reactnativehmsscan/HmsScanModule.kt +192 -0
- package/android/src/main/java/com/unif/reactnativehmsscan/HmsScanResultMapper.kt +145 -0
- package/android/src/main/java/com/unif/reactnativehmsscan/HmsScanView.kt +257 -0
- package/android/src/main/java/com/unif/reactnativehmsscan/HmsScanViewManager.kt +66 -0
- package/android/src/main/java/com/unif/reactnativehmsscan/ReactNativeHmsScanPackage.kt +49 -0
- package/ios/HmsScanModule.h +20 -0
- package/ios/HmsScanModule.mm +161 -0
- package/ios/HmsScanResultMapper.h +45 -0
- package/ios/HmsScanResultMapper.mm +297 -0
- package/ios/HmsScanView.h +24 -0
- package/ios/HmsScanView.mm +322 -0
- package/lib/module/HmsScanView.js +43 -0
- package/lib/module/HmsScanView.js.map +1 -0
- package/lib/module/HmsScanViewNativeComponent.ts +44 -0
- package/lib/module/NativeHmsScan.js +11 -0
- package/lib/module/NativeHmsScan.js.map +1 -0
- package/lib/module/Scanner/DeniedOverlay.js +60 -0
- package/lib/module/Scanner/DeniedOverlay.js.map +1 -0
- package/lib/module/Scanner/ResultFail.js +79 -0
- package/lib/module/Scanner/ResultFail.js.map +1 -0
- package/lib/module/Scanner/ResultFocus.js +174 -0
- package/lib/module/Scanner/ResultFocus.js.map +1 -0
- package/lib/module/Scanner/ScanHint.js +44 -0
- package/lib/module/Scanner/ScanHint.js.map +1 -0
- package/lib/module/Scanner/ScanToolbar.js +103 -0
- package/lib/module/Scanner/ScanToolbar.js.map +1 -0
- package/lib/module/Scanner/ScanTopBar.js +87 -0
- package/lib/module/Scanner/ScanTopBar.js.map +1 -0
- package/lib/module/Scanner/Scanner.js +184 -0
- package/lib/module/Scanner/Scanner.js.map +1 -0
- package/lib/module/Scanner/Viewfinder.js +159 -0
- package/lib/module/Scanner/Viewfinder.js.map +1 -0
- package/lib/module/Scanner/scanChrome.js +23 -0
- package/lib/module/Scanner/scanChrome.js.map +1 -0
- package/lib/module/decodeImage.js +32 -0
- package/lib/module/decodeImage.js.map +1 -0
- package/lib/module/format.js +66 -0
- package/lib/module/format.js.map +1 -0
- package/lib/module/index.js +21 -0
- package/lib/module/index.js.map +1 -0
- package/lib/module/mock.js +30 -0
- package/lib/module/mock.js.map +1 -0
- package/lib/module/package.json +1 -0
- package/lib/module/permissions.js +28 -0
- package/lib/module/permissions.js.map +1 -0
- package/lib/module/types.js +46 -0
- package/lib/module/types.js.map +1 -0
- package/lib/typescript/jest.setup.d.ts +1 -0
- package/lib/typescript/jest.setup.d.ts.map +1 -0
- package/lib/typescript/package.json +1 -0
- package/lib/typescript/src/HmsScanView.d.ts +36 -0
- package/lib/typescript/src/HmsScanView.d.ts.map +1 -0
- package/lib/typescript/src/HmsScanViewNativeComponent.d.ts +32 -0
- package/lib/typescript/src/HmsScanViewNativeComponent.d.ts.map +1 -0
- package/lib/typescript/src/NativeHmsScan.d.ts +17 -0
- package/lib/typescript/src/NativeHmsScan.d.ts.map +1 -0
- package/lib/typescript/src/Scanner/DeniedOverlay.d.ts +7 -0
- package/lib/typescript/src/Scanner/DeniedOverlay.d.ts.map +1 -0
- package/lib/typescript/src/Scanner/ResultFail.d.ts +7 -0
- package/lib/typescript/src/Scanner/ResultFail.d.ts.map +1 -0
- package/lib/typescript/src/Scanner/ResultFocus.d.ts +12 -0
- package/lib/typescript/src/Scanner/ResultFocus.d.ts.map +1 -0
- package/lib/typescript/src/Scanner/ScanHint.d.ts +8 -0
- package/lib/typescript/src/Scanner/ScanHint.d.ts.map +1 -0
- package/lib/typescript/src/Scanner/ScanToolbar.d.ts +9 -0
- package/lib/typescript/src/Scanner/ScanToolbar.d.ts.map +1 -0
- package/lib/typescript/src/Scanner/ScanTopBar.d.ts +10 -0
- package/lib/typescript/src/Scanner/ScanTopBar.d.ts.map +1 -0
- package/lib/typescript/src/Scanner/Scanner.d.ts +34 -0
- package/lib/typescript/src/Scanner/Scanner.d.ts.map +1 -0
- package/lib/typescript/src/Scanner/Viewfinder.d.ts +11 -0
- package/lib/typescript/src/Scanner/Viewfinder.d.ts.map +1 -0
- package/lib/typescript/src/Scanner/scanChrome.d.ts +19 -0
- package/lib/typescript/src/Scanner/scanChrome.d.ts.map +1 -0
- package/lib/typescript/src/decodeImage.d.ts +12 -0
- package/lib/typescript/src/decodeImage.d.ts.map +1 -0
- package/lib/typescript/src/format.d.ts +13 -0
- package/lib/typescript/src/format.d.ts.map +1 -0
- package/lib/typescript/src/index.d.ts +10 -0
- package/lib/typescript/src/index.d.ts.map +1 -0
- package/lib/typescript/src/mock.d.ts +12 -0
- package/lib/typescript/src/mock.d.ts.map +1 -0
- package/lib/typescript/src/permissions.d.ts +9 -0
- package/lib/typescript/src/permissions.d.ts.map +1 -0
- package/lib/typescript/src/types.d.ts +62 -0
- package/lib/typescript/src/types.d.ts.map +1 -0
- package/package.json +174 -0
- package/src/HmsScanView.tsx +87 -0
- package/src/HmsScanViewNativeComponent.ts +44 -0
- package/src/NativeHmsScan.ts +24 -0
- package/src/Scanner/DeniedOverlay.tsx +59 -0
- package/src/Scanner/ResultFail.tsx +71 -0
- package/src/Scanner/ResultFocus.tsx +118 -0
- package/src/Scanner/ScanHint.tsx +38 -0
- package/src/Scanner/ScanToolbar.tsx +93 -0
- package/src/Scanner/ScanTopBar.tsx +74 -0
- package/src/Scanner/Scanner.tsx +238 -0
- package/src/Scanner/Viewfinder.tsx +115 -0
- package/src/Scanner/scanChrome.ts +20 -0
- package/src/decodeImage.ts +36 -0
- package/src/format.ts +93 -0
- package/src/index.tsx +35 -0
- package/src/mock.ts +43 -0
- package/src/permissions.ts +27 -0
- package/src/types.ts +138 -0
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { forwardRef, type ComponentRef } from 'react';
|
|
2
|
+
import type { ViewProps } from 'react-native';
|
|
3
|
+
import NativeHmsScanView from './HmsScanViewNativeComponent';
|
|
4
|
+
import { formatsToCsv, parseResultsJson } from './format';
|
|
5
|
+
import type { BarcodeFormat, ScanResult } from './types';
|
|
6
|
+
|
|
7
|
+
/** 暗光提示 / 手电状态。 */
|
|
8
|
+
export interface TorchStatus {
|
|
9
|
+
/** 环境暗到建议显示手电按钮(仅 Android 上报)。 */
|
|
10
|
+
available: boolean;
|
|
11
|
+
/** 手电当前是否点亮。 */
|
|
12
|
+
on: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface HmsScanViewProps extends ViewProps {
|
|
16
|
+
/** 限定码制;不传 = 全部。 */
|
|
17
|
+
formats?: BarcodeFormat[];
|
|
18
|
+
/** 连续扫码,默认 true。 */
|
|
19
|
+
continuous?: boolean;
|
|
20
|
+
/** 暂停/恢复扫码,默认 false。 */
|
|
21
|
+
paused?: boolean;
|
|
22
|
+
/** 手电筒,默认 false(iOS 为尽力而为)。 */
|
|
23
|
+
torch?: boolean;
|
|
24
|
+
/** 命中一个或多个码时回调(已解析为强类型)。 */
|
|
25
|
+
onScanResult?: (results: ScanResult[]) => void;
|
|
26
|
+
/** 相机/解码出错时回调。 */
|
|
27
|
+
onScanError?: (error: { code: string; message: string }) => void;
|
|
28
|
+
/** 暗光提示 / 手电状态变化时回调。 */
|
|
29
|
+
onTorchStatus?: (status: TorchStatus) => void;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* 底层 headless 扫码相机组件:只渲染相机预览并发出扫码结果事件。
|
|
34
|
+
* 取景框 / 扫描线 / 手电按钮等 UI 由上层(如 `<Scanner>`)用普通 RN 视图叠加绘制。
|
|
35
|
+
*
|
|
36
|
+
* Android = 华为 RemoteView,iOS = HmsCustomScanViewController(Scan SDK-Plus / ScanKitFrameWork)。
|
|
37
|
+
*/
|
|
38
|
+
export const HmsScanView = forwardRef<
|
|
39
|
+
ComponentRef<typeof NativeHmsScanView>,
|
|
40
|
+
HmsScanViewProps
|
|
41
|
+
>(function HmsScanView(
|
|
42
|
+
{
|
|
43
|
+
formats,
|
|
44
|
+
continuous = true,
|
|
45
|
+
paused = false,
|
|
46
|
+
torch = false,
|
|
47
|
+
onScanResult,
|
|
48
|
+
onScanError,
|
|
49
|
+
onTorchStatus,
|
|
50
|
+
...viewProps
|
|
51
|
+
},
|
|
52
|
+
ref
|
|
53
|
+
) {
|
|
54
|
+
return (
|
|
55
|
+
<NativeHmsScanView
|
|
56
|
+
{...viewProps}
|
|
57
|
+
ref={ref}
|
|
58
|
+
formatsCsv={formatsToCsv(formats)}
|
|
59
|
+
continuous={continuous}
|
|
60
|
+
paused={paused}
|
|
61
|
+
torch={torch}
|
|
62
|
+
onScanResult={
|
|
63
|
+
onScanResult
|
|
64
|
+
? (e) => onScanResult(parseResultsJson(e.nativeEvent.resultsJson))
|
|
65
|
+
: undefined
|
|
66
|
+
}
|
|
67
|
+
onScanError={
|
|
68
|
+
onScanError
|
|
69
|
+
? (e) =>
|
|
70
|
+
onScanError({
|
|
71
|
+
code: e.nativeEvent.code,
|
|
72
|
+
message: e.nativeEvent.message,
|
|
73
|
+
})
|
|
74
|
+
: undefined
|
|
75
|
+
}
|
|
76
|
+
onTorchStatus={
|
|
77
|
+
onTorchStatus
|
|
78
|
+
? (e) =>
|
|
79
|
+
onTorchStatus({
|
|
80
|
+
available: e.nativeEvent.available,
|
|
81
|
+
on: e.nativeEvent.on,
|
|
82
|
+
})
|
|
83
|
+
: undefined
|
|
84
|
+
}
|
|
85
|
+
/>
|
|
86
|
+
);
|
|
87
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { ViewProps } from 'react-native';
|
|
2
|
+
import type {
|
|
3
|
+
DirectEventHandler,
|
|
4
|
+
WithDefault,
|
|
5
|
+
} from 'react-native/Libraries/Types/CodegenTypes';
|
|
6
|
+
import codegenNativeComponent from 'react-native/Libraries/Utilities/codegenNativeComponent';
|
|
7
|
+
|
|
8
|
+
// RN codegen Fabric 组件规范(注册名 "HmsScanView")。
|
|
9
|
+
// 事件 payload 都用基础类型 / 单字符串,绕开 codegen 对复杂事件结构的限制:
|
|
10
|
+
// 扫码结果以 JSON 字符串回传(由 src/HmsScanView.tsx 解析成 ScanResult[])。
|
|
11
|
+
|
|
12
|
+
type ScanResultEvent = Readonly<{
|
|
13
|
+
/** JSON 编码的 ScanResult[]。 */
|
|
14
|
+
resultsJson: string;
|
|
15
|
+
}>;
|
|
16
|
+
|
|
17
|
+
type ScanErrorEvent = Readonly<{
|
|
18
|
+
code: string;
|
|
19
|
+
message: string;
|
|
20
|
+
}>;
|
|
21
|
+
|
|
22
|
+
type TorchStatusEvent = Readonly<{
|
|
23
|
+
/** 环境是否暗到建议显示手电按钮(Android OnLightVisibleCallBack)。 */
|
|
24
|
+
available: boolean;
|
|
25
|
+
/** 手电当前是否点亮。 */
|
|
26
|
+
on: boolean;
|
|
27
|
+
}>;
|
|
28
|
+
|
|
29
|
+
export interface NativeProps extends ViewProps {
|
|
30
|
+
/** 限定码制(逗号分隔的 BarcodeFormat),空串 = 全部。 */
|
|
31
|
+
formatsCsv?: WithDefault<string, ''>;
|
|
32
|
+
/** 连续扫码(命中后继续,不自动停)。 */
|
|
33
|
+
continuous?: WithDefault<boolean, true>;
|
|
34
|
+
/** 暂停/恢复扫码(命中后想停在结果卡时用)。 */
|
|
35
|
+
paused?: WithDefault<boolean, false>;
|
|
36
|
+
/** 手电筒(Android 直接控制;iOS 走 AVCaptureDevice 尽力补)。 */
|
|
37
|
+
torch?: WithDefault<boolean, false>;
|
|
38
|
+
|
|
39
|
+
onScanResult?: DirectEventHandler<ScanResultEvent>;
|
|
40
|
+
onScanError?: DirectEventHandler<ScanErrorEvent>;
|
|
41
|
+
onTorchStatus?: DirectEventHandler<TorchStatusEvent>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export default codegenNativeComponent<NativeProps>('HmsScanView');
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { TurboModule } from 'react-native';
|
|
2
|
+
import { TurboModuleRegistry } from 'react-native';
|
|
3
|
+
|
|
4
|
+
// RN codegen 规范(宿主 App build 时生成 NativeHmsScanSpec)。
|
|
5
|
+
// 注:为规避 codegen 对"对象数组 / 字符串字面量联合"的类型限制,
|
|
6
|
+
// decodeImage 返回 JSON 编码的 ScanResult[](由 src/format.ts 解析回强类型),
|
|
7
|
+
// 权限状态返回字符串(由 src/permissions.ts 归一)。
|
|
8
|
+
export interface Spec extends TurboModule {
|
|
9
|
+
/**
|
|
10
|
+
* 解码本地图片里的条码 / 二维码。
|
|
11
|
+
* @param uri 本地图片:file:// / content:// / ph:// / 绝对路径(不下载远程 URL)。
|
|
12
|
+
* @param formatsCsv 限定码制(逗号分隔的 BarcodeFormat),空串 = 全部。
|
|
13
|
+
* @returns JSON 编码的 ScanResult[](可能为空数组)。
|
|
14
|
+
*/
|
|
15
|
+
decodeImage(uri: string, formatsCsv: string): Promise<string>;
|
|
16
|
+
|
|
17
|
+
/** 当前相机权限:'granted' | 'denied' | 'blocked' | 'undetermined'。 */
|
|
18
|
+
getCameraPermissionStatus(): Promise<string>;
|
|
19
|
+
|
|
20
|
+
/** 发起相机权限请求,返回请求后的状态字符串。 */
|
|
21
|
+
requestCameraPermission(): Promise<string>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export default TurboModuleRegistry.getEnforcing<Spec>('HmsScan');
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { StyleSheet, View } from 'react-native';
|
|
2
|
+
import {
|
|
3
|
+
Button,
|
|
4
|
+
Empty,
|
|
5
|
+
useThemedStyles,
|
|
6
|
+
r,
|
|
7
|
+
type ColorTokens,
|
|
8
|
+
} from '@unif/react-native-design';
|
|
9
|
+
|
|
10
|
+
interface DeniedOverlayProps {
|
|
11
|
+
onClose?: () => void;
|
|
12
|
+
onSettings: () => void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// 相机权限未授权态:无相机可显示,故用主题背景的全屏空态(与 react-native-camera 的 NoPermission 一致)。
|
|
16
|
+
export function DeniedOverlay({ onClose, onSettings }: DeniedOverlayProps) {
|
|
17
|
+
const s = useThemedStyles(makeStyles);
|
|
18
|
+
return (
|
|
19
|
+
<View style={s.overlay}>
|
|
20
|
+
<Empty
|
|
21
|
+
icon="permission-denied"
|
|
22
|
+
title="需要相机权限"
|
|
23
|
+
desc="开启相机后,才能扫描商品条码与门店二维码。请前往系统设置授权。"
|
|
24
|
+
/>
|
|
25
|
+
<View style={styles.row}>
|
|
26
|
+
{onClose && <Button label="取消" variant="ghost" onPress={onClose} style={styles.btn} />}
|
|
27
|
+
<Button
|
|
28
|
+
label="去设置开启"
|
|
29
|
+
variant="primary"
|
|
30
|
+
leftIcon="settings"
|
|
31
|
+
onPress={onSettings}
|
|
32
|
+
style={styles.btn}
|
|
33
|
+
/>
|
|
34
|
+
</View>
|
|
35
|
+
</View>
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const styles = StyleSheet.create({
|
|
40
|
+
row: { flexDirection: 'row', columnGap: r(12), marginTop: r(8) },
|
|
41
|
+
btn: { flex: 1 },
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const makeStyles = (c: ColorTokens) =>
|
|
45
|
+
StyleSheet.create({
|
|
46
|
+
overlay: {
|
|
47
|
+
position: 'absolute',
|
|
48
|
+
top: 0,
|
|
49
|
+
left: 0,
|
|
50
|
+
right: 0,
|
|
51
|
+
bottom: 0,
|
|
52
|
+
zIndex: 40,
|
|
53
|
+
backgroundColor: c.background,
|
|
54
|
+
alignItems: 'center',
|
|
55
|
+
justifyContent: 'center',
|
|
56
|
+
paddingHorizontal: r(40),
|
|
57
|
+
rowGap: r(8),
|
|
58
|
+
},
|
|
59
|
+
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { useEffect, useRef } from 'react';
|
|
2
|
+
import { Animated, StyleSheet, View } from 'react-native';
|
|
3
|
+
import {
|
|
4
|
+
Button,
|
|
5
|
+
Empty,
|
|
6
|
+
useThemedStyles,
|
|
7
|
+
r,
|
|
8
|
+
type ColorTokens,
|
|
9
|
+
type ShadowTokens,
|
|
10
|
+
} from '@unif/react-native-design';
|
|
11
|
+
|
|
12
|
+
interface ResultFailProps {
|
|
13
|
+
bottomInset: number;
|
|
14
|
+
onRetry: () => void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// 未识别失败的底部弹层:design 的 Empty(error-alert)+ Button(重新扫描)。
|
|
18
|
+
// 聚焦款不加背景遮罩,与浮层确认卡保持一致。
|
|
19
|
+
export function ResultFail({ bottomInset, onRetry }: ResultFailProps) {
|
|
20
|
+
const s = useThemedStyles(makeStyles);
|
|
21
|
+
const anim = useRef(new Animated.Value(0)).current;
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
Animated.timing(anim, { toValue: 1, duration: 320, useNativeDriver: true }).start();
|
|
24
|
+
}, [anim]);
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<Animated.View
|
|
28
|
+
style={[
|
|
29
|
+
s.sheet,
|
|
30
|
+
{ transform: [{ translateY: anim.interpolate({ inputRange: [0, 1], outputRange: [r(320), 0] }) }] },
|
|
31
|
+
]}
|
|
32
|
+
>
|
|
33
|
+
<View style={s.grabber} />
|
|
34
|
+
<View style={[styles.inner, { paddingBottom: bottomInset + r(14) }]}>
|
|
35
|
+
<Empty
|
|
36
|
+
icon="error-alert"
|
|
37
|
+
title="未识别到条码"
|
|
38
|
+
desc="请将条码完整置于框内、保持平整并对准光线后重试。"
|
|
39
|
+
/>
|
|
40
|
+
<Button label="重新扫描" leftIcon="retry" variant="primary" block onPress={onRetry} />
|
|
41
|
+
</View>
|
|
42
|
+
</Animated.View>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const styles = StyleSheet.create({
|
|
47
|
+
inner: { paddingHorizontal: r(18), paddingTop: r(8), rowGap: r(16) },
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
const makeStyles = (c: ColorTokens, shadow: ShadowTokens) =>
|
|
51
|
+
StyleSheet.create({
|
|
52
|
+
sheet: {
|
|
53
|
+
position: 'absolute',
|
|
54
|
+
left: 0,
|
|
55
|
+
right: 0,
|
|
56
|
+
bottom: 0,
|
|
57
|
+
zIndex: 50,
|
|
58
|
+
backgroundColor: c.surface,
|
|
59
|
+
borderTopLeftRadius: r(20),
|
|
60
|
+
borderTopRightRadius: r(20),
|
|
61
|
+
paddingTop: r(10),
|
|
62
|
+
...shadow.floating,
|
|
63
|
+
},
|
|
64
|
+
grabber: {
|
|
65
|
+
width: r(40),
|
|
66
|
+
height: r(4),
|
|
67
|
+
borderRadius: 999,
|
|
68
|
+
backgroundColor: c.surfaceContainerHighest,
|
|
69
|
+
alignSelf: 'center',
|
|
70
|
+
},
|
|
71
|
+
});
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { useEffect, useRef } from 'react';
|
|
2
|
+
import { Animated, StyleSheet, Text, View } from 'react-native';
|
|
3
|
+
import {
|
|
4
|
+
Avatar,
|
|
5
|
+
Button,
|
|
6
|
+
Card,
|
|
7
|
+
StatusDot,
|
|
8
|
+
useColors,
|
|
9
|
+
useThemedStyles,
|
|
10
|
+
r,
|
|
11
|
+
rf,
|
|
12
|
+
fw,
|
|
13
|
+
fontMono,
|
|
14
|
+
} from '@unif/react-native-design';
|
|
15
|
+
import type { ScanProduct } from '../types';
|
|
16
|
+
|
|
17
|
+
interface ResultFocusProps {
|
|
18
|
+
product: ScanProduct;
|
|
19
|
+
/** 识别耗时(ms),用于 "已识别 · 0.6s"。<=0 则只显示 "已识别"。 */
|
|
20
|
+
detectMs: number;
|
|
21
|
+
bottomInset: number;
|
|
22
|
+
onContinue: () => void;
|
|
23
|
+
onConfirm: () => void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// 聚焦款"浮层确认卡":design Card + 2px 主色描边,悬浮在相机之上(无遮罩)。
|
|
27
|
+
export function ResultFocus({
|
|
28
|
+
product,
|
|
29
|
+
detectMs,
|
|
30
|
+
bottomInset,
|
|
31
|
+
onContinue,
|
|
32
|
+
onConfirm,
|
|
33
|
+
}: ResultFocusProps) {
|
|
34
|
+
const c = useColors();
|
|
35
|
+
const s = useThemedStyles(makeStyles);
|
|
36
|
+
const anim = useRef(new Animated.Value(0)).current;
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
Animated.timing(anim, { toValue: 1, duration: 280, useNativeDriver: true }).start();
|
|
39
|
+
}, [anim]);
|
|
40
|
+
|
|
41
|
+
const brandChar = product.brandChar ?? (product.brand ?? product.name).charAt(0);
|
|
42
|
+
const badge = detectMs > 0 ? `已识别 · ${(detectMs / 1000).toFixed(1)}s` : '已识别';
|
|
43
|
+
const caption = product.priceCaption ?? '建议零售';
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<Animated.View
|
|
47
|
+
style={[
|
|
48
|
+
styles.wrap,
|
|
49
|
+
{ bottom: bottomInset + r(18) },
|
|
50
|
+
{
|
|
51
|
+
opacity: anim,
|
|
52
|
+
transform: [{ translateY: anim.interpolate({ inputRange: [0, 1], outputRange: [r(24), 0] }) }],
|
|
53
|
+
},
|
|
54
|
+
]}
|
|
55
|
+
>
|
|
56
|
+
<Card borderColor={c.primary} borderWidth={2} borderRadius={r(16)} padding={r(14)}>
|
|
57
|
+
{/* 头部:成功点 · 已识别 · 条码 */}
|
|
58
|
+
<View style={styles.header}>
|
|
59
|
+
<StatusDot status="done" size={r(20)} />
|
|
60
|
+
<Text style={s.badge}>{badge}</Text>
|
|
61
|
+
<View style={styles.spacer} />
|
|
62
|
+
{!!product.barcode && <Text style={s.barcode}>{product.barcode}</Text>}
|
|
63
|
+
</View>
|
|
64
|
+
|
|
65
|
+
{/* 商品行 */}
|
|
66
|
+
<View style={styles.body}>
|
|
67
|
+
<Avatar label={brandChar} variant="brand" size="xl" />
|
|
68
|
+
<View style={styles.info}>
|
|
69
|
+
<Text style={s.name} numberOfLines={2}>
|
|
70
|
+
{product.brand ? `${product.brand} ` : ''}
|
|
71
|
+
{product.name}
|
|
72
|
+
</Text>
|
|
73
|
+
{!!(product.spec || product.stockShort) && (
|
|
74
|
+
<Text style={s.sub} numberOfLines={1}>
|
|
75
|
+
{[product.spec, product.stockShort ? `库存${product.stockShort}` : null]
|
|
76
|
+
.filter(Boolean)
|
|
77
|
+
.join(' · ')}
|
|
78
|
+
</Text>
|
|
79
|
+
)}
|
|
80
|
+
</View>
|
|
81
|
+
{!!product.price && (
|
|
82
|
+
<View style={styles.priceCol}>
|
|
83
|
+
<Text style={s.price}>{product.price}</Text>
|
|
84
|
+
<Text style={s.caption}>{caption}</Text>
|
|
85
|
+
</View>
|
|
86
|
+
)}
|
|
87
|
+
</View>
|
|
88
|
+
|
|
89
|
+
{/* 操作 */}
|
|
90
|
+
<View style={styles.actions}>
|
|
91
|
+
<Button label="继续扫描" variant="secondary" onPress={onContinue} style={styles.btn} />
|
|
92
|
+
<Button label="确认" variant="primary" onPress={onConfirm} style={styles.btn} />
|
|
93
|
+
</View>
|
|
94
|
+
</Card>
|
|
95
|
+
</Animated.View>
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const styles = StyleSheet.create({
|
|
100
|
+
wrap: { position: 'absolute', left: r(16), right: r(16), zIndex: 50 },
|
|
101
|
+
header: { flexDirection: 'row', alignItems: 'center', columnGap: r(7), marginBottom: r(12) },
|
|
102
|
+
spacer: { flex: 1 },
|
|
103
|
+
body: { flexDirection: 'row', alignItems: 'center', columnGap: r(14), marginBottom: r(14) },
|
|
104
|
+
info: { flex: 1, minWidth: 0 },
|
|
105
|
+
priceCol: { alignItems: 'flex-end' },
|
|
106
|
+
actions: { flexDirection: 'row', columnGap: r(10) },
|
|
107
|
+
btn: { flex: 1 },
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
const makeStyles = (c: ReturnType<typeof useColors>) =>
|
|
111
|
+
StyleSheet.create({
|
|
112
|
+
badge: { fontSize: rf(13), fontWeight: fw.semi, color: c.foreground },
|
|
113
|
+
barcode: { fontSize: rf(12), color: c.foregroundSubtle, fontFamily: fontMono },
|
|
114
|
+
name: { fontSize: rf(16), fontWeight: fw.semi, color: c.foreground, lineHeight: rf(21) },
|
|
115
|
+
sub: { fontSize: rf(12.5), color: c.foregroundMuted, marginTop: r(4) },
|
|
116
|
+
price: { fontSize: rf(24), fontWeight: fw.heavy, color: c.primary, lineHeight: rf(24) },
|
|
117
|
+
caption: { fontSize: rf(11), color: c.foregroundSubtle, marginTop: r(3) },
|
|
118
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { StyleSheet, Text, View } from 'react-native';
|
|
2
|
+
import { Spinner, r, rf, fw } from '@unif/react-native-design';
|
|
3
|
+
import { scanChrome } from './scanChrome';
|
|
4
|
+
|
|
5
|
+
interface ScanHintProps {
|
|
6
|
+
detecting: boolean;
|
|
7
|
+
/** 取景态提示文案。 */
|
|
8
|
+
text: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// 取景框下方提示。识别中显示 design 的 Spinner(白)+ "识别中…"。
|
|
12
|
+
export function ScanHint({ detecting, text }: ScanHintProps) {
|
|
13
|
+
return (
|
|
14
|
+
<View style={styles.row}>
|
|
15
|
+
{detecting && <Spinner size={r(15)} color={scanChrome.white} />}
|
|
16
|
+
<Text style={styles.text}>{detecting ? '识别中…' : text}</Text>
|
|
17
|
+
</View>
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const styles = StyleSheet.create({
|
|
22
|
+
row: {
|
|
23
|
+
marginTop: r(18),
|
|
24
|
+
flexDirection: 'row',
|
|
25
|
+
alignItems: 'center',
|
|
26
|
+
justifyContent: 'center',
|
|
27
|
+
columnGap: r(7),
|
|
28
|
+
},
|
|
29
|
+
text: {
|
|
30
|
+
color: scanChrome.hintText,
|
|
31
|
+
fontSize: rf(13.5),
|
|
32
|
+
fontWeight: fw.medium,
|
|
33
|
+
letterSpacing: 0.2,
|
|
34
|
+
textShadowColor: scanChrome.textShadow,
|
|
35
|
+
textShadowOffset: { width: 0, height: 1 },
|
|
36
|
+
textShadowRadius: 6,
|
|
37
|
+
},
|
|
38
|
+
});
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
|
2
|
+
import { Icon, useColors, r, rf, fw, type IconName } from '@unif/react-native-design';
|
|
3
|
+
import { scanChrome } from './scanChrome';
|
|
4
|
+
|
|
5
|
+
interface ScanToolbarProps {
|
|
6
|
+
flash: boolean;
|
|
7
|
+
bottomInset: number;
|
|
8
|
+
onFlash: () => void;
|
|
9
|
+
onAlbum: () => void;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// 底部工具栏:手电筒 · 相册(按定稿,去掉了搜索 / 输入码)。点亮态用主题主色。
|
|
13
|
+
export function ScanToolbar({ flash, bottomInset, onFlash, onAlbum }: ScanToolbarProps) {
|
|
14
|
+
const c = useColors();
|
|
15
|
+
return (
|
|
16
|
+
<View style={[styles.bar, { bottom: bottomInset + r(50) }]} pointerEvents="box-none">
|
|
17
|
+
<ToolbarItem
|
|
18
|
+
icon={flash ? 'flash-on' : 'flash-off'}
|
|
19
|
+
label={flash ? '已开灯' : '手电筒'}
|
|
20
|
+
activeColor={flash ? c.primary : undefined}
|
|
21
|
+
onPress={onFlash}
|
|
22
|
+
/>
|
|
23
|
+
<ToolbarItem icon="image" label="相册" onPress={onAlbum} />
|
|
24
|
+
</View>
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function ToolbarItem({
|
|
29
|
+
icon,
|
|
30
|
+
label,
|
|
31
|
+
activeColor,
|
|
32
|
+
onPress,
|
|
33
|
+
}: {
|
|
34
|
+
icon: IconName;
|
|
35
|
+
label: string;
|
|
36
|
+
activeColor?: string;
|
|
37
|
+
onPress: () => void;
|
|
38
|
+
}) {
|
|
39
|
+
return (
|
|
40
|
+
<Pressable
|
|
41
|
+
onPress={onPress}
|
|
42
|
+
style={styles.item}
|
|
43
|
+
accessibilityRole="button"
|
|
44
|
+
accessibilityLabel={label}
|
|
45
|
+
>
|
|
46
|
+
{({ pressed }) => (
|
|
47
|
+
<>
|
|
48
|
+
<View
|
|
49
|
+
style={[
|
|
50
|
+
styles.circle,
|
|
51
|
+
activeColor
|
|
52
|
+
? { backgroundColor: activeColor, borderColor: scanChrome.glassBorderActive }
|
|
53
|
+
: { backgroundColor: scanChrome.glassDisc2, borderColor: scanChrome.glassBorder },
|
|
54
|
+
pressed && { opacity: 0.7 },
|
|
55
|
+
]}
|
|
56
|
+
>
|
|
57
|
+
<Icon name={icon} size={r(23)} color={scanChrome.white} />
|
|
58
|
+
</View>
|
|
59
|
+
<Text style={styles.label}>{label}</Text>
|
|
60
|
+
</>
|
|
61
|
+
)}
|
|
62
|
+
</Pressable>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const styles = StyleSheet.create({
|
|
67
|
+
bar: {
|
|
68
|
+
position: 'absolute',
|
|
69
|
+
left: 0,
|
|
70
|
+
right: 0,
|
|
71
|
+
zIndex: 30,
|
|
72
|
+
flexDirection: 'row',
|
|
73
|
+
justifyContent: 'center',
|
|
74
|
+
columnGap: r(40),
|
|
75
|
+
},
|
|
76
|
+
item: { width: r(72), alignItems: 'center', rowGap: r(7) },
|
|
77
|
+
circle: {
|
|
78
|
+
width: r(54),
|
|
79
|
+
height: r(54),
|
|
80
|
+
borderRadius: 999,
|
|
81
|
+
alignItems: 'center',
|
|
82
|
+
justifyContent: 'center',
|
|
83
|
+
borderWidth: StyleSheet.hairlineWidth,
|
|
84
|
+
},
|
|
85
|
+
label: {
|
|
86
|
+
fontSize: rf(12),
|
|
87
|
+
fontWeight: fw.medium,
|
|
88
|
+
color: scanChrome.toolbarLabel,
|
|
89
|
+
textShadowColor: 'rgba(0,0,0,0.4)',
|
|
90
|
+
textShadowOffset: { width: 0, height: 1 },
|
|
91
|
+
textShadowRadius: 4,
|
|
92
|
+
},
|
|
93
|
+
});
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
|
3
|
+
import { Icon, r, rf, fw } from '@unif/react-native-design';
|
|
4
|
+
import { scanChrome } from './scanChrome';
|
|
5
|
+
|
|
6
|
+
interface ScanTopBarProps {
|
|
7
|
+
title: string;
|
|
8
|
+
topInset: number;
|
|
9
|
+
onClose?: () => void;
|
|
10
|
+
trailing?: ReactNode;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// 顶栏:关闭键 · 标题 · 尾部插槽。叠在深色相机预览之上,故文字/图标恒为白(不随主题)。
|
|
14
|
+
// 设计稿用了毛玻璃,RN 无内建模糊,这里以半透明深色底近似(design 的 BlurLayer 需额外原生模糊库)。
|
|
15
|
+
export function ScanTopBar({ title, topInset, onClose, trailing }: ScanTopBarProps) {
|
|
16
|
+
return (
|
|
17
|
+
<View style={[styles.bar, { paddingTop: topInset }]} pointerEvents="box-none">
|
|
18
|
+
<Pressable
|
|
19
|
+
onPress={onClose}
|
|
20
|
+
hitSlop={8}
|
|
21
|
+
accessibilityRole="button"
|
|
22
|
+
accessibilityLabel="关闭"
|
|
23
|
+
style={({ pressed }) => [styles.disc, pressed && styles.pressed]}
|
|
24
|
+
>
|
|
25
|
+
<Icon name="close" size={r(20)} color={scanChrome.white} />
|
|
26
|
+
</Pressable>
|
|
27
|
+
|
|
28
|
+
<Text style={styles.title}>{title}</Text>
|
|
29
|
+
|
|
30
|
+
<View style={styles.trailing}>{trailing}</View>
|
|
31
|
+
</View>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const styles = StyleSheet.create({
|
|
36
|
+
bar: {
|
|
37
|
+
position: 'absolute',
|
|
38
|
+
top: 0,
|
|
39
|
+
left: 0,
|
|
40
|
+
right: 0,
|
|
41
|
+
zIndex: 30,
|
|
42
|
+
paddingHorizontal: r(14),
|
|
43
|
+
paddingBottom: r(8),
|
|
44
|
+
flexDirection: 'row',
|
|
45
|
+
alignItems: 'center',
|
|
46
|
+
justifyContent: 'space-between',
|
|
47
|
+
},
|
|
48
|
+
disc: {
|
|
49
|
+
width: r(38),
|
|
50
|
+
height: r(38),
|
|
51
|
+
borderRadius: 999,
|
|
52
|
+
alignItems: 'center',
|
|
53
|
+
justifyContent: 'center',
|
|
54
|
+
backgroundColor: scanChrome.glassDisc,
|
|
55
|
+
borderWidth: StyleSheet.hairlineWidth,
|
|
56
|
+
borderColor: scanChrome.glassBorder,
|
|
57
|
+
},
|
|
58
|
+
pressed: { opacity: 0.6 },
|
|
59
|
+
title: {
|
|
60
|
+
color: scanChrome.white,
|
|
61
|
+
fontSize: rf(16),
|
|
62
|
+
fontWeight: fw.semi,
|
|
63
|
+
letterSpacing: 0.2,
|
|
64
|
+
textShadowColor: scanChrome.textShadow,
|
|
65
|
+
textShadowOffset: { width: 0, height: 1 },
|
|
66
|
+
textShadowRadius: 6,
|
|
67
|
+
},
|
|
68
|
+
trailing: {
|
|
69
|
+
width: r(38),
|
|
70
|
+
height: r(38),
|
|
71
|
+
alignItems: 'center',
|
|
72
|
+
justifyContent: 'center',
|
|
73
|
+
},
|
|
74
|
+
});
|