Files
digital-pilates/app/food/food-recognition.tsx
richarjiang 4ae419754a feat(food): 添加拍摄指引弹窗与相册选择功能
- 在相机界面新增“拍摄示例”弹窗,展示正确/错误拍摄对比图
- 底部控制栏增加相册选择按钮与帮助按钮
- 优化控制栏布局为左右分布,提升操作便捷性
- 移除 food-recognition 中冗余的 isUploading 状态,简化上传流程
2025-09-04 10:52:00 +08:00

747 lines
21 KiB
TypeScript
Raw Permalink 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 { 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 } = useCosUpload();
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');
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
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 {
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',
},
});