initial commit

This commit is contained in:
2026-06-29 01:59:27 -04:00
commit d988695498
34 changed files with 9397 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
{
"enabledPlugins": {
"expo@claude-plugins-official": true
}
}
+5
View File
@@ -0,0 +1,5 @@
# App Store Connect API key values for local release commands.
# Keep the AuthKey_*.p8 file outside the repo or rely on asc's Keychain storage.
ASC_KEY_ID=
ASC_ISSUER_ID=
ASC_PRIVATE_KEY_PATH=
+42
View File
@@ -0,0 +1,42 @@
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
# dependencies
node_modules/
# Expo
.expo/
dist/
web-build/
expo-env.d.ts
build/
# Native
.kotlin/
*.orig.*
*.jks
*.p8
*.p12
*.key
*.mobileprovision
# Metro
.metro-health-check*
# debug
npm-debug.*
yarn-debug.*
yarn-error.*
# macOS
.DS_Store
*.pem
# local env files
.env*.local
# typescript
*.tsbuildinfo
# generated native folders
/ios
/android
+1
View File
@@ -0,0 +1 @@
legacy-peer-deps=true
+3
View File
@@ -0,0 +1,3 @@
# Expo HAS CHANGED
Read the exact versioned docs at https://docs.expo.dev/versions/v56.0.0/ before writing any code.
+23
View File
@@ -0,0 +1,23 @@
import { SafeAreaProvider } from 'react-native-safe-area-context';
import { StatusBar } from 'expo-status-bar';
import { StyleSheet, View } from 'react-native';
import { HotDogCamera } from './src/components/HotDogCamera';
import { colors } from './src/theme';
export default function App() {
return (
<SafeAreaProvider>
<View style={styles.container}>
<StatusBar style="light" />
<HotDogCamera />
</View>
</SafeAreaProvider>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.bg,
},
});
+1
View File
@@ -0,0 +1 @@
@AGENTS.md
+21
View File
@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2015-present 650 Industries, Inc. (aka Expo)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+47
View File
@@ -0,0 +1,47 @@
# Hotdog
A Silicon Valley-inspired app that uses your iPhone camera to detect whether you're looking at a hot dog.
Built with **Expo SDK 56**, `expo-camera`, and on-device image classification. No server required.
## How it works
1. The camera shows a live preview.
2. Tap the shutter to capture a frame.
3. iOS uses the native vision classifier first, then falls back to MobileNet if needed.
4. MobileNet checks ImageNet labels on-device and applies a confidence/margin threshold.
5. The app shows **Hot dog** or **Not hot dog**, saves the scan to a local gallery, and can export a shareable image.
## Run on iOS
```bash
npm install
npx expo run:ios --device
```
Or start the dev server and scan the QR code with Expo Go on a physical iPhone (camera + ML require a real device):
```bash
npm start
```
> **Note:** Use a physical iOS device. The simulator has no camera, and ML inference works best on hardware.
## Permissions
The app requests camera access on first launch. Photo permissions are used for importing images and saving share cards. iOS permission text is configured in `app.json`.
## Release
This project uses local Xcode/App Store Connect tooling rather than EAS. See [docs/release-ios.md](docs/release-ios.md).
## Tech stack
- Expo SDK 56
- expo-camera
- expo-image-picker + expo-media-library
- expo-blur + expo-glass-effect
- @tensorflow/tfjs + @tensorflow/tfjs-react-native
- @tensorflow-models/mobilenet (ImageNet)
- @dariyd/react-native-image-description
- react-native-fs (required peer of tfjs-react-native)
+61
View File
@@ -0,0 +1,61 @@
{
"expo": {
"name": "hotdog",
"slug": "hotdog",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/icon.png",
"userInterfaceStyle": "light",
"ios": {
"supportsTablet": true,
"bundleIdentifier": "com.soconnor.hotdog",
"icon": "./assets/hotdog-app.icon",
"infoPlist": {
"NSCameraUsageDescription": "Hotdog needs the camera to detect whether you're looking at a hot dog.",
"NSPhotoLibraryAddUsageDescription": "Hotdog saves scanned photos to your library.",
"NSPhotoLibraryUsageDescription": "Hotdog needs photo access to import images from your gallery."
}
},
"plugins": [
[
"expo-build-properties",
{
"ios": {
"deploymentTarget": "17.0"
}
}
],
[
"expo-camera",
{
"cameraPermission": "Hotdog needs the camera to detect whether you're looking at a hot dog."
}
],
[
"expo-image-picker",
{
"photosPermission": "Hotdog needs photo access to import images from your gallery."
}
],
[
"expo-media-library",
{
"photosPermission": "Hotdog saves scanned photos to your library.",
"savePhotosPermission": "Hotdog saves scanned photos to your library."
}
]
],
"android": {
"adaptiveIcon": {
"backgroundColor": "#E6F4FE",
"foregroundImage": "./assets/android-icon-foreground.png",
"backgroundImage": "./assets/android-icon-background.png",
"monochromeImage": "./assets/android-icon-monochrome.png"
},
"predictiveBackGestureEnabled": false
},
"web": {
"favicon": "./assets/favicon.png"
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><!--!Font Awesome Free 7.3.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2026 Fonticons, Inc.--><path d="M352 64C331.5 64 311.9 72.1 297.4 86.6L86.6 297.4C72.1 311.9 64 331.5 64 352C64 364.2 66.9 376.1 72.2 386.7L386.7 72.2C376.1 66.9 364.2 64 352 64zM288 576C308.5 576 328.1 567.9 342.6 553.4L553.4 342.6C567.9 328.1 576 308.5 576 288C576 275.8 573.1 263.9 567.8 253.3L253.3 567.8C264 573.2 275.9 576 288 576zM520.6 232.6C551.8 201.4 551.8 150.7 520.6 119.5C489.4 88.3 438.7 88.3 407.5 119.5L119.5 407.5C88.3 438.7 88.3 489.4 119.5 520.6C150.7 551.8 201.4 551.8 232.6 520.6L520.6 232.6z"/></svg>

After

Width:  |  Height:  |  Size: 713 B

+69
View File
@@ -0,0 +1,69 @@
{
"fill-specializations" : [
{
"value" : {
"automatic-gradient" : "display-p3:0.96783,0.66040,0.70754,1.00000"
}
},
{
"appearance" : "dark",
"value" : {
"automatic-gradient" : "display-p3:0.19389,0.01343,0.00168,1.00000"
}
}
],
"groups" : [
{
"layers" : [
{
"fill-specializations" : [
{
"value" : {
"automatic-gradient" : "display-p3:0.96783,0.17954,0.22102,1.00000"
}
},
{
"appearance" : "tinted",
"value" : {
"automatic-gradient" : "display-p3:0.96783,0.17954,0.22102,1.00000",
"orientation" : {
"start" : {
"x" : 0.5,
"y" : 0
},
"stop" : {
"x" : 0.5,
"y" : 0.7
}
}
}
}
],
"image-name" : "hotdog-solid-full.svg",
"name" : "hotdog-solid-full",
"position" : {
"scale" : 1.45,
"translation-in-points" : [
0,
0
]
}
}
],
"shadow" : {
"kind" : "neutral",
"opacity" : 0.5
},
"translucency" : {
"enabled" : true,
"value" : 0.5
}
}
],
"supported-platforms" : {
"circles" : [
"watchOS"
],
"squares" : "shared"
}
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 384 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

+18
View File
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>destination</key>
<string>export</string>
<key>method</key>
<string>app-store-connect</string>
<key>signingStyle</key>
<string>automatic</string>
<key>stripSwiftSymbols</key>
<true/>
<key>teamID</key>
<string>V98X722US5</string>
<key>uploadSymbols</key>
<true/>
</dict>
</plist>
+96
View File
@@ -0,0 +1,96 @@
# iOS Release
This project uses the local Xcode/App Store Connect path, not EAS.
## Current Native Settings
- Bundle ID: `com.soconnor.hotdog`
- Team ID: `V98X722US5`
- Version: `1.0`
- Build number: `1`
- Scheme: `hotdog`
- Workspace: `ios/hotdog.xcworkspace`
## One-Time Setup
1. Create the app record in App Store Connect using bundle ID `com.soconnor.hotdog`.
2. Create an App Store Connect API key in App Store Connect > Users and Access > Integrations > App Store Connect API.
3. Authenticate the App Store Connect CLI:
```bash
npx @onmyway133/asc-cli auth login
```
The CLI stores the private key in macOS Keychain by default. Do not commit `AuthKey_*.p8`.
For upload commands that use `xcrun altool`, also make the key available to Apple tooling. The usual local setup is:
```bash
mkdir -p ~/.appstoreconnect/private_keys
cp /path/to/AuthKey_YOURKEYID.p8 ~/.appstoreconnect/private_keys/
export ASC_KEY_ID=YOURKEYID
export ASC_ISSUER_ID=YOUR-ISSUER-UUID
```
## Build And Upload
Run a static check first:
```bash
npm run typecheck
```
Archive a release build:
```bash
npm run ios:archive
```
Export an App Store Connect IPA:
```bash
npm run ios:export
```
The export uses `config/ExportOptions.AppStore.plist`.
Upload the IPA:
```bash
npm run ios:upload
```
The uploaded build will appear in App Store Connect after Apple finishes processing it.
## App Store Connect CLI
Use the `asc` script for App Store Connect management:
```bash
npm run asc -- auth status --validate
npm run asc -- apps list
npm run asc -- builds list --app-id APP_ID
npm run asc -- testflight groups list --app-id APP_ID
```
Common release flow after a build is processed:
```bash
npm run asc -- apps list
npm run asc -- versions list --app-id APP_ID
npm run asc -- builds list --app-id APP_ID
npm run asc -- versions attach-build --version-id VERSION_ID --build-id BUILD_ID
```
Submit for review only after metadata, screenshots, pricing, privacy details, and review details are complete:
```bash
npm run asc -- submit create --app-id APP_ID --version-id VERSION_ID --platform IOS
```
## Notes
- Use TestFlight first, even for a small app.
- Increment the build number before each upload.
- Keep `.p8`, `.p12`, provisioning profiles, and local env files out of git.
- If Xcode signing fails, open `ios/hotdog.xcworkspace`, select the `hotdog` target, and confirm the Apple team/signing settings.
+8
View File
@@ -0,0 +1,8 @@
import { registerRootComponent } from 'expo';
import App from './App';
// registerRootComponent calls AppRegistry.registerComponent('main', () => App);
// It also ensures that whether you load the app in Expo Go or in a native build,
// the environment is set up appropriately
registerRootComponent(App);
+7089
View File
File diff suppressed because it is too large Load Diff
+48
View File
@@ -0,0 +1,48 @@
{
"name": "hotdog",
"version": "1.0.0",
"main": "index.ts",
"dependencies": {
"@dariyd/react-native-image-description": "^1.0.4",
"@expo/vector-icons": "^15.0.2",
"@react-native-async-storage/async-storage": "2.2.0",
"@tensorflow-models/mobilenet": "^2.1.1",
"@tensorflow/tfjs": "^4.22.0",
"@tensorflow/tfjs-react-native": "^1.0.0",
"expo": "~56.0.12",
"expo-blur": "~56.0.3",
"expo-build-properties": "~56.0.19",
"expo-camera": "~56.0.8",
"expo-file-system": "~56.0.8",
"expo-gl": "~56.0.5",
"expo-glass-effect": "~56.0.4",
"expo-image-manipulator": "~56.0.19",
"expo-image-picker": "~56.0.18",
"expo-media-library": "~56.0.7",
"expo-status-bar": "~56.0.4",
"jpeg-js": "^0.4.4",
"react": "19.2.3",
"react-native": "0.85.3",
"react-native-fs": "^2.20.0",
"react-native-safe-area-context": "~5.7.0",
"react-native-svg": "15.15.4",
"react-native-view-shot": "5.1.0"
},
"devDependencies": {
"@types/jpeg-js": "^0.3.0",
"@types/react": "~19.2.2",
"typescript": "~6.0.3"
},
"scripts": {
"start": "expo start",
"android": "expo run:android",
"ios": "expo run:ios",
"web": "expo start --web",
"typecheck": "tsc --noEmit",
"ios:archive": "xcodebuild -workspace ios/hotdog.xcworkspace -scheme hotdog -configuration Release -destination 'generic/platform=iOS' -archivePath build/ios/hotdog.xcarchive -allowProvisioningUpdates archive",
"ios:export": "xcodebuild -exportArchive -archivePath build/ios/hotdog.xcarchive -exportPath build/ios/export -exportOptionsPlist config/ExportOptions.AppStore.plist -allowProvisioningUpdates",
"ios:upload": "xcrun altool --upload-app --type ios --file build/ios/export/hotdog.ipa --apiKey \"$ASC_KEY_ID\" --apiIssuer \"$ASC_ISSUER_ID\"",
"asc": "npx @onmyway133/asc-cli"
},
"private": true
}
+214
View File
@@ -0,0 +1,214 @@
import { BlurView } from 'expo-blur';
import { useEffect, useState, type ComponentType, type RefObject } from 'react';
import {
AccessibilityInfo,
Platform,
StyleSheet,
View,
type ViewProps,
type ViewStyle,
} from 'react-native';
import { glass, layout } from '../theme';
type GlassSurfaceProps = ViewProps & {
tintColor?: string;
fallbackStyle?: ViewStyle;
isInteractive?: boolean;
blurTarget?: RefObject<View | null>;
/** Solid-background panels (e.g. gallery) skip blur and use a lighter fill. */
appearance?: 'chrome' | 'sheet';
};
type GlassModule = {
GlassView: ComponentType<
ViewProps & {
glassEffectStyle?: string;
colorScheme?: string;
tintColor?: string;
isInteractive?: boolean;
}
>;
GlassContainer?: ComponentType<{
children: React.ReactNode;
style?: ViewStyle;
spacing?: number;
}>;
isLiquidGlassAvailable: () => boolean;
isGlassEffectAPIAvailable: () => boolean;
};
let glassModule: GlassModule | null | undefined;
function getGlassModule(): GlassModule | null {
if (glassModule !== undefined) return glassModule;
if (Platform.OS !== 'ios') {
glassModule = null;
return glassModule;
}
try {
glassModule = require('expo-glass-effect') as GlassModule;
} catch {
glassModule = null;
}
return glassModule;
}
function nativeGlassAvailable(): boolean {
const mod = getGlassModule();
if (!mod) return false;
try {
return mod.isLiquidGlassAvailable() && mod.isGlassEffectAPIAvailable();
} catch {
return false;
}
}
export function useLiquidGlass(): boolean {
return nativeGlassAvailable();
}
export function GlassContainer({
children,
style,
spacing = layout.chromeGap,
}: {
children: React.ReactNode;
style?: ViewStyle;
spacing?: number;
}) {
const mod = getGlassModule();
if (!nativeGlassAvailable() || !mod?.GlassContainer) {
return <View style={style}>{children}</View>;
}
const NativeGlassContainer = mod.GlassContainer as ComponentType<{
children: React.ReactNode;
style?: ViewStyle;
spacing?: number;
}>;
return (
<NativeGlassContainer style={style} spacing={spacing}>
{children}
</NativeGlassContainer>
);
}
export function GlassSurface({
style,
children,
tintColor,
fallbackStyle,
isInteractive = false,
blurTarget,
appearance = 'chrome',
...props
}: GlassSurfaceProps) {
const glassEnabled = nativeGlassAvailable() && appearance === 'chrome';
const [reduceTransparency, setReduceTransparency] = useState(false);
useEffect(() => {
let mounted = true;
async function check() {
const isReduced = await AccessibilityInfo.isReduceTransparencyEnabled();
if (mounted) setReduceTransparency(isReduced);
}
void check();
const subscription = AccessibilityInfo.addEventListener(
'reduceTransparencyChanged',
setReduceTransparency,
);
return () => {
mounted = false;
subscription.remove();
};
}, []);
const blurIntensity = reduceTransparency
? glass.blurIntensityReduced
: glass.blurIntensity;
const blurTint = reduceTransparency
? glass.blurTintReduced
: glass.blurTint;
const scrim = reduceTransparency ? glass.scrimReduced : glass.scrim;
const showScrim = appearance === 'chrome';
if (appearance === 'sheet') {
return (
<View style={[styles.surface, styles.sheet, fallbackStyle, style]} {...props}>
{tintColor ? (
<View
pointerEvents="none"
style={[StyleSheet.absoluteFill, { backgroundColor: tintColor }]}
/>
) : null}
{children}
</View>
);
}
const mod = getGlassModule();
if (glassEnabled && mod?.GlassView) {
const NativeGlassView = mod.GlassView;
return (
<NativeGlassView
{...props}
style={[styles.surface, style]}
glassEffectStyle="clear"
colorScheme="dark"
tintColor={tintColor}
isInteractive={isInteractive}
>
{showScrim ? (
<View
pointerEvents="none"
style={[StyleSheet.absoluteFill, { backgroundColor: scrim }]}
/>
) : null}
{children}
</NativeGlassView>
);
}
return (
<View style={[styles.surface, fallbackStyle, style]} {...props}>
<BlurView
blurTarget={blurTarget}
blurMethod="dimezisBlurView"
intensity={blurIntensity}
tint={blurTint}
style={StyleSheet.absoluteFill}
/>
{showScrim ? (
<View
pointerEvents="none"
style={[StyleSheet.absoluteFill, { backgroundColor: scrim }]}
/>
) : null}
{tintColor ? (
<View
pointerEvents="none"
style={[StyleSheet.absoluteFill, { backgroundColor: tintColor }]}
/>
) : null}
{children}
</View>
);
}
const styles = StyleSheet.create({
surface: {
overflow: 'hidden',
borderRadius: layout.radius,
},
sheet: {
backgroundColor: glass.sheetFill,
},
});
+905
View File
@@ -0,0 +1,905 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
import { Feather } from '@expo/vector-icons';
import { BlurTargetView } from 'expo-blur';
import { CameraView, useCameraPermissions } from 'expo-camera';
import * as MediaLibrary from 'expo-media-library';
import {
useCallback,
useEffect,
useRef,
useState,
type ElementRef,
type RefObject,
} from 'react';
import {
ActionSheetIOS,
ActivityIndicator,
Alert,
Animated,
Easing,
Image,
Modal,
Platform,
Pressable,
Share,
StyleSheet,
Text,
View,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import ViewShot from 'react-native-view-shot';
import {
classifyImageUri,
ClassificationResult,
initClassifier,
} from '../lib/classifier';
import { ShareableImage } from './ShareableImage';
import { ScanGallery } from './ScanGallery';
import { GlassContainer, GlassSurface, useLiquidGlass } from './GlassSurface';
import { HotDogIcon } from './HotDogIcon';
import { addToGallery, type GalleryItem } from '../lib/gallery';
import { colors, glass, layout } from '../theme';
type ScanPhase = 'idle' | 'capturing' | 'analyzing';
type CapturedScan = {
uri: string;
result: ClassificationResult;
};
const ONBOARDING_STORAGE_KEY = 'hotdog.onboarding.v1';
const onboardingSteps = [
{
icon: 'camera' as const,
title: 'Point at lunch',
body: 'Aim the camera at anything that might be a hot dog.',
},
{
icon: 'aperture' as const,
title: 'Tap to scan',
body: 'The app freezes the frame and decides on-device.',
},
{
icon: 'share' as const,
title: 'Save the verdict',
body: 'Open the gallery or share a certified result.',
},
];
function VerdictBanner({
result,
scanPhase,
modelReady,
progressWidth,
blurTarget,
}: {
result: ClassificationResult | null;
scanPhase: ScanPhase;
modelReady: boolean;
progressWidth: Animated.AnimatedInterpolation<string>;
blurTarget: RefObject<View | null>;
}) {
const isBusy = scanPhase !== 'idle';
const verdict = isBusy || !modelReady ? null : (result?.isHotDog ?? null);
let title = 'Ready to scan';
if (!modelReady) title = 'Loading model';
else if (scanPhase === 'capturing') title = 'Capturing';
else if (scanPhase === 'analyzing') title = 'Analyzing';
else if (verdict === true) title = 'Hot dog';
else if (verdict === false) title = 'Not hot dog';
const confidence =
result && !isBusy && modelReady
? `${Math.round(result.confidence * 100)}%`
: null;
const accentColor =
verdict === true
? colors.hotDog
: verdict === false
? colors.notHotDog
: colors.text;
const progressColor =
verdict === true
? colors.hotDog
: verdict === false
? colors.notHotDog
: colors.progressFill;
const glassTint =
verdict === true
? glass.tintHotDog
: verdict === false
? glass.tintNotHotDog
: undefined;
return (
<GlassSurface
style={styles.banner}
tintColor={glassTint}
blurTarget={blurTarget}
>
<View style={styles.bannerBody}>
<View style={styles.bannerMain}>
<View style={styles.bannerIconSlot}>
<HotDogIcon size={26} color={colors.accent} />
</View>
<View style={styles.bannerContent}>
<Text style={styles.bannerLabel}>HOTDOG</Text>
<Text style={[styles.bannerTitle, { color: accentColor }]}>
{title}
</Text>
{confidence && (
<Text style={[styles.bannerConfidence, { color: accentColor }]}>
{confidence}
</Text>
)}
</View>
<View style={styles.bannerIconSlot} />
</View>
<View style={styles.progressTrack}>
<Animated.View
style={[
styles.progressFill,
{ width: progressWidth, backgroundColor: progressColor },
]}
/>
</View>
</View>
</GlassSurface>
);
}
function OnboardingModal({
visible,
onClose,
}: {
visible: boolean;
onClose: () => void;
}) {
return (
<Modal
visible={visible}
animationType="fade"
transparent
onRequestClose={onClose}
>
<View style={styles.onboardingBackdrop}>
<GlassSurface appearance="sheet" style={styles.onboardingCard}>
<View style={styles.onboardingHero}>
<View style={styles.onboardingIcon}>
<HotDogIcon size={34} color={colors.accent} />
</View>
<Text style={styles.onboardingTitle}>Not Hotdog</Text>
<Text style={styles.onboardingBody}>
One button. One question. The stakes could not be lower.
</Text>
</View>
<View style={styles.onboardingSteps}>
{onboardingSteps.map((step) => (
<View key={step.title} style={styles.onboardingStep}>
<View style={styles.onboardingStepIcon}>
<Feather name={step.icon} size={18} color={colors.text} />
</View>
<View style={styles.onboardingStepText}>
<Text style={styles.onboardingStepTitle}>{step.title}</Text>
<Text style={styles.onboardingStepBody}>{step.body}</Text>
</View>
</View>
))}
</View>
<Pressable
style={styles.onboardingButton}
onPress={onClose}
accessibilityLabel="Start scanning"
>
<Text style={styles.onboardingButtonText}>Start Scanning</Text>
</Pressable>
</GlassSurface>
</View>
</Modal>
);
}
function DockControls({
isBusy,
modelReady,
capturedScan,
onGallery,
onScan,
onExport,
}: {
isBusy: boolean;
modelReady: boolean;
capturedScan: CapturedScan | null;
onGallery: () => void;
onScan: () => void;
onExport: () => void;
}) {
return (
<>
<Pressable
style={[styles.dockButton, isBusy && styles.dockButtonDisabled]}
onPress={onGallery}
disabled={isBusy}
accessibilityLabel="Gallery"
>
<Feather name="image" size={20} color={colors.text} />
</Pressable>
<Pressable
style={[
styles.shutter,
(!modelReady || isBusy) && styles.shutterDisabled,
]}
onPress={onScan}
disabled={!modelReady || isBusy}
accessibilityLabel={capturedScan ? 'Back to camera' : 'Scan'}
>
{capturedScan ? (
<Feather name="rotate-ccw" size={26} color={colors.text} />
) : (
<View style={styles.shutterInner} />
)}
</Pressable>
<Pressable
style={[
styles.dockButton,
(!capturedScan || isBusy) && styles.dockButtonDisabled,
]}
onPress={onExport}
disabled={!capturedScan || isBusy}
accessibilityLabel="Save or share"
>
<Feather name="share" size={20} color={colors.text} />
</Pressable>
</>
);
}
export function HotDogCamera() {
const cameraRef = useRef<CameraView>(null);
const blurTargetRef = useRef<View>(null);
const shareShotRef = useRef<ElementRef<typeof ViewShot>>(null);
const progressAnim = useRef(new Animated.Value(0)).current;
const progressLoopRef = useRef<Animated.CompositeAnimation | null>(null);
const [permission, requestPermission] = useCameraPermissions();
const [mediaPermission, requestMediaPermission] =
MediaLibrary.usePermissions();
const [modelReady, setModelReady] = useState(false);
const [modelError, setModelError] = useState<string | null>(null);
const [scanPhase, setScanPhase] = useState<ScanPhase>('idle');
const [capturedScan, setCapturedScan] = useState<CapturedScan | null>(null);
const [frozenFrameUri, setFrozenFrameUri] = useState<string | null>(null);
const [exportMessage, setExportMessage] = useState<string | null>(null);
const [galleryOpen, setGalleryOpen] = useState(false);
const [onboardingVisible, setOnboardingVisible] = useState(false);
const scanningRef = useRef(false);
const liquidGlass = useLiquidGlass();
useEffect(() => {
let cancelled = false;
initClassifier()
.then(() => {
if (!cancelled) setModelReady(true);
})
.catch((error: unknown) => {
if (!cancelled) {
setModelError(
error instanceof Error ? error.message : 'Failed to load model',
);
}
});
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
let cancelled = false;
async function loadOnboardingState() {
const dismissed = await AsyncStorage.getItem(ONBOARDING_STORAGE_KEY);
if (!cancelled && dismissed !== 'dismissed') {
setOnboardingVisible(true);
}
}
if (permission?.granted) {
void loadOnboardingState();
}
return () => {
cancelled = true;
};
}, [permission?.granted]);
useEffect(() => {
progressLoopRef.current?.stop();
progressLoopRef.current = null;
if (!modelReady) {
progressAnim.setValue(0);
const loadingLoop = Animated.loop(
Animated.sequence([
Animated.timing(progressAnim, {
toValue: 0.65,
duration: 1100,
easing: Easing.inOut(Easing.quad),
useNativeDriver: false,
}),
Animated.timing(progressAnim, {
toValue: 0.12,
duration: 1100,
easing: Easing.inOut(Easing.quad),
useNativeDriver: false,
}),
]),
);
progressLoopRef.current = loadingLoop;
loadingLoop.start();
return () => loadingLoop.stop();
}
if (scanPhase === 'idle') {
progressAnim.setValue(capturedScan ? 1 : 0);
return;
}
if (scanPhase === 'capturing') {
progressAnim.setValue(0);
Animated.timing(progressAnim, {
toValue: 0.35,
duration: 180,
easing: Easing.out(Easing.quad),
useNativeDriver: false,
}).start();
return;
}
progressAnim.setValue(0.35);
const loop = Animated.loop(
Animated.sequence([
Animated.timing(progressAnim, {
toValue: 0.92,
duration: 850,
easing: Easing.inOut(Easing.quad),
useNativeDriver: false,
}),
Animated.timing(progressAnim, {
toValue: 0.35,
duration: 850,
easing: Easing.inOut(Easing.quad),
useNativeDriver: false,
}),
]),
);
progressLoopRef.current = loop;
loop.start();
return () => loop.stop();
}, [scanPhase, progressAnim, modelReady, capturedScan]);
const captureShareImage = useCallback(async (): Promise<string | null> => {
if (!shareShotRef.current?.capture) return null;
try {
await new Promise((resolve) => requestAnimationFrame(() => resolve(undefined)));
return await shareShotRef.current.capture();
} catch {
return null;
}
}, []);
const handleReset = useCallback(() => {
setCapturedScan(null);
setFrozenFrameUri(null);
setScanPhase('idle');
setExportMessage(null);
scanningRef.current = false;
}, []);
const handleSave = useCallback(async () => {
if (!capturedScan) return;
if (!mediaPermission?.granted) {
const response = await requestMediaPermission();
if (!response.granted) {
setExportMessage('Photos permission denied');
return;
}
}
try {
const shareUri = await captureShareImage();
if (!shareUri) {
setExportMessage('Save failed');
return;
}
await MediaLibrary.saveToLibraryAsync(shareUri);
setExportMessage('Saved');
} catch {
setExportMessage('Save failed');
}
}, [
capturedScan,
captureShareImage,
mediaPermission?.granted,
requestMediaPermission,
]);
const handleShare = useCallback(async () => {
if (!capturedScan) return;
try {
const shareUri = await captureShareImage();
if (!shareUri) {
setExportMessage('Share failed');
return;
}
await Share.share({ url: shareUri });
} catch {
setExportMessage('Share failed');
}
}, [capturedScan, captureShareImage]);
const handleExport = useCallback(() => {
if (!capturedScan || scanPhase !== 'idle') return;
if (Platform.OS === 'ios') {
ActionSheetIOS.showActionSheetWithOptions(
{
options: ['Share', 'Save to Photos', 'Cancel'],
cancelButtonIndex: 2,
},
(index) => {
if (index === 0) void handleShare();
if (index === 1) void handleSave();
},
);
return;
}
Alert.alert('Photo', undefined, [
{ text: 'Share', onPress: () => void handleShare() },
{ text: 'Save', onPress: () => void handleSave() },
{ text: 'Cancel', style: 'cancel' },
]);
}, [capturedScan, handleSave, handleShare, scanPhase]);
const handleScan = useCallback(async () => {
if (!modelReady || scanningRef.current) return;
if (capturedScan) {
handleReset();
return;
}
if (!cameraRef.current) return;
scanningRef.current = true;
setScanPhase('capturing');
setExportMessage(null);
try {
const photo = await cameraRef.current.takePictureAsync({
quality: 0.9,
skipProcessing: false,
shutterSound: false,
});
if (!photo?.uri) return;
setFrozenFrameUri(photo.uri);
setScanPhase('analyzing');
const classification = await classifyImageUri(photo.uri);
const scan = { uri: photo.uri, result: classification };
setCapturedScan(scan);
await addToGallery(photo.uri, classification);
} catch {
setCapturedScan(null);
setFrozenFrameUri(null);
} finally {
setFrozenFrameUri(null);
scanningRef.current = false;
setScanPhase('idle');
}
}, [capturedScan, handleReset, modelReady]);
const handleOnboardingClose = useCallback(() => {
setOnboardingVisible(false);
void AsyncStorage.setItem(ONBOARDING_STORAGE_KEY, 'dismissed');
}, []);
const handleGallerySelect = useCallback((item: GalleryItem) => {
setCapturedScan({ uri: item.uri, result: item.result });
setScanPhase('idle');
setExportMessage(null);
scanningRef.current = false;
}, []);
const handleGalleryImport = useCallback(
async (uri: string, result: ClassificationResult) => {
const item = await addToGallery(uri, result);
setCapturedScan({ uri: item.uri, result: item.result });
setScanPhase('idle');
setExportMessage(null);
scanningRef.current = false;
},
[],
);
const progressWidth = progressAnim.interpolate({
inputRange: [0, 1],
outputRange: ['0%', '100%'],
});
if (!permission) {
return (
<View style={styles.centered}>
<ActivityIndicator size="large" color={colors.accent} />
</View>
);
}
if (!permission.granted) {
return (
<SafeAreaView style={styles.centered}>
<Feather name="camera-off" size={32} color={colors.textMuted} />
<Text style={styles.permissionTitle}>Camera required</Text>
<Pressable style={styles.primaryButton} onPress={requestPermission}>
<Text style={styles.primaryButtonText}>Continue</Text>
</Pressable>
</SafeAreaView>
);
}
if (modelError) {
return (
<SafeAreaView style={styles.centered}>
<Text style={styles.errorTitle}>Model failed</Text>
<Pressable style={styles.secondaryButton} onPress={handleReset}>
<Text style={styles.secondaryButtonText}>Retry</Text>
</Pressable>
</SafeAreaView>
);
}
const isBusy = scanPhase !== 'idle';
const displayFrameUri = frozenFrameUri ?? capturedScan?.uri ?? null;
const showLiveCamera = !displayFrameUri;
const chrome = (
<>
<VerdictBanner
result={capturedScan?.result ?? null}
scanPhase={scanPhase}
modelReady={modelReady}
progressWidth={progressWidth}
blurTarget={blurTargetRef}
/>
<GlassSurface
style={styles.dock}
isInteractive
blurTarget={blurTargetRef}
>
<View style={styles.dockInner}>
<DockControls
isBusy={isBusy}
modelReady={modelReady}
capturedScan={capturedScan}
onGallery={() => setGalleryOpen(true)}
onScan={handleScan}
onExport={handleExport}
/>
</View>
</GlassSurface>
</>
);
return (
<View style={styles.container}>
<BlurTargetView ref={blurTargetRef} style={styles.backdrop}>
{showLiveCamera && (
<CameraView ref={cameraRef} style={styles.camera} facing="back" />
)}
{displayFrameUri && (
<Image source={{ uri: displayFrameUri }} style={styles.frozenFrame} />
)}
</BlurTargetView>
{capturedScan && (
<ShareableImage
ref={shareShotRef}
uri={capturedScan.uri}
result={capturedScan.result}
/>
)}
<SafeAreaView style={styles.overlay} pointerEvents="box-none">
{liquidGlass ? (
<GlassContainer spacing={layout.chromeGap} style={styles.chrome}>
{chrome}
</GlassContainer>
) : (
<View style={styles.chrome}>{chrome}</View>
)}
{exportMessage && (
<GlassSurface style={styles.exportToast} blurTarget={blurTargetRef}>
<Text style={styles.exportMessage}>{exportMessage}</Text>
</GlassSurface>
)}
</SafeAreaView>
<ScanGallery
visible={galleryOpen}
onClose={() => setGalleryOpen(false)}
onSelect={handleGallerySelect}
onImport={handleGalleryImport}
/>
<OnboardingModal
visible={onboardingVisible}
onClose={handleOnboardingClose}
/>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.bg,
},
backdrop: {
...StyleSheet.absoluteFill,
},
camera: {
flex: 1,
},
frozenFrame: {
...StyleSheet.absoluteFill,
resizeMode: 'cover',
},
overlay: {
...StyleSheet.absoluteFill,
justifyContent: 'space-between',
paddingHorizontal: 20,
},
chrome: {
flex: 1,
justifyContent: 'space-between',
paddingBottom: 8,
},
centered: {
flex: 1,
backgroundColor: colors.bg,
alignItems: 'center',
justifyContent: 'center',
padding: 32,
gap: 16,
},
permissionTitle: {
color: colors.text,
fontSize: 17,
fontWeight: '600',
},
primaryButton: {
backgroundColor: colors.accent,
paddingHorizontal: 22,
paddingVertical: 12,
borderRadius: layout.radius,
},
primaryButtonText: {
color: colors.text,
fontSize: 15,
fontWeight: '600',
},
secondaryButton: {
backgroundColor: 'rgba(255, 255, 255, 0.14)',
paddingHorizontal: 18,
paddingVertical: 11,
borderRadius: layout.radius,
},
secondaryButtonText: {
color: colors.text,
fontSize: 15,
fontWeight: '600',
},
errorTitle: {
color: colors.notHotDog,
fontSize: 17,
fontWeight: '600',
},
banner: {
height: layout.bannerHeight,
},
bannerBody: {
flex: 1,
paddingBottom: 0,
justifyContent: 'space-between',
},
bannerMain: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 12,
},
bannerIconSlot: {
width: 32,
alignItems: 'center',
justifyContent: 'center',
},
bannerContent: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
gap: 2,
},
bannerLabel: {
color: colors.textMuted,
fontSize: 10,
fontWeight: '700',
letterSpacing: 1.8,
textAlign: 'center',
},
bannerConfidence: {
fontSize: 13,
fontWeight: '700',
fontVariant: ['tabular-nums'],
textAlign: 'center',
},
bannerTitle: {
fontSize: 22,
fontWeight: '700',
letterSpacing: -0.3,
textAlign: 'center',
},
progressTrack: {
height: 2,
backgroundColor: colors.progressTrack,
borderBottomLeftRadius: layout.radius,
borderBottomRightRadius: layout.radius,
marginHorizontal: -12,
overflow: 'hidden',
},
progressFill: {
height: '100%',
},
dock: {},
dockInner: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 10,
paddingVertical: 12,
},
dockButton: {
width: layout.controlSize,
height: layout.controlSize,
borderRadius: layout.controlSize / 2,
alignItems: 'center',
justifyContent: 'center',
},
dockButtonDisabled: {
opacity: glass.disabledOpacity,
},
exportToast: {
alignSelf: 'center',
marginBottom: 4,
paddingHorizontal: 14,
paddingVertical: 8,
},
exportMessage: {
color: colors.textMuted,
fontSize: 12,
textAlign: 'center',
},
shutter: {
width: layout.shutterSize,
height: layout.shutterSize,
borderRadius: layout.shutterSize / 2,
borderWidth: 3,
borderColor: 'rgba(255, 255, 255, 0.95)',
alignItems: 'center',
justifyContent: 'center',
},
shutterDisabled: {
opacity: glass.disabledOpacity,
},
shutterInner: {
width: layout.shutterInnerSize,
height: layout.shutterInnerSize,
borderRadius: layout.shutterInnerSize / 2,
backgroundColor: colors.text,
},
onboardingBackdrop: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(0, 0, 0, 0.68)',
paddingHorizontal: 24,
},
onboardingCard: {
width: '100%',
maxWidth: 380,
padding: 22,
},
onboardingHero: {
alignItems: 'center',
gap: 10,
marginBottom: 22,
},
onboardingIcon: {
width: 58,
height: 58,
borderRadius: 29,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(249, 115, 22, 0.14)',
},
onboardingTitle: {
color: colors.text,
fontSize: 24,
fontWeight: '800',
textAlign: 'center',
},
onboardingBody: {
color: colors.textMuted,
fontSize: 15,
lineHeight: 21,
textAlign: 'center',
},
onboardingSteps: {
gap: 16,
},
onboardingStep: {
flexDirection: 'row',
alignItems: 'flex-start',
gap: 12,
},
onboardingStepIcon: {
width: 34,
height: 34,
borderRadius: 17,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(255, 255, 255, 0.1)',
},
onboardingStepText: {
flex: 1,
gap: 3,
},
onboardingStepTitle: {
color: colors.text,
fontSize: 15,
fontWeight: '700',
},
onboardingStepBody: {
color: colors.textMuted,
fontSize: 14,
lineHeight: 19,
},
onboardingButton: {
minHeight: 48,
alignItems: 'center',
justifyContent: 'center',
marginTop: 24,
borderRadius: layout.radius,
backgroundColor: colors.accent,
},
onboardingButtonText: {
color: colors.text,
fontSize: 16,
fontWeight: '800',
},
});
+17
View File
@@ -0,0 +1,17 @@
import Svg, { Path } from 'react-native-svg';
type HotDogIconProps = {
size?: number;
color?: string;
};
export function HotDogIcon({ size = 24, color = '#fff' }: HotDogIconProps) {
return (
<Svg width={size} height={size} viewBox="0 0 640 640" fill="none">
<Path
fill={color}
d="M352 64C331.5 64 311.9 72.1 297.4 86.6L86.6 297.4C72.1 311.9 64 331.5 64 352C64 364.2 66.9 376.1 72.2 386.7L386.7 72.2C376.1 66.9 364.2 64 352 64zM288 576C308.5 576 328.1 567.9 342.6 553.4L553.4 342.6C567.9 328.1 576 308.5 576 288C576 275.8 573.1 263.9 567.8 253.3L253.3 567.8C264 573.2 275.9 576 288 576zM520.6 232.6C551.8 201.4 551.8 150.7 520.6 119.5C489.4 88.3 438.7 88.3 407.5 119.5L119.5 407.5C88.3 438.7 88.3 489.4 119.5 520.6C150.7 551.8 201.4 551.8 232.6 520.6L520.6 232.6z"
/>
</Svg>
);
}
+251
View File
@@ -0,0 +1,251 @@
import { Feather } from '@expo/vector-icons';
import * as ImagePicker from 'expo-image-picker';
import { useCallback, useEffect, useState } from 'react';
import {
Alert,
FlatList,
Image,
Modal,
Pressable,
StyleSheet,
Text,
View,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { classifyImageUri, type ClassificationResult } from '../lib/classifier';
import { loadGallery, removeFromGallery, type GalleryItem } from '../lib/gallery';
import { colors, layout } from '../theme';
import { GlassSurface } from './GlassSurface';
import { HotDogIcon } from './HotDogIcon';
type ScanGalleryProps = {
visible: boolean;
onClose: () => void;
onSelect: (item: GalleryItem) => void;
onImport: (uri: string, result: ClassificationResult) => void;
};
export function ScanGallery({
visible,
onClose,
onSelect,
onImport,
}: ScanGalleryProps) {
const [items, setItems] = useState<GalleryItem[]>([]);
const [loading, setLoading] = useState(false);
const refresh = useCallback(async () => {
setItems(await loadGallery());
}, []);
useEffect(() => {
if (visible) void refresh();
}, [visible, refresh]);
const handleImport = useCallback(async () => {
const permission = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (!permission.granted) {
Alert.alert('Photos access needed to import an image.');
return;
}
setLoading(true);
try {
const picked = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ['images'],
quality: 0.9,
});
if (picked.canceled || !picked.assets[0]?.uri) return;
const uri = picked.assets[0].uri;
const result = await classifyImageUri(uri);
onImport(uri, result);
onClose();
} catch {
Alert.alert('Could not scan that photo.');
} finally {
setLoading(false);
}
}, [onClose, onImport]);
const handleDelete = useCallback(
(item: GalleryItem) => {
Alert.alert('Delete scan?', undefined, [
{ text: 'Cancel', style: 'cancel' },
{
text: 'Delete',
style: 'destructive',
onPress: () => {
void removeFromGallery(item.id).then(refresh);
},
},
]);
},
[refresh],
);
return (
<Modal visible={visible} animationType="slide" onRequestClose={onClose}>
<View style={styles.root}>
<SafeAreaView edges={['top', 'left', 'right']} style={styles.safe}>
<View style={styles.headerWrap}>
<GlassSurface appearance="sheet" style={styles.header}>
<View style={styles.headerRow}>
<Pressable
onPress={onClose}
style={styles.headerButton}
accessibilityLabel="Close gallery"
>
<Feather name="x" size={22} color={colors.text} />
</Pressable>
<Text style={styles.headerTitle}>Gallery</Text>
<Pressable
onPress={() => void handleImport()}
style={styles.headerButton}
disabled={loading}
accessibilityLabel="Import photo"
>
<Feather name="plus" size={22} color={colors.text} />
</Pressable>
</View>
</GlassSurface>
</View>
<FlatList
data={items}
keyExtractor={(item) => item.id}
numColumns={3}
contentContainerStyle={styles.grid}
columnWrapperStyle={styles.gridRow}
ListEmptyComponent={
<View style={styles.empty}>
<HotDogIcon size={40} color={colors.textMuted} />
<Text style={styles.emptyTitle}>No scans yet</Text>
<Text style={styles.emptyBody}>
Scan a hot dog or tap + to import a photo.
</Text>
</View>
}
renderItem={({ item }) => (
<Pressable
style={styles.cell}
onPress={() => {
onSelect(item);
onClose();
}}
onLongPress={() => handleDelete(item)}
accessibilityLabel={
item.result.isHotDog ? 'Hot dog scan' : 'Not hot dog scan'
}
>
<Image source={{ uri: item.uri }} style={styles.thumb} />
<View
style={[
styles.badge,
{
backgroundColor: item.result.isHotDog
? colors.hotDog
: colors.notHotDog,
},
]}
>
{item.result.isHotDog ? (
<HotDogIcon size={12} color={colors.text} />
) : (
<Feather name="x" size={12} color={colors.text} />
)}
</View>
</Pressable>
)}
/>
</SafeAreaView>
</View>
</Modal>
);
}
const GAP = 8;
const styles = StyleSheet.create({
root: {
flex: 1,
backgroundColor: colors.bg,
},
safe: {
flex: 1,
paddingHorizontal: 16,
},
headerWrap: {
marginTop: layout.galleryHeaderTop,
marginBottom: 16,
},
header: {
paddingHorizontal: 8,
paddingVertical: 8,
},
headerRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
headerButton: {
width: layout.controlSize,
height: layout.controlSize,
alignItems: 'center',
justifyContent: 'center',
},
headerTitle: {
color: colors.text,
fontSize: 17,
fontWeight: '700',
},
grid: {
paddingBottom: 24,
flexGrow: 1,
},
gridRow: {
gap: GAP,
marginBottom: GAP,
},
cell: {
flex: 1,
aspectRatio: 1,
borderRadius: layout.radius,
overflow: 'hidden',
backgroundColor: 'rgba(255, 255, 255, 0.04)',
},
thumb: {
width: '100%',
height: '100%',
},
badge: {
position: 'absolute',
top: 6,
right: 6,
width: 22,
height: 22,
borderRadius: 11,
alignItems: 'center',
justifyContent: 'center',
},
empty: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
paddingTop: 80,
gap: 10,
paddingHorizontal: 32,
},
emptyTitle: {
color: colors.text,
fontSize: 18,
fontWeight: '700',
},
emptyBody: {
color: colors.textMuted,
fontSize: 15,
textAlign: 'center',
lineHeight: 21,
},
});
+99
View File
@@ -0,0 +1,99 @@
import { forwardRef, type ElementRef } from 'react';
import { Image, StyleSheet, Text, View } from 'react-native';
import ViewShot from 'react-native-view-shot';
import { ClassificationResult } from '../lib/classifier';
import { getShareQuip, getShareVerdictLabel } from '../lib/shareQuips';
import { colors } from '../theme';
const SHARE_WIDTH = 1080;
const SHARE_HEIGHT = 1440;
type ShareableImageProps = {
uri: string;
result: ClassificationResult;
};
export const ShareableImage = forwardRef<ElementRef<typeof ViewShot>, ShareableImageProps>(
function ShareableImage({ uri, result }, ref) {
const isHotDog = result.isHotDog;
const verdictColor = isHotDog ? colors.hotDog : colors.notHotDog;
return (
<ViewShot
ref={ref}
options={{ format: 'jpg', quality: 0.92, result: 'tmpfile' }}
style={styles.offscreen}
>
<View style={styles.canvas}>
<Image source={{ uri }} style={styles.photo} />
<View style={styles.footer}>
<View style={[styles.verdictPill, { backgroundColor: verdictColor }]}>
<Text style={styles.verdictText}>
{getShareVerdictLabel(isHotDog)}
</Text>
</View>
<Text style={styles.quip}>{getShareQuip(isHotDog)}</Text>
<Text style={styles.meta}>
{Math.round(result.confidence * 100)}% hot dog energy · hotdog app
</Text>
</View>
</View>
</ViewShot>
);
},
);
const styles = StyleSheet.create({
offscreen: {
position: 'absolute',
top: -5000,
left: 0,
width: SHARE_WIDTH,
height: SHARE_HEIGHT,
},
canvas: {
width: SHARE_WIDTH,
height: SHARE_HEIGHT,
backgroundColor: '#000',
},
photo: {
...StyleSheet.absoluteFill,
resizeMode: 'cover',
},
footer: {
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
paddingHorizontal: 56,
paddingBottom: 72,
paddingTop: 48,
backgroundColor: colors.shareFooter,
borderTopWidth: 4,
borderTopColor: '#1f1f1f',
gap: 20,
},
verdictPill: {
alignSelf: 'flex-start',
paddingHorizontal: 28,
paddingVertical: 12,
borderRadius: 999,
},
verdictText: {
color: '#fff',
fontSize: 36,
fontWeight: '800',
letterSpacing: 2,
},
quip: {
color: '#fff',
fontSize: 40,
fontWeight: '600',
lineHeight: 48,
},
meta: {
color: '#a3a3a3',
fontSize: 24,
fontWeight: '500',
},
});
+197
View File
@@ -0,0 +1,197 @@
import { classifyImageWithVision } from './vision';
import * as tf from '@tensorflow/tfjs';
import '@tensorflow/tfjs-react-native';
import * as mobilenet from '@tensorflow-models/mobilenet';
import { decodeJpeg, fetch } from '@tensorflow/tfjs-react-native';
import * as ImageManipulator from 'expo-image-manipulator';
import { Platform } from 'react-native';
const HOT_DOG_IMAGENET_LABEL = 'hotdog, hot dog, red hot';
const MODEL_INPUT_SIZE = 224;
const HOT_DOG_LABEL_PATTERNS = [
/hot[\s_-]?dog/,
/frankfurter/,
/\bwiener\b/,
/red[\s_-]?hot/,
];
const CONFUSABLE_TOP_LABELS = [
/pizza/,
/burger/,
/sandwich/,
/person/,
/dog\b(?!.*hot)/,
/cat\b/,
/car\b/,
/phone/,
];
let mobileNetModel: mobilenet.MobileNet | null = null;
let initPromise: Promise<void> | null = null;
let useVision = Platform.OS === 'ios';
export type ClassificationResult = {
isHotDog: boolean;
confidence: number;
topLabel: string;
};
function normalizeLabel(label: string): string {
return label.toLowerCase().replace(/_/g, ' ').trim();
}
function isHotDogLabel(label: string): boolean {
const normalized = normalizeLabel(label);
return HOT_DOG_LABEL_PATTERNS.some((pattern) => pattern.test(normalized));
}
function isConfusableLabel(label: string): boolean {
const normalized = normalizeLabel(label);
return CONFUSABLE_TOP_LABELS.some((pattern) => pattern.test(normalized));
}
function decideFromHotDogScore(
hotDogScore: number,
topLabel: string,
runnerUpScore: number,
): boolean {
if (hotDogScore < 0.18) return false;
const margin = hotDogScore - runnerUpScore;
const topIsConfusable = isConfusableLabel(topLabel) && !isHotDogLabel(topLabel);
if (hotDogScore >= 0.45) return true;
if (hotDogScore >= 0.28 && margin >= 0.05) return true;
if (hotDogScore >= 0.22 && !topIsConfusable) return true;
return false;
}
async function prepareImageUri(uri: string, quality: number): Promise<string> {
const resized = await ImageManipulator.manipulateAsync(
uri,
[{ resize: { width: MODEL_INPUT_SIZE } }],
{
compress: quality,
format: ImageManipulator.SaveFormat.JPEG,
},
);
return resized.uri;
}
async function initMobileNet(): Promise<void> {
await tf.ready();
await tf.setBackend('rn-webgl');
mobileNetModel = await mobilenet.load({ version: 2, alpha: 1.0 });
const warmupTensor = tf.zeros<tf.Rank.R3>([
MODEL_INPUT_SIZE,
MODEL_INPUT_SIZE,
3,
]);
try {
await mobileNetModel.classify(warmupTensor, 1);
} finally {
warmupTensor.dispose();
}
}
export async function initClassifier(): Promise<void> {
if (initPromise) return initPromise;
initPromise = (async () => {
if (useVision) {
return;
}
await initMobileNet();
})();
return initPromise;
}
export function isClassifierReady(): boolean {
return useVision || mobileNetModel !== null;
}
async function classifyWithAppleVision(
uri: string,
): Promise<ClassificationResult> {
const result = await classifyImageWithVision(uri, {
minimumConfidence: 0.12,
maxResults: 15,
iosUseMlKit: true,
});
if (!result.success || result.labels.length === 0) {
throw new Error(result.error ?? 'Vision classification failed');
}
const sorted = [...result.labels].sort((a, b) => b.confidence - a.confidence);
const hotDogHits = sorted.filter((label) => isHotDogLabel(label.identifier));
const hotDogScore = hotDogHits[0]?.confidence ?? 0;
const top = sorted[0];
const runnerUp = sorted.find((label) => !isHotDogLabel(label.identifier));
return {
isHotDog: decideFromHotDogScore(
hotDogScore,
top.identifier,
runnerUp?.confidence ?? 0,
),
confidence: hotDogScore,
topLabel: top.identifier,
};
}
async function classifyWithMobileNet(
uri: string,
): Promise<ClassificationResult> {
if (!mobileNetModel) {
throw new Error('Classifier not initialized');
}
const preparedUri = await prepareImageUri(uri, 0.85);
const response = await fetch(preparedUri, {}, { isBinary: true });
const rawImageData = await response.arrayBuffer();
const imageTensor = decodeJpeg(new Uint8Array(rawImageData));
try {
const predictions = await mobileNetModel.classify(imageTensor, 20);
const hotDogPrediction = predictions.find(
(p) => p.className === HOT_DOG_IMAGENET_LABEL,
);
const hotDogScore = hotDogPrediction?.probability ?? 0;
const top = predictions[0];
const runnerUp = predictions.find(
(p) => p.className !== HOT_DOG_IMAGENET_LABEL,
);
return {
isHotDog: decideFromHotDogScore(
hotDogScore,
top?.className ?? 'unknown',
runnerUp?.probability ?? 0,
),
confidence: hotDogScore,
topLabel: top?.className ?? 'unknown',
};
} finally {
imageTensor.dispose();
}
}
export async function classifyImageUri(
uri: string,
): Promise<ClassificationResult> {
if (useVision) {
try {
return await classifyWithAppleVision(uri);
} catch {
useVision = false;
await initMobileNet();
}
}
return classifyWithMobileNet(uri);
}
+79
View File
@@ -0,0 +1,79 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
import * as FileSystem from 'expo-file-system/legacy';
import type { ClassificationResult } from './classifier';
export type GalleryItem = {
id: string;
uri: string;
result: ClassificationResult;
createdAt: number;
};
const STORAGE_KEY = 'hotdog.gallery.v1';
const GALLERY_DIR = `${FileSystem.documentDirectory ?? ''}gallery/`;
async function ensureGalleryDir(): Promise<void> {
const info = await FileSystem.getInfoAsync(GALLERY_DIR);
if (!info.exists) {
await FileSystem.makeDirectoryAsync(GALLERY_DIR, { intermediates: true });
}
}
export async function loadGallery(): Promise<GalleryItem[]> {
try {
const raw = await AsyncStorage.getItem(STORAGE_KEY);
if (!raw) return [];
const items = JSON.parse(raw) as GalleryItem[];
const existing: GalleryItem[] = [];
for (const item of items) {
const info = await FileSystem.getInfoAsync(item.uri);
if (info.exists) existing.push(item);
}
if (existing.length !== items.length) {
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(existing));
}
return existing.sort((a, b) => b.createdAt - a.createdAt);
} catch {
return [];
}
}
export async function addToGallery(
sourceUri: string,
result: ClassificationResult,
): Promise<GalleryItem> {
await ensureGalleryDir();
const id = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const destUri = `${GALLERY_DIR}${id}.jpg`;
await FileSystem.copyAsync({ from: sourceUri, to: destUri });
const item: GalleryItem = {
id,
uri: destUri,
result,
createdAt: Date.now(),
};
const items = await loadGallery();
items.unshift(item);
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(items));
return item;
}
export async function removeFromGallery(id: string): Promise<void> {
const items = await loadGallery();
const item = items.find((entry) => entry.id === id);
if (item) {
await FileSystem.deleteAsync(item.uri, { idempotent: true });
}
const next = items.filter((entry) => entry.id !== id);
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(next));
}
+22
View File
@@ -0,0 +1,22 @@
export function getShareQuip(isHotDog: boolean): string {
const hotDogQuips = [
'I saw a hot dog and simply had to document it.',
'Some people photograph sunsets. I photograph hot dogs.',
'This is art. This is culture. This is a hot dog.',
'Certified meat tube moment.',
];
const notHotDogQuips = [
'Not a hot dog. My lawyer advised me to say that.',
'Jian-Yang was right. Not hot dog.',
'I approached with hope. I left with a non-hot dog.',
'False alarm. The bun was lying to me.',
];
const pool = isHotDog ? hotDogQuips : notHotDogQuips;
return pool[Math.floor(Math.random() * pool.length)];
}
export function getShareVerdictLabel(isHotDog: boolean): string {
return isHotDog ? 'HOT DOG' : 'NOT HOT DOG';
}
+32
View File
@@ -0,0 +1,32 @@
type VisionLabel = {
identifier: string;
confidence: number;
};
type VisionResult = {
success: boolean;
labels: VisionLabel[];
error?: string;
};
type VisionOptions = {
minimumConfidence?: number;
maxResults?: number;
iosUseMlKit?: boolean;
};
type VisionModule = {
classifyImage: (
imageUri: string,
options?: VisionOptions,
) => Promise<VisionResult>;
};
const vision = require('@dariyd/react-native-image-description') as VisionModule;
export function classifyImageWithVision(
imageUri: string,
options?: VisionOptions,
): Promise<VisionResult> {
return vision.classifyImage(imageUri, options);
}
+36
View File
@@ -0,0 +1,36 @@
export const colors = {
bg: '#090909',
accent: '#f97316',
text: '#ffffff',
textMuted: 'rgba(255, 255, 255, 0.72)',
textFaint: 'rgba(255, 255, 255, 0.5)',
hotDog: '#22c55e',
notHotDog: '#ef4444',
progressTrack: 'rgba(255, 255, 255, 0.22)',
progressFill: 'rgba(255, 255, 255, 0.95)',
shareFooter: '#0d0d0d',
};
/** Shared blur + tint values for all chrome surfaces. */
export const glass = {
blurIntensity: 48,
blurIntensityReduced: 64,
blurTint: 'systemUltraThinMaterialDark' as const,
blurTintReduced: 'systemThinMaterialDark' as const,
scrim: 'rgba(0, 0, 0, 0.06)',
scrimReduced: 'rgba(0, 0, 0, 0.16)',
sheetFill: 'rgba(255, 255, 255, 0.06)',
tintHotDog: 'rgba(34, 197, 94, 0.14)',
tintNotHotDog: 'rgba(239, 68, 68, 0.14)',
disabledOpacity: 0.45,
};
export const layout = {
bannerHeight: 88,
radius: 20,
chromeGap: 20,
controlSize: 44,
shutterSize: 72,
shutterInnerSize: 58,
galleryHeaderTop: 72,
};
+7
View File
@@ -0,0 +1,7 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true,
"skipLibCheck": true
}
}