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,77 @@
import React from 'react';
import { StyleSheet } from 'react-native';
import { ExternalLink } from './ExternalLink';
import { MonoText } from './StyledText';
import { Text, View } from './Themed';
import Colors from '@/constants/Colors';
export default function EditScreenInfo({ path }: { path: string }) {
return (
<View>
<View style={styles.getStartedContainer}>
<Text
style={styles.getStartedText}
lightColor="rgba(0,0,0,0.8)"
darkColor="rgba(255,255,255,0.8)">
Open up the code for this screen:
</Text>
<View
style={[styles.codeHighlightContainer, styles.homeScreenFilename]}
darkColor="rgba(255,255,255,0.05)"
lightColor="rgba(0,0,0,0.05)">
<MonoText>{path}</MonoText>
</View>
<Text
style={styles.getStartedText}
lightColor="rgba(0,0,0,0.8)"
darkColor="rgba(255,255,255,0.8)">
Change any of the text, save the file, and your app will automatically update.
</Text>
</View>
<View style={styles.helpContainer}>
<ExternalLink
style={styles.helpLink}
href="https://docs.expo.io/get-started/create-a-new-app/#opening-the-app-on-your-phonetablet">
<Text style={styles.helpLinkText} lightColor={Colors.light.tint}>
Tap here if your app doesn't automatically update after making changes
</Text>
</ExternalLink>
</View>
</View>
);
}
const styles = StyleSheet.create({
getStartedContainer: {
alignItems: 'center',
marginHorizontal: 50,
},
homeScreenFilename: {
marginVertical: 7,
},
codeHighlightContainer: {
borderRadius: 3,
paddingHorizontal: 4,
},
getStartedText: {
fontSize: 17,
lineHeight: 24,
textAlign: 'center',
},
helpContainer: {
marginTop: 15,
marginHorizontal: 20,
alignItems: 'center',
},
helpLink: {
paddingVertical: 15,
},
helpLinkText: {
textAlign: 'center',
},
});

View file

@ -0,0 +1,25 @@
import { Link } from 'expo-router';
import * as WebBrowser from 'expo-web-browser';
import React from 'react';
import { Platform } from 'react-native';
export function ExternalLink(
props: Omit<React.ComponentProps<typeof Link>, 'href'> & { href: string }
) {
return (
<Link
target="_blank"
{...props}
// @ts-expect-error: External URLs are not typed.
href={props.href}
onPress={(e) => {
if (Platform.OS !== 'web') {
// Prevent the default behavior of linking to the default browser on native.
e.preventDefault();
// Open the link in an in-app browser.
WebBrowser.openBrowserAsync(props.href as string);
}
}}
/>
);
}

View file

@ -0,0 +1,5 @@
import { Text, TextProps } from './Themed';
export function MonoText(props: TextProps) {
return <Text {...props} style={[props.style, { fontFamily: 'SpaceMono' }]} />;
}

45
components/Themed.tsx Normal file
View file

@ -0,0 +1,45 @@
/**
* Learn more about Light and Dark modes:
* https://docs.expo.io/guides/color-schemes/
*/
import { Text as DefaultText, View as DefaultView } from 'react-native';
import Colors from '@/constants/Colors';
import { useColorScheme } from './useColorScheme';
type ThemeProps = {
lightColor?: string;
darkColor?: string;
};
export type TextProps = ThemeProps & DefaultText['props'];
export type ViewProps = ThemeProps & DefaultView['props'];
export function useThemeColor(
props: { light?: string; dark?: string },
colorName: keyof typeof Colors.light & keyof typeof Colors.dark
) {
const theme = useColorScheme() ?? 'light';
const colorFromProps = props[theme];
if (colorFromProps) {
return colorFromProps;
} else {
return Colors[theme][colorName];
}
}
export function Text(props: TextProps) {
const { style, lightColor, darkColor, ...otherProps } = props;
const color = useThemeColor({ light: lightColor, dark: darkColor }, 'text');
return <DefaultText style={[{ color }, style]} {...otherProps} />;
}
export function View(props: ViewProps) {
const { style, lightColor, darkColor, ...otherProps } = props;
const backgroundColor = useThemeColor({ light: lightColor, dark: darkColor }, 'background');
return <DefaultView style={[{ backgroundColor }, style]} {...otherProps} />;
}

View file

@ -0,0 +1,10 @@
import * as React from 'react';
import renderer from 'react-test-renderer';
import { MonoText } from '../StyledText';
it(`renders correctly`, () => {
const tree = renderer.create(<MonoText>Snapshot test!</MonoText>).toJSON();
expect(tree).toMatchSnapshot();
});

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

View file

@ -0,0 +1,4 @@
// This function is web-only as native doesn't currently support server (or build-time) rendering.
export function useClientOnlyValue<S, C>(server: S, client: C): S | C {
return client;
}

View file

@ -0,0 +1,12 @@
import React from 'react';
// `useEffect` is not invoked during server rendering, meaning
// we can use this to determine if we're on the server or not.
export function useClientOnlyValue<S, C>(server: S, client: C): S | C {
const [value, setValue] = React.useState<S | C>(server);
React.useEffect(() => {
setValue(client);
}, [client]);
return value;
}

View file

@ -0,0 +1 @@
export { useColorScheme } from 'react-native';

View file

@ -0,0 +1,8 @@
// NOTE: The default React Native styling doesn't support server rendering.
// Server rendered styles should not change between the first render of the HTML
// and the first render on the client. Typically, web developers will use CSS media queries
// to render different styles on the client and server, these aren't directly supported in React Native
// but can be achieved using a styling library like Nativewind.
export function useColorScheme() {
return 'light';
}