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

View File

@ -20,7 +20,8 @@
"axios": "^1.6.0",
"expo": "~54.0.23",
"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-crypto": "^15.0.7",
"expo-font": "~14.0.9",
@ -7203,6 +7204,18 @@
"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": {
"version": "17.0.9",
"resolved": "https://registry.npmjs.org/expo-camera/-/expo-camera-17.0.9.tgz",
@ -7282,6 +7295,15 @@
"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": {
"version": "15.0.7",
"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",
"expo": "~54.0.23",
"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-crypto": "^15.0.7",
"expo-font": "~14.0.9",

View File

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

View File

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