initial commit
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"enabledPlugins": {
|
||||
"expo@claude-plugins-official": true
|
||||
}
|
||||
}
|
||||
@@ -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=
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 77 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
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 |
@@ -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"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 384 KiB |
|
After Width: | Height: | Size: 17 KiB |
@@ -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>
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "expo/tsconfig.base",
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"skipLibCheck": true
|
||||
}
|
||||
}
|
||||