scry/app/(tabs)/index.tsx
Chris Kruining 83ab4df537
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>
2026-02-09 16:16:34 +01:00

406 lines
9.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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