Banquet email input floats above keyboard; scan only within the reticle
All checks were successful
Build Android APK / build-apk (push) Successful in 43m56s
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>
This commit is contained in:
parent
65aa878192
commit
7a6653b658
3 changed files with 89 additions and 28 deletions
|
|
@ -186,6 +186,31 @@ export default function ScannerScreen() {
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
{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.
|
||||||
|
<View style={styles.emailBar}>
|
||||||
|
<TextInput
|
||||||
|
style={styles.emailInput}
|
||||||
|
placeholder="Look up an email"
|
||||||
|
placeholderTextColor={theme.textDim}
|
||||||
|
value={manualEmail}
|
||||||
|
onChangeText={setManualEmail}
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
keyboardType="email-address"
|
||||||
|
returnKeyType="search"
|
||||||
|
onSubmitEditing={() => manualEmail.trim() && runBanquet({ email: manualEmail.trim() })}
|
||||||
|
/>
|
||||||
|
<Pressable
|
||||||
|
style={styles.emailBtn}
|
||||||
|
onPress={() => manualEmail.trim() && runBanquet({ email: manualEmail.trim() })}
|
||||||
|
>
|
||||||
|
<Text style={styles.emailBtnText}>Look up</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
<View style={styles.scannerArea}>
|
<View style={styles.scannerArea}>
|
||||||
<QRScanner onScan={handleScan} active={phase === "scanning"} />
|
<QRScanner onScan={handleScan} active={phase === "scanning"} />
|
||||||
{phase === "scanning" && (
|
{phase === "scanning" && (
|
||||||
|
|
@ -264,29 +289,6 @@ export default function ScannerScreen() {
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{mode === "banquet" && (phase === "scanning" || phase === "banquet") && (
|
|
||||||
<View style={styles.emailBar}>
|
|
||||||
<TextInput
|
|
||||||
style={styles.emailInput}
|
|
||||||
placeholder="Or look up an email manually"
|
|
||||||
placeholderTextColor={theme.textDim}
|
|
||||||
value={manualEmail}
|
|
||||||
onChangeText={setManualEmail}
|
|
||||||
autoCapitalize="none"
|
|
||||||
autoCorrect={false}
|
|
||||||
keyboardType="email-address"
|
|
||||||
returnKeyType="search"
|
|
||||||
onSubmitEditing={() => manualEmail.trim() && runBanquet({ email: manualEmail.trim() })}
|
|
||||||
/>
|
|
||||||
<Pressable
|
|
||||||
style={styles.emailBtn}
|
|
||||||
onPress={() => manualEmail.trim() && runBanquet({ email: manualEmail.trim() })}
|
|
||||||
>
|
|
||||||
<Text style={styles.emailBtnText}>Look up</Text>
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { useRef } from "react";
|
import { useRef } from "react";
|
||||||
import { StyleSheet, View, Text, Pressable } from "react-native";
|
import { StyleSheet, View, Text, Pressable, type LayoutChangeEvent } from "react-native";
|
||||||
import { CameraView, useCameraPermissions } from "expo-camera";
|
import { CameraView, useCameraPermissions, type BarcodeScanningResult } from "expo-camera";
|
||||||
import { theme } from "../lib/theme";
|
import { theme } from "../lib/theme";
|
||||||
|
|
||||||
export interface QRScannerProps {
|
export interface QRScannerProps {
|
||||||
|
|
@ -8,10 +8,48 @@ export interface QRScannerProps {
|
||||||
active: boolean;
|
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) {
|
export default function QRScanner({ onScan, active }: QRScannerProps) {
|
||||||
const [permission, requestPermission] = useCameraPermissions();
|
const [permission, requestPermission] = useCameraPermissions();
|
||||||
const lastScan = useRef<{ code: string; at: number }>({ code: "", at: 0 });
|
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) {
|
if (!permission) {
|
||||||
return <View style={styles.fill} />;
|
return <View style={styles.fill} />;
|
||||||
|
|
@ -31,10 +69,13 @@ export default function QRScanner({ onScan, active }: QRScannerProps) {
|
||||||
<CameraView
|
<CameraView
|
||||||
style={styles.fill}
|
style={styles.fill}
|
||||||
facing="back"
|
facing="back"
|
||||||
|
onLayout={onLayout}
|
||||||
barcodeScannerSettings={{ barcodeTypes: ["qr"] }}
|
barcodeScannerSettings={{ barcodeTypes: ["qr"] }}
|
||||||
onBarcodeScanned={
|
onBarcodeScanned={
|
||||||
active
|
active
|
||||||
? ({ data }) => {
|
? (res) => {
|
||||||
|
if (!inReticle(res)) return; // ignore codes outside the square
|
||||||
|
const data = res.data;
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
// Debounce repeated frames of the same code.
|
// Debounce repeated frames of the same code.
|
||||||
if (data === lastScan.current.code && now - lastScan.current.at < 3000) return;
|
if (data === lastScan.current.code && now - lastScan.current.at < 3000) return;
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,10 @@ export default function QRScanner({ onScan, active }: QRScannerProps) {
|
||||||
video.play().catch(() => {});
|
video.play().catch(() => {});
|
||||||
}
|
}
|
||||||
const detector = new BarcodeDetector({ formats: ["qr_code"] });
|
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;
|
let busy = false;
|
||||||
const tick = async () => {
|
const tick = async () => {
|
||||||
rafRef.current = requestAnimationFrame(tick);
|
rafRef.current = requestAnimationFrame(tick);
|
||||||
|
|
@ -68,7 +72,21 @@ export default function QRScanner({ onScan, active }: QRScannerProps) {
|
||||||
if (busy || !activeRef.current) return;
|
if (busy || !activeRef.current) return;
|
||||||
busy = true;
|
busy = true;
|
||||||
try {
|
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) {
|
if (codes && codes.length) {
|
||||||
const data = codes[0].rawValue;
|
const data = codes[0].rawValue;
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue