591 lines
15 KiB
TypeScript
591 lines
15 KiB
TypeScript
/**
|
|
* 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, configureDebug } 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<CameraHandle>(null);
|
|
|
|
// Local scan state
|
|
const [isProcessing, setIsProcessing] = useState(false);
|
|
const [flashEnabled, setFlashEnabled] = useState(false);
|
|
const [isAddingToCollection, setIsAddingToCollection] = useState(false);
|
|
const [lastScanResult, setLastScanResult] = useState<ScanResult | null>(null);
|
|
const [scanError, setScanError] = useState<string | null>(null);
|
|
const [debugEnabled, setDebugEnabled] = useState(false);
|
|
|
|
// 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"
|
|
);
|
|
|
|
// Toggle debug mode
|
|
const toggleDebug = useCallback(() => {
|
|
const newState = !debugEnabled;
|
|
setDebugEnabled(newState);
|
|
configureDebug({
|
|
enabled: newState,
|
|
albumName: "Scry Debug",
|
|
});
|
|
console.log("[Scry] Debug mode:", newState ? "ON" : "OFF");
|
|
}, [debugEnabled]);
|
|
|
|
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,
|
|
debug: debugEnabled,
|
|
}
|
|
);
|
|
|
|
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, debugEnabled]);
|
|
|
|
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 (
|
|
<View style={styles.centered}>
|
|
<ActivityIndicator size="large" color="#007AFF" />
|
|
<Text style={styles.loadingText}>Checking camera permission...</Text>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
// Permission denied
|
|
if (!hasPermission) {
|
|
return (
|
|
<View style={styles.centered}>
|
|
<FontAwesome name="camera" size={64} color="#666" />
|
|
<Text style={styles.permissionTitle}>Camera Access Required</Text>
|
|
<Text style={styles.permissionText}>
|
|
Scry needs camera access to scan your Magic cards.
|
|
</Text>
|
|
<Pressable style={styles.permissionButton} onPress={requestPermission}>
|
|
<Text style={styles.permissionButtonText}>Enable Camera</Text>
|
|
</Pressable>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<View style={styles.container}>
|
|
{/* Adaptive camera - uses expo-camera in Expo Go, Vision Camera in production */}
|
|
<AdaptiveCamera ref={cameraRef} flashEnabled={flashEnabled} />
|
|
|
|
{/* Overlay with scan box */}
|
|
<View style={styles.overlay}>
|
|
{/* Top dark area */}
|
|
<View style={styles.overlayTop} />
|
|
|
|
{/* Middle row with scan box */}
|
|
<View style={styles.overlayMiddle}>
|
|
<View style={styles.overlaySide} />
|
|
<View style={styles.scanBox}>
|
|
{/* Corner markers */}
|
|
<View style={[styles.corner, styles.cornerTopLeft]} />
|
|
<View style={[styles.corner, styles.cornerTopRight]} />
|
|
<View style={[styles.corner, styles.cornerBottomLeft]} />
|
|
<View style={[styles.corner, styles.cornerBottomRight]} />
|
|
</View>
|
|
<View style={styles.overlaySide} />
|
|
</View>
|
|
|
|
{/* Bottom dark area */}
|
|
<View style={styles.overlayBottom} />
|
|
</View>
|
|
|
|
{/* Instructions */}
|
|
<View style={styles.instructionContainer}>
|
|
<Text style={styles.instructionText}>
|
|
{hashesLoaded
|
|
? `Position card in frame • ${cardHashes.length} cards loaded`
|
|
: "Loading card database..."}
|
|
</Text>
|
|
{isExpoGo && (
|
|
<Text style={styles.devModeText}>Dev mode (expo-camera)</Text>
|
|
)}
|
|
</View>
|
|
|
|
{/* Scan result overlay */}
|
|
{lastScanResult && (
|
|
<View style={styles.resultContainer}>
|
|
<View style={styles.resultCard}>
|
|
{lastScanResult.imageUri && (
|
|
<Image
|
|
source={{ uri: lastScanResult.imageUri }}
|
|
style={styles.resultImage}
|
|
resizeMode="contain"
|
|
/>
|
|
)}
|
|
<View style={styles.resultInfo}>
|
|
<Text style={styles.resultName}>{lastScanResult.cardName}</Text>
|
|
<Text style={styles.resultSet}>{lastScanResult.setCode.toUpperCase()}</Text>
|
|
<Text style={styles.resultConfidence}>
|
|
{Math.round(lastScanResult.confidence * 100)}% match
|
|
</Text>
|
|
</View>
|
|
<Pressable
|
|
style={[styles.addButton, !userId && styles.addButtonDisabled]}
|
|
onPress={handleAddToCollection}
|
|
disabled={!userId || isAddingToCollection}
|
|
>
|
|
{isAddingToCollection ? (
|
|
<ActivityIndicator size="small" color="#fff" />
|
|
) : (
|
|
<>
|
|
<FontAwesome name="plus" size={20} color="#fff" />
|
|
<Text style={styles.addButtonText}>Add</Text>
|
|
</>
|
|
)}
|
|
</Pressable>
|
|
</View>
|
|
</View>
|
|
)}
|
|
|
|
{/* Error message */}
|
|
{scanError && (
|
|
<View style={styles.errorContainer}>
|
|
<Text style={styles.errorMessage}>{scanError}</Text>
|
|
<Pressable onPress={clearScanState}>
|
|
<Text style={styles.errorDismiss}>Dismiss</Text>
|
|
</Pressable>
|
|
</View>
|
|
)}
|
|
|
|
{/* Controls */}
|
|
<View style={styles.controls}>
|
|
{/* Flash toggle */}
|
|
<Pressable style={styles.controlButton} onPress={() => setFlashEnabled(!flashEnabled)}>
|
|
<FontAwesome
|
|
name="bolt"
|
|
size={24}
|
|
color={flashEnabled ? "#FFD700" : "#fff"}
|
|
/>
|
|
</Pressable>
|
|
|
|
{/* Capture button */}
|
|
<Pressable
|
|
style={[styles.captureButton, isProcessing && styles.captureButtonDisabled]}
|
|
onPress={handleCapture}
|
|
disabled={isProcessing || !hashesLoaded}
|
|
>
|
|
{isProcessing ? (
|
|
<ActivityIndicator size="small" color="#007AFF" />
|
|
) : (
|
|
<View style={styles.captureButtonInner} />
|
|
)}
|
|
</Pressable>
|
|
|
|
{/* Debug toggle */}
|
|
<Pressable style={styles.controlButton} onPress={toggleDebug}>
|
|
<FontAwesome
|
|
name="bug"
|
|
size={24}
|
|
color={debugEnabled ? "#FF6B6B" : "#fff"}
|
|
/>
|
|
</Pressable>
|
|
</View>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
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",
|
|
},
|
|
});
|