diff --git a/lstMobile/app.json b/lstMobile/app.json index 5f55696..6ffdbfc 100644 --- a/lstMobile/app.json +++ b/lstMobile/app.json @@ -15,8 +15,8 @@ "foregroundImage": "./assets/adaptive-icon-white.png", "backgroundColor": "#ffffff" }, - "versionCode": 8, - "minSupportedVersionCode": 4, + "versionCode": 10, + "minSupportedVersionCode": 5, "predictiveBackGestureEnabled": false, "package": "net.alpla.lst.mobile" }, @@ -40,10 +40,11 @@ "image": "./assets/splash.png", "backgroundColor": "#000000" }, - "imageWidth": 200 + "imageWidth": 200 } } - ] + ], + "expo-audio" ], "experiments": { "typedRoutes": true, diff --git a/lstMobile/assets/sounds/bad.wav b/lstMobile/assets/sounds/bad.wav new file mode 100644 index 0000000..3a85707 Binary files /dev/null and b/lstMobile/assets/sounds/bad.wav differ diff --git a/lstMobile/assets/sounds/good.wav b/lstMobile/assets/sounds/good.wav new file mode 100644 index 0000000..24a13d4 Binary files /dev/null and b/lstMobile/assets/sounds/good.wav differ diff --git a/lstMobile/package-lock.json b/lstMobile/package-lock.json index a10f73d..11f9e38 100644 --- a/lstMobile/package-lock.json +++ b/lstMobile/package-lock.json @@ -19,12 +19,16 @@ "babel-preset-expo": "^55.0.18", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "date-fns-tz": "^3.2.0", "expo": "~55.0.15", "expo-application": "~55.0.14", + "expo-audio": "~55.0.14", + "expo-av": "^16.0.8", "expo-constants": "~55.0.14", "expo-device": "~55.0.15", "expo-font": "~55.0.6", "expo-glass-effect": "~55.0.10", + "expo-haptics": "~55.0.14", "expo-image": "~55.0.8", "expo-linking": "~55.0.13", "expo-router": "~55.0.12", @@ -5808,6 +5812,26 @@ "devOptional": true, "license": "MIT" }, + "node_modules/date-fns": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", + "license": "MIT", + "peer": true, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, + "node_modules/date-fns-tz": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/date-fns-tz/-/date-fns-tz-3.2.0.tgz", + "integrity": "sha512-sg8HqoTEulcbbbVXeg84u5UnlsQa8GS5QXMqjjYIhS4abEVVKIUwe0/l/UhrZdKaL/W5eWZNlbTeEIiOXTcsBQ==", + "license": "MIT", + "peerDependencies": { + "date-fns": "^3.0.0 || ^4.0.0" + } + }, "node_modules/dateformat": { "version": "4.6.3", "resolved": "https://registry.npmjs.org/dateformat/-/dateformat-4.6.3.tgz", @@ -7586,6 +7610,35 @@ "react-native": "*" } }, + "node_modules/expo-audio": { + "version": "55.0.14", + "resolved": "https://registry.npmjs.org/expo-audio/-/expo-audio-55.0.14.tgz", + "integrity": "sha512-Biy6ffKXrnKHgcWSVWLKVdWLNhV/pj1JWJeotY6nDR6fVe8mjXQDCvi6EbaSFPdffVHym6UB2siKzWUNSnG+kQ==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "expo-asset": "*", + "react": "*", + "react-native": "*" + } + }, + "node_modules/expo-av": { + "version": "16.0.8", + "resolved": "https://registry.npmjs.org/expo-av/-/expo-av-16.0.8.tgz", + "integrity": "sha512-cmVPftGR/ca7XBgs7R6ky36lF3OC0/MM/lpgX/yXqfv0jASTsh7AYX9JxHCwFmF+Z6JEB1vne9FDx4GiLcGreQ==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*", + "react-native-web": "*" + }, + "peerDependenciesMeta": { + "react-native-web": { + "optional": true + } + } + }, "node_modules/expo-constants": { "version": "55.0.14", "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-55.0.14.tgz", @@ -7647,6 +7700,15 @@ "react-native": "*" } }, + "node_modules/expo-haptics": { + "version": "55.0.14", + "resolved": "https://registry.npmjs.org/expo-haptics/-/expo-haptics-55.0.14.tgz", + "integrity": "sha512-KjDItBsA9mi1f5nRwf8g1wOdfEcLHwvEdt5Jl1sMCDETR/homcGOl+F3QIiPOl/PRlbGVieQsjTtF4DGtHOj6g==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-image": { "version": "55.0.8", "resolved": "https://registry.npmjs.org/expo-image/-/expo-image-55.0.8.tgz", diff --git a/lstMobile/package.json b/lstMobile/package.json index da5d614..bcd735a 100644 --- a/lstMobile/package.json +++ b/lstMobile/package.json @@ -9,8 +9,9 @@ "ios": "expo run:ios", "web": "expo start --web", "lint": "expo lint", - "build:apk:clean": "expo prebuild --clean && cd android && gradlew.bat assembleRelease ", - "build:apk": "expo prebuild && cd android && gradlew.bat assembleRelease && cd .. && copy /Y android\\app\\build\\outputs\\apk\\release\\app-release.apk downloads\\mobile\\lst-mobile.apk", + "build:apk:clean": "expo prebuild --clean && cd android && gradlew.bat assembleRelease && npm run copy:apk", + "build:apk": "expo prebuild && cd android && gradlew.bat assembleRelease && npm run copy:apk", + "copy:apk": "cd android && copy /Y app\\build\\outputs\\apk\\release\\app-release.apk ..\\..\\downloads\\mobile\\lst-mobile.apk", "update": "adb install android/app/build/outputs/apk/release/app-release.apk" }, "dependencies": { @@ -25,12 +26,16 @@ "babel-preset-expo": "^55.0.18", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "date-fns-tz": "^3.2.0", "expo": "~55.0.15", "expo-application": "~55.0.14", + "expo-audio": "~55.0.14", + "expo-av": "^16.0.8", "expo-constants": "~55.0.14", "expo-device": "~55.0.15", "expo-font": "~55.0.6", "expo-glass-effect": "~55.0.10", + "expo-haptics": "~55.0.14", "expo-image": "~55.0.8", "expo-linking": "~55.0.13", "expo-router": "~55.0.12", diff --git a/lstMobile/plugins/withZebraScanner.js b/lstMobile/plugins/withZebraScanner.js index fd37ab4..77fd8f6 100644 --- a/lstMobile/plugins/withZebraScanner.js +++ b/lstMobile/plugins/withZebraScanner.js @@ -138,18 +138,12 @@ class ZebraScannerModule( fun ensureProfile() { val profileName = "LST_MOBILE" - // Create profile (safe to call even if it exists) sendCommand( "com.symbol.datawedge.api.CREATE_PROFILE", profileName ) Thread.sleep(500) - // Configure profile - val profileConfig = Bundle().apply { - putString("PROFILE_NAME", profileName) - putString("PROFILE_ENABLED", "true") - putString("CONFIG_MODE", "CREATE_IF_NOT_EXIST") val barcodeConfig = Bundle().apply { putString("PLUGIN_NAME", "BARCODE") @@ -157,8 +151,19 @@ class ZebraScannerModule( val props = Bundle().apply { putString("scanner_input_enabled", "true") + + // Auto-select internal scanner putString("scanner_selection", "auto") - putString("trigger_mode", "2") // 2 = HARD trigger only (recommended) wakes scanner up + putString("scanner_selection_by_identifier", "AUTO") + + // Hardware trigger behavior + putString("hardware_trigger_enabled", "true") + putString("trigger_mode", "2") // 2 = HARD trigger + + // Disable Zebra's loud initial decode feedback + putString("decode_audio_feedback_uri", "") + putString("decode_haptic_feedback", "false") + putString("decode_led_feedback", "false") } putBundle("PARAM_LIST", props) @@ -172,7 +177,7 @@ class ZebraScannerModule( putString("intent_output_enabled", "true") putString("intent_action", scanAction) putString("intent_delivery", "2") // broadcast - putString("intent_use_content_provider", "false") // optional but helps + putString("intent_use_content_provider", "false") } putBundle("PARAM_LIST", props) @@ -189,16 +194,19 @@ class ZebraScannerModule( putBundle("PARAM_LIST", props) } + val profileConfig = Bundle().apply { + putString("PROFILE_NAME", profileName) + putString("PROFILE_ENABLED", "true") + putString("CONFIG_MODE", "CREATE_IF_NOT_EXIST") - putParcelableArrayList( - "PLUGIN_CONFIG", - arrayListOf(barcodeConfig, intentConfig, keystrokeConfig) - ) + putParcelableArrayList( + "PLUGIN_CONFIG", + arrayListOf(barcodeConfig, intentConfig, keystrokeConfig) + ) } sendCommand("com.symbol.datawedge.api.SET_CONFIG", profileConfig) - // Associate with your app val appConfig = Bundle().apply { putString("PACKAGE_NAME", reactContext.packageName) putStringArray("ACTIVITY_LIST", arrayOf("*")) @@ -211,6 +219,12 @@ class ZebraScannerModule( } sendCommand("com.symbol.datawedge.api.SET_CONFIG", associateConfig) + + // Runtime nudge: make sure scanner input is enabled for the active profile + sendCommand( + "com.symbol.datawedge.api.SCANNER_INPUT_PLUGIN", + "ENABLE_PLUGIN" + ) } } `; diff --git a/lstMobile/src/app/(tabs)/_layout.tsx b/lstMobile/src/app/(tabs)/_layout.tsx index 6da0a4c..9714d84 100644 --- a/lstMobile/src/app/(tabs)/_layout.tsx +++ b/lstMobile/src/app/(tabs)/_layout.tsx @@ -34,6 +34,14 @@ export default function TabsLayout() { parseInt(serverPort || "0", 10) >= 50000 ? null : "/(tabs)/logs", }} /> + {/* = 50000 ? null : "/(tabs)/logs", + }} + /> */} ); } diff --git a/lstMobile/src/app/index.tsx b/lstMobile/src/app/index.tsx index 331da3d..8a53119 100644 --- a/lstMobile/src/app/index.tsx +++ b/lstMobile/src/app/index.tsx @@ -1,3 +1,4 @@ +import axios from "axios"; import { Redirect, useRouter } from "expo-router"; import { useEffect, useState } from "react"; import { ActivityIndicator, Text, View } from "react-native"; @@ -11,6 +12,7 @@ export default function Index() { const hasHydrated = useAppStore((s) => s.hasHydrated); const serverPort = useAppStore((s) => s.serverPort); + const serverIp = useAppStore((s) => s.serverIp); const hasValidSetup = useAppStore((s) => s.hasValidSetup); useEffect(() => { @@ -31,6 +33,23 @@ export default function Index() { return; } + // checking for lst. + console.log( + `http://${serverIp}:${parseInt(serverPort || "0", 10) >= 50000 ? "3000" : serverPort}/lst/api/mobile/version`, + ); + try { + const res = await axios.get( + `http://${serverIp}:${parseInt(serverPort || "0", 10) >= 50000 ? "3000" : serverPort}/lst/api/mobile/version`, + { + timeout: 5000, + }, + ); + + console.log(res.data); + } catch (error) { + console.log("Error: ", error); + } + setMessage(Checking scanner mode...); await devDelay(1500); @@ -60,7 +79,7 @@ export default function Index() { }; startup(); - }, [hasHydrated, hasValidSetup, serverPort, router]); + }, [hasHydrated, hasValidSetup, serverPort, serverIp, router]); if (ready) { return ; diff --git a/lstMobile/src/components/ProdScanner.tsx b/lstMobile/src/components/ProdScanner.tsx index 3ed76b0..6330b7f 100644 --- a/lstMobile/src/components/ProdScanner.tsx +++ b/lstMobile/src/components/ProdScanner.tsx @@ -1,6 +1,9 @@ +import { format } from "date-fns-tz"; import { useCallback, useEffect, useState } from "react"; import { Text, View } from "react-native"; import { useAppStore } from "../hooks/useAppStore"; +import { useScannerStore } from "../hooks/useScannerStore"; +import { scannerFeedback } from "../lib/feedbackScan"; import { sendTcpMessage } from "../lib/tcpScan"; import { type ZebraScanResult, zebraScanner } from "../lib/ZebraScanner"; import { ScannedLabelBox } from "./ScannedLabels"; @@ -9,23 +12,32 @@ const STX = "\x02"; const ETX = "\x03"; export default function ProdScanner() { - const [lastScan, setLastScan] = useState(null); + const lastScan = useScannerStore((s) => s.lastScan); + const setLastScan = useScannerStore((s) => s.setLastScan); const [tagScans, setTagScans] = useState([]); const scannerIdFromStore = useAppStore((s) => s.scannerId); const serverIp = useAppStore((s) => s.serverIp); const serverPort = useAppStore((s) => s.serverPort); + const [bgColor, setBGColor] = useState(null); const handleScan = useCallback( async (scan: ZebraScanResult) => { - const scanned = scan.data; - - let commandToSend = `${STX}${scannerIdFromStore}@${scanned}${ETX}`; + let commandToSend = `${STX}${scannerIdFromStore}@${scan.data}${ETX}`; + await scannerFeedback({ + type: "good", + sound: true, + vibrate: true, + led: true, + }); // if we are sscc we need to scan like this .... 98@]C100090087710038712256 if (scan.data.startsWith("000")) { - commandToSend = `${STX}${scannerIdFromStore}@]C1${scanned}${ETX}`; + commandToSend = `${STX}${scannerIdFromStore}@]C1${scan.data}${ETX}`; setTagScans((prev: any) => [ - parseInt(scanned.slice(10, -1) || "000", 10).toString(), + { + label: parseInt(scan.data.slice(10, -1) || "000", 10).toString(), + date: format(new Date(Date.now()), "HH:mm"), + }, ...prev, ]); } @@ -35,19 +47,44 @@ export default function ProdScanner() { setTagScans([]); } - const something = await sendTcpMessage( + const scanned = (await sendTcpMessage( commandToSend, serverIp, parseInt(serverPort || "0", 10), - ); + )) as any; // Later this is where your TCP send goes. // const response = await sendTcpMessage(tcpMessage); - setLastScan(something.data[0]); + console.log(scanned); + await scannerFeedback({ + type: scanned.data[0]?.type === "error" ? "bad" : "good", + sound: true, + vibrate: true, + led: true, + }); + + if (scanned.data[0]?.type !== "error") { + setBGColor("bg-green-500"); + setTimeout(() => { + setBGColor(null); + }, 1 * 1000); + } + + if (scanned.data[0]?.type === "error") { + setBGColor("bg-red-500"); + setTimeout(() => { + setBGColor(null); + }, 1 * 1000); + } + setLastScan(scanned.data[0]); //console.log("TCP response:", something); }, - [scannerIdFromStore, serverIp, serverPort], + [scannerIdFromStore, serverIp, serverPort, setLastScan], ); + const clearScans = () => { + setTagScans([]); + }; + console.log(lastScan); useEffect(() => { @@ -65,21 +102,17 @@ export default function ProdScanner() { }; }, [handleScan]); return ( - + - + Scanner ID: {parseInt(scannerIdFromStore || "0", 10)} {!lastScan ? ( - - Waiting on scan.... + + Ready to scan + Waiting for first scan... ) : ( - - {lastScan?.action} - - - {lastScan?.type === "error" ? ( + - {lastScan?.message} + {lastScan.action} - ) : ( - - - {lastScan?.prompt} - - - {lastScan?.message} - - - )} + + + {lastScan.message} + + )} - - + + + ); } diff --git a/lstMobile/src/components/ScannedLabels.tsx b/lstMobile/src/components/ScannedLabels.tsx index 5708ab4..ba0b82c 100644 --- a/lstMobile/src/components/ScannedLabels.tsx +++ b/lstMobile/src/components/ScannedLabels.tsx @@ -1,50 +1,55 @@ -import { ScrollView, Text, View } from "react-native"; +import { ScrollView, View } from "react-native"; +import { SafeAreaView } from "react-native-safe-area-context"; +import { Button } from "@/components/ui/button"; +import { Text } from "@/components/ui/text"; type ScannedLabel = { - id: string; - barcode: string; - createdAt: string; + label: string; + date: Date; }; type ScannedLabelBoxProps = { labels: ScannedLabel[]; + color: string | null; + clearScan: () => void; }; -export function ScannedLabelBox({ labels }: ScannedLabelBoxProps) { +export function ScannedLabelBox({ + labels, + color, + clearScan, +}: ScannedLabelBoxProps) { return ( - - - Current scanned labels - + + + + Current scanned labels + + - + {labels.length === 0 ? ( - No labels scanned yet + No labels scanned yet ) : ( - labels.map((label) => ( - - {label} - - )) + + {labels.map((i, index) => ( + + + {i.label} - {i.date.toString()} + + + ))} + )} - + {labels.length !== 0 && ( + + )} + ); } diff --git a/lstMobile/src/components/ui/button.tsx b/lstMobile/src/components/ui/button.tsx new file mode 100644 index 0000000..9d11ba1 --- /dev/null +++ b/lstMobile/src/components/ui/button.tsx @@ -0,0 +1,106 @@ +import { TextClassContext } from '@/components/ui/text'; +import { cn } from '@/lib/utils'; +import { cva, type VariantProps } from 'class-variance-authority'; +import { Platform, Pressable } from 'react-native'; + +const buttonVariants = cva( + cn( + 'group shrink-0 flex-row items-center justify-center gap-2 rounded-md shadow-none', + Platform.select({ + web: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap outline-none transition-all focus-visible:ring-[3px] disabled:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0", + }) + ), + { + variants: { + variant: { + default: cn( + 'bg-primary active:bg-primary/90 shadow-sm shadow-black/5', + Platform.select({ web: 'hover:bg-primary/90' }) + ), + destructive: cn( + 'bg-destructive active:bg-destructive/90 dark:bg-destructive/60 shadow-sm shadow-black/5', + Platform.select({ + web: 'hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40', + }) + ), + outline: cn( + 'border-border bg-background active:bg-accent dark:bg-input/30 dark:border-input dark:active:bg-input/50 border shadow-sm shadow-black/5', + Platform.select({ + web: 'hover:bg-accent dark:hover:bg-input/50', + }) + ), + secondary: cn( + 'bg-secondary active:bg-secondary/80 shadow-sm shadow-black/5', + Platform.select({ web: 'hover:bg-secondary/80' }) + ), + ghost: cn( + 'active:bg-accent dark:active:bg-accent/50', + Platform.select({ web: 'hover:bg-accent dark:hover:bg-accent/50' }) + ), + link: '', + }, + size: { + default: cn('h-10 px-4 py-2 sm:h-9', Platform.select({ web: 'has-[>svg]:px-3' })), + sm: cn('h-9 gap-1.5 rounded-md px-3 sm:h-8', Platform.select({ web: 'has-[>svg]:px-2.5' })), + lg: cn('h-11 rounded-md px-6 sm:h-10', Platform.select({ web: 'has-[>svg]:px-4' })), + icon: 'h-10 w-10 sm:h-9 sm:w-9', + }, + }, + defaultVariants: { + variant: 'default', + size: 'default', + }, + } +); + +const buttonTextVariants = cva( + cn( + 'text-foreground text-sm font-medium', + Platform.select({ web: 'pointer-events-none transition-colors' }) + ), + { + variants: { + variant: { + default: 'text-primary-foreground', + destructive: 'text-white', + outline: cn( + 'group-active:text-accent-foreground', + Platform.select({ web: 'group-hover:text-accent-foreground' }) + ), + secondary: 'text-secondary-foreground', + ghost: 'group-active:text-accent-foreground', + link: cn( + 'text-primary group-active:underline', + Platform.select({ web: 'underline-offset-4 hover:underline group-hover:underline' }) + ), + }, + size: { + default: '', + sm: '', + lg: '', + icon: '', + }, + }, + defaultVariants: { + variant: 'default', + size: 'default', + }, + } +); + +type ButtonProps = React.ComponentProps & VariantProps; + +function Button({ className, variant, size, ...props }: ButtonProps) { + return ( + + + + ); +} + +export { Button, buttonTextVariants, buttonVariants }; +export type { ButtonProps }; diff --git a/lstMobile/src/hooks/useAppStore.ts b/lstMobile/src/hooks/useAppStore.ts index 9262430..e8cbfcf 100644 --- a/lstMobile/src/hooks/useAppStore.ts +++ b/lstMobile/src/hooks/useAppStore.ts @@ -33,10 +33,8 @@ type AppActions = { setValidationStatus: (status: ValidationStatus, validatedAt?: string) => void; setAppVersion: (value?: string) => void; setHasHydrated: (value: boolean) => void; - updateAppState: (updates: Partial) => void; resetApp: () => void; - hasValidSetup: () => boolean; canEnterApp: () => boolean; getServerUrl: () => string; @@ -50,15 +48,11 @@ const defaultAppState: AppState = { scannerId: "0001", stageId: undefined, deviceName: undefined, - setupCompleted: false, isRegistered: false, - lastValidationStatus: "idle", lastValidationAt: undefined, - appVersion: undefined, - hasHydrated: false, }; @@ -74,28 +68,23 @@ export const useAppStore = create()( setDeviceName: (value) => set({ deviceName: value }), setSetupCompleted: (value) => set({ setupCompleted: value }), setIsRegistered: (value) => set({ isRegistered: value }), - setValidationStatus: (status, validatedAt) => set({ lastValidationStatus: status, lastValidationAt: validatedAt, }), - setAppVersion: (value) => set({ appVersion: value }), setHasHydrated: (value) => set({ hasHydrated: value }), - updateAppState: (updates) => set((state) => ({ ...state, ...updates, })), - resetApp: () => set({ ...defaultAppState, hasHydrated: true, }), - hasValidSetup: () => { const state = get(); return Boolean( @@ -104,7 +93,6 @@ export const useAppStore = create()( state.setupCompleted, ); }, - canEnterApp: () => { const state = get(); return Boolean( diff --git a/lstMobile/src/hooks/useScannerStore.ts b/lstMobile/src/hooks/useScannerStore.ts new file mode 100644 index 0000000..91f6427 --- /dev/null +++ b/lstMobile/src/hooks/useScannerStore.ts @@ -0,0 +1,31 @@ +import { create } from "zustand"; + +type LastScan = { + action?: string; + type?: "success" | "error" | string; + prompt?: string; + message?: string; + timestamp?: number; +}; + +type ScannerStore = { + lastScan: LastScan | null; + setLastScan: (scan: LastScan | null) => void; + clearLastScan: () => void; +}; + +export const useScannerStore = create((set) => ({ + lastScan: null, + + setLastScan: (scan) => + set({ + lastScan: scan + ? { + ...scan, + timestamp: Date.now(), + } + : null, + }), + + clearLastScan: () => set({ lastScan: null }), +})); diff --git a/lstMobile/src/lib/feedbackScan.ts b/lstMobile/src/lib/feedbackScan.ts new file mode 100644 index 0000000..6bc5bc1 --- /dev/null +++ b/lstMobile/src/lib/feedbackScan.ts @@ -0,0 +1,38 @@ +import { createAudioPlayer } from "expo-audio"; +import * as Haptics from "expo-haptics"; + +export type ScanFeedback = { + type: "good" | "bad"; + sound?: boolean; + vibrate?: boolean; + led?: boolean; +}; + +const goodSound = createAudioPlayer(require("../../assets/sounds/good.wav")); +const badSound = createAudioPlayer(require("../../assets/sounds/bad.wav")); + +export async function scannerFeedback({ + type, + sound = true, + vibrate = true, + led = true, +}: ScanFeedback) { + if (sound) { + const player = type === "good" ? goodSound : badSound; + player.seekTo(0); + player.play(); + } + + if (vibrate) { + await Haptics.notificationAsync( + type === "good" + ? Haptics.NotificationFeedbackType.Success + : Haptics.NotificationFeedbackType.Error, + ); + } + + if (led) { + // Zebra LED hook goes here + // More below 👇 + } +}