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

View file

@ -0,0 +1,48 @@
/**
* Camera component using expo-camera (works in Expo Go).
*/
import React, { forwardRef, useImperativeHandle, useRef } from "react";
import { StyleSheet } from "react-native";
import { CameraView } from "expo-camera";
export interface CameraHandle {
takePhoto: () => Promise<{ uri: string }>;
}
interface ExpoCameraProps {
flashEnabled?: boolean;
}
export const ExpoCamera = forwardRef<CameraHandle, ExpoCameraProps>(
({ flashEnabled = false }, ref) => {
const cameraRef = useRef<CameraView>(null);
useImperativeHandle(ref, () => ({
takePhoto: async () => {
if (!cameraRef.current) {
throw new Error("Camera not ready");
}
const photo = await cameraRef.current.takePictureAsync({
quality: 0.8,
base64: false,
});
if (!photo) {
throw new Error("Failed to capture photo");
}
return { uri: photo.uri };
},
}));
return (
<CameraView
ref={cameraRef}
style={StyleSheet.absoluteFill}
facing="back"
flash={flashEnabled ? "on" : "off"}
/>
);
}
);
ExpoCamera.displayName = "ExpoCamera";

View file

@ -0,0 +1,53 @@
/**
* Camera component using react-native-vision-camera (production builds).
*/
import React, { forwardRef, useImperativeHandle, useRef } from "react";
import { StyleSheet } from "react-native";
import { Camera, useCameraDevice } from "react-native-vision-camera";
export interface CameraHandle {
takePhoto: () => Promise<{ uri: string }>;
}
interface VisionCameraProps {
flashEnabled?: boolean;
}
export const VisionCamera = forwardRef<CameraHandle, VisionCameraProps>(
({ flashEnabled = false }, ref) => {
const device = useCameraDevice("back");
const cameraRef = useRef<Camera>(null);
useImperativeHandle(ref, () => ({
takePhoto: async () => {
if (!cameraRef.current) {
throw new Error("Camera not ready");
}
const photo = await cameraRef.current.takePhoto({
flash: flashEnabled ? "on" : "off",
enableShutterSound: false,
});
return { uri: photo.path };
},
}));
if (!device) {
return null;
}
return (
<Camera
ref={cameraRef}
style={StyleSheet.absoluteFill}
device={device}
isActive={true}
photo={true}
/>
);
}
);
VisionCamera.displayName = "VisionCamera";
export { useCameraDevice };

View file

@ -0,0 +1,64 @@
/**
* Adaptive camera component that uses expo-camera in Expo Go
* and react-native-vision-camera in production builds.
*/
import React, { forwardRef, lazy, Suspense } from "react";
import { View, ActivityIndicator, StyleSheet, Text } from "react-native";
import Constants from "expo-constants";
export interface CameraHandle {
takePhoto: () => Promise<{ uri: string }>;
}
interface AdaptiveCameraProps {
flashEnabled?: boolean;
}
// Detect if running in Expo Go
const isExpoGo = Constants.appOwnership === "expo";
// Lazy load the appropriate camera component
const ExpoCamera = lazy(() =>
import("./ExpoCamera").then((m) => ({ default: m.ExpoCamera }))
);
const VisionCamera = lazy(() =>
import("./VisionCamera").then((m) => ({ default: m.VisionCamera }))
);
const CameraLoading = () => (
<View style={styles.loading}>
<ActivityIndicator size="large" color="#007AFF" />
<Text style={styles.loadingText}>Initializing camera...</Text>
</View>
);
export const AdaptiveCamera = forwardRef<CameraHandle, AdaptiveCameraProps>(
(props, ref) => {
const CameraComponent = isExpoGo ? ExpoCamera : VisionCamera;
return (
<Suspense fallback={<CameraLoading />}>
<CameraComponent ref={ref} {...props} />
</Suspense>
);
}
);
AdaptiveCamera.displayName = "AdaptiveCamera";
const styles = StyleSheet.create({
loading: {
...StyleSheet.absoluteFillObject,
backgroundColor: "#000",
justifyContent: "center",
alignItems: "center",
},
loadingText: {
marginTop: 16,
color: "#888",
fontSize: 14,
},
});
export { isExpoGo };