scry/app/(tabs)/scan.tsx
Chris Kruining b4e4ff73ec
.
2026-02-09 16:35:08 +01:00

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",
},
});