Files
digital-pilates/app/voice-record.tsx
2025-10-14 16:31:19 +08:00

916 lines
25 KiB
TypeScript
Raw 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 { useColorScheme } from '@/hooks/useColorScheme';
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
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 safeAreaTop = useSafeAreaTop()
const theme = useColorScheme() ?? 'light';
const colorTokens = Colors[theme];
const { mealType = 'dinner' } = useLocalSearchParams<{ mealType?: string }>();
const dispatch = useAppDispatch();
// 状态管理
const [recordState, setRecordState] = useState<VoiceRecordState>('idle');
const [recognizedText, setRecognizedText] = useState('');
const [isListening, setIsListening] = useState(false);
const [analysisProgress, setAnalysisProgress] = useState(0);
// 用于跟踪组件是否已卸载,防止在组件卸载后设置状态
const isMountedRef = useRef(true);
const progressIntervalRef = useRef<ReturnType<typeof setInterval> | 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 (
<View style={[styles.container, { backgroundColor: colorTokens.background }]}>
<HeaderBar
title="一句话记录"
onBack={handleBack}
tone={theme}
variant="elevated"
/>
<View style={{
paddingTop: safeAreaTop
}} />
<View style={styles.content}>
{/* 上半部分:介绍 */}
<View style={styles.topSection}>
<View style={styles.introContainer}>
<Text style={[styles.introDescription, { color: colorTokens.textSecondary }]}>
AI将智能分析营养成分和卡路里
</Text>
</View>
</View>
{/* 中间部分:录音动画区域 */}
<View style={styles.middleSection}>
<View style={styles.animationContainer}>
{/* 背景波浪效果 */}
{recordState === 'listening' && (
<>
{[1, 2, 3].map((index) => (
<Animated.View
key={index}
style={[
styles.waveRing,
{
transform: [
{
scale: waveAnimation.interpolate({
inputRange: [0, 1],
outputRange: [0.8, 2 + index * 0.3],
}),
},
],
opacity: waveAnimation.interpolate({
inputRange: [0, 0.5, 1],
outputRange: [0.6, 0.3, 0],
}),
},
]}
/>
))}
</>
)}
{/* 科幻分析特效 */}
{recordState === 'analyzing' && (
<>
{/* 外光环 */}
<Animated.View
style={[
styles.glowRing,
{
opacity: glowAnimation.interpolate({
inputRange: [0, 1],
outputRange: [0.3, 0.8],
}),
transform: [
{
scale: glowAnimation.interpolate({
inputRange: [0, 1],
outputRange: [1.2, 1.6],
}),
},
],
},
]}
/>
{/* 内光环 */}
<Animated.View
style={[
styles.innerGlowRing,
{
opacity: glowAnimation.interpolate({
inputRange: [0, 1],
outputRange: [0.5, 1],
}),
transform: [
{
scale: glowAnimation.interpolate({
inputRange: [0, 1],
outputRange: [0.9, 1.1],
}),
},
],
},
]}
/>
</>
)}
{/* 主录音按钮 */}
<Animated.View
style={[
styles.recordButton,
{
backgroundColor: buttonConfig.color,
transform: [
{ scale: scaleAnimation },
{ scale: pulseAnimation },
],
},
]}
>
<TouchableOpacity
style={styles.recordButtonInner}
onPress={buttonConfig.onPress}
activeOpacity={0.8}
disabled={recordState === 'processing' || recordState === 'analyzing'}
>
<Ionicons
name={buttonConfig.icon as any}
size={buttonConfig.size}
color="white"
/>
</TouchableOpacity>
</Animated.View>
</View>
</View>
{/* 下半部分:状态文本和示例 */}
<View style={styles.bottomSection}>
<View style={styles.statusContainer}>
<Text style={[styles.statusText, { color: colorTokens.text }]}>
{getStatusText()}
</Text>
{recordState === 'listening' && (
<Text style={[styles.hintText, { color: colorTokens.textSecondary }]}>
</Text>
)}
{/* 食物记录示例 */}
{recordState === 'idle' && (
<BlurView intensity={20} tint={theme} style={styles.examplesContainer}>
<View style={styles.examplesContent}>
<Text style={[styles.examplesTitle, { color: colorTokens.text }]}>
</Text>
<View style={styles.examplesList}>
<Text style={[styles.exampleText, { color: colorTokens.textSecondary }]}>
&ldquo;&rdquo;
</Text>
<Text style={[styles.exampleText, { color: colorTokens.textSecondary }]}>
&ldquo;150&rdquo;
</Text>
<Text style={[styles.exampleText, { color: colorTokens.textSecondary }]}>
&ldquo;&rdquo;
</Text>
</View>
</View>
</BlurView>
)}
{recordState === 'analyzing' && (
<View style={styles.analysisProgressContainer}>
<Text style={[styles.progressText, { color: colorTokens.text }]}>
: {Math.round(analysisProgress)}%
</Text>
<View style={styles.progressBarContainer}>
<Animated.View
style={[
styles.progressBar,
{
width: progressAnimation.interpolate({
inputRange: [0, 1],
outputRange: ['0%', '100%'],
}),
},
]}
/>
</View>
<Text style={[styles.analysisHint, { color: colorTokens.textSecondary }]}>
AI正在深度分析您的食物描述...
</Text>
</View>
)}
</View>
</View>
{/* 识别结果 */}
{recognizedText && (
<BlurView intensity={20} tint={theme} style={styles.resultContainer}>
<View style={styles.resultContent}>
<Text style={[styles.resultLabel, { color: colorTokens.textSecondary }]}>
:
</Text>
<Text style={[styles.resultText, { color: colorTokens.text }]}>
{recognizedText}
</Text>
{recordState === 'result' && (
<View style={styles.resultActions}>
<TouchableOpacity
style={[styles.actionButton, styles.retryButton]}
onPress={retryRecording}
>
<Ionicons name="refresh" size={16} color="#7B68EE" />
<Text style={styles.retryButtonText}></Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.actionButton, styles.confirmButton]}
onPress={confirmResult}
>
<Ionicons name="checkmark" size={16} color="white" />
<Text style={styles.confirmButtonText}>使</Text>
</TouchableOpacity>
</View>
)}
</View>
</BlurView>
)}
</View>
</View>
);
}
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',
},
});