Migrate from .NET MAUI to Expo + Convex
Complete rewrite of Scry using TypeScript stack:
- Expo/React Native with Expo Router (file-based routing)
- Convex backend (serverless functions + real-time database)
- Adaptive camera system (expo-camera in Expo Go, Vision Camera in production)
- React Native Skia + fast-opencv for image processing
- GDPR-compliant auth setup with Zitadel OIDC (pending configuration)
Key features:
- Card recognition pipeline ported to TypeScript
- Perceptual hashing (192-bit color pHash)
- CLAHE preprocessing for lighting normalization
- Local SQLite cache with Convex sync
- Collection management with offline support
Removes all .NET/MAUI code (src/, test/, tools/).
💘 Generated with Crush
Assisted-by: Claude Opus 4.5 via Crush <crush@charm.land>
This commit is contained in:
parent
56499d5af9
commit
83ab4df537
138 changed files with 19136 additions and 7681 deletions
572
app/(tabs)/scan.tsx
Normal file
572
app/(tabs)/scan.tsx
Normal file
|
|
@ -0,0 +1,572 @@
|
|||
/**
|
||||
* 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<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);
|
||||
|
||||
// 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 (
|
||||
<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>
|
||||
|
||||
{/* Placeholder for symmetry */}
|
||||
<View style={styles.controlButton} />
|
||||
</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",
|
||||
},
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue