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

View File

@@ -62,7 +62,7 @@ export default function FoodCameraScreen() {
<HeaderBar
title="食物拍摄"
onBack={() => router.back()}
transparent={true}
backColor='#ffffff'
/>
<View style={styles.permissionContainer}>
<Ionicons name="camera-outline" size={64} color="#999" />
@@ -96,10 +96,9 @@ export default function FoodCameraScreen() {
});
if (photo) {
// TODO: 处理拍摄的照片,可以传递到下一个页面进行食物识别
// 跳转到食物识别页面
console.log('照片拍摄成功:', photo.uri);
Alert.alert('拍摄成功', '照片已保存,后续会添加食物识别功能');
// router.push(`/food-recognition?imageUri=${photo.uri}&mealType=${currentMealType}`);
router.replace(`/food/food-recognition?imageUri=${encodeURIComponent(photo.uri)}&mealType=${currentMealType}`);
}
} catch (error) {
console.error('拍照失败:', error);
@@ -121,8 +120,7 @@ export default function FoodCameraScreen() {
if (!result.canceled && result.assets[0]) {
const imageUri = result.assets[0].uri;
console.log('从相册选择的照片:', imageUri);
Alert.alert('选择成功', '照片已选择,后续会添加食物识别功能');
// router.push(`/food-recognition?imageUri=${imageUri}&mealType=${currentMealType}`);
router.push(`/food/food-recognition?imageUri=${encodeURIComponent(imageUri)}&mealType=${currentMealType}`);
}
} catch (error) {
console.error('选择照片失败:', error);
@@ -149,7 +147,7 @@ export default function FoodCameraScreen() {
title=""
onBack={() => router.back()}
transparent={true}
backColor={'#333'}
backColor={'#fff'}
/>
{/* 主要内容区域 */}
@@ -157,7 +155,7 @@ export default function FoodCameraScreen() {
{/* 取景框容器 */}
<View style={styles.cameraFrameContainer}>
<Text style={styles.hintText}></Text>
{/* 相机取景框 */}
<View style={styles.cameraFrame}>
<CameraView

View File

@@ -0,0 +1,750 @@
import { HeaderBar } from '@/components/ui/HeaderBar';
import { Colors } from '@/constants/Colors';
import { useAppDispatch } from '@/hooks/redux';
import { useCosUpload } from '@/hooks/useCosUpload';
import { recognizeFood } from '@/services/foodRecognition';
import { saveRecognitionResult, setError, setLoading } from '@/store/foodRecognitionSlice';
import { Ionicons } from '@expo/vector-icons';
import { useLocalSearchParams, useRouter } from 'expo-router';
import React, { useEffect, useRef, useState } from 'react';
import {
ActivityIndicator,
Alert,
Animated,
Easing,
Image,
ScrollView,
StyleSheet,
Text,
TouchableOpacity,
View
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
export default function FoodRecognitionScreen() {
const router = useRouter();
const params = useLocalSearchParams<{
imageUri?: string;
mealType?: string;
}>();
const { imageUri, mealType } = params;
const { upload, uploading } = useCosUpload();
const [isUploading, setIsUploading] = useState(false);
const [showRecognitionProcess, setShowRecognitionProcess] = useState(false);
const [recognitionLogs, setRecognitionLogs] = useState<string[]>([]);
const [currentStep, setCurrentStep] = useState<'idle' | 'uploading' | 'recognizing' | 'completed' | 'failed'>('idle');
const dispatch = useAppDispatch();
// 动画引用
const scaleAnim = useRef(new Animated.Value(1)).current;
const fadeAnim = useRef(new Animated.Value(0)).current;
const slideAnim = useRef(new Animated.Value(50)).current;
const progressAnim = useRef(new Animated.Value(0)).current;
const pulseAnim = useRef(new Animated.Value(1)).current;
// 启动动画效果
useEffect(() => {
if (showRecognitionProcess) {
// 进入动画
Animated.parallel([
Animated.timing(fadeAnim, {
toValue: 1,
duration: 600,
easing: Easing.out(Easing.cubic),
useNativeDriver: true,
}),
Animated.timing(slideAnim, {
toValue: 0,
duration: 600,
easing: Easing.out(Easing.cubic),
useNativeDriver: true,
})
]).start();
// 启动进度条动画
if (currentStep === 'uploading' || currentStep === 'recognizing') {
Animated.timing(progressAnim, {
toValue: currentStep === 'uploading' ? 0.5 : 1,
duration: 2000,
easing: Easing.inOut(Easing.ease),
useNativeDriver: false,
}).start();
}
// 脉冲动画
const pulseAnimation = Animated.loop(
Animated.sequence([
Animated.timing(pulseAnim, {
toValue: 1.1,
duration: 800,
easing: Easing.inOut(Easing.sin),
useNativeDriver: true,
}),
Animated.timing(pulseAnim, {
toValue: 1,
duration: 800,
easing: Easing.inOut(Easing.sin),
useNativeDriver: true,
})
])
);
if (currentStep === 'uploading' || currentStep === 'recognizing') {
pulseAnimation.start();
} else {
pulseAnimation.stop();
pulseAnim.setValue(1);
}
} else {
fadeAnim.setValue(0);
slideAnim.setValue(50);
progressAnim.setValue(0);
}
}, [showRecognitionProcess, currentStep]);
const addLog = (message: string) => {
setRecognitionLogs(prev => [...prev, message]);
};
const handleConfirm = async () => {
if (!imageUri) return;
// 按钮动画效果
Animated.sequence([
Animated.timing(scaleAnim, {
toValue: 0.95,
duration: 100,
useNativeDriver: true,
}),
Animated.timing(scaleAnim, {
toValue: 1,
duration: 100,
useNativeDriver: true,
})
]).start();
try {
setShowRecognitionProcess(true);
setRecognitionLogs([]);
setCurrentStep('uploading');
setIsUploading(true);
dispatch(setLoading(true));
addLog('📤 正在上传图片到云端...');
// 上传图片到 COS
const { url } = await upload(
{ uri: imageUri, name: 'food-image.jpg', type: 'image/jpeg' },
{ prefix: 'food-images/' }
);
addLog('✅ 图片上传完成');
addLog('🤖 AI大模型分析中...');
setCurrentStep('recognizing');
// 调用食物识别 API
const recognitionResult = await recognizeFood({
imageUrls: [url]
});
console.log('食物识别结果:', recognitionResult);
if (!recognitionResult.isFoodDetected) {
addLog('❌ 识别失败:未检测到食物');
addLog(`💭 ${recognitionResult.nonFoodMessage || recognitionResult.analysisText}`);
setCurrentStep('failed');
return;
}
addLog('✅ AI分析完成');
addLog(`🎯 识别置信度: ${recognitionResult.confidence}%`);
addLog(`🍽️ 识别到 ${recognitionResult.items.length} 种食物`);
setCurrentStep('completed');
// 生成唯一的识别ID
const recognitionId = `recognition_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
// 保存识别结果到 Redux
await dispatch(saveRecognitionResult({
id: recognitionId,
result: recognitionResult
}));
// 延迟跳转,让用户看到完成状态
setTimeout(() => {
router.replace(`/food/analysis-result?imageUri=${encodeURIComponent(url)}&mealType=${mealType}&recognitionId=${recognitionId}`);
}, 1500);
} catch (error) {
console.warn('食物识别失败', error);
addLog('❌ 识别过程出错');
addLog(`💥 ${error instanceof Error ? error.message : '未知错误'}`);
setCurrentStep('failed');
dispatch(setError('食物识别失败,请重试'));
} finally {
setIsUploading(false);
dispatch(setLoading(false));
}
};
const handleRetry = () => {
setShowRecognitionProcess(false);
setCurrentStep('idle');
setRecognitionLogs([]);
dispatch(setError(null));
router.back()
};
const handleGoBack = () => {
if (showRecognitionProcess && currentStep !== 'failed') {
Alert.alert(
'正在识别中',
'识别过程尚未完成,确定要返回吗?',
[
{ text: '继续识别', style: 'cancel' },
{ text: '返回', style: 'destructive', onPress: () => router.back() }
]
);
} else {
router.back();
}
};
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}>
<HeaderBar
title={showRecognitionProcess ? "食物识别" : "确认食物"}
onBack={handleGoBack}
/>
{/* 主要内容区域 */}
<ScrollView style={styles.contentContainer} showsVerticalScrollIndicator={false}>
{!showRecognitionProcess ? (
// 确认界面
<>
{/* 照片卡片 */}
<View style={styles.photoCard}>
<View style={styles.photoFrame}>
<Image
source={{ uri: imageUri }}
style={styles.photoImage}
resizeMode="cover"
/>
{/* 餐次标签叠加 */}
{mealType && (
<View style={styles.mealTypeBadge}>
<Text style={styles.mealTypeBadgeText}>
{getMealTypeLabel(mealType)}
</Text>
</View>
)}
</View>
</View>
{/* AI 识别说明卡片 */}
<View style={styles.infoCard}>
<View style={styles.infoHeader}>
<View style={styles.aiIconContainer}>
<Ionicons name="sparkles" size={20} color={Colors.light.primary} />
</View>
<Text style={styles.infoTitle}></Text>
</View>
<Text style={styles.infoDescription}>
AI
</Text>
</View>
{/* 底部按钮区域 */}
<View style={styles.bottomContainer}>
<Animated.View style={[styles.confirmButtonContainer, { transform: [{ scale: scaleAnim }] }]}>
<TouchableOpacity
style={styles.confirmButton}
onPress={handleConfirm}
activeOpacity={0.8}
>
<View style={styles.confirmButtonContent}>
<Ionicons name="scan" size={20} color={Colors.light.onPrimary} style={{ marginRight: 8 }} />
<Text style={styles.confirmButtonText}></Text>
</View>
</TouchableOpacity>
</Animated.View>
</View>
</>
) : (
// 识别过程界面
<Animated.View style={[styles.recognitionContainer, {
opacity: fadeAnim,
transform: [{ translateY: slideAnim }]
}]}>
{/* 照片缩略图卡片 */}
<View style={styles.thumbnailCard}>
<Image
source={{ uri: imageUri }}
style={styles.thumbnailImage}
resizeMode="cover"
/>
<View style={styles.thumbnailInfo}>
{mealType && (
<View style={styles.thumbnailMealType}>
<Text style={styles.thumbnailMealTypeText}>
{getMealTypeLabel(mealType)}
</Text>
</View>
)}
<Text style={styles.thumbnailTitle}>AI ...</Text>
</View>
</View>
{/* 进度指示卡片 */}
<View style={styles.progressCard}>
<View style={styles.progressHeader}>
<Animated.View style={[styles.statusIconAnimated, { transform: [{ scale: pulseAnim }] }]}>
<View style={[styles.statusIcon, {
backgroundColor: currentStep === 'uploading' || currentStep === 'recognizing' ? Colors.light.primary :
currentStep === 'completed' ? Colors.light.success :
currentStep === 'failed' ? Colors.light.danger : Colors.light.neutral200
}]}>
{currentStep === 'uploading' || currentStep === 'recognizing' ? (
<ActivityIndicator size="small" color={Colors.light.onPrimary} />
) : currentStep === 'completed' ? (
<Ionicons name="checkmark" size={20} color={Colors.light.onPrimary} />
) : currentStep === 'failed' ? (
<Ionicons name="close" size={20} color={Colors.light.onPrimary} />
) : null}
</View>
</Animated.View>
<View style={styles.progressInfo}>
<Text style={styles.statusText}>{
currentStep === 'idle' ? '准备中' :
currentStep === 'uploading' ? '上传图片中' :
currentStep === 'recognizing' ? 'AI 分析中' :
currentStep === 'completed' ? '识别完成' :
currentStep === 'failed' ? '识别失败' : ''
}</Text>
<Text style={styles.statusSubtext}>{
currentStep === 'uploading' ? '正在将图片上传到云端处理...' :
currentStep === 'recognizing' ? '智能模型正在分析食物成分...' :
currentStep === 'completed' ? '即将跳转到分析结果页面' :
currentStep === 'failed' ? '请检查网络连接或重新拍照' : ''
}</Text>
</View>
</View>
{/* 进度条 */}
{(currentStep === 'uploading' || currentStep === 'recognizing') && (
<View style={styles.progressBarContainer}>
<View style={styles.progressBarBackground}>
<Animated.View
style={[styles.progressBarFill, {
width: progressAnim.interpolate({
inputRange: [0, 1],
outputRange: ['0%', '100%'],
})
}]}
/>
</View>
</View>
)}
</View>
{/* 识别日志卡片 */}
<View style={styles.logCard}>
<View style={styles.logHeader}>
<Ionicons name="document-text-outline" size={18} color={Colors.light.primary} />
<Text style={styles.logTitle}></Text>
</View>
<ScrollView
style={styles.logScrollView}
contentContainerStyle={styles.logContent}
showsVerticalScrollIndicator={false}
>
{recognitionLogs.map((log, index) => (
<Animated.View
key={index}
style={[styles.logItem, {
opacity: fadeAnim,
transform: [{ translateX: slideAnim }]
}]}
>
<Text style={styles.logText}>{log}</Text>
</Animated.View>
))}
{recognitionLogs.length === 0 && (
<Text style={styles.logPlaceholder}>...</Text>
)}
</ScrollView>
</View>
{/* 重试按钮 */}
{currentStep === 'failed' && (
<View style={styles.bottomContainer}>
<TouchableOpacity
style={styles.retryButton}
onPress={handleRetry}
activeOpacity={0.8}
>
<Ionicons name="refresh" size={18} color={Colors.light.onPrimary} style={{ marginRight: 8 }} />
<Text style={styles.retryButtonText}></Text>
</TouchableOpacity>
</View>
)}
</Animated.View>
)}
</ScrollView>
</View>
);
}
// 获取餐次标签
function getMealTypeLabel(mealType: string): string {
const mealTypeMap: Record<string, string> = {
breakfast: '早餐',
lunch: '午餐',
dinner: '晚餐',
snack: '加餐',
};
return mealTypeMap[mealType] || '未知餐次';
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: Colors.light.pageBackgroundEmphasis,
},
contentContainer: {
flex: 1,
paddingHorizontal: 16,
paddingTop: 16,
},
// 照片卡片样式
photoCard: {
backgroundColor: Colors.light.card,
borderRadius: 24,
padding: 20,
marginBottom: 16,
shadowColor: '#000',
shadowOffset: { width: 0, height: 8 },
shadowOpacity: 0.08,
shadowRadius: 20,
elevation: 8,
},
photoFrame: {
width: '100%',
aspectRatio: 1,
borderRadius: 20,
overflow: 'hidden',
backgroundColor: Colors.light.neutral100,
position: 'relative',
},
photoImage: {
width: '100%',
height: '100%',
},
mealTypeBadge: {
position: 'absolute',
top: 16,
right: 16,
paddingHorizontal: 14,
paddingVertical: 8,
backgroundColor: 'rgba(122, 90, 248, 0.95)',
borderRadius: 20,
backdropFilter: 'blur(10px)',
},
mealTypeBadgeText: {
color: Colors.light.onPrimary,
fontSize: 13,
fontWeight: '700',
letterSpacing: 0.3,
},
// 信息卡片样式
infoCard: {
backgroundColor: Colors.light.card,
borderRadius: 20,
padding: 20,
marginBottom: 24,
shadowColor: Colors.light.primary,
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.06,
shadowRadius: 16,
elevation: 4,
borderWidth: 1,
borderColor: Colors.light.heroSurfaceTint,
},
infoHeader: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 12,
},
aiIconContainer: {
width: 32,
height: 32,
borderRadius: 16,
backgroundColor: Colors.light.heroSurfaceTint,
alignItems: 'center',
justifyContent: 'center',
marginRight: 12,
},
infoTitle: {
fontSize: 18,
fontWeight: '700',
color: Colors.light.text,
letterSpacing: 0.2,
},
infoDescription: {
fontSize: 14,
color: Colors.light.textSecondary,
lineHeight: 22,
letterSpacing: 0.1,
},
// 按钮样式
bottomContainer: {
paddingBottom: 40,
paddingTop: 8,
},
confirmButtonContainer: {},
confirmButton: {
backgroundColor: Colors.light.primary,
paddingVertical: 18,
paddingHorizontal: 32,
borderRadius: 28,
alignItems: 'center',
shadowColor: Colors.light.primary,
shadowOffset: { width: 0, height: 8 },
shadowOpacity: 0.35,
shadowRadius: 20,
elevation: 8,
},
confirmButtonContent: {
flexDirection: 'row',
alignItems: 'center',
},
confirmButtonText: {
color: Colors.light.onPrimary,
fontSize: 16,
fontWeight: '700',
letterSpacing: 0.4,
},
// 识别过程容器
recognitionContainer: {
flex: 1,
},
// 缩略图卡片
thumbnailCard: {
backgroundColor: Colors.light.card,
borderRadius: 20,
padding: 16,
marginBottom: 16,
flexDirection: 'row',
alignItems: 'center',
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.06,
shadowRadius: 12,
elevation: 4,
},
thumbnailImage: {
width: 70,
height: 70,
borderRadius: 16,
},
thumbnailInfo: {
flex: 1,
marginLeft: 16,
},
thumbnailMealType: {
alignSelf: 'flex-start',
paddingHorizontal: 12,
paddingVertical: 6,
backgroundColor: Colors.light.primary,
borderRadius: 16,
marginBottom: 6,
},
thumbnailMealTypeText: {
color: Colors.light.onPrimary,
fontSize: 11,
fontWeight: '700',
letterSpacing: 0.2,
},
thumbnailTitle: {
fontSize: 16,
fontWeight: '600',
color: Colors.light.text,
letterSpacing: 0.1,
},
// 进度卡片
progressCard: {
backgroundColor: Colors.light.card,
borderRadius: 20,
padding: 20,
marginBottom: 16,
shadowColor: '#000',
shadowOffset: { width: 0, height: 6 },
shadowOpacity: 0.08,
shadowRadius: 16,
elevation: 6,
},
progressHeader: {
flexDirection: 'row',
alignItems: 'flex-start',
},
statusIconAnimated: {},
statusIcon: {
width: 48,
height: 48,
borderRadius: 24,
alignItems: 'center',
justifyContent: 'center',
marginRight: 16,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 8,
elevation: 3,
},
progressInfo: {
flex: 1,
justifyContent: 'center',
},
statusText: {
fontSize: 18,
fontWeight: '700',
color: Colors.light.text,
marginBottom: 4,
letterSpacing: 0.2,
},
statusSubtext: {
fontSize: 14,
color: Colors.light.textSecondary,
lineHeight: 20,
letterSpacing: 0.1,
},
// 进度条
progressBarContainer: {
marginTop: 20,
},
progressBarBackground: {
width: '100%',
height: 8,
backgroundColor: Colors.light.neutral100,
borderRadius: 4,
overflow: 'hidden',
},
progressBarFill: {
height: '100%',
backgroundColor: Colors.light.primary,
borderRadius: 4,
},
// 日志卡片
logCard: {
backgroundColor: Colors.light.card,
borderRadius: 20,
padding: 20,
marginBottom: 16,
flex: 1,
minHeight: 200,
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.06,
shadowRadius: 12,
elevation: 4,
},
logHeader: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 16,
},
logTitle: {
fontSize: 16,
fontWeight: '700',
color: Colors.light.text,
marginLeft: 8,
letterSpacing: 0.2,
},
logScrollView: {
flex: 1,
backgroundColor: Colors.light.heroSurfaceTint,
borderRadius: 16,
paddingHorizontal: 16,
paddingVertical: 12,
},
logContent: {
flexGrow: 1,
},
logItem: {
paddingVertical: 6,
paddingHorizontal: 4,
},
logText: {
fontSize: 14,
color: Colors.light.text,
lineHeight: 22,
letterSpacing: 0.1,
},
logPlaceholder: {
fontSize: 14,
color: Colors.light.textMuted,
fontStyle: 'italic',
textAlign: 'center',
marginTop: 40,
},
// 重试按钮
retryButton: {
backgroundColor: Colors.light.primary,
paddingVertical: 18,
paddingHorizontal: 32,
borderRadius: 28,
alignItems: 'center',
flexDirection: 'row',
justifyContent: 'center',
shadowColor: Colors.light.primary,
shadowOffset: { width: 0, height: 8 },
shadowOpacity: 0.35,
shadowRadius: 20,
elevation: 8,
},
retryButtonText: {
color: Colors.light.onPrimary,
fontSize: 16,
fontWeight: '700',
letterSpacing: 0.4,
},
// 通用样式
errorContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
errorText: {
fontSize: 16,
color: Colors.light.textMuted,
textAlign: 'center',
},
});