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

376
app/modal.tsx Normal file
View file

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