Files
digital-pilates/app/food/analysis-result.tsx
richarjiang 6cb0435b30 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.
2025-09-04 10:18:42 +08:00

885 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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,
},
});