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
58
app/(tabs)/_layout.tsx
Normal file
58
app/(tabs)/_layout.tsx
Normal 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
406
app/(tabs)/index.tsx
Normal 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
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",
|
||||
},
|
||||
});
|
||||
313
app/(tabs)/settings.tsx
Normal file
313
app/(tabs)/settings.tsx
Normal 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
38
app/+html.tsx
Normal 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
40
app/+not-found.tsx
Normal 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
72
app/_layout.tsx
Normal 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
376
app/modal.tsx
Normal 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
31
app/two.tsx
Normal 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%',
|
||||
},
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue