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:
Chris Kruining 2026-02-09 16:16:34 +01:00
parent 56499d5af9
commit 83ab4df537
No known key found for this signature in database
GPG key ID: EB894A3560CCCAD2
138 changed files with 19136 additions and 7681 deletions

58
app/(tabs)/_layout.tsx Normal file
View file

@ -0,0 +1,58 @@
import React from "react";
import FontAwesome from "@expo/vector-icons/FontAwesome";
import { Tabs } from "expo-router";
import { useColorScheme } from "@/components/useColorScheme";
import Colors from "@/constants/Colors";
function TabBarIcon(props: {
name: React.ComponentProps<typeof FontAwesome>["name"];
color: string;
}) {
return <FontAwesome size={24} style={{ marginBottom: -3 }} {...props} />;
}
export default function TabLayout() {
const colorScheme = useColorScheme();
return (
<Tabs
screenOptions={{
tabBarActiveTintColor: Colors[colorScheme ?? "light"].tint,
tabBarInactiveTintColor: "#888",
tabBarStyle: {
backgroundColor: colorScheme === "dark" ? "#1a1a1a" : "#fff",
borderTopColor: colorScheme === "dark" ? "#333" : "#eee",
},
headerStyle: {
backgroundColor: colorScheme === "dark" ? "#1a1a1a" : "#fff",
},
headerTintColor: colorScheme === "dark" ? "#fff" : "#000",
}}
>
<Tabs.Screen
name="index"
options={{
title: "Collection",
tabBarIcon: ({ color }) => (
<TabBarIcon name="th-large" color={color} />
),
}}
/>
<Tabs.Screen
name="scan"
options={{
title: "Scan",
headerShown: false,
tabBarIcon: ({ color }) => <TabBarIcon name="camera" color={color} />,
}}
/>
<Tabs.Screen
name="settings"
options={{
title: "Settings",
tabBarIcon: ({ color }) => <TabBarIcon name="cog" color={color} />,
}}
/>
</Tabs>
);
}

406
app/(tabs)/index.tsx Normal file
View file

@ -0,0 +1,406 @@
/**
* Collection screen - displays user's scanned cards.
*/
import React, { useState, useCallback, useMemo } from "react";
import {
StyleSheet,
View,
Text,
FlatList,
Image,
Pressable,
TextInput,
RefreshControl,
Dimensions,
ActivityIndicator,
} from "react-native";
import { FontAwesome } from "@expo/vector-icons";
import { useQuery } from "convex/react";
import { useRouter } from "expo-router";
import { api } from "../../convex/_generated/api";
import { useCurrentUser } from "@/lib/hooks";
const { width: SCREEN_WIDTH } = Dimensions.get("window");
const CARD_WIDTH = (SCREEN_WIDTH - 48) / 3;
const CARD_HEIGHT = CARD_WIDTH * (88 / 63); // MTG aspect ratio
interface CollectionItem {
id: string;
cardId: string;
name: string;
setCode: string;
imageUri?: string;
quantity: number;
isFoil: boolean;
addedAt: number;
}
export default function CollectionScreen() {
const router = useRouter();
const [searchQuery, setSearchQuery] = useState("");
const [refreshing, setRefreshing] = useState(false);
const [sortBy, setSortBy] = useState<"name" | "set" | "recent">("recent");
// Get authenticated user
const { user, isAuthenticated } = useCurrentUser();
const userId = user?._id ?? null;
// Fetch collection from Convex
const rawCollection = useQuery(
api.collections.getByUser,
userId ? { userId: userId as any } : "skip"
);
// Transform Convex data to UI format
const collection = useMemo<CollectionItem[]>(() => {
if (!rawCollection) return [];
return rawCollection.map((entry) => ({
id: entry._id,
cardId: entry.cardId,
name: entry.card?.name || "Unknown",
setCode: entry.card?.setCode || "???",
imageUri: entry.card?.imageUri,
quantity: entry.quantity,
isFoil: entry.isFoil,
addedAt: entry.addedAt,
}));
}, [rawCollection]);
// Filter collection by search query
const filteredCollection = useMemo(
() =>
collection.filter((item) =>
item.name.toLowerCase().includes(searchQuery.toLowerCase())
),
[collection, searchQuery]
);
// Sort collection
const sortedCollection = useMemo(
() =>
[...filteredCollection].sort((a, b) => {
switch (sortBy) {
case "name":
return a.name.localeCompare(b.name);
case "set":
return a.setCode.localeCompare(b.setCode);
case "recent":
default:
return b.addedAt - a.addedAt;
}
}),
[filteredCollection, sortBy]
);
const onRefresh = useCallback(async () => {
setRefreshing(true);
// Convex automatically syncs - just show loading briefly
await new Promise((resolve) => setTimeout(resolve, 500));
setRefreshing(false);
}, []);
const renderCard = useCallback(
({ item }: { item: CollectionItem }) => (
<Pressable
style={styles.cardContainer}
onPress={() =>
router.push({
pathname: "/modal",
params: {
collectionEntryId: item.id,
cardId: item.cardId,
},
})
}
>
{item.imageUri ? (
<Image
source={{ uri: item.imageUri }}
style={styles.cardImage}
resizeMode="cover"
/>
) : (
<View style={[styles.cardImage, styles.cardPlaceholder]}>
<FontAwesome name="question" size={24} color="#666" />
</View>
)}
{item.quantity > 1 && (
<View style={styles.quantityBadge}>
<Text style={styles.quantityText}>×{item.quantity}</Text>
</View>
)}
{item.isFoil && (
<View style={styles.foilBadge}>
<FontAwesome name="star" size={10} color="#FFD700" />
</View>
)}
</Pressable>
),
[router]
);
const renderEmpty = useCallback(
() => (
<View style={styles.emptyContainer}>
<FontAwesome name="inbox" size={64} color="#444" />
<Text style={styles.emptyTitle}>No Cards Yet</Text>
<Text style={styles.emptyText}>
{userId
? "Start scanning cards to build your collection!"
: "Sign in to start building your collection!"}
</Text>
</View>
),
[userId]
);
// Loading state
if (rawCollection === undefined && userId) {
return (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color="#007AFF" />
<Text style={styles.loadingText}>Loading collection...</Text>
</View>
);
}
// Total cards count
const totalCount = collection.reduce((sum, item) => sum + item.quantity, 0);
return (
<View style={styles.container}>
{/* Search bar */}
<View style={styles.searchContainer}>
<FontAwesome
name="search"
size={16}
color="#888"
style={styles.searchIcon}
/>
<TextInput
style={styles.searchInput}
placeholder="Search your collection..."
placeholderTextColor="#666"
value={searchQuery}
onChangeText={setSearchQuery}
autoCapitalize="none"
autoCorrect={false}
/>
{searchQuery.length > 0 && (
<Pressable onPress={() => setSearchQuery("")}>
<FontAwesome name="times-circle" size={16} color="#666" />
</Pressable>
)}
</View>
{/* Stats and sort bar */}
<View style={styles.statsBar}>
<Text style={styles.statsText}>
{sortedCollection.length} unique
{collection.length > 0 && ` (${totalCount} total)`}
</Text>
<View style={styles.sortButtons}>
<Pressable
style={[
styles.sortButton,
sortBy === "recent" && styles.sortButtonActive,
]}
onPress={() => setSortBy("recent")}
>
<Text
style={[
styles.sortButtonText,
sortBy === "recent" && styles.sortButtonTextActive,
]}
>
Recent
</Text>
</Pressable>
<Pressable
style={[
styles.sortButton,
sortBy === "name" && styles.sortButtonActive,
]}
onPress={() => setSortBy("name")}
>
<Text
style={[
styles.sortButtonText,
sortBy === "name" && styles.sortButtonTextActive,
]}
>
Name
</Text>
</Pressable>
<Pressable
style={[
styles.sortButton,
sortBy === "set" && styles.sortButtonActive,
]}
onPress={() => setSortBy("set")}
>
<Text
style={[
styles.sortButtonText,
sortBy === "set" && styles.sortButtonTextActive,
]}
>
Set
</Text>
</Pressable>
</View>
</View>
{/* Card grid */}
<FlatList
data={sortedCollection}
renderItem={renderCard}
keyExtractor={(item) => item.id}
numColumns={3}
contentContainerStyle={styles.gridContent}
ListEmptyComponent={renderEmpty}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
tintColor="#007AFF"
/>
}
/>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: "#1a1a1a",
},
searchContainer: {
flexDirection: "row",
alignItems: "center",
backgroundColor: "#2a2a2a",
marginHorizontal: 16,
marginTop: 16,
marginBottom: 8,
paddingHorizontal: 12,
borderRadius: 10,
},
searchIcon: {
marginRight: 8,
},
searchInput: {
flex: 1,
height: 44,
color: "#fff",
fontSize: 16,
},
statsBar: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
paddingHorizontal: 16,
paddingVertical: 8,
},
statsText: {
color: "#888",
fontSize: 14,
},
sortButtons: {
flexDirection: "row",
gap: 4,
},
sortButton: {
paddingHorizontal: 10,
paddingVertical: 6,
borderRadius: 6,
},
sortButtonActive: {
backgroundColor: "#333",
},
sortButtonText: {
color: "#666",
fontSize: 12,
fontWeight: "500",
},
sortButtonTextActive: {
color: "#fff",
},
gridContent: {
padding: 16,
paddingTop: 8,
},
cardContainer: {
width: CARD_WIDTH,
height: CARD_HEIGHT,
marginRight: 8,
marginBottom: 8,
borderRadius: 8,
overflow: "hidden",
},
cardImage: {
width: "100%",
height: "100%",
backgroundColor: "#2a2a2a",
borderRadius: 8,
},
cardPlaceholder: {
justifyContent: "center",
alignItems: "center",
},
quantityBadge: {
position: "absolute",
top: 4,
right: 4,
backgroundColor: "rgba(0,0,0,0.8)",
paddingHorizontal: 6,
paddingVertical: 2,
borderRadius: 4,
},
quantityText: {
color: "#fff",
fontSize: 11,
fontWeight: "600",
},
foilBadge: {
position: "absolute",
top: 4,
left: 4,
backgroundColor: "rgba(0,0,0,0.8)",
padding: 4,
borderRadius: 4,
},
emptyContainer: {
flex: 1,
justifyContent: "center",
alignItems: "center",
paddingTop: 100,
paddingHorizontal: 40,
},
emptyTitle: {
marginTop: 20,
fontSize: 22,
fontWeight: "bold",
color: "#fff",
},
emptyText: {
marginTop: 8,
fontSize: 16,
color: "#888",
textAlign: "center",
},
loadingContainer: {
flex: 1,
justifyContent: "center",
alignItems: "center",
backgroundColor: "#1a1a1a",
},
loadingText: {
marginTop: 16,
color: "#888",
fontSize: 16,
},
});

572
app/(tabs)/scan.tsx Normal file
View 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",
},
});

313
app/(tabs)/settings.tsx Normal file
View file

@ -0,0 +1,313 @@
/**
* Settings screen for Scry app.
*/
import React, { useState } from "react";
import {
StyleSheet,
View,
Text,
ScrollView,
Pressable,
Switch,
ActivityIndicator,
Alert,
} from "react-native";
import { FontAwesome } from "@expo/vector-icons";
import { useQuery } from "convex/react";
import { api } from "../../convex/_generated/api";
import { useSync } from "@/lib/hooks/useSync";
import { useHashCache } from "@/lib/context";
interface SettingRowProps {
icon: React.ComponentProps<typeof FontAwesome>["name"];
title: string;
subtitle?: string;
onPress?: () => void;
rightElement?: React.ReactNode;
destructive?: boolean;
}
function SettingRow({
icon,
title,
subtitle,
onPress,
rightElement,
destructive,
}: SettingRowProps) {
return (
<Pressable
style={({ pressed }) => [
styles.settingRow,
pressed && onPress && styles.settingRowPressed,
]}
onPress={onPress}
disabled={!onPress}
>
<View style={[styles.settingIcon, destructive && styles.settingIconDestructive]}>
<FontAwesome name={icon} size={18} color={destructive ? "#FF6B6B" : "#007AFF"} />
</View>
<View style={styles.settingContent}>
<Text style={[styles.settingTitle, destructive && styles.settingTitleDestructive]}>
{title}
</Text>
{subtitle && <Text style={styles.settingSubtitle}>{subtitle}</Text>}
</View>
{rightElement || (onPress && <FontAwesome name="chevron-right" size={14} color="#666" />)}
</Pressable>
);
}
function SettingSection({ title, children }: { title: string; children: React.ReactNode }) {
return (
<View style={styles.section}>
<Text style={styles.sectionTitle}>{title}</Text>
<View style={styles.sectionContent}>{children}</View>
</View>
);
}
export default function SettingsScreen() {
const [cardDetectionEnabled, setCardDetectionEnabled] = useState(true);
const [rotationMatchingEnabled, setRotationMatchingEnabled] = useState(true);
// Get hash count from context
const { cardHashes, hashesLoaded } = useHashCache();
// Sync hook for cache management
const { isInitialized, isSyncing, lastSync, localCardCount, error: syncError, sync, clearCache } =
useSync();
// Get total card count from Convex
const cardCount = useQuery(api.cards.count);
const formatLastSync = (timestamp: number) => {
if (!timestamp) return "Never";
const date = new Date(timestamp);
return date.toLocaleDateString() + " " + date.toLocaleTimeString();
};
const handleClearCache = () => {
Alert.alert(
"Clear Local Cache",
"This will remove all downloaded card data. You'll need to sync again to scan cards.",
[
{ text: "Cancel", style: "cancel" },
{
text: "Clear",
style: "destructive",
onPress: async () => {
await clearCache();
Alert.alert("Cache Cleared", "Local card data has been removed.");
},
},
]
);
};
const handleManualSync = async () => {
await sync();
Alert.alert("Sync Complete", `${localCardCount} cards now available for scanning.`);
};
return (
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
{/* Database section */}
<SettingSection title="Database">
<SettingRow
icon="database"
title="Card Database"
subtitle={
isInitialized
? `${localCardCount.toLocaleString()} cards cached locally`
: isSyncing
? "Loading..."
: "Not initialized"
}
rightElement={isSyncing ? <ActivityIndicator size="small" color="#007AFF" /> : undefined}
/>
<SettingRow
icon="refresh"
title="Sync Now"
subtitle={`Last sync: ${formatLastSync(lastSync)}`}
onPress={handleManualSync}
rightElement={isSyncing ? <ActivityIndicator size="small" color="#007AFF" /> : undefined}
/>
{syncError && (
<SettingRow icon="exclamation-triangle" title="Sync Error" subtitle={syncError} destructive />
)}
</SettingSection>
{/* Recognition section */}
<SettingSection title="Recognition">
<SettingRow
icon="crop"
title="Card Detection"
subtitle="Automatically detect card boundaries"
rightElement={
<Switch
value={cardDetectionEnabled}
onValueChange={setCardDetectionEnabled}
trackColor={{ true: "#007AFF" }}
/>
}
/>
<SettingRow
icon="rotate-right"
title="Rotation Matching"
subtitle="Try multiple rotations for matching"
rightElement={
<Switch
value={rotationMatchingEnabled}
onValueChange={setRotationMatchingEnabled}
trackColor={{ true: "#007AFF" }}
/>
}
/>
</SettingSection>
{/* Collection section */}
<SettingSection title="Collection">
<SettingRow
icon="th-large"
title="Cards in Database"
subtitle={`${(cardCount ?? 0).toLocaleString()} cards available`}
/>
<SettingRow
icon="cloud-upload"
title="Export Collection"
subtitle="Export as CSV or JSON"
onPress={() => {
// TODO: Implement export
}}
/>
</SettingSection>
{/* About section */}
<SettingSection title="About">
<SettingRow icon="info-circle" title="Version" subtitle="1.0.0 (Expo + Convex)" />
<SettingRow
icon="github"
title="Source Code"
subtitle="View on Forgejo"
onPress={() => {
// TODO: Open source URL
}}
/>
</SettingSection>
{/* Danger zone */}
<SettingSection title="Danger Zone">
<SettingRow
icon="trash"
title="Clear Collection"
subtitle="Remove all cards from collection"
onPress={() => {
Alert.alert(
"Clear Collection",
"This will remove all cards from your collection. This cannot be undone.",
[
{ text: "Cancel", style: "cancel" },
{ text: "Clear", style: "destructive", onPress: () => {} },
]
);
}}
destructive
/>
<SettingRow
icon="eraser"
title="Clear Local Cache"
subtitle="Remove downloaded card data"
onPress={handleClearCache}
destructive
/>
</SettingSection>
{/* Footer */}
<View style={styles.footer}>
<Text style={styles.footerText}>Scry Card scanner for Magic: The Gathering</Text>
<Text style={styles.footerSubtext}>Card data © Wizards of the Coast</Text>
</View>
</ScrollView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: "#1a1a1a",
},
content: {
paddingBottom: 40,
},
section: {
marginTop: 24,
},
sectionTitle: {
color: "#888",
fontSize: 13,
fontWeight: "600",
textTransform: "uppercase",
letterSpacing: 0.5,
marginLeft: 16,
marginBottom: 8,
},
sectionContent: {
backgroundColor: "#2a2a2a",
borderRadius: 12,
marginHorizontal: 16,
overflow: "hidden",
},
settingRow: {
flexDirection: "row",
alignItems: "center",
padding: 14,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: "#3a3a3a",
},
settingRowPressed: {
backgroundColor: "#333",
},
settingIcon: {
width: 32,
height: 32,
borderRadius: 8,
backgroundColor: "rgba(0,122,255,0.1)",
justifyContent: "center",
alignItems: "center",
marginRight: 12,
},
settingIconDestructive: {
backgroundColor: "rgba(255,107,107,0.1)",
},
settingContent: {
flex: 1,
},
settingTitle: {
color: "#fff",
fontSize: 16,
},
settingTitleDestructive: {
color: "#FF6B6B",
},
settingSubtitle: {
color: "#888",
fontSize: 13,
marginTop: 2,
},
footer: {
marginTop: 40,
alignItems: "center",
paddingHorizontal: 16,
},
footerText: {
color: "#666",
fontSize: 14,
},
footerSubtext: {
color: "#444",
fontSize: 12,
marginTop: 4,
},
});

38
app/+html.tsx Normal file
View file

@ -0,0 +1,38 @@
import { ScrollViewStyleReset } from 'expo-router/html';
// This file is web-only and used to configure the root HTML for every
// web page during static rendering.
// The contents of this function only run in Node.js environments and
// do not have access to the DOM or browser APIs.
export default function Root({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta httpEquiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no" />
{/*
Disable body scrolling on web. This makes ScrollView components work closer to how they do on native.
However, body scrolling is often nice to have for mobile web. If you want to enable it, remove this line.
*/}
<ScrollViewStyleReset />
{/* Using raw CSS styles as an escape-hatch to ensure the background color never flickers in dark-mode. */}
<style dangerouslySetInnerHTML={{ __html: responsiveBackground }} />
{/* Add any additional <head> elements that you want globally available on web... */}
</head>
<body>{children}</body>
</html>
);
}
const responsiveBackground = `
body {
background-color: #fff;
}
@media (prefers-color-scheme: dark) {
body {
background-color: #000;
}
}`;

40
app/+not-found.tsx Normal file
View file

@ -0,0 +1,40 @@
import { Link, Stack } from 'expo-router';
import { StyleSheet } from 'react-native';
import { Text, View } from '@/components/Themed';
export default function NotFoundScreen() {
return (
<>
<Stack.Screen options={{ title: 'Oops!' }} />
<View style={styles.container}>
<Text style={styles.title}>This screen doesn't exist.</Text>
<Link href="/" style={styles.link}>
<Text style={styles.linkText}>Go to home screen!</Text>
</Link>
</View>
</>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
padding: 20,
},
title: {
fontSize: 20,
fontWeight: 'bold',
},
link: {
marginTop: 15,
paddingVertical: 15,
},
linkText: {
fontSize: 14,
color: '#2e78b7',
},
});

72
app/_layout.tsx Normal file
View file

@ -0,0 +1,72 @@
import React, { useEffect } from "react";
import FontAwesome from "@expo/vector-icons/FontAwesome";
import { useFonts } from "expo-font";
import * as SplashScreen from "expo-splash-screen";
import { Stack } from "expo-router";
import { ConvexProvider, ConvexReactClient } from "convex/react";
import { HashCacheProvider } from "@/lib/context";
import { useColorScheme } from "@/components/useColorScheme";
// Initialize Convex client
const convex = new ConvexReactClient(
process.env.EXPO_PUBLIC_CONVEX_URL as string
);
// Keep splash screen visible while loading fonts
SplashScreen.preventAutoHideAsync();
export default function RootLayout() {
const [loaded, error] = useFonts({
SpaceMono: require("../assets/fonts/SpaceMono-Regular.ttf"),
...FontAwesome.font,
});
useEffect(() => {
if (error) throw error;
}, [error]);
useEffect(() => {
if (loaded) {
SplashScreen.hideAsync();
}
}, [loaded]);
if (!loaded) {
return null;
}
return (
<ConvexProvider client={convex}>
<HashCacheProvider>
<RootLayoutNav />
</HashCacheProvider>
</ConvexProvider>
);
}
function RootLayoutNav() {
const colorScheme = useColorScheme();
return (
<Stack
screenOptions={{
headerStyle: {
backgroundColor: colorScheme === "dark" ? "#1a1a1a" : "#fff",
},
headerTintColor: colorScheme === "dark" ? "#fff" : "#000",
contentStyle: {
backgroundColor: colorScheme === "dark" ? "#1a1a1a" : "#fff",
},
}}
>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen
name="modal"
options={{
presentation: "modal",
title: "Card Details",
}}
/>
</Stack>
);
}

376
app/modal.tsx Normal file
View file

@ -0,0 +1,376 @@
/**
* Card detail modal - shows full card info with quantity controls.
*/
import React, { useState } from "react";
import {
StyleSheet,
View,
Text,
Image,
Pressable,
ScrollView,
ActivityIndicator,
Alert,
Dimensions,
Platform,
} from "react-native";
import { StatusBar } from "expo-status-bar";
import { useLocalSearchParams, useRouter } from "expo-router";
import { FontAwesome } from "@expo/vector-icons";
import { useQuery, useMutation } from "convex/react";
import { api } from "../convex/_generated/api";
import { useCurrentUser } from "@/lib/hooks";
const { width: SCREEN_WIDTH } = Dimensions.get("window");
const CARD_WIDTH = SCREEN_WIDTH - 64;
const CARD_HEIGHT = CARD_WIDTH * (88 / 63);
export default function CardDetailModal() {
const router = useRouter();
const params = useLocalSearchParams<{
collectionEntryId: string;
cardId: string;
}>();
// Get authenticated user
const { user } = useCurrentUser();
const userId = user?._id ?? null;
const [isUpdating, setIsUpdating] = useState(false);
// Fetch card details
const card = useQuery(
api.cards.getByScryfallId,
params.cardId ? { scryfallId: params.cardId } : "skip"
);
// Fetch collection entry
const collection = useQuery(
api.collections.getByUser,
userId ? { userId: userId as any } : "skip"
);
const collectionEntry = collection?.find(
(entry) => entry._id === params.collectionEntryId
);
// Mutations
const updateQuantity = useMutation(api.collections.updateQuantity);
const removeCard = useMutation(api.collections.remove);
const handleQuantityChange = async (delta: number) => {
if (!collectionEntry) return;
const newQuantity = collectionEntry.quantity + delta;
if (newQuantity <= 0) {
handleRemove();
return;
}
setIsUpdating(true);
try {
await updateQuantity({
entryId: collectionEntry._id,
quantity: newQuantity,
});
} catch (error) {
console.error("Failed to update quantity:", error);
Alert.alert("Error", "Failed to update quantity");
} finally {
setIsUpdating(false);
}
};
const handleRemove = () => {
Alert.alert(
"Remove Card",
`Remove ${card?.name || "this card"} from your collection?`,
[
{ text: "Cancel", style: "cancel" },
{
text: "Remove",
style: "destructive",
onPress: async () => {
if (!collectionEntry) return;
setIsUpdating(true);
try {
await removeCard({ entryId: collectionEntry._id });
router.back();
} catch (error) {
console.error("Failed to remove card:", error);
Alert.alert("Error", "Failed to remove card");
setIsUpdating(false);
}
},
},
]
);
};
// Loading state
if (!card || !collectionEntry) {
return (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color="#007AFF" />
<StatusBar style={Platform.OS === "ios" ? "light" : "auto"} />
</View>
);
}
return (
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
{/* Card image */}
<View style={styles.imageContainer}>
{card.imageUri ? (
<Image
source={{ uri: card.imageUri }}
style={styles.cardImage}
resizeMode="contain"
/>
) : (
<View style={[styles.cardImage, styles.cardPlaceholder]}>
<FontAwesome name="image" size={48} color="#666" />
</View>
)}
</View>
{/* Card info */}
<View style={styles.infoContainer}>
<Text style={styles.cardName}>{card.name}</Text>
<View style={styles.metaRow}>
<View style={styles.metaItem}>
<Text style={styles.metaLabel}>Set</Text>
<Text style={styles.metaValue}>{card.setCode.toUpperCase()}</Text>
</View>
<View style={styles.metaItem}>
<Text style={styles.metaLabel}>Number</Text>
<Text style={styles.metaValue}>{card.collectorNumber}</Text>
</View>
<View style={styles.metaItem}>
<Text style={styles.metaLabel}>Rarity</Text>
<Text style={styles.metaValue}>{card.rarity}</Text>
</View>
</View>
{card.artist && (
<View style={styles.artistRow}>
<FontAwesome name="paint-brush" size={12} color="#888" />
<Text style={styles.artistText}>{card.artist}</Text>
</View>
)}
</View>
{/* Quantity controls */}
<View style={styles.quantityContainer}>
<Text style={styles.quantityLabel}>Quantity</Text>
<View style={styles.quantityControls}>
<Pressable
style={styles.quantityButton}
onPress={() => handleQuantityChange(-1)}
disabled={isUpdating}
>
<FontAwesome name="minus" size={16} color="#fff" />
</Pressable>
<View style={styles.quantityDisplay}>
{isUpdating ? (
<ActivityIndicator size="small" color="#007AFF" />
) : (
<Text style={styles.quantityValue}>{collectionEntry.quantity}</Text>
)}
</View>
<Pressable
style={styles.quantityButton}
onPress={() => handleQuantityChange(1)}
disabled={isUpdating}
>
<FontAwesome name="plus" size={16} color="#fff" />
</Pressable>
</View>
{collectionEntry.isFoil && (
<View style={styles.foilBadge}>
<FontAwesome name="star" size={12} color="#FFD700" />
<Text style={styles.foilText}>Foil</Text>
</View>
)}
</View>
{/* Added date */}
<Text style={styles.addedDate}>
Added {new Date(collectionEntry.addedAt).toLocaleDateString()}
</Text>
{/* Actions */}
<View style={styles.actions}>
<Pressable
style={[styles.actionButton, styles.removeButton]}
onPress={handleRemove}
disabled={isUpdating}
>
<FontAwesome name="trash" size={16} color="#FF6B6B" />
<Text style={styles.removeButtonText}>Remove from Collection</Text>
</Pressable>
</View>
<StatusBar style={Platform.OS === "ios" ? "light" : "auto"} />
</ScrollView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: "#1a1a1a",
},
content: {
paddingBottom: 40,
},
loadingContainer: {
flex: 1,
justifyContent: "center",
alignItems: "center",
backgroundColor: "#1a1a1a",
},
imageContainer: {
alignItems: "center",
paddingTop: 20,
paddingBottom: 24,
},
cardImage: {
width: CARD_WIDTH,
height: CARD_HEIGHT,
borderRadius: 12,
backgroundColor: "#2a2a2a",
},
cardPlaceholder: {
justifyContent: "center",
alignItems: "center",
},
infoContainer: {
paddingHorizontal: 24,
marginBottom: 24,
},
cardName: {
color: "#fff",
fontSize: 24,
fontWeight: "bold",
textAlign: "center",
marginBottom: 16,
},
metaRow: {
flexDirection: "row",
justifyContent: "center",
gap: 24,
},
metaItem: {
alignItems: "center",
},
metaLabel: {
color: "#666",
fontSize: 12,
textTransform: "uppercase",
marginBottom: 4,
},
metaValue: {
color: "#fff",
fontSize: 16,
fontWeight: "600",
},
artistRow: {
flexDirection: "row",
justifyContent: "center",
alignItems: "center",
gap: 8,
marginTop: 16,
},
artistText: {
color: "#888",
fontSize: 14,
fontStyle: "italic",
},
quantityContainer: {
backgroundColor: "#2a2a2a",
marginHorizontal: 24,
padding: 20,
borderRadius: 12,
alignItems: "center",
marginBottom: 16,
},
quantityLabel: {
color: "#888",
fontSize: 12,
textTransform: "uppercase",
marginBottom: 12,
},
quantityControls: {
flexDirection: "row",
alignItems: "center",
gap: 20,
},
quantityButton: {
width: 44,
height: 44,
borderRadius: 22,
backgroundColor: "#007AFF",
justifyContent: "center",
alignItems: "center",
},
quantityDisplay: {
width: 60,
alignItems: "center",
},
quantityValue: {
color: "#fff",
fontSize: 28,
fontWeight: "bold",
},
foilBadge: {
flexDirection: "row",
alignItems: "center",
gap: 6,
marginTop: 12,
paddingHorizontal: 12,
paddingVertical: 6,
backgroundColor: "rgba(255,215,0,0.1)",
borderRadius: 6,
},
foilText: {
color: "#FFD700",
fontSize: 12,
fontWeight: "600",
},
addedDate: {
color: "#666",
fontSize: 12,
textAlign: "center",
marginBottom: 24,
},
actions: {
paddingHorizontal: 24,
},
actionButton: {
flexDirection: "row",
justifyContent: "center",
alignItems: "center",
gap: 8,
padding: 16,
borderRadius: 12,
},
removeButton: {
backgroundColor: "rgba(255,107,107,0.1)",
borderWidth: 1,
borderColor: "rgba(255,107,107,0.3)",
},
removeButtonText: {
color: "#FF6B6B",
fontSize: 16,
fontWeight: "600",
},
});

31
app/two.tsx Normal file
View file

@ -0,0 +1,31 @@
import { StyleSheet } from 'react-native';
import EditScreenInfo from '@/components/EditScreenInfo';
import { Text, View } from '@/components/Themed';
export default function TabTwoScreen() {
return (
<View style={styles.container}>
<Text style={styles.title}>Tab Two</Text>
<View style={styles.separator} lightColor="#eee" darkColor="rgba(255,255,255,0.1)" />
<EditScreenInfo path="app/(tabs)/two.tsx" />
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
},
title: {
fontSize: 20,
fontWeight: 'bold',
},
separator: {
marginVertical: 30,
height: 1,
width: '80%',
},
});