feat: add food camera and recognition features

- Implemented FoodCameraScreen for capturing food images with meal type selection.
- Created FoodRecognitionScreen for processing and recognizing food images.
- Added Redux slice for managing food recognition state and results.
- Integrated image upload functionality to cloud storage.
- Enhanced UI components for better user experience during food recognition.
- Updated FloatingFoodOverlay to navigate to the new camera screen.
- Added food recognition service for API interaction.
- Improved styling and layout for various components.
This commit is contained in:
richarjiang
2025-09-04 10:18:42 +08:00
parent 0b75087855
commit 6cb0435b30
9 changed files with 1798 additions and 17 deletions

View File

@@ -0,0 +1,885 @@
import { CircularRing } from '@/components/CircularRing';
import { HeaderBar } from '@/components/ui/HeaderBar';
import { Colors } from '@/constants/Colors';
import { ROUTES } from '@/constants/Routes';
import { useAppSelector } from '@/hooks/redux';
import { addDietRecord, type CreateDietRecordDto, type MealType } from '@/services/dietRecords';
import { selectFoodRecognitionResult } from '@/store/foodRecognitionSlice';
import { Ionicons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient';
import { useLocalSearchParams, useRouter } from 'expo-router';
import React, { useEffect, useState } from 'react';
import {
ActivityIndicator,
Image,
Modal,
ScrollView,
StyleSheet,
Text,
TouchableOpacity,
View
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
// 模拟食物摄入列表数据
const mockFoodItems = [
{
id: '1',
name: '每日豆奶',
emoji: '🥛',
amount: 190,
unit: 'g',
calories: 80,
protein: 4.6,
carbohydrate: 4.0,
fat: 4.2,
},
{
id: '2',
name: '全麦面包',
emoji: '🍞',
amount: 50,
unit: 'g',
calories: 120,
protein: 4.0,
carbohydrate: 22.0,
fat: 2.0,
},
{
id: '3',
name: '香蕉',
emoji: '🍌',
amount: 100,
unit: 'g',
calories: 89,
protein: 1.1,
carbohydrate: 23.0,
fat: 0.3,
}
];
// 餐次映射
const MEAL_TYPE_MAP = {
breakfast: '早餐',
lunch: '午餐',
dinner: '晚餐',
snack: '加餐'
};
export default function FoodAnalysisResultScreen() {
const router = useRouter();
const params = useLocalSearchParams<{
imageUri?: string;
mealType?: string;
recognitionId?: string;
}>();
const [foodItems, setFoodItems] = useState(mockFoodItems);
const [currentMealType, setCurrentMealType] = useState<MealType>((params.mealType as MealType) || 'breakfast');
const [showMealSelector, setShowMealSelector] = useState(false);
const [isRecording, setIsRecording] = useState(false);
const { imageUri, recognitionId } = params;
// 从 Redux 获取识别结果
const recognitionResult = useAppSelector(recognitionId ? selectFoodRecognitionResult(recognitionId) : () => null);
// 处理识别结果数据
useEffect(() => {
if (recognitionResult) {
// 检查是否识别到食物
if (!recognitionResult.isFoodDetected) {
// 非食物检测情况,清空食物列表
setFoodItems([]);
return;
}
// 将识别结果转换为食物项格式
const convertedItems = recognitionResult.items.map((item, index) => ({
id: item.id || `item-${index}`,
name: item.foodName,
emoji: getRandomFoodEmoji(), // 使用随机emoji
amount: parseInt(item.portion.match(/\d+/)?.[0] || '100'),
unit: item.portion.includes('g') ? 'g' : item.portion.includes('ml') ? 'ml' : 'g',
calories: item.calories,
protein: item.nutritionData.proteinGrams || 0,
carbohydrate: item.nutritionData.carbohydrateGrams || 0,
fat: item.nutritionData.fatGrams || 0,
}));
setFoodItems(convertedItems);
// 如果识别结果中有餐次信息,更新餐次
if (recognitionResult.items.length > 0 && recognitionResult.items[0].mealType) {
const mealTypeFromApi = recognitionResult.items[0].mealType as MealType;
if (['breakfast', 'lunch', 'dinner', 'snack'].includes(mealTypeFromApi)) {
setCurrentMealType(mealTypeFromApi);
}
}
}
}, [recognitionResult]);
const handleSaveToDiary = async () => {
if (isRecording || foodItems.length === 0) return;
setIsRecording(true);
try {
// 逐个记录所有食物
for (const item of foodItems) {
const dietRecordData: CreateDietRecordDto = {
mealType: currentMealType,
foodName: item.name,
foodDescription: `${item.amount}${item.unit}`,
portionDescription: `${item.amount}${item.unit}`,
estimatedCalories: item.calories,
proteinGrams: item.protein,
carbohydrateGrams: item.carbohydrate,
fatGrams: item.fat,
source: 'vision',
mealTime: new Date().toISOString(),
imageUrl: imageUri,
};
await addDietRecord(dietRecordData);
}
router.replace(ROUTES.TAB_STATISTICS)
} catch (error) {
console.error('记录饮食失败:', error);
} finally {
setIsRecording(false);
}
};
// 计算所有食物的总营养数据
const totalCalories = foodItems.reduce((sum, item) => sum + item.calories, 0);
const totalProtein = foodItems.reduce((sum, item) => sum + item.protein, 0);
const totalCarbohydrate = foodItems.reduce((sum, item) => sum + item.carbohydrate, 0);
const totalFat = foodItems.reduce((sum, item) => sum + item.fat, 0);
// 计算营养比例(基于推荐日摄入量)
const proteinPercentage = Math.round((totalProtein / 50) * 100); // 推荐50g
const fatPercentage = Math.round((totalFat / 65) * 100); // 推荐65g
const carbohydratePercentage = Math.round((totalCarbohydrate / 300) * 100); // 推荐300g
// 删除食物项
const handleRemoveFood = (itemId: string) => {
setFoodItems(prev => prev.filter(item => item.id !== itemId));
};
// 获取随机食物emoji
function getRandomFoodEmoji(): string {
const foodEmojis = ['🍎', '🍌', '🍞', '🥛', '🥗', '🍗', '🍖', '🥕', '🥦', '🥬', '🍅', '🥒', '🍇', '🥝', '🍓'];
return foodEmojis[Math.floor(Math.random() * foodEmojis.length)];
}
// 处理餐次选择
const handleMealTypeSelect = (selectedMealType: MealType) => {
setCurrentMealType(selectedMealType);
setShowMealSelector(false);
};
// 餐次选择选项
const mealOptions = [
{ key: 'breakfast' as const, label: '早餐', color: '#FF6B35' },
{ key: 'lunch' as const, label: '午餐', color: '#4CAF50' },
{ key: 'dinner' as const, label: '晚餐', color: '#2196F3' },
{ key: 'snack' as const, label: '加餐', color: '#FF9800' },
];
if (!imageUri) {
return (
<SafeAreaView style={styles.container}>
<HeaderBar
title="分析结果"
onBack={() => router.back()}
/>
<View style={styles.errorContainer}>
<Text style={styles.errorText}></Text>
</View>
</SafeAreaView>
);
}
return (
<View style={styles.container}>
{/* 背景渐变 */}
<LinearGradient
colors={['#f5e5fbff', '#e5fcfeff', '#eefdffff', '#e6f6fcff']}
style={styles.gradientBackground}
start={{ x: 0, y: 0 }}
end={{ x: 0, y: 1 }}
/>
{/* 装饰性圆圈 */}
<View style={styles.decorativeCircle1} />
<View style={styles.decorativeCircle2} />
<View style={styles.decorativeCircle3} />
<HeaderBar
title="分析结果"
onBack={() => router.back()}
transparent={true}
/>
<ScrollView style={styles.scrollContainer} showsVerticalScrollIndicator={false}>
{/* 食物主图 */}
<View style={styles.imageContainer}>
<Image
source={{ uri: imageUri }}
style={styles.foodImage}
resizeMode="cover"
/>
{/* 识别信息气泡 */}
<View style={styles.descriptionBubble}>
<Text style={styles.descriptionText}>
{recognitionResult ?
`置信度: ${recognitionResult.confidence}%` :
'2025年9月4日'
}
</Text>
</View>
</View>
{/* 营养信息卡片 */}
<View style={styles.nutritionCard}>
{/* 卡路里 */}
<View style={styles.calorieSection}>
<Text style={styles.calorieValue}>{totalCalories}</Text>
<Text style={styles.calorieUnit}></Text>
</View>
{/* 营养圆环图 */}
<View style={styles.nutritionRings}>
<NutritionRing
label="蛋白质"
value={totalProtein.toFixed(1)}
unit="克"
percentage={Math.min(100, proteinPercentage)}
color="#4CAF50"
/>
<NutritionRing
label="脂肪"
value={totalFat.toFixed(1)}
unit="克"
percentage={Math.min(100, fatPercentage)}
color="#FF9800"
/>
<NutritionRing
label="碳水"
value={totalCarbohydrate.toFixed(1)}
unit="克"
percentage={Math.min(100, carbohydratePercentage)}
color="#2196F3"
/>
</View>
{/* 食物摄入部分 */}
<View style={styles.foodIntakeSection}>
<Text style={styles.foodIntakeTitle}>
{recognitionResult ? '识别结果' : '食物摄入'}
</Text>
{recognitionResult && recognitionResult.analysisText && (
<Text style={styles.analysisText}>{recognitionResult.analysisText}</Text>
)}
{/* 非食物检测提示 */}
{recognitionResult && !recognitionResult.isFoodDetected && (
<View style={styles.nonFoodContainer}>
<View style={styles.nonFoodIcon}>
<Ionicons name="alert-circle-outline" size={48} color="#FF9800" />
</View>
<Text style={styles.nonFoodTitle}></Text>
<Text style={styles.nonFoodMessage}>
{recognitionResult.nonFoodMessage || recognitionResult.analysisText}
</Text>
<View style={styles.nonFoodSuggestions}>
<Text style={styles.nonFoodSuggestionsTitle}></Text>
<Text style={styles.nonFoodSuggestionItem}> </Text>
<Text style={styles.nonFoodSuggestionItem}> </Text>
<Text style={styles.nonFoodSuggestionItem}> 线</Text>
</View>
</View>
)}
{/* 食物列表 */}
{foodItems.length > 0 && foodItems.map((item, index) => (
<View key={item.id} style={[styles.foodIntakeRow, index > 0 && { marginTop: 8 }]}>
<View style={styles.foodIntakeInfo}>
<View style={styles.foodIntakeIcon}>
<Text style={styles.foodIconEmoji}>{item.emoji}</Text>
</View>
<View style={styles.foodIntakeDetails}>
<Text style={styles.foodIntakeName}>{item.name}</Text>
<Text style={styles.foodIntakeAmount}>{item.amount}{item.unit}</Text>
</View>
</View>
<View style={styles.foodIntakeCalories}>
<Text style={styles.foodIntakeCaloriesValue}>{item.calories}</Text>
<TouchableOpacity style={styles.editButton}>
<Ionicons name="create-outline" size={16} color="#666" />
</TouchableOpacity>
<TouchableOpacity
style={styles.deleteButton}
onPress={() => handleRemoveFood(item.id)}
>
<Ionicons name="trash-outline" size={16} color="#666" />
</TouchableOpacity>
</View>
</View>
))}
</View>
</View>
</ScrollView>
{/* 底部餐次选择和记录按钮 */}
{recognitionResult && !recognitionResult.isFoodDetected ? (
// 非食物检测情况显示重新拍照按钮
<View style={styles.bottomContainer}>
<TouchableOpacity
style={styles.retakePhotoButton}
onPress={() => router.back()}
activeOpacity={0.8}
>
<Ionicons name="camera-outline" size={20} color={Colors.light.onPrimary} style={{ marginRight: 8 }} />
<Text style={styles.retakePhotoButtonText}></Text>
</TouchableOpacity>
</View>
) : (
// 正常食物识别情况
<View style={styles.bottomContainer}>
<TouchableOpacity
style={styles.mealSelector}
onPress={() => setShowMealSelector(true)}
>
<View style={[
styles.mealIndicator,
{ backgroundColor: mealOptions.find(option => option.key === currentMealType)?.color || '#FF6B35' }
]} />
<Text style={styles.mealText}>{MEAL_TYPE_MAP[currentMealType as keyof typeof MEAL_TYPE_MAP]}</Text>
<Ionicons name="chevron-down" size={16} color="#333" />
</TouchableOpacity>
<TouchableOpacity
style={[
styles.recordButton,
(isRecording || foodItems.length === 0) && styles.recordButtonDisabled
]}
disabled={isRecording || foodItems.length === 0}
onPress={handleSaveToDiary}
>
{isRecording ? (
<ActivityIndicator size="small" color="#FFF" />
) : (
<Text style={styles.recordButtonText}></Text>
)}
</TouchableOpacity>
</View>
)}
{/* 餐次选择弹窗 */}
<Modal
visible={showMealSelector}
transparent={true}
animationType="fade"
onRequestClose={() => setShowMealSelector(false)}
>
<View style={styles.mealSelectorOverlay}>
<TouchableOpacity
style={styles.mealSelectorBackdrop}
onPress={() => setShowMealSelector(false)}
/>
<View style={styles.mealSelectorModal}>
<View style={styles.mealSelectorHeader}>
<Text style={styles.mealSelectorTitle}></Text>
<TouchableOpacity onPress={() => setShowMealSelector(false)}>
<Ionicons name="close" size={24} color="#666" />
</TouchableOpacity>
</View>
<View style={styles.mealOptionsContainer}>
{mealOptions.map((option) => (
<TouchableOpacity
key={option.key}
style={[
styles.mealOption,
currentMealType === option.key && styles.mealOptionActive
]}
onPress={() => handleMealTypeSelect(option.key)}
>
<View style={[styles.mealOptionIndicator, { backgroundColor: option.color }]} />
<Text style={[
styles.mealOptionText,
currentMealType === option.key && styles.mealOptionTextActive
]}>
{option.label}
</Text>
{currentMealType === option.key && (
<Ionicons name="checkmark" size={20} color="#4CAF50" />
)}
</TouchableOpacity>
))}
</View>
</View>
</View>
</Modal>
</View>
);
}
// 营养圆环组件
function NutritionRing({
label,
value,
unit,
percentage,
color
}: {
label: string;
value: string | number;
unit?: string;
percentage: number;
color: string;
}) {
return (
<View style={styles.nutritionRingContainer}>
<View style={styles.ringWrapper}>
<CircularRing
size={60}
strokeWidth={4}
trackColor="#E2E8F0"
progressColor={color}
progress={percentage / 100}
showCenterText={false}
/>
<View style={styles.ringCenter}>
<Text style={styles.ringCenterText}>{percentage}%</Text>
</View>
</View>
<Text style={styles.nutritionRingLabel}>{label}</Text>
<Text style={styles.nutritionRingValue}>
{value}{unit}
</Text>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#f5e5fbff',
},
gradientBackground: {
position: 'absolute',
left: 0,
right: 0,
top: 0,
bottom: 0,
},
decorativeCircle1: {
position: 'absolute',
top: 120,
right: 30,
width: 80,
height: 80,
borderRadius: 40,
backgroundColor: '#0EA5E9',
opacity: 0.08,
},
decorativeCircle2: {
position: 'absolute',
top: 250,
left: -20,
width: 60,
height: 60,
borderRadius: 30,
backgroundColor: '#8B74F3',
opacity: 0.06,
},
decorativeCircle3: {
position: 'absolute',
bottom: 200,
right: -15,
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: '#0EA5E9',
opacity: 0.05,
},
scrollContainer: {
flex: 1,
},
imageContainer: {
position: 'relative',
height: 300,
},
foodImage: {
width: '100%',
height: '100%',
},
descriptionBubble: {
position: 'absolute',
top: 20,
left: 20,
backgroundColor: 'rgba(255, 255, 255, 0.9)',
paddingHorizontal: 12,
paddingVertical: 8,
borderRadius: 16,
shadowColor: '#000',
shadowOffset: {
width: 0,
height: 2,
},
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 3,
},
descriptionText: {
fontSize: 14,
color: Colors.light.text,
fontWeight: '500',
},
nutritionCard: {
backgroundColor: Colors.light.background,
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
marginTop: -60,
paddingTop: 24,
paddingHorizontal: 20,
paddingBottom: 40,
shadowColor: '#000',
shadowOffset: {
width: 0,
height: -4,
},
shadowOpacity: 0.1,
shadowRadius: 12,
elevation: 8,
},
calorieSection: {
flexDirection: 'row',
alignItems: 'baseline',
marginBottom: 8,
},
calorieValue: {
fontSize: 32,
fontWeight: '700',
lineHeight: 32,
},
calorieUnit: {
fontSize: 16,
color: Colors.light.textSecondary,
marginLeft: 8,
},
foodName: {
fontSize: 20,
fontWeight: '600',
color: Colors.light.text,
marginBottom: 24,
},
nutritionHeader: {
marginBottom: 16,
},
nutritionTitle: {
fontSize: 18,
fontWeight: '600',
color: Colors.light.text,
textAlign: 'center',
},
nutritionRings: {
flexDirection: 'row',
justifyContent: 'space-around',
marginBottom: 32,
paddingVertical: 20,
},
nutritionRingContainer: {
alignItems: 'center',
},
ringWrapper: {
position: 'relative',
marginBottom: 8,
},
ringCenter: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
alignItems: 'center',
justifyContent: 'center',
},
ringCenterText: {
fontSize: 14,
fontWeight: '600',
color: Colors.light.text,
},
nutritionRingLabel: {
fontSize: 14,
color: Colors.light.textSecondary,
fontWeight: '500',
marginBottom: 2,
},
nutritionRingValue: {
fontSize: 12,
color: Colors.light.text,
fontWeight: '600',
},
foodIntakeSection: {
marginBottom: 20,
},
foodIntakeTitle: {
fontSize: 18,
fontWeight: '600',
color: Colors.light.text,
marginBottom: 12,
},
foodIntakeRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: 12,
paddingHorizontal: 16,
backgroundColor: '#F8F9FA',
borderRadius: 12,
},
foodIntakeInfo: {
flexDirection: 'row',
alignItems: 'center',
flex: 1,
},
foodIntakeIcon: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: '#FFF',
alignItems: 'center',
justifyContent: 'center',
marginRight: 12,
},
foodIconEmoji: {
fontSize: 20,
},
foodIntakeDetails: {
flex: 1,
},
foodIntakeName: {
fontSize: 16,
fontWeight: '500',
color: Colors.light.text,
marginBottom: 2,
},
foodIntakeAmount: {
fontSize: 14,
color: Colors.light.textSecondary,
},
foodIntakeCalories: {
flexDirection: 'row',
alignItems: 'center',
},
foodIntakeCaloriesValue: {
fontSize: 16,
fontWeight: '600',
color: Colors.light.text,
marginRight: 8,
},
editButton: {
padding: 4,
marginRight: 4,
},
deleteButton: {
padding: 4,
},
bottomContainer: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 16,
paddingVertical: 12,
backgroundColor: '#FFF',
borderTopWidth: 1,
borderTopColor: '#E5E5E5',
},
mealSelector: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 12,
paddingVertical: 8,
backgroundColor: '#F8F9FA',
borderRadius: 20,
},
mealIndicator: {
width: 8,
height: 8,
borderRadius: 4,
backgroundColor: '#FF6B35',
marginRight: 8,
},
mealText: {
fontSize: 14,
color: '#333',
marginRight: 4,
},
recordButton: {
backgroundColor: Colors.light.primary,
paddingHorizontal: 24,
paddingVertical: 10,
borderRadius: 20,
},
recordButtonText: {
fontSize: 16,
color: '#FFF',
fontWeight: '500',
},
recordButtonDisabled: {
backgroundColor: '#CCC',
},
// 餐次选择弹窗样式
mealSelectorOverlay: {
flex: 1,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
justifyContent: 'flex-end',
},
mealSelectorBackdrop: {
flex: 1,
},
mealSelectorModal: {
backgroundColor: '#FFF',
borderTopLeftRadius: 20,
borderTopRightRadius: 20,
paddingBottom: 34,
},
mealSelectorHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingHorizontal: 20,
paddingVertical: 16,
borderBottomWidth: 1,
borderBottomColor: '#E5E5E5',
},
mealSelectorTitle: {
fontSize: 18,
fontWeight: '600',
color: '#333',
},
mealOptionsContainer: {
paddingHorizontal: 20,
paddingVertical: 16,
},
mealOption: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 16,
paddingHorizontal: 16,
borderRadius: 12,
marginBottom: 8,
backgroundColor: '#F8F9FA',
},
mealOptionActive: {
backgroundColor: '#E8F5E8',
borderWidth: 1,
borderColor: '#4CAF50',
},
mealOptionIndicator: {
width: 12,
height: 12,
borderRadius: 6,
marginRight: 12,
},
mealOptionText: {
flex: 1,
fontSize: 16,
color: '#333',
fontWeight: '500',
},
mealOptionTextActive: {
color: '#4CAF50',
},
errorContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
errorText: {
fontSize: 16,
color: Colors.light.textMuted,
textAlign: 'center',
},
analysisText: {
fontSize: 14,
color: Colors.light.textSecondary,
marginBottom: 12,
lineHeight: 20,
},
// 非食物检测样式
nonFoodContainer: {
backgroundColor: '#FFF8E1',
borderRadius: 16,
padding: 24,
alignItems: 'center',
marginVertical: 16,
borderWidth: 1,
borderColor: '#FFE0B2',
},
nonFoodIcon: {
marginBottom: 16,
},
nonFoodTitle: {
fontSize: 18,
fontWeight: '600',
color: '#E65100',
marginBottom: 8,
textAlign: 'center',
},
nonFoodMessage: {
fontSize: 16,
color: Colors.light.text,
textAlign: 'center',
lineHeight: 24,
marginBottom: 16,
},
nonFoodSuggestions: {
alignSelf: 'stretch',
},
nonFoodSuggestionsTitle: {
fontSize: 16,
fontWeight: '600',
color: Colors.light.text,
marginBottom: 8,
},
nonFoodSuggestionItem: {
fontSize: 14,
color: Colors.light.textSecondary,
marginBottom: 4,
lineHeight: 20,
},
retakePhotoButton: {
backgroundColor: Colors.light.primary,
paddingVertical: 16,
paddingHorizontal: 32,
borderRadius: 28,
alignItems: 'center',
flexDirection: 'row',
justifyContent: 'center',
flex: 1,
shadowColor: Colors.light.primary,
shadowOffset: {
width: 0,
height: 4,
},
shadowOpacity: 0.3,
shadowRadius: 8,
elevation: 6,
},
retakePhotoButtonText: {
color: Colors.light.onPrimary,
fontSize: 16,
fontWeight: '600',
letterSpacing: 0.5,
},
});