diff --git a/app/app/index.tsx b/app/app/index.tsx
index 324d545..851d9c7 100644
--- a/app/app/index.tsx
+++ b/app/app/index.tsx
@@ -186,6 +186,31 @@ export default function ScannerScreen() {
))}
+ {mode === "banquet" && (phase === "scanning" || phase === "banquet") && (
+ // Kept above the scanner (near the top) so the on-screen keyboard,
+ // which covers the bottom of the screen, never hides this input.
+
+ manualEmail.trim() && runBanquet({ email: manualEmail.trim() })}
+ />
+ manualEmail.trim() && runBanquet({ email: manualEmail.trim() })}
+ >
+ Look up
+
+
+ )}
+
{phase === "scanning" && (
@@ -264,29 +289,6 @@ export default function ScannerScreen() {
)}
-
- {mode === "banquet" && (phase === "scanning" || phase === "banquet") && (
-
- manualEmail.trim() && runBanquet({ email: manualEmail.trim() })}
- />
- manualEmail.trim() && runBanquet({ email: manualEmail.trim() })}
- >
- Look up
-
-
- )}
);
}
diff --git a/app/components/QRScanner.tsx b/app/components/QRScanner.tsx
index 56f40b8..c2b9b1c 100644
--- a/app/components/QRScanner.tsx
+++ b/app/components/QRScanner.tsx
@@ -1,6 +1,6 @@
import { useRef } from "react";
-import { StyleSheet, View, Text, Pressable } from "react-native";
-import { CameraView, useCameraPermissions } from "expo-camera";
+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 {
@@ -8,10 +8,48 @@ export interface QRScannerProps {
active: boolean;
}
-/** Native (Android/iOS) scanner using expo-camera. */
+// 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 ;
@@ -31,10 +69,13 @@ export default function QRScanner({ onScan, active }: QRScannerProps) {
{
+ ? (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;
diff --git a/app/components/QRScanner.web.tsx b/app/components/QRScanner.web.tsx
index db5f1d6..82d4cbb 100644
--- a/app/components/QRScanner.web.tsx
+++ b/app/components/QRScanner.web.tsx
@@ -56,6 +56,10 @@ export default function QRScanner({ onScan, active }: QRScannerProps) {
video.play().catch(() => {});
}
const detector = new BarcodeDetector({ formats: ["qr_code"] });
+ // Offscreen canvas holding just the centered reticle crop — we only run
+ // detection on this region so QR codes elsewhere in view are ignored.
+ const canvas = document.createElement("canvas");
+ const cctx = canvas.getContext("2d", { willReadFrequently: true });
let busy = false;
const tick = async () => {
rafRef.current = requestAnimationFrame(tick);
@@ -68,7 +72,21 @@ export default function QRScanner({ onScan, active }: QRScannerProps) {
if (busy || !activeRef.current) return;
busy = true;
try {
- const codes = await detector.detect(v);
+ // Crop the central square of the frame (matches the on-screen
+ // reticle) and detect only within it.
+ const vw = v.videoWidth;
+ const vh = v.videoHeight;
+ let target: HTMLVideoElement | HTMLCanvasElement = v;
+ if (cctx && vw && vh) {
+ const side = Math.round(Math.min(vw, vh) * 0.62);
+ const sx = Math.round((vw - side) / 2);
+ const sy = Math.round((vh - side) / 2);
+ canvas.width = side;
+ canvas.height = side;
+ cctx.drawImage(v, sx, sy, side, side, 0, 0, side, side);
+ target = canvas;
+ }
+ const codes = await detector.detect(target);
if (codes && codes.length) {
const data = codes[0].rawValue;
const now = Date.now();