diff --git a/apps/mobile/app.json b/apps/mobile/app.json index 97a9138..94f2258 100644 --- a/apps/mobile/app.json +++ b/apps/mobile/app.json @@ -15,21 +15,28 @@ "**/*" ], "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" } -} +} \ No newline at end of file diff --git a/apps/mobile/package-lock.json b/apps/mobile/package-lock.json index cdea7e5..99b42d8 100644 --- a/apps/mobile/package-lock.json +++ b/apps/mobile/package-lock.json @@ -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", diff --git a/apps/mobile/package.json b/apps/mobile/package.json index a03c30a..b485e96 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -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", diff --git a/apps/mobile/src/app/(tabs)/index.tsx b/apps/mobile/src/app/(tabs)/index.tsx index f20b888..e0d323f 100644 --- a/apps/mobile/src/app/(tabs)/index.tsx +++ b/apps/mobile/src/app/(tabs)/index.tsx @@ -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() { setTrackMealModalVisible(true)} onAddWaterPress={() => setAddWaterModalVisible(true)} + onScanFoodPress={() => setScanFoodModalVisible(true)} /> + setScanFoodModalVisible(false)} + onAddFood={handleAddScannedFood} + /> + {/* Recent Activity Section */} diff --git a/apps/mobile/src/components/QuickActionGrid.tsx b/apps/mobile/src/components/QuickActionGrid.tsx index 697ba78..0064e4a 100644 --- a/apps/mobile/src/components/QuickActionGrid.tsx +++ b/apps/mobile/src/components/QuickActionGrid.tsx @@ -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 ( Quick Actions @@ -67,6 +68,7 @@ export function QuickActionGrid({ onTrackMealPress, onAddWaterPress }: QuickActi icon="scan" label="Scan Food" gradient={theme.gradients.purple} + onPress={onScanFoodPress} /> diff --git a/apps/mobile/src/components/ScanFoodModal.tsx b/apps/mobile/src/components/ScanFoodModal.tsx new file mode 100644 index 0000000..dfaa5c0 --- /dev/null +++ b/apps/mobile/src/components/ScanFoodModal.tsx @@ -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 ( + + + + + Camera Permission Required + + We need access to your camera to scan food barcodes. + + + + Grant Permission + + + + Cancel + + + + + ); + } + + return ( + + + {!foodData ? ( + <> + + + + + Scan Food Barcode + + + + + + + Position barcode within frame + + + + ) : ( + + + + + + Food Details + + + + + + + + + + + {foodData.name} + {foodData.servingSize} + + + {foodData.calories} + kcal per serving + + + + Number of Servings + + setServings(String(Math.max(0.5, parseFloat(servings || '1') - 0.5)))} + style={styles.servingsButton} + > + + + + setServings(String(parseFloat(servings || '1') + 0.5))} + style={styles.servingsButton} + > + + + + + + + Total Calories + + {Math.round(foodData.calories * parseFloat(servings || '1'))} kcal + + + + + + + Scan Again + + + + + Add to Diary + + + + + )} + + + ); +} + +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, + }, +});