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:
parent
56499d5af9
commit
83ab4df537
138 changed files with 19136 additions and 7681 deletions
77
components/EditScreenInfo.tsx
Normal file
77
components/EditScreenInfo.tsx
Normal 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',
|
||||
},
|
||||
});
|
||||
25
components/ExternalLink.tsx
Normal file
25
components/ExternalLink.tsx
Normal 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);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
5
components/StyledText.tsx
Normal file
5
components/StyledText.tsx
Normal 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
45
components/Themed.tsx
Normal 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} />;
|
||||
}
|
||||
10
components/__tests__/StyledText-test.js
Normal file
10
components/__tests__/StyledText-test.js
Normal 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();
|
||||
});
|
||||
48
components/camera/ExpoCamera.tsx
Normal file
48
components/camera/ExpoCamera.tsx
Normal 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";
|
||||
53
components/camera/VisionCamera.tsx
Normal file
53
components/camera/VisionCamera.tsx
Normal 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 };
|
||||
64
components/camera/index.tsx
Normal file
64
components/camera/index.tsx
Normal 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 };
|
||||
4
components/useClientOnlyValue.ts
Normal file
4
components/useClientOnlyValue.ts
Normal 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;
|
||||
}
|
||||
12
components/useClientOnlyValue.web.ts
Normal file
12
components/useClientOnlyValue.web.ts
Normal 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;
|
||||
}
|
||||
1
components/useColorScheme.ts
Normal file
1
components/useColorScheme.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { useColorScheme } from 'react-native';
|
||||
8
components/useColorScheme.web.ts
Normal file
8
components/useColorScheme.web.ts
Normal 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';
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue