diff --git a/CLAUDE.md b/CLAUDE.md index 9339cc5..e701cef 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,11 +12,12 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co - **Navigation**: - File-based routing in `app/` directory with nested layouts - Tab-based navigation with custom styling and haptic feedback - - Route constants defined in `constants/Routes.ts` + - Route constants defined in `constants/Routes.ts`, every page should use Routes define and jump - **UI System**: - Themed components (`ThemedText`, `ThemedView`) with color scheme support - Custom icon system with `IconSymbol` component for iOS symbols - Reusable UI components in `components/ui/` + - UI Colors in `constants/Colors.ts` - **Data Layer**: - API services in `services/` directory with centralized API client - AsyncStorage for local persistence diff --git a/app/food/analysis-result.tsx b/app/food/analysis-result.tsx new file mode 100644 index 0000000..a5c6843 --- /dev/null +++ b/app/food/analysis-result.tsx @@ -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((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 ( + + router.back()} + /> + + 未找到图片 + + + ); + } + + return ( + + {/* 背景渐变 */} + + + {/* 装饰性圆圈 */} + + + + + router.back()} + transparent={true} + /> + + + {/* 食物主图 */} + + + + {/* 识别信息气泡 */} + + + {recognitionResult ? + `置信度: ${recognitionResult.confidence}%` : + '2025年9月4日' + } + + + + + {/* 营养信息卡片 */} + + {/* 卡路里 */} + + {totalCalories} + 千卡 + + + {/* 营养圆环图 */} + + + + + + + + {/* 食物摄入部分 */} + + + {recognitionResult ? '识别结果' : '食物摄入'} + + {recognitionResult && recognitionResult.analysisText && ( + {recognitionResult.analysisText} + )} + + {/* 非食物检测提示 */} + {recognitionResult && !recognitionResult.isFoodDetected && ( + + + + + 未识别到食物 + + {recognitionResult.nonFoodMessage || recognitionResult.analysisText} + + + 建议: + • 确保图片中包含食物 + • 尝试更清晰的照片角度 + • 避免过度模糊或光线不足 + + + )} + + {/* 食物列表 */} + {foodItems.length > 0 && foodItems.map((item, index) => ( + 0 && { marginTop: 8 }]}> + + + {item.emoji} + + + {item.name} + {item.amount}{item.unit} + + + + + {item.calories}千卡 + + + + handleRemoveFood(item.id)} + > + + + + + ))} + + + + + {/* 底部餐次选择和记录按钮 */} + {recognitionResult && !recognitionResult.isFoodDetected ? ( + // 非食物检测情况显示重新拍照按钮 + + router.back()} + activeOpacity={0.8} + > + + 重新拍照 + + + ) : ( + // 正常食物识别情况 + + setShowMealSelector(true)} + > + option.key === currentMealType)?.color || '#FF6B35' } + ]} /> + {MEAL_TYPE_MAP[currentMealType as keyof typeof MEAL_TYPE_MAP]} + + + + + {isRecording ? ( + + ) : ( + 记录 + )} + + + )} + + {/* 餐次选择弹窗 */} + setShowMealSelector(false)} + > + + setShowMealSelector(false)} + /> + + + 选择餐次 + setShowMealSelector(false)}> + + + + + {mealOptions.map((option) => ( + handleMealTypeSelect(option.key)} + > + + + {option.label} + + {currentMealType === option.key && ( + + )} + + ))} + + + + + + ); +} + +// 营养圆环组件 +function NutritionRing({ + label, + value, + unit, + percentage, + color +}: { + label: string; + value: string | number; + unit?: string; + percentage: number; + color: string; +}) { + return ( + + + + + {percentage}% + + + {label} + + {value}{unit} + + + ); +} + + +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, + }, +}); \ No newline at end of file diff --git a/app/food-camera.tsx b/app/food/camera.tsx similarity index 95% rename from app/food-camera.tsx rename to app/food/camera.tsx index 267305c..96c5925 100644 --- a/app/food-camera.tsx +++ b/app/food/camera.tsx @@ -62,7 +62,7 @@ export default function FoodCameraScreen() { router.back()} - transparent={true} + backColor='#ffffff' /> @@ -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() { {/* 取景框容器 */} 确保食物在取景框内 - + {/* 相机取景框 */} (); + + const { imageUri, mealType } = params; + const { upload, uploading } = useCosUpload(); + const [isUploading, setIsUploading] = useState(false); + const [showRecognitionProcess, setShowRecognitionProcess] = useState(false); + const [recognitionLogs, setRecognitionLogs] = useState([]); + 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 ( + + router.back()} + /> + + 未找到图片 + + + ); + } + + return ( + + + + {/* 主要内容区域 */} + + {!showRecognitionProcess ? ( + // 确认界面 + <> + {/* 照片卡片 */} + + + + + {/* 餐次标签叠加 */} + {mealType && ( + + + {getMealTypeLabel(mealType)} + + + )} + + + + {/* AI 识别说明卡片 */} + + + + + + 智能食物识别 + + + AI 将分析您的照片,识别食物种类、估算营养成分,为您生成详细的营养分析报告 + + + + {/* 底部按钮区域 */} + + + + + + 开始智能识别 + + + + + + ) : ( + // 识别过程界面 + + {/* 照片缩略图卡片 */} + + + + {mealType && ( + + + {getMealTypeLabel(mealType)} + + + )} + AI 识别中... + + + + {/* 进度指示卡片 */} + + + + + {currentStep === 'uploading' || currentStep === 'recognizing' ? ( + + ) : currentStep === 'completed' ? ( + + ) : currentStep === 'failed' ? ( + + ) : null} + + + + { + currentStep === 'idle' ? '准备中' : + currentStep === 'uploading' ? '上传图片中' : + currentStep === 'recognizing' ? 'AI 分析中' : + currentStep === 'completed' ? '识别完成' : + currentStep === 'failed' ? '识别失败' : '' + } + { + currentStep === 'uploading' ? '正在将图片上传到云端处理...' : + currentStep === 'recognizing' ? '智能模型正在分析食物成分...' : + currentStep === 'completed' ? '即将跳转到分析结果页面' : + currentStep === 'failed' ? '请检查网络连接或重新拍照' : '' + } + + + + {/* 进度条 */} + {(currentStep === 'uploading' || currentStep === 'recognizing') && ( + + + + + + )} + + + {/* 识别日志卡片 */} + + + + 进度 + + + {recognitionLogs.map((log, index) => ( + + {log} + + ))} + {recognitionLogs.length === 0 && ( + 等待处理开始... + )} + + + + {/* 重试按钮 */} + {currentStep === 'failed' && ( + + + + 返回重新拍照 + + + )} + + )} + + + ); +} + +// 获取餐次标签 +function getMealTypeLabel(mealType: string): string { + const mealTypeMap: Record = { + 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', + }, +}); \ No newline at end of file diff --git a/components/FloatingFoodOverlay.tsx b/components/FloatingFoodOverlay.tsx index ab187ad..1d451e3 100644 --- a/components/FloatingFoodOverlay.tsx +++ b/components/FloatingFoodOverlay.tsx @@ -27,13 +27,13 @@ export function FloatingFoodOverlay({ visible, onClose, mealType = 'dinner' }: F const handlePhotoRecognition = () => { onClose(); - router.push(`/food-camera?mealType=${mealType}`); + router.push(`/food/camera?mealType=${mealType}`); }; const menuItems = [ { id: 'scan', - title: '扫描', + title: 'AI拍照识别', icon: '📷', backgroundColor: '#4FC3F7', onPress: handlePhotoRecognition, @@ -66,7 +66,7 @@ export function FloatingFoodOverlay({ visible, onClose, mealType = 'dinner' }: F - 日常记录 + 记录方式 diff --git a/components/NutritionRadarCard.tsx b/components/NutritionRadarCard.tsx index aa05d7c..dba1b31 100644 --- a/components/NutritionRadarCard.tsx +++ b/components/NutritionRadarCard.tsx @@ -120,7 +120,7 @@ export function NutritionRadarCard({ 更新: {dayjs(nutritionSummary?.updatedAt).format('MM-DD HH:mm')} - + @@ -362,10 +362,10 @@ const styles = StyleSheet.create({ fontSize: 24, }, addButton: { - width: 16, - height: 16, - borderRadius: 8, - backgroundColor: '#e5e8ecff', + width: 22, + height: 22, + borderRadius: 12, + backgroundColor: '#c1c1eeff', marginLeft: 8, alignItems: 'center', justifyContent: 'center', diff --git a/services/foodRecognition.ts b/services/foodRecognition.ts new file mode 100644 index 0000000..268e353 --- /dev/null +++ b/services/foodRecognition.ts @@ -0,0 +1,34 @@ +import { api } from '@/services/api'; + +export type FoodNutritionData = { + proteinGrams?: number; + carbohydrateGrams?: number; + fatGrams?: number; + fiberGrams?: number; +}; + +export type FoodConfirmationOption = { + id: string; + label: string; + foodName: string; + portion: string; + calories: number; + mealType: string; + nutritionData: FoodNutritionData; +}; + +export type FoodRecognitionRequest = { + imageUrls: string[]; +}; + +export type FoodRecognitionResponse = { + items: FoodConfirmationOption[]; + analysisText: string; + confidence: number; + isFoodDetected: boolean; + nonFoodMessage?: string; +}; + +export async function recognizeFood(request: FoodRecognitionRequest): Promise { + return api.post('/ai-coach/food-recognition', request); +} \ No newline at end of file diff --git a/store/foodRecognitionSlice.ts b/store/foodRecognitionSlice.ts new file mode 100644 index 0000000..6254191 --- /dev/null +++ b/store/foodRecognitionSlice.ts @@ -0,0 +1,111 @@ +import { type FoodRecognitionResponse } from '@/services/foodRecognition'; +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; + +// 食物识别状态类型定义 +export interface FoodRecognitionState { + // 按ID存储的识别结果 + recognitionResults: Record; + + // 当前正在处理的识别ID + currentRecognitionId: string | null; + + // 加载状态 + loading: boolean; + + // 错误信息 + error: string | null; +} + +// 初始状态 +const initialState: FoodRecognitionState = { + recognitionResults: {}, + currentRecognitionId: null, + loading: false, + error: null, +}; + +const foodRecognitionSlice = createSlice({ + name: 'foodRecognition', + initialState, + reducers: { + // 设置加载状态 + setLoading: (state, action: PayloadAction) => { + state.loading = action.payload; + if (action.payload) { + state.error = null; + } + }, + + // 设置错误信息 + setError: (state, action: PayloadAction) => { + state.error = action.payload; + state.loading = false; + }, + + // 保存识别结果 + saveRecognitionResult: (state, action: PayloadAction<{ id: string; result: FoodRecognitionResponse }>) => { + const { id, result } = action.payload; + state.recognitionResults[id] = result; + state.currentRecognitionId = id; + state.loading = false; + state.error = null; + }, + + // 设置当前识别ID + setCurrentRecognitionId: (state, action: PayloadAction) => { + state.currentRecognitionId = action.payload; + }, + + // 清除指定的识别结果 + clearRecognitionResult: (state, action: PayloadAction) => { + const id = action.payload; + delete state.recognitionResults[id]; + if (state.currentRecognitionId === id) { + state.currentRecognitionId = null; + } + }, + + // 清除所有识别结果 + clearAllRecognitionResults: (state) => { + state.recognitionResults = {}; + state.currentRecognitionId = null; + state.error = null; + }, + + // 清除错误 + clearError: (state) => { + state.error = null; + }, + }, +}); + +// Action creators +export const { + setLoading, + setError, + saveRecognitionResult, + setCurrentRecognitionId, + clearRecognitionResult, + clearAllRecognitionResults, + clearError, +} = foodRecognitionSlice.actions; + +// Selectors +export const selectFoodRecognitionResult = (id: string) => (state: { foodRecognition: FoodRecognitionState }) => + state.foodRecognition.recognitionResults[id] || null; + +export const selectCurrentFoodRecognitionResult = (state: { foodRecognition: FoodRecognitionState }) => { + const currentId = state.foodRecognition.currentRecognitionId; + return currentId ? state.foodRecognition.recognitionResults[currentId] || null : null; +}; + +export const selectFoodRecognitionLoading = (state: { foodRecognition: FoodRecognitionState }) => + state.foodRecognition.loading; + +export const selectFoodRecognitionError = (state: { foodRecognition: FoodRecognitionState }) => + state.foodRecognition.error; + +export const selectCurrentRecognitionId = (state: { foodRecognition: FoodRecognitionState }) => + state.foodRecognition.currentRecognitionId; + +export default foodRecognitionSlice.reducer; \ No newline at end of file diff --git a/store/index.ts b/store/index.ts index b4cb839..49f268c 100644 --- a/store/index.ts +++ b/store/index.ts @@ -3,6 +3,7 @@ import challengeReducer from './challengeSlice'; import checkinReducer, { addExercise, autoSyncCheckin, removeExercise, replaceExercises, setNote, toggleExerciseCompleted } from './checkinSlice'; import exerciseLibraryReducer from './exerciseLibrarySlice'; import foodLibraryReducer from './foodLibrarySlice'; +import foodRecognitionReducer from './foodRecognitionSlice'; import goalsReducer from './goalsSlice'; import healthReducer from './healthSlice'; import moodReducer from './moodSlice'; @@ -56,6 +57,7 @@ export const store = configureStore({ scheduleExercise: scheduleExerciseReducer, exerciseLibrary: exerciseLibraryReducer, foodLibrary: foodLibraryReducer, + foodRecognition: foodRecognitionReducer, workout: workoutReducer, water: waterReducer, },