From 7a6653b658f9679cf6b4d6fdd55816efeb12bf8a Mon Sep 17 00:00:00 2001 From: Hank Date: Thu, 9 Jul 2026 06:42:07 +0000 Subject: [PATCH] Banquet email input floats above keyboard; scan only within the reticle - 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 --- app/app/index.tsx | 48 ++++++++++++++++--------------- app/components/QRScanner.tsx | 49 +++++++++++++++++++++++++++++--- app/components/QRScanner.web.tsx | 20 ++++++++++++- 3 files changed, 89 insertions(+), 28 deletions(-) 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();