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