feat(ui): 统一健康卡片标题图标并优化语音录音稳定性

- 为所有健康数据卡片添加对应功能图标,提升视觉一致性
- 将“小鱼干”文案统一为“能量值”,并更新获取说明
- 语音录音页面增加组件卸载保护、错误提示与资源清理逻辑
- 个人页支持毛玻璃按钮样式,默认用户名置空
- 新增血氧、饮食、心情、压力、睡眠、步数、体重等图标资源
- 升级 react-native-purchases 至 9.4.3
- 移除 useAuthGuard 调试日志
This commit is contained in:
richarjiang
2025-09-16 09:35:50 +08:00
parent 42b6b2076c
commit 63ed820e93
26 changed files with 359 additions and 103 deletions

View File

@@ -9,7 +9,7 @@ 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 React, { useCallback, useEffect, useRef, useState } from 'react';
import {
Alert,
Animated,
@@ -33,6 +33,10 @@ export default function VoiceRecordScreen() {
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;
@@ -108,50 +112,78 @@ export default function VoiceRecordScreen() {
progressAnimation.setValue(0);
};
// 语音识别回调
const onSpeechStart = () => {
// 语音识别回调 - 使用 useCallback 避免每次渲染重新创建
const onSpeechStart = useCallback(() => {
console.log('语音开始');
if (!isMountedRef.current) return;
setIsListening(true);
setRecordState('listening');
startPulseAnimation();
startWaveAnimation();
};
}, []);
const onSpeechRecognized = () => {
const onSpeechRecognized = useCallback(() => {
console.log('语音识别中...');
};
}, []);
const onSpeechEnd = useCallback(() => {
console.log('语音结束');
if (!isMountedRef.current) return;
const onSpeechEnd = () => {
setIsListening(false);
setRecordState('processing');
stopAnimations();
};
}, []);
const onSpeechError = (error: any) => {
const onSpeechError = useCallback((error: any) => {
console.log('语音识别错误:', error);
if (!isMountedRef.current) return;
setIsListening(false);
setRecordState('idle');
stopAnimations();
};
const onSpeechResults = (event: any) => {
// 显示更友好的错误信息
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] || '';
setRecognizedText(text);
setRecordState('result');
if (text.trim()) {
setRecognizedText(text);
setRecordState('result');
} else {
setRecordState('idle');
Alert.alert('提示', '未识别到有效内容,请重新录音');
}
stopAnimations();
};
}, []);
const onSpeechPartialResults = useCallback((event: any) => {
if (!isMountedRef.current) return;
const onSpeechPartialResults = (event: any) => {
const text = event.value?.[0] || '';
setRecognizedText(text);
};
}, []);
const onSpeechVolumeChanged = useCallback((event: any) => {
if (!isMountedRef.current) return;
const onSpeechVolumeChanged = (event: any) => {
// 根据音量调整动画
const volume = event.value || 0;
const scale = 1 + (volume * 0.1);
scaleAnimation.setValue(Math.min(scale, 1.5));
};
}, []);
useEffect(() => {
// 初始化语音识别
@@ -164,57 +196,91 @@ export default function VoiceRecordScreen() {
Voice.onSpeechVolumeChanged = onSpeechVolumeChanged;
return () => {
Voice.destroy().then(Voice.removeAllListeners);
// 标记组件已卸载
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.start('zh-CN'); // 设置为中文识别
// 确保之前的识别已停止
await Voice.stop();
// 添加短暂延迟确保资源清理完成
await new Promise(resolve => setTimeout(resolve, 100));
// 启动新的语音识别
await Voice.start('zh-CN');
} catch (error) {
console.log('启动语音识别失败:', error);
setRecordState('idle');
Alert.alert('录音失败', '无法启动语音识别,请检查权限设置');
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 () => {
// 停止所有动画
stopAnimations();
// 重置所有状态
setRecognizedText('');
setAnalysisProgress(0);
setIsListening(false);
setRecordState('idle');
// 确保语音识别已停止
try {
await Voice.stop();
} catch {
// 忽略停止错误,可能已经停止了
}
// 停止所有动画
stopAnimations();
// 延迟一点再开始新的录音,确保状态完全重置
setTimeout(() => {
startRecording();
}, 100);
// 重置所有状态
setRecognizedText('');
setAnalysisProgress(0);
setIsListening(false);
setRecordState('idle');
// 确保语音识别已停止
await Voice.stop();
// 延迟一点再开始新的录音,确保状态完全重置
setTimeout(() => {
startRecording();
}, 200);
} catch (error) {
console.log('重新录音失败:', error);
setRecordState('idle');
setIsListening(false);
}
};
// 确认并分析食物文本
@@ -233,10 +299,19 @@ export default function VoiceRecordScreen() {
startAnalysisAnimation();
// 模拟进度更新
const progressInterval = setInterval(() => {
progressIntervalRef.current = setInterval(() => {
if (!isMountedRef.current) {
if (progressIntervalRef.current) {
clearInterval(progressIntervalRef.current);
}
return;
}
setAnalysisProgress(prev => {
if (prev >= 90) {
clearInterval(progressInterval);
if (progressIntervalRef.current) {
clearInterval(progressIntervalRef.current);
}
return prev;
}
return prev + Math.random() * 15;
@@ -248,7 +323,12 @@ export default function VoiceRecordScreen() {
const result = await analyzeFoodFromText({ text: recognizedText });
// 清理进度定时器
clearInterval(progressInterval);
if (progressIntervalRef.current) {
clearInterval(progressIntervalRef.current);
}
if (!isMountedRef.current) return;
setAnalysisProgress(100);
// 生成识别结果ID并保存到Redux
@@ -272,8 +352,17 @@ export default function VoiceRecordScreen() {
} 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));
@@ -281,11 +370,24 @@ export default function VoiceRecordScreen() {
}
};
const handleBack = () => {
if (isListening) {
stopRecording();
const handleBack = async () => {
try {
// 如果正在录音,先停止
if (isListening) {
await stopRecording();
}
// 停止所有动画
stopAnimations();
// 确保语音识别完全停止
await Voice.stop();
router.back();
} catch (error) {
console.log('返回时清理资源失败:', error);
router.back();
}
router.back();
};
// 获取状态对应的UI文本