import { useRef } from "react"; import { StyleSheet, View, Text, Pressable, type LayoutChangeEvent } from "react-native"; import { CameraView, useCameraPermissions, type BarcodeScanningResult } from "expo-camera"; import { theme } from "../lib/theme"; export interface QRScannerProps { onScan: (code: string) => void; active: boolean; } // Side of the accept square, matching the visible reticle (index.tsx uses 240), // with a little tolerance so a code aimed inside the box always registers. const RETICLE = 260; /** Native (Android/iOS) scanner using expo-camera. Only accepts codes whose * position falls within the centered reticle square. */ export default function QRScanner({ onScan, active }: QRScannerProps) { const [permission, requestPermission] = useCameraPermissions(); const lastScan = useRef<{ code: string; at: number }>({ code: "", at: 0 }); const layout = useRef({ w: 0, h: 0 }); const onLayout = (e: LayoutChangeEvent) => { layout.current = { w: e.nativeEvent.layout.width, h: e.nativeEvent.layout.height }; }; // True if the scanned code sits inside the centered reticle. Fails OPEN when // geometry is missing/unknown so scanning never silently breaks. const inReticle = (res: BarcodeScanningResult): boolean => { const { w, h } = layout.current; if (!w || !h) return true; const pts = res.cornerPoints as { x: number; y: number }[] | undefined; let cx: number | undefined; let cy: number | undefined; if (pts && pts.length) { cx = pts.reduce((s, p) => s + p.x, 0) / pts.length; cy = pts.reduce((s, p) => s + p.y, 0) / pts.length; } else { const b: any = (res as any).bounds; if (b?.origin && b?.size) { cx = b.origin.x + b.size.width / 2; cy = b.origin.y + b.size.height / 2; } } if (cx === undefined || cy === undefined) return true; // Some platforms report normalized [0,1] coords — scale to view size. if (cx <= 1 && cy <= 1) { cx *= w; cy *= h; } const half = RETICLE / 2; return Math.abs(cx - w / 2) <= half && Math.abs(cy - h / 2) <= half; }; if (!permission) { return ; } if (!permission.granted) { return ( Camera access is needed to scan tickets. Grant camera permission ); } return ( { if (!inReticle(res)) return; // ignore codes outside the square const data = res.data; const now = Date.now(); // Debounce repeated frames of the same code. if (data === lastScan.current.code && now - lastScan.current.at < 3000) return; lastScan.current = { code: data, at: now }; onScan(data); } : undefined } /> ); } const styles = StyleSheet.create({ fill: { flex: 1, width: "100%", height: "100%" }, center: { alignItems: "center", justifyContent: "center", padding: 24, backgroundColor: theme.bg }, msg: { color: theme.text, fontSize: 16, textAlign: "center", marginBottom: 20 }, btn: { backgroundColor: theme.primary, paddingHorizontal: 20, paddingVertical: 12, borderRadius: 10 }, btnText: { color: "#fff", fontSize: 16, fontWeight: "600" }, });