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

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