scan food

with mock db, real db[openfoodfacts] to be implemented
This commit is contained in:
echo 2025-12-01 19:49:10 +01:00
parent ec370a3c17
commit db0d2cf215
6 changed files with 501 additions and 7 deletions

View File

@ -15,21 +15,28 @@
"**/*" "**/*"
], ],
"ios": { "ios": {
"supportsTablet": true "supportsTablet": true,
"infoPlist": {
"NSCameraUsageDescription": "This app uses the camera to scan food barcodes and identify nutritional information."
}
}, },
"android": { "android": {
"adaptiveIcon": { "adaptiveIcon": {
"foregroundImage": "./assets/adaptive-icon.png", "foregroundImage": "./assets/adaptive-icon.png",
"backgroundColor": "#ffffff" "backgroundColor": "#ffffff"
} },
"permissions": [
"CAMERA"
]
}, },
"web": { "web": {
"favicon": "./assets/favicon.png" "favicon": "./assets/favicon.png"
}, },
"plugins": [ "plugins": [
"expo-router", "expo-router",
"expo-font" "expo-font",
"expo-barcode-scanner"
], ],
"scheme": "fitai" "scheme": "fitai"
} }
} }

View File

@ -20,7 +20,8 @@
"axios": "^1.6.0", "axios": "^1.6.0",
"expo": "~54.0.23", "expo": "~54.0.23",
"expo-auth-session": "^7.0.8", "expo-auth-session": "^7.0.8",
"expo-camera": "~17.0.0", "expo-barcode-scanner": "^13.0.1",
"expo-camera": "~17.0.9",
"expo-constants": "^18.0.10", "expo-constants": "^18.0.10",
"expo-crypto": "^15.0.7", "expo-crypto": "^15.0.7",
"expo-font": "~14.0.9", "expo-font": "~14.0.9",
@ -7203,6 +7204,18 @@
"react-native": "*" "react-native": "*"
} }
}, },
"node_modules/expo-barcode-scanner": {
"version": "13.0.1",
"resolved": "https://registry.npmjs.org/expo-barcode-scanner/-/expo-barcode-scanner-13.0.1.tgz",
"integrity": "sha512-xBGLT1An2gpAMIQRTLU3oHydKohX8r8F9/ait1Fk9Vgd0GraFZbP4IiT7nHMlaw4H6E7Muucf7vXpGV6u7d4HQ==",
"license": "MIT",
"dependencies": {
"expo-image-loader": "~4.7.0"
},
"peerDependencies": {
"expo": "*"
}
},
"node_modules/expo-camera": { "node_modules/expo-camera": {
"version": "17.0.9", "version": "17.0.9",
"resolved": "https://registry.npmjs.org/expo-camera/-/expo-camera-17.0.9.tgz", "resolved": "https://registry.npmjs.org/expo-camera/-/expo-camera-17.0.9.tgz",
@ -7282,6 +7295,15 @@
"expo": "*" "expo": "*"
} }
}, },
"node_modules/expo-image-loader": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/expo-image-loader/-/expo-image-loader-4.7.0.tgz",
"integrity": "sha512-cx+MxxsAMGl9AiWnQUzrkJMJH4eNOGlu7XkLGnAXSJrRoIiciGaKqzeaD326IyCTV+Z1fXvIliSgNW+DscvD8g==",
"license": "MIT",
"peerDependencies": {
"expo": "*"
}
},
"node_modules/expo-linear-gradient": { "node_modules/expo-linear-gradient": {
"version": "15.0.7", "version": "15.0.7",
"resolved": "https://registry.npmjs.org/expo-linear-gradient/-/expo-linear-gradient-15.0.7.tgz", "resolved": "https://registry.npmjs.org/expo-linear-gradient/-/expo-linear-gradient-15.0.7.tgz",

View File

@ -26,7 +26,8 @@
"axios": "^1.6.0", "axios": "^1.6.0",
"expo": "~54.0.23", "expo": "~54.0.23",
"expo-auth-session": "^7.0.8", "expo-auth-session": "^7.0.8",
"expo-camera": "~17.0.0", "expo-barcode-scanner": "^13.0.1",
"expo-camera": "~17.0.9",
"expo-constants": "^18.0.10", "expo-constants": "^18.0.10",
"expo-crypto": "^15.0.7", "expo-crypto": "^15.0.7",
"expo-font": "~14.0.9", "expo-font": "~14.0.9",

View File

@ -9,6 +9,7 @@ import { QuickActionGrid } from "../../components/QuickActionGrid";
import { TrackMealModal } from "../../components/TrackMealModal"; import { TrackMealModal } from "../../components/TrackMealModal";
import { AddWaterModal } from "../../components/AddWaterModal"; import { AddWaterModal } from "../../components/AddWaterModal";
import { HydrationWidget } from "../../components/HydrationWidget"; import { HydrationWidget } from "../../components/HydrationWidget";
import { ScanFoodModal } from "../../components/ScanFoodModal";
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
export default function HomeScreen() { export default function HomeScreen() {
@ -16,6 +17,7 @@ export default function HomeScreen() {
const [refreshing, setRefreshing] = useState(false); const [refreshing, setRefreshing] = useState(false);
const [trackMealModalVisible, setTrackMealModalVisible] = useState(false); const [trackMealModalVisible, setTrackMealModalVisible] = useState(false);
const [addWaterModalVisible, setAddWaterModalVisible] = useState(false); const [addWaterModalVisible, setAddWaterModalVisible] = useState(false);
const [scanFoodModalVisible, setScanFoodModalVisible] = useState(false);
const [calories, setCalories] = useState(0); const [calories, setCalories] = useState(0);
const [waterIntake, setWaterIntake] = useState(0); const [waterIntake, setWaterIntake] = useState(0);
@ -51,6 +53,11 @@ export default function HomeScreen() {
setWaterIntake(0); setWaterIntake(0);
}; };
const handleAddScannedFood = (scannedCalories: number) => {
setCalories(prev => prev + scannedCalories);
setScanFoodModalVisible(false);
};
const resetAllCounters = async () => { const resetAllCounters = async () => {
setCalories(0); setCalories(0);
setWaterIntake(0); setWaterIntake(0);
@ -134,6 +141,7 @@ export default function HomeScreen() {
<QuickActionGrid <QuickActionGrid
onTrackMealPress={() => setTrackMealModalVisible(true)} onTrackMealPress={() => setTrackMealModalVisible(true)}
onAddWaterPress={() => setAddWaterModalVisible(true)} onAddWaterPress={() => setAddWaterModalVisible(true)}
onScanFoodPress={() => setScanFoodModalVisible(true)}
/> />
<TrackMealModal <TrackMealModal
@ -150,6 +158,12 @@ export default function HomeScreen() {
onResetData={handleResetWater} onResetData={handleResetWater}
/> />
<ScanFoodModal
visible={scanFoodModalVisible}
onClose={() => setScanFoodModalVisible(false)}
onAddFood={handleAddScannedFood}
/>
{/* Recent Activity Section */} {/* Recent Activity Section */}
<View style={styles.section}> <View style={styles.section}>
<View style={styles.sectionHeader}> <View style={styles.sectionHeader}>

View File

@ -39,9 +39,10 @@ function QuickActionItem({ icon, label, gradient, onPress }: QuickActionProps) {
interface QuickActionGridProps { interface QuickActionGridProps {
onTrackMealPress?: () => void; onTrackMealPress?: () => void;
onAddWaterPress?: () => void; onAddWaterPress?: () => void;
onScanFoodPress?: () => void;
} }
export function QuickActionGrid({ onTrackMealPress, onAddWaterPress }: QuickActionGridProps) { export function QuickActionGrid({ onTrackMealPress, onAddWaterPress, onScanFoodPress }: QuickActionGridProps) {
return ( return (
<View style={styles.container}> <View style={styles.container}>
<Text style={styles.sectionTitle}>Quick Actions</Text> <Text style={styles.sectionTitle}>Quick Actions</Text>
@ -67,6 +68,7 @@ export function QuickActionGrid({ onTrackMealPress, onAddWaterPress }: QuickActi
icon="scan" icon="scan"
label="Scan Food" label="Scan Food"
gradient={theme.gradients.purple} gradient={theme.gradients.purple}
onPress={onScanFoodPress}
/> />
</View> </View>
</View> </View>

View File

@ -0,0 +1,448 @@
import React, { useState, useEffect } from 'react';
import { View, Text, StyleSheet, Modal, TouchableOpacity, TextInput, Alert } from 'react-native';
import { CameraView, useCameraPermissions } from 'expo-camera';
import { Ionicons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient';
import { theme } from '../styles/theme';
interface ScanFoodModalProps {
visible: boolean;
onClose: () => void;
onAddFood: (calories: number) => void;
}
// Mock food database
const FOOD_DATABASE: { [key: string]: { name: string; calories: number; servingSize: string } } = {
'0123456789': { name: 'Apple', calories: 95, servingSize: '1 medium' },
'9876543210': { name: 'Banana', calories: 105, servingSize: '1 medium' },
'5555555555': { name: 'Protein Bar', calories: 200, servingSize: '1 bar' },
'1111111111': { name: 'Greek Yogurt', calories: 150, servingSize: '1 cup' },
};
export function ScanFoodModal({ visible, onClose, onAddFood }: ScanFoodModalProps) {
const [permission, requestPermission] = useCameraPermissions();
const [scanned, setScanned] = useState(false);
const [foodData, setFoodData] = useState<{ name: string; calories: number; servingSize: string } | null>(null);
const [servings, setServings] = useState('1');
useEffect(() => {
if (visible) {
setScanned(false);
setFoodData(null);
setServings('1');
}
}, [visible]);
const handleBarCodeScanned = ({ data }: { data: string }) => {
if (scanned) return;
setScanned(true);
// Look up food in database
const food = FOOD_DATABASE[data];
if (food) {
setFoodData(food);
} else {
// Mock data for unknown barcodes
setFoodData({
name: 'Unknown Food',
calories: 150,
servingSize: '1 serving'
});
}
};
const handleConfirm = () => {
if (foodData) {
const totalCalories = foodData.calories * parseFloat(servings || '1');
onAddFood(totalCalories);
onClose();
}
};
const handleRescan = () => {
setScanned(false);
setFoodData(null);
setServings('1');
};
if (!permission) {
return null;
}
if (!permission.granted) {
return (
<Modal visible={visible} transparent animationType="slide">
<View style={styles.permissionContainer}>
<View style={styles.permissionContent}>
<Ionicons name="camera-outline" size={64} color={theme.colors.primary} />
<Text style={styles.permissionTitle}>Camera Permission Required</Text>
<Text style={styles.permissionText}>
We need access to your camera to scan food barcodes.
</Text>
<TouchableOpacity onPress={requestPermission} style={styles.permissionButtonContainer}>
<LinearGradient
colors={theme.gradients.primary}
style={styles.permissionButton}
>
<Text style={styles.permissionButtonText}>Grant Permission</Text>
</LinearGradient>
</TouchableOpacity>
<TouchableOpacity onPress={onClose} style={styles.cancelButton}>
<Text style={styles.cancelButtonText}>Cancel</Text>
</TouchableOpacity>
</View>
</View>
</Modal>
);
}
return (
<Modal visible={visible} animationType="slide">
<View style={styles.container}>
{!foodData ? (
<>
<View style={styles.header}>
<TouchableOpacity onPress={onClose} style={styles.closeButton}>
<Ionicons name="close" size={28} color="#fff" />
</TouchableOpacity>
<Text style={styles.title}>Scan Food Barcode</Text>
<View style={{ width: 28 }} />
</View>
<CameraView
style={styles.camera}
facing="back"
onBarcodeScanned={scanned ? undefined : handleBarCodeScanned}
barcodeScannerSettings={{
barcodeTypes: ['ean13', 'ean8', 'upc_a', 'upc_e', 'code128', 'code39'],
}}
>
<View style={styles.scanOverlay}>
<View style={styles.scanFrame} />
<Text style={styles.scanText}>Position barcode within frame</Text>
</View>
</CameraView>
</>
) : (
<View style={styles.resultContainer}>
<View style={styles.resultHeader}>
<TouchableOpacity onPress={onClose} style={styles.closeButton}>
<Ionicons name="close" size={28} color={theme.colors.gray900} />
</TouchableOpacity>
<Text style={styles.resultTitle}>Food Details</Text>
<View style={{ width: 28 }} />
</View>
<View style={styles.foodCard}>
<View style={styles.foodIconContainer}>
<LinearGradient
colors={theme.gradients.success}
style={styles.foodIcon}
>
<Ionicons name="restaurant" size={32} color="#fff" />
</LinearGradient>
</View>
<Text style={styles.foodName}>{foodData.name}</Text>
<Text style={styles.servingSize}>{foodData.servingSize}</Text>
<View style={styles.caloriesBadge}>
<Text style={styles.caloriesValue}>{foodData.calories}</Text>
<Text style={styles.caloriesLabel}>kcal per serving</Text>
</View>
<View style={styles.servingsContainer}>
<Text style={styles.label}>Number of Servings</Text>
<View style={styles.servingsInput}>
<TouchableOpacity
onPress={() => setServings(String(Math.max(0.5, parseFloat(servings || '1') - 0.5)))}
style={styles.servingsButton}
>
<Ionicons name="remove" size={20} color={theme.colors.primary} />
</TouchableOpacity>
<TextInput
style={styles.servingsValue}
value={servings}
onChangeText={setServings}
keyboardType="decimal-pad"
/>
<TouchableOpacity
onPress={() => setServings(String(parseFloat(servings || '1') + 0.5))}
style={styles.servingsButton}
>
<Ionicons name="add" size={20} color={theme.colors.primary} />
</TouchableOpacity>
</View>
</View>
<View style={styles.totalCalories}>
<Text style={styles.totalLabel}>Total Calories</Text>
<Text style={styles.totalValue}>
{Math.round(foodData.calories * parseFloat(servings || '1'))} kcal
</Text>
</View>
</View>
<View style={styles.buttonRow}>
<TouchableOpacity onPress={handleRescan} style={styles.rescanButton}>
<Text style={styles.rescanButtonText}>Scan Again</Text>
</TouchableOpacity>
<TouchableOpacity onPress={handleConfirm} style={styles.confirmButtonContainer}>
<LinearGradient
colors={theme.gradients.success}
style={styles.confirmButton}
>
<Text style={styles.confirmButtonText}>Add to Diary</Text>
</LinearGradient>
</TouchableOpacity>
</View>
</View>
)}
</View>
</Modal>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#000',
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
padding: 20,
paddingTop: 60,
backgroundColor: 'rgba(0, 0, 0, 0.8)',
},
closeButton: {
padding: 4,
},
title: {
fontSize: 18,
fontWeight: '700',
color: '#fff',
},
camera: {
flex: 1,
},
scanOverlay: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: 'rgba(0, 0, 0, 0.3)',
},
scanFrame: {
width: 250,
height: 250,
borderWidth: 2,
borderColor: '#fff',
borderRadius: 20,
backgroundColor: 'transparent',
},
scanText: {
color: '#fff',
fontSize: 16,
fontWeight: '600',
marginTop: 24,
textAlign: 'center',
},
resultContainer: {
flex: 1,
backgroundColor: theme.colors.background,
},
resultHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
padding: 20,
paddingTop: 60,
backgroundColor: '#fff',
},
resultTitle: {
fontSize: 18,
fontWeight: '700',
color: theme.colors.gray900,
},
foodCard: {
margin: 20,
padding: 24,
backgroundColor: '#fff',
borderRadius: 24,
alignItems: 'center',
...theme.shadows.medium,
},
foodIconContainer: {
marginBottom: 16,
},
foodIcon: {
width: 80,
height: 80,
borderRadius: 40,
justifyContent: 'center',
alignItems: 'center',
},
foodName: {
fontSize: 24,
fontWeight: '700',
color: theme.colors.gray900,
marginBottom: 8,
textAlign: 'center',
},
servingSize: {
fontSize: 16,
color: theme.colors.gray600,
marginBottom: 24,
},
caloriesBadge: {
backgroundColor: theme.colors.gray50,
paddingHorizontal: 24,
paddingVertical: 16,
borderRadius: 16,
alignItems: 'center',
marginBottom: 32,
},
caloriesValue: {
fontSize: 32,
fontWeight: '700',
color: theme.colors.primary,
},
caloriesLabel: {
fontSize: 14,
color: theme.colors.gray600,
marginTop: 4,
},
servingsContainer: {
width: '100%',
marginBottom: 24,
},
label: {
fontSize: 16,
fontWeight: '600',
color: theme.colors.gray700,
marginBottom: 12,
},
servingsInput: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 16,
},
servingsButton: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: theme.colors.gray100,
justifyContent: 'center',
alignItems: 'center',
},
servingsValue: {
fontSize: 24,
fontWeight: '700',
color: theme.colors.gray900,
textAlign: 'center',
minWidth: 60,
},
totalCalories: {
width: '100%',
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingTop: 24,
borderTopWidth: 1,
borderTopColor: theme.colors.gray200,
},
totalLabel: {
fontSize: 16,
fontWeight: '600',
color: theme.colors.gray700,
},
totalValue: {
fontSize: 20,
fontWeight: '700',
color: theme.colors.gray900,
},
buttonRow: {
flexDirection: 'row',
padding: 20,
gap: 12,
},
rescanButton: {
flex: 1,
paddingVertical: 16,
borderRadius: 20,
backgroundColor: theme.colors.gray100,
alignItems: 'center',
},
rescanButtonText: {
fontSize: 16,
fontWeight: '700',
color: theme.colors.gray700,
},
confirmButtonContainer: {
flex: 1,
},
confirmButton: {
paddingVertical: 16,
borderRadius: 20,
alignItems: 'center',
},
confirmButtonText: {
fontSize: 16,
fontWeight: '700',
color: '#fff',
},
permissionContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: 'rgba(0, 0, 0, 0.5)',
},
permissionContent: {
backgroundColor: '#fff',
borderRadius: 24,
padding: 32,
margin: 20,
alignItems: 'center',
...theme.shadows.strong,
},
permissionTitle: {
fontSize: 20,
fontWeight: '700',
color: theme.colors.gray900,
marginTop: 16,
marginBottom: 8,
},
permissionText: {
fontSize: 16,
color: theme.colors.gray600,
textAlign: 'center',
marginBottom: 24,
},
permissionButtonContainer: {
width: '100%',
marginBottom: 12,
},
permissionButton: {
paddingVertical: 16,
borderRadius: 20,
alignItems: 'center',
},
permissionButtonText: {
fontSize: 16,
fontWeight: '700',
color: '#fff',
},
cancelButton: {
paddingVertical: 12,
},
cancelButtonText: {
fontSize: 16,
fontWeight: '600',
color: theme.colors.gray600,
},
});