Files
digital-pilates/app/voice-record.tsx

808 lines
22 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 { 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, { 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<VoiceRecordState>('idle');
const [recognizedText, setRecognizedText] = useState('');
const [isListening, setIsListening] = useState(false);
const [analysisProgress, setAnalysisProgress] = useState(0);
// 动画相关
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);
};
// 语音识别回调
const onSpeechStart = () => {
setIsListening(true);
setRecordState('listening');
startPulseAnimation();
startWaveAnimation();
};
const onSpeechRecognized = () => {
console.log('语音识别中...');
};
const onSpeechEnd = () => {
setIsListening(false);
setRecordState('processing');
stopAnimations();
};
const onSpeechError = (error: any) => {
console.log('语音识别错误:', error);
setIsListening(false);
setRecordState('idle');
stopAnimations();
};
const onSpeechResults = (event: any) => {
const text = event.value?.[0] || '';
setRecognizedText(text);
setRecordState('result');
stopAnimations();
};
const onSpeechPartialResults = (event: any) => {
const text = event.value?.[0] || '';
setRecognizedText(text);
};
const onSpeechVolumeChanged = (event: any) => {
// 根据音量调整动画
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 () => {
Voice.destroy().then(Voice.removeAllListeners);
};
}, [onSpeechStart, onSpeechRecognized, onSpeechEnd, onSpeechError, onSpeechResults, onSpeechPartialResults, onSpeechVolumeChanged]);
// 开始录音
const startRecording = async () => {
try {
setRecognizedText('');
triggerHapticFeedback('impactMedium');
await Voice.start('zh-CN'); // 设置为中文识别
} catch (error) {
console.log('启动语音识别失败:', error);
setRecordState('idle');
Alert.alert('录音失败', '无法启动语音识别,请检查权限设置');
}
};
// 停止录音
const stopRecording = async () => {
try {
await Voice.stop();
triggerHapticFeedback('impactLight');
} catch (error) {
console.log('停止语音识别失败:', error);
}
};
// 重新录音
const retryRecording = async () => {
// 停止所有动画
stopAnimations();
// 重置所有状态
setRecognizedText('');
setAnalysisProgress(0);
setIsListening(false);
setRecordState('idle');
// 确保语音识别已停止
try {
await Voice.stop();
} catch {
// 忽略停止错误,可能已经停止了
}
// 延迟一点再开始新的录音,确保状态完全重置
setTimeout(() => {
startRecording();
}, 100);
};
// 确认并分析食物文本
const confirmResult = async () => {
if (!recognizedText.trim()) {
Alert.alert('提示', '请先进行语音识别');
return;
}
try {
triggerHapticFeedback('impactMedium');
setRecordState('analyzing');
setAnalysisProgress(0);
// 启动科幻分析动画
startAnalysisAnimation();
// 模拟进度更新
const progressInterval = setInterval(() => {
setAnalysisProgress(prev => {
if (prev >= 90) {
clearInterval(progressInterval);
return prev;
}
return prev + Math.random() * 15;
});
}, 500);
// 调用文本分析API
dispatch(setLoading(true));
const result = await analyzeFoodFromText({ text: recognizedText });
// 清理进度定时器
clearInterval(progressInterval);
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);
stopAnimations();
setRecordState('result');
const errorMessage = error instanceof Error ? error.message : '分析失败,请重试';
dispatch(setError(errorMessage));
Alert.alert('分析失败', errorMessage);
}
};
const handleBack = () => {
if (isListening) {
stopRecording();
}
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={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',
},
});