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