import { HeaderBar } from '@/components/ui/HeaderBar'; import { Colors } from '@/constants/Colors'; import { useAppDispatch } from '@/hooks/redux'; import { useColorScheme } from '@/hooks/useColorScheme'; import { analyzeFoodFromText } from '@/services/foodRecognition'; import { saveRecognitionResult, setError, setLoading } from '@/store/foodRecognitionSlice'; import { triggerHapticFeedback } from '@/utils/haptics'; import { Ionicons } from '@expo/vector-icons'; import Voice from '@react-native-voice/voice'; import { BlurView } from 'expo-blur'; import { router, useLocalSearchParams } from 'expo-router'; import React, { useCallback, useEffect, useRef, useState } from 'react'; import { Alert, Animated, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; type VoiceRecordState = 'idle' | 'listening' | 'processing' | 'result' | 'analyzing'; export default function VoiceRecordScreen() { const theme = useColorScheme() ?? 'light'; const colorTokens = Colors[theme]; const { mealType = 'dinner' } = useLocalSearchParams<{ mealType?: string }>(); const dispatch = useAppDispatch(); // 状态管理 const [recordState, setRecordState] = useState('idle'); const [recognizedText, setRecognizedText] = useState(''); const [isListening, setIsListening] = useState(false); const [analysisProgress, setAnalysisProgress] = useState(0); // 用于跟踪组件是否已卸载,防止在组件卸载后设置状态 const isMountedRef = useRef(true); const progressIntervalRef = useRef | null>(null); // 动画相关 const scaleAnimation = useRef(new Animated.Value(1)).current; const pulseAnimation = useRef(new Animated.Value(1)).current; const waveAnimation = useRef(new Animated.Value(0)).current; const glowAnimation = useRef(new Animated.Value(0)).current; const progressAnimation = useRef(new Animated.Value(0)).current; // 启动脉动动画 const startPulseAnimation = () => { Animated.loop( Animated.sequence([ Animated.timing(pulseAnimation, { toValue: 1.2, duration: 800, useNativeDriver: true, }), Animated.timing(pulseAnimation, { toValue: 1, duration: 800, useNativeDriver: true, }), ]) ).start(); }; // 启动波浪动画 const startWaveAnimation = () => { Animated.loop( Animated.timing(waveAnimation, { toValue: 1, duration: 1500, useNativeDriver: false, }) ).start(); }; // 启动科幻分析动画 const startAnalysisAnimation = () => { // 光环动画 Animated.loop( Animated.sequence([ Animated.timing(glowAnimation, { toValue: 1, duration: 2000, useNativeDriver: false, }), Animated.timing(glowAnimation, { toValue: 0, duration: 2000, useNativeDriver: false, }), ]) ).start(); // 进度条动画 Animated.timing(progressAnimation, { toValue: 1, duration: 8000, // 8秒完成 useNativeDriver: false, }).start(); }; // 停止所有动画 const stopAnimations = () => { pulseAnimation.stopAnimation(); waveAnimation.stopAnimation(); glowAnimation.stopAnimation(); progressAnimation.stopAnimation(); scaleAnimation.setValue(1); pulseAnimation.setValue(1); waveAnimation.setValue(0); glowAnimation.setValue(0); progressAnimation.setValue(0); }; // 语音识别回调 - 使用 useCallback 避免每次渲染重新创建 const onSpeechStart = useCallback(() => { console.log('语音开始'); if (!isMountedRef.current) return; setIsListening(true); setRecordState('listening'); startPulseAnimation(); startWaveAnimation(); }, []); const onSpeechRecognized = useCallback(() => { console.log('语音识别中...'); }, []); const onSpeechEnd = useCallback(() => { console.log('语音结束'); if (!isMountedRef.current) return; setIsListening(false); setRecordState('processing'); stopAnimations(); }, []); const onSpeechError = useCallback((error: any) => { console.log('语音识别错误:', error); if (!isMountedRef.current) return; setIsListening(false); setRecordState('idle'); stopAnimations(); // 显示更友好的错误信息 if (error.error?.code === '7') { Alert.alert('提示', '没有检测到语音输入,请重试'); } else if (error.error?.code === '2') { Alert.alert('提示', '网络连接异常,请检查网络后重试'); } else { Alert.alert('提示', '语音识别出现问题,请重试'); } }, []); const onSpeechResults = useCallback((event: any) => { console.log('语音识别结果:', event); if (!isMountedRef.current) return; const text = event.value?.[0] || ''; if (text.trim()) { setRecognizedText(text); setRecordState('result'); } else { setRecordState('idle'); Alert.alert('提示', '未识别到有效内容,请重新录音'); } stopAnimations(); }, []); const onSpeechPartialResults = useCallback((event: any) => { if (!isMountedRef.current) return; const text = event.value?.[0] || ''; setRecognizedText(text); }, []); const onSpeechVolumeChanged = useCallback((event: any) => { if (!isMountedRef.current) return; // 根据音量调整动画 const volume = event.value || 0; const scale = 1 + (volume * 0.1); scaleAnimation.setValue(Math.min(scale, 1.5)); }, []); useEffect(() => { // 初始化语音识别 Voice.onSpeechStart = onSpeechStart; Voice.onSpeechRecognized = onSpeechRecognized; Voice.onSpeechEnd = onSpeechEnd; Voice.onSpeechError = onSpeechError; Voice.onSpeechResults = onSpeechResults; Voice.onSpeechPartialResults = onSpeechPartialResults; Voice.onSpeechVolumeChanged = onSpeechVolumeChanged; return () => { // 标记组件已卸载 isMountedRef.current = false; // 清理进度定时器 if (progressIntervalRef.current) { clearInterval(progressIntervalRef.current); } // 清理语音识别资源 const cleanup = async () => { try { await Voice.stop(); await Voice.destroy(); Voice.removeAllListeners(); } catch (error) { console.log('清理语音识别资源失败:', error); } }; cleanup(); }; }, [onSpeechStart, onSpeechRecognized, onSpeechEnd, onSpeechError, onSpeechResults, onSpeechPartialResults, onSpeechVolumeChanged]); // 开始录音 const startRecording = async () => { try { // 重置状态 setRecognizedText(''); setRecordState('idle'); triggerHapticFeedback('impactMedium'); // 确保之前的识别已停止 await Voice.stop(); // 添加短暂延迟确保资源清理完成 await new Promise(resolve => setTimeout(resolve, 100)); // 启动新的语音识别 await Voice.start('zh-CN'); } catch (error) { console.log('启动语音识别失败:', error); setRecordState('idle'); setIsListening(false); Alert.alert('录音失败', '无法启动语音识别,请检查麦克风权限设置'); } }; // 停止录音 const stopRecording = async () => { try { console.log('停止录音'); setIsListening(false); await Voice.stop(); triggerHapticFeedback('impactLight'); } catch (error) { console.log('停止语音识别失败:', error); setIsListening(false); setRecordState('idle'); } }; // 重新录音 const retryRecording = async () => { try { // 停止所有动画 stopAnimations(); // 重置所有状态 setRecognizedText(''); setAnalysisProgress(0); setIsListening(false); setRecordState('idle'); // 确保语音识别已停止 await Voice.stop(); // 延迟一点再开始新的录音,确保状态完全重置 setTimeout(() => { startRecording(); }, 200); } catch (error) { console.log('重新录音失败:', error); setRecordState('idle'); setIsListening(false); } }; // 确认并分析食物文本 const confirmResult = async () => { if (!recognizedText.trim()) { Alert.alert('提示', '请先进行语音识别'); return; } try { triggerHapticFeedback('impactMedium'); setRecordState('analyzing'); setAnalysisProgress(0); // 启动科幻分析动画 startAnalysisAnimation(); // 模拟进度更新 progressIntervalRef.current = setInterval(() => { if (!isMountedRef.current) { if (progressIntervalRef.current) { clearInterval(progressIntervalRef.current); } return; } setAnalysisProgress(prev => { if (prev >= 90) { if (progressIntervalRef.current) { clearInterval(progressIntervalRef.current); } return prev; } return prev + Math.random() * 15; }); }, 500); // 调用文本分析API dispatch(setLoading(true)); const result = await analyzeFoodFromText({ text: recognizedText }); // 清理进度定时器 if (progressIntervalRef.current) { clearInterval(progressIntervalRef.current); } if (!isMountedRef.current) return; setAnalysisProgress(100); // 生成识别结果ID并保存到Redux const recognitionId = `text_${Date.now()}`; dispatch(saveRecognitionResult({ id: recognitionId, result })); // 停止动画并导航到结果页面 stopAnimations(); // 延迟一点让用户看到100%完成 setTimeout(() => { router.replace({ pathname: '/food/analysis-result', params: { recognitionId, mealType: mealType, hideRecordBar: 'false' } }); }, 800); } catch (error) { console.error('食物分析失败:', error); // 清理进度定时器 if (progressIntervalRef.current) { clearInterval(progressIntervalRef.current); } if (!isMountedRef.current) return; stopAnimations(); setRecordState('result'); dispatch(setLoading(false)); const errorMessage = error instanceof Error ? error.message : '分析失败,请重试'; dispatch(setError(errorMessage)); Alert.alert('分析失败', errorMessage); } }; const handleBack = async () => { try { // 如果正在录音,先停止 if (isListening) { await stopRecording(); } // 停止所有动画 stopAnimations(); // 确保语音识别完全停止 await Voice.stop(); router.back(); } catch (error) { console.log('返回时清理资源失败:', error); router.back(); } }; // 获取状态对应的UI文本 const getStatusText = () => { switch (recordState) { case 'idle': return '轻触麦克风开始录音'; case 'listening': return '正在聆听中,请开始说话...'; case 'processing': return 'AI正在处理语音内容...'; case 'analyzing': return 'AI大模型深度分析营养成分中...'; case 'result': return '语音识别完成,请确认结果'; default: return ''; } }; // 获取主按钮配置 const getMainButtonConfig = () => { switch (recordState) { case 'idle': return { onPress: startRecording, color: '#7B68EE', icon: 'mic', size: 80, }; case 'listening': return { onPress: stopRecording, color: '#FF6B6B', icon: 'stop', size: 80, }; case 'processing': return { onPress: () => { }, color: '#FFA07A', icon: 'hourglass', size: 80, }; case 'analyzing': return { onPress: () => { }, color: '#00D4AA', icon: 'analytics', size: 80, }; case 'result': return { onPress: confirmResult, color: '#4ECDC4', icon: 'checkmark', size: 80, }; } }; const buttonConfig = getMainButtonConfig(); return ( {/* 上半部分:介绍 */} 通过语音描述您的饮食内容,AI将智能分析营养成分和卡路里 {/* 中间部分:录音动画区域 */} {/* 背景波浪效果 */} {recordState === 'listening' && ( <> {[1, 2, 3].map((index) => ( ))} )} {/* 科幻分析特效 */} {recordState === 'analyzing' && ( <> {/* 外光环 */} {/* 内光环 */} )} {/* 主录音按钮 */} {/* 下半部分:状态文本和示例 */} {getStatusText()} {recordState === 'listening' && ( 说出您想记录的食物内容 )} {/* 食物记录示例 */} {recordState === 'idle' && ( 记录示例: “今早吃了两个煎蛋、一片全麦面包和一杯牛奶” “午饭吃了红烧肉约150克、米饭一小碗、青菜一份” “晚饭吃了蒸蛋羹、紫菜蛋花汤、小米粥一碗” )} {recordState === 'analyzing' && ( 分析进度: {Math.round(analysisProgress)}% AI正在深度分析您的食物描述... )} {/* 识别结果 */} {recognizedText && ( 识别结果: {recognizedText} {recordState === 'result' && ( 重新录音 确认使用 )} )} ); } const styles = StyleSheet.create({ container: { flex: 1, }, content: { flex: 1, paddingHorizontal: 20, paddingTop: 20, paddingBottom: 40, }, topSection: { alignItems: 'center', paddingBottom: 20, }, middleSection: { flex: 1, alignItems: 'center', justifyContent: 'center', minHeight: 200, }, bottomSection: { alignItems: 'center', }, introContainer: { alignItems: 'center', paddingHorizontal: 20, }, introTitle: { fontSize: 24, fontWeight: 'bold', marginBottom: 8, textAlign: 'center', }, introDescription: { fontSize: 16, lineHeight: 24, textAlign: 'center', paddingHorizontal: 10, }, animationContainer: { alignItems: 'center', justifyContent: 'center', height: 200, width: 200, flex: 1, }, waveRing: { position: 'absolute', width: 160, height: 160, borderRadius: 80, backgroundColor: 'rgba(123, 104, 238, 0.1)', borderWidth: 1, borderColor: 'rgba(123, 104, 238, 0.2)', }, recordButton: { width: 160, height: 160, borderRadius: 80, alignItems: 'center', justifyContent: 'center', shadowColor: '#000', shadowOffset: { width: 0, height: 8, }, shadowOpacity: 0.3, shadowRadius: 12, elevation: 12, }, recordButtonInner: { width: '100%', height: '100%', alignItems: 'center', justifyContent: 'center', borderRadius: 80, }, statusContainer: { alignItems: 'center', paddingHorizontal: 20, }, statusText: { fontSize: 18, fontWeight: '600', marginBottom: 8, }, hintText: { fontSize: 14, textAlign: 'center', lineHeight: 20, }, examplesContainer: { marginTop: 24, marginHorizontal: 20, borderRadius: 16, overflow: 'hidden', }, examplesContent: { padding: 20, backgroundColor: 'rgba(255, 255, 255, 0.9)', borderRadius: 16, alignItems: 'center', }, examplesTitle: { fontSize: 16, fontWeight: '600', marginBottom: 16, }, examplesList: { paddingHorizontal: 10, gap: 8, }, exampleText: { fontSize: 14, lineHeight: 22, textAlign: 'center', paddingHorizontal: 12, paddingVertical: 8, marginVertical: 4, }, resultContainer: { position: 'absolute', bottom: 100, left: 20, right: 20, borderRadius: 16, overflow: 'hidden', }, resultContent: { padding: 20, backgroundColor: 'rgba(255, 255, 255, 0.9)', borderRadius: 16, }, resultLabel: { fontSize: 12, fontWeight: '500', marginBottom: 8, }, resultText: { fontSize: 16, fontWeight: '600', lineHeight: 24, marginBottom: 16, }, resultActions: { flexDirection: 'row', gap: 12, }, actionButton: { flex: 1, flexDirection: 'row', alignItems: 'center', justifyContent: 'center', paddingVertical: 12, paddingHorizontal: 16, borderRadius: 12, gap: 6, }, retryButton: { backgroundColor: 'rgba(123, 104, 238, 0.1)', borderWidth: 1, borderColor: '#7B68EE', }, confirmButton: { backgroundColor: '#7B68EE', }, retryButtonText: { fontSize: 14, fontWeight: '500', color: '#7B68EE', }, confirmButtonText: { fontSize: 14, fontWeight: '500', color: 'white', }, // 科幻分析特效样式 glowRing: { position: 'absolute', width: 200, height: 200, borderRadius: 100, backgroundColor: 'rgba(0, 212, 170, 0.1)', borderWidth: 2, borderColor: '#00D4AA', shadowColor: '#00D4AA', shadowOffset: { width: 0, height: 0 }, shadowOpacity: 0.8, shadowRadius: 20, }, innerGlowRing: { position: 'absolute', width: 180, height: 180, borderRadius: 90, backgroundColor: 'rgba(0, 212, 170, 0.05)', borderWidth: 1, borderColor: 'rgba(0, 212, 170, 0.3)', }, analysisProgressContainer: { alignItems: 'center', marginTop: 20, paddingHorizontal: 20, }, progressText: { fontSize: 16, fontWeight: '600', marginBottom: 12, }, progressBarContainer: { width: '100%', height: 6, backgroundColor: 'rgba(0, 212, 170, 0.2)', borderRadius: 3, overflow: 'hidden', marginBottom: 8, }, progressBar: { height: '100%', backgroundColor: '#00D4AA', borderRadius: 3, shadowColor: '#00D4AA', shadowOffset: { width: 0, height: 0 }, shadowOpacity: 0.8, shadowRadius: 4, }, analysisHint: { fontSize: 14, textAlign: 'center', lineHeight: 20, fontStyle: 'italic', }, });