/** * Camera scanning screen for card recognition. */ import React, { useCallback, useRef, useState, useEffect } from "react"; import { StyleSheet, View, Text, Pressable, ActivityIndicator, Dimensions, Image, Alert, } from "react-native"; import { FontAwesome } from "@expo/vector-icons"; import { useMutation, useQuery } from "convex/react"; import { api } from "../../convex/_generated/api"; import { useCameraPermission } from "@/lib/hooks/useCamera"; import { useCurrentUser } from "@/lib/hooks"; import { useHashCache } from "@/lib/context"; import { recognizeCard } from "@/lib/recognition"; import { loadImageAsBase64 } from "@/lib/recognition/imageLoader"; import { decodeImageBase64 } from "@/lib/recognition/skiaDecoder"; import { AdaptiveCamera, CameraHandle, isExpoGo } from "@/components/camera"; const { height: SCREEN_HEIGHT } = Dimensions.get("window"); const CARD_ASPECT_RATIO = 63 / 88; // Width / Height const SCAN_BOX_HEIGHT = SCREEN_HEIGHT * 0.5; const SCAN_BOX_WIDTH = SCAN_BOX_HEIGHT * CARD_ASPECT_RATIO; interface ScanResult { cardId: string; cardName: string; setCode: string; imageUri?: string; confidence: number; distance: number; timestamp: number; } export default function ScanScreen() { const { hasPermission, isLoading: permLoading, requestPermission } = useCameraPermission(); const cameraRef = useRef(null); // Local scan state const [isProcessing, setIsProcessing] = useState(false); const [flashEnabled, setFlashEnabled] = useState(false); const [isAddingToCollection, setIsAddingToCollection] = useState(false); const [lastScanResult, setLastScanResult] = useState(null); const [scanError, setScanError] = useState(null); // Hash cache from context const { cardHashes, hashesLoaded } = useHashCache(); // Get authenticated user const { user, isAuthenticated } = useCurrentUser(); const userId = user?._id ?? null; // Convex mutations const addToCollection = useMutation(api.collections.add); const getCardByScryfallId = useQuery( api.cards.getByScryfallId, lastScanResult ? { scryfallId: lastScanResult.cardId } : "skip" ); const clearScanState = useCallback(() => { setLastScanResult(null); setScanError(null); }, []); // Auto-clear scan result after 5 seconds useEffect(() => { if (lastScanResult) { const timer = setTimeout(() => { clearScanState(); }, 5000); return () => clearTimeout(timer); } }, [lastScanResult, clearScanState]); const handleCapture = useCallback(async () => { if (!cameraRef.current || isProcessing || !hashesLoaded) return; setIsProcessing(true); try { // Take photo using adaptive camera const photo = await cameraRef.current.takePhoto(); console.log("[Scry] Photo captured:", photo.uri); // Load and resize image for processing const { base64 } = await loadImageAsBase64( photo.uri, 480, // Target width for processing 640 // Target height ); if (!base64) { throw new Error("Failed to load image data"); } // Decode to RGBA pixels using Skia const decoded = decodeImageBase64(base64); if (!decoded) { throw new Error("Failed to decode image pixels"); } console.log("[Scry] Image decoded:", decoded.width, "x", decoded.height); console.log("[Scry] Matching against", cardHashes.length, "cards"); // Run recognition const result = recognizeCard( decoded.pixels, decoded.width, decoded.height, cardHashes, { enableCardDetection: true, enableRotationMatching: true, minConfidence: 0.85, } ); console.log("[Scry] Recognition result:", result); if (result.success && result.match) { // Find the card info from our hashes const matchedCard = cardHashes.find((c) => c.id === result.match!.cardId); setLastScanResult({ cardId: result.match.cardId, cardName: matchedCard?.name || "Unknown Card", setCode: matchedCard?.setCode || "???", imageUri: matchedCard?.imageUri, confidence: result.match.confidence, distance: result.match.distance, timestamp: Date.now(), }); setScanError(null); } else { setScanError(result.error || "No match found. Try adjusting lighting or angle."); setLastScanResult(null); } } catch (error) { console.error("[Scry] Capture error:", error); setScanError(error instanceof Error ? error.message : "Failed to capture image"); setLastScanResult(null); } finally { setIsProcessing(false); } }, [isProcessing, hashesLoaded, cardHashes]); const handleAddToCollection = useCallback(async () => { if (!lastScanResult || !userId || !getCardByScryfallId) return; setIsAddingToCollection(true); try { await addToCollection({ userId: userId as any, cardId: getCardByScryfallId._id, quantity: 1, isFoil: false, }); Alert.alert( "Added to Collection", `${lastScanResult.cardName} has been added to your collection.`, [{ text: "OK" }] ); clearScanState(); } catch (error) { console.error("[Scry] Failed to add to collection:", error); Alert.alert("Error", "Failed to add card to collection. Please try again.", [ { text: "OK" }, ]); } finally { setIsAddingToCollection(false); } }, [lastScanResult, userId, getCardByScryfallId, addToCollection, clearScanState]); // Loading state if (permLoading) { return ( Checking camera permission... ); } // Permission denied if (!hasPermission) { return ( Camera Access Required Scry needs camera access to scan your Magic cards. Enable Camera ); } return ( {/* Adaptive camera - uses expo-camera in Expo Go, Vision Camera in production */} {/* Overlay with scan box */} {/* Top dark area */} {/* Middle row with scan box */} {/* Corner markers */} {/* Bottom dark area */} {/* Instructions */} {hashesLoaded ? `Position card in frame • ${cardHashes.length} cards loaded` : "Loading card database..."} {isExpoGo && ( Dev mode (expo-camera) )} {/* Scan result overlay */} {lastScanResult && ( {lastScanResult.imageUri && ( )} {lastScanResult.cardName} {lastScanResult.setCode.toUpperCase()} {Math.round(lastScanResult.confidence * 100)}% match {isAddingToCollection ? ( ) : ( <> Add )} )} {/* Error message */} {scanError && ( {scanError} Dismiss )} {/* Controls */} {/* Flash toggle */} setFlashEnabled(!flashEnabled)}> {/* Capture button */} {isProcessing ? ( ) : ( )} {/* Placeholder for symmetry */} ); } const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: "#000", }, centered: { flex: 1, justifyContent: "center", alignItems: "center", backgroundColor: "#1a1a1a", padding: 20, }, loadingText: { marginTop: 16, color: "#888", fontSize: 16, }, permissionTitle: { marginTop: 20, fontSize: 24, fontWeight: "bold", color: "#fff", }, permissionText: { marginTop: 12, fontSize: 16, color: "#888", textAlign: "center", maxWidth: 280, }, permissionButton: { marginTop: 24, backgroundColor: "#007AFF", paddingHorizontal: 32, paddingVertical: 14, borderRadius: 12, }, permissionButtonText: { color: "#fff", fontSize: 18, fontWeight: "600", }, errorText: { marginTop: 16, fontSize: 18, color: "#FF6B6B", textAlign: "center", }, overlay: { ...StyleSheet.absoluteFillObject, }, overlayTop: { flex: 1, backgroundColor: "rgba(0,0,0,0.6)", }, overlayMiddle: { flexDirection: "row", height: SCAN_BOX_HEIGHT, }, overlaySide: { flex: 1, backgroundColor: "rgba(0,0,0,0.6)", }, scanBox: { width: SCAN_BOX_WIDTH, height: SCAN_BOX_HEIGHT, borderWidth: 2, borderColor: "rgba(255,255,255,0.3)", borderRadius: 12, }, overlayBottom: { flex: 1, backgroundColor: "rgba(0,0,0,0.6)", }, corner: { position: "absolute", width: 24, height: 24, borderColor: "#007AFF", }, cornerTopLeft: { top: -2, left: -2, borderTopWidth: 4, borderLeftWidth: 4, borderTopLeftRadius: 12, }, cornerTopRight: { top: -2, right: -2, borderTopWidth: 4, borderRightWidth: 4, borderTopRightRadius: 12, }, cornerBottomLeft: { bottom: -2, left: -2, borderBottomWidth: 4, borderLeftWidth: 4, borderBottomLeftRadius: 12, }, cornerBottomRight: { bottom: -2, right: -2, borderBottomWidth: 4, borderRightWidth: 4, borderBottomRightRadius: 12, }, instructionContainer: { position: "absolute", top: 60, left: 0, right: 0, alignItems: "center", }, instructionText: { color: "#fff", fontSize: 14, backgroundColor: "rgba(0,0,0,0.5)", paddingHorizontal: 16, paddingVertical: 8, borderRadius: 20, }, devModeText: { marginTop: 8, color: "#FFD700", fontSize: 12, backgroundColor: "rgba(0,0,0,0.5)", paddingHorizontal: 12, paddingVertical: 4, borderRadius: 12, }, resultContainer: { position: "absolute", bottom: 140, left: 16, right: 16, }, resultCard: { flexDirection: "row", backgroundColor: "rgba(0,0,0,0.9)", borderRadius: 12, padding: 12, alignItems: "center", }, resultImage: { width: 50, height: 70, borderRadius: 4, }, resultInfo: { flex: 1, marginLeft: 12, }, resultName: { color: "#fff", fontSize: 16, fontWeight: "600", }, resultSet: { color: "#888", fontSize: 12, marginTop: 2, }, resultConfidence: { color: "#4CD964", fontSize: 12, marginTop: 4, }, addButton: { flexDirection: "row", alignItems: "center", backgroundColor: "#4CD964", paddingHorizontal: 16, paddingVertical: 10, borderRadius: 8, gap: 6, }, addButtonDisabled: { opacity: 0.5, }, addButtonText: { color: "#fff", fontSize: 14, fontWeight: "600", }, errorContainer: { position: "absolute", bottom: 140, left: 16, right: 16, backgroundColor: "rgba(255,107,107,0.95)", borderRadius: 12, padding: 16, flexDirection: "row", alignItems: "center", justifyContent: "space-between", }, errorMessage: { color: "#fff", fontSize: 14, flex: 1, }, errorDismiss: { color: "#fff", fontSize: 14, fontWeight: "600", marginLeft: 12, }, controls: { position: "absolute", bottom: 40, left: 0, right: 0, flexDirection: "row", justifyContent: "space-around", alignItems: "center", paddingHorizontal: 40, }, controlButton: { width: 50, height: 50, justifyContent: "center", alignItems: "center", }, captureButton: { width: 72, height: 72, borderRadius: 36, backgroundColor: "#fff", justifyContent: "center", alignItems: "center", borderWidth: 4, borderColor: "rgba(255,255,255,0.3)", }, captureButtonDisabled: { opacity: 0.5, }, captureButtonInner: { width: 56, height: 56, borderRadius: 28, backgroundColor: "#fff", }, });