All checks were successful
Build Android APK / build-apk (push) Successful in 43m56s
- Move the banquet manual-email input to the top of the scanner (under the mode tabs) so the on-screen keyboard, which covers the bottom, never hides it. - Restrict QR detection to the centered reticle square: native filters codes by their reported position (fails open if geometry is unavailable); web crops the central square of the frame before detecting. Codes elsewhere in view are ignored. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
97 lines
3.6 KiB
TypeScript
97 lines
3.6 KiB
TypeScript
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 <View style={styles.fill} />;
|
|
}
|
|
if (!permission.granted) {
|
|
return (
|
|
<View style={[styles.fill, styles.center]}>
|
|
<Text style={styles.msg}>Camera access is needed to scan tickets.</Text>
|
|
<Pressable style={styles.btn} onPress={requestPermission}>
|
|
<Text style={styles.btnText}>Grant camera permission</Text>
|
|
</Pressable>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<CameraView
|
|
style={styles.fill}
|
|
facing="back"
|
|
onLayout={onLayout}
|
|
barcodeScannerSettings={{ barcodeTypes: ["qr"] }}
|
|
onBarcodeScanned={
|
|
active
|
|
? (res) => {
|
|
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" },
|
|
});
|