feat(ui): 统一健康卡片标题图标并优化语音录音稳定性
- 为所有健康数据卡片添加对应功能图标,提升视觉一致性 - 将“小鱼干”文案统一为“能量值”,并更新获取说明 - 语音录音页面增加组件卸载保护、错误提示与资源清理逻辑 - 个人页支持毛玻璃按钮样式,默认用户名置空 - 新增血氧、饮食、心情、压力、睡眠、步数、体重等图标资源 - 升级 react-native-purchases 至 9.4.3 - 移除 useAuthGuard 调试日志
@@ -512,7 +512,7 @@ export default function GoalsScreen() {
|
||||
text: '每日目标通知',
|
||||
onPress: async () => {
|
||||
try {
|
||||
const userName = userProfile?.name || '小海豹';
|
||||
const userName = userProfile?.name || '';
|
||||
const notificationIds = await GoalNotificationHelpers.scheduleGoalNotifications(
|
||||
{
|
||||
title: '每日运动目标',
|
||||
@@ -533,7 +533,7 @@ export default function GoalsScreen() {
|
||||
text: '每周目标通知',
|
||||
onPress: async () => {
|
||||
try {
|
||||
const userName = userProfile?.name || '小海豹';
|
||||
const userName = userProfile?.name || '';
|
||||
const notificationIds = await GoalNotificationHelpers.scheduleGoalNotifications(
|
||||
{
|
||||
title: '每周运动目标',
|
||||
@@ -557,7 +557,7 @@ export default function GoalsScreen() {
|
||||
text: '目标达成通知',
|
||||
onPress: async () => {
|
||||
try {
|
||||
const userName = userProfile?.name || '小海豹';
|
||||
const userName = userProfile?.name || '';
|
||||
await GoalNotificationHelpers.sendGoalAchievementNotification(userName, '每日运动目标');
|
||||
Alert.alert('成功', '目标达成通知已发送');
|
||||
} catch (error) {
|
||||
|
||||
@@ -6,12 +6,14 @@ import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
||||
import { useNotifications } from '@/hooks/useNotifications';
|
||||
import { DEFAULT_MEMBER_NAME, fetchActivityHistory, fetchMyProfile } from '@/store/userSlice';
|
||||
import { getItem, setItem } from '@/utils/kvStore';
|
||||
import { log } from '@/utils/logger';
|
||||
import { getNotificationEnabled, setNotificationEnabled as saveNotificationEnabled } from '@/utils/userPreferences';
|
||||
import { getItem, setItem } from '@/utils/kvStore';
|
||||
import { Button, Host, Text as SwiftText } from '@expo/ui/swift-ui';
|
||||
import { frame, glassEffect } from '@expo/ui/swift-ui/modifiers';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
|
||||
import { useFocusEffect } from '@react-navigation/native';
|
||||
import { isLiquidGlassAvailable } from 'expo-glass-effect';
|
||||
import { Image } from 'expo-image';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
@@ -25,6 +27,7 @@ export default function PersonalScreen() {
|
||||
const { confirmLogout, confirmDeleteAccount, isLoggedIn, pushIfAuthedElseLogin } = useAuthGuard();
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
const isLgAvaliable = isLiquidGlassAvailable()
|
||||
|
||||
// 推送通知相关
|
||||
const {
|
||||
@@ -212,9 +215,35 @@ export default function PersonalScreen() {
|
||||
<Text style={styles.userName}>{displayName}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<TouchableOpacity style={styles.editButton} onPress={() => pushIfAuthedElseLogin('/profile/edit')}>
|
||||
{isLgAvaliable ? <Host style={{
|
||||
marginRight: 18,
|
||||
}}>
|
||||
<Button
|
||||
variant='default'
|
||||
onPress={() => {
|
||||
console.log(111111);
|
||||
|
||||
// pushIfAuthedElseLogin('/profile/edit')
|
||||
}}
|
||||
modifiers={[
|
||||
frame({
|
||||
width: 60,
|
||||
height: 30,
|
||||
}),
|
||||
glassEffect({
|
||||
glass: {
|
||||
variant: 'regular',
|
||||
interactive: true
|
||||
}
|
||||
})
|
||||
]} >
|
||||
<SwiftText size={14} color='black' weight={'medium'}>编辑</SwiftText>
|
||||
</Button>
|
||||
</Host> : <TouchableOpacity style={styles.editButton} onPress={() => pushIfAuthedElseLogin('/profile/edit')}>
|
||||
<Text style={styles.editButtonText}>编辑</Text>
|
||||
</TouchableOpacity>
|
||||
</TouchableOpacity>}
|
||||
|
||||
|
||||
</View>
|
||||
|
||||
</View>
|
||||
@@ -381,7 +410,7 @@ export default function PersonalScreen() {
|
||||
transition={200}
|
||||
cachePolicy="memory-disk"
|
||||
/> */}
|
||||
<Text style={styles.fishRecordText}>鱼干记录</Text>
|
||||
<Text style={styles.fishRecordText}>能量记录</Text>
|
||||
</View>
|
||||
<ActivityHeatMap />
|
||||
{menuSections.map((section, index) => (
|
||||
|
||||
@@ -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] || '';
|
||||
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,37 +196,70 @@ 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 () => {
|
||||
try {
|
||||
// 停止所有动画
|
||||
stopAnimations();
|
||||
|
||||
@@ -205,16 +270,17 @@ export default function VoiceRecordScreen() {
|
||||
setRecordState('idle');
|
||||
|
||||
// 确保语音识别已停止
|
||||
try {
|
||||
await Voice.stop();
|
||||
} catch {
|
||||
// 忽略停止错误,可能已经停止了
|
||||
}
|
||||
|
||||
// 延迟一点再开始新的录音,确保状态完全重置
|
||||
setTimeout(() => {
|
||||
startRecording();
|
||||
}, 100);
|
||||
}, 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 = () => {
|
||||
const handleBack = async () => {
|
||||
try {
|
||||
// 如果正在录音,先停止
|
||||
if (isListening) {
|
||||
stopRecording();
|
||||
await stopRecording();
|
||||
}
|
||||
|
||||
// 停止所有动画
|
||||
stopAnimations();
|
||||
|
||||
// 确保语音识别完全停止
|
||||
await Voice.stop();
|
||||
|
||||
router.back();
|
||||
} catch (error) {
|
||||
console.log('返回时清理资源失败:', error);
|
||||
router.back();
|
||||
}
|
||||
};
|
||||
|
||||
// 获取状态对应的UI文本
|
||||
|
||||
BIN
assets/images/icons/icon-blood-oxygen.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 33 KiB |
BIN
assets/images/icons/icon-healthy-diet.png
Normal file
|
After Width: | Height: | Size: 37 KiB |
BIN
assets/images/icons/icon-mood.png
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
assets/images/icons/icon-pressure.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
assets/images/icons/icon-sleep.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
assets/images/icons/icon-step.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
assets/images/icons/icon-weight.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
@@ -184,23 +184,23 @@ const ActivityHeatMap = () => {
|
||||
>
|
||||
<View style={[styles.popoverContent, { backgroundColor: colors.card }]}>
|
||||
<Text style={[styles.popoverTitle, { color: colors.text }]}>
|
||||
小鱼干可以用来与小海豹进行对话
|
||||
能量值的积攒后续可以用来兑换 AI 相关权益
|
||||
</Text>
|
||||
<Text style={[styles.popoverSubtitle, { color: colors.text }]}>
|
||||
获取说明
|
||||
</Text>
|
||||
<View style={styles.popoverList}>
|
||||
<Text style={[styles.popoverItem, { color: colors.textMuted }]}>
|
||||
1. 每日登录获得小鱼干+1
|
||||
1. 每日登录获得能量值+1
|
||||
</Text>
|
||||
<Text style={[styles.popoverItem, { color: colors.textMuted }]}>
|
||||
2. 每日记录心情获得小鱼干+1
|
||||
2. 每日记录心情获得能量值+1
|
||||
</Text>
|
||||
<Text style={[styles.popoverItem, { color: colors.textMuted }]}>
|
||||
3. 记饮食获得小鱼干+1
|
||||
3. 记饮食获得能量值+1
|
||||
</Text>
|
||||
<Text style={[styles.popoverItem, { color: colors.textMuted }]}>
|
||||
4. 完成一次目标获得小鱼干+1
|
||||
4. 完成一次目标获得能量值+1
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { AnimatedNumber } from '@/components/AnimatedNumber';
|
||||
import { Image } from 'expo-image';
|
||||
import React from 'react';
|
||||
import { StyleSheet, Text, View } from 'react-native';
|
||||
|
||||
@@ -35,6 +36,10 @@ export function BasalMetabolismCard({ value, resetToken, style }: BasalMetabolis
|
||||
{/* 头部区域 */}
|
||||
<View style={styles.header}>
|
||||
<View style={styles.leftSection}>
|
||||
<Image
|
||||
source={require('@/assets/images/icons/icon-fire.png')}
|
||||
style={styles.titleIcon}
|
||||
/>
|
||||
<Text style={styles.title}>基础代谢</Text>
|
||||
</View>
|
||||
<View style={[styles.statusBadge, { backgroundColor: status.color + '20' }]}>
|
||||
@@ -117,6 +122,12 @@ const styles = StyleSheet.create({
|
||||
title: {
|
||||
fontSize: 14,
|
||||
color: '#0F172A',
|
||||
fontWeight: '600',
|
||||
},
|
||||
titleIcon: {
|
||||
width: 16,
|
||||
height: 16,
|
||||
marginRight: 4,
|
||||
},
|
||||
statusBadge: {
|
||||
paddingHorizontal: 8,
|
||||
|
||||
@@ -2,7 +2,7 @@ import { MoodCheckin, getMoodConfig } from '@/services/moodCheckins';
|
||||
import dayjs from 'dayjs';
|
||||
import LottieView from 'lottie-react-native';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
import { Image, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
|
||||
interface MoodCardProps {
|
||||
moodCheckin: MoodCheckin | null;
|
||||
@@ -23,7 +23,13 @@ export function MoodCard({ moodCheckin, onPress }: MoodCardProps) {
|
||||
return (
|
||||
<TouchableOpacity onPress={onPress} style={styles.moodCardContent} >
|
||||
<View style={styles.moodCardHeader}>
|
||||
<View style={styles.titleContainer}>
|
||||
<Image
|
||||
source={require('@/assets/images/icons/icon-mood.png')}
|
||||
style={styles.titleIcon}
|
||||
/>
|
||||
<Text style={styles.cardTitle}>心情</Text>
|
||||
</View>
|
||||
<LottieView
|
||||
ref={animationRef}
|
||||
source={require('@/assets/lottie/mood/mood_demo.json')}
|
||||
@@ -59,9 +65,22 @@ const styles = StyleSheet.create({
|
||||
alignItems: 'center',
|
||||
},
|
||||
|
||||
titleContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
|
||||
titleIcon: {
|
||||
width: 16,
|
||||
height: 16,
|
||||
marginRight: 6,
|
||||
resizeMode: 'contain',
|
||||
},
|
||||
|
||||
cardTitle: {
|
||||
fontSize: 14,
|
||||
color: '#192126',
|
||||
fontWeight: '600'
|
||||
},
|
||||
|
||||
lottieAnimation: {
|
||||
|
||||
@@ -140,7 +140,13 @@ export function NutritionRadarCard({
|
||||
return (
|
||||
<TouchableOpacity style={styles.card} onPress={handleNavigateToRecords} activeOpacity={0.8}>
|
||||
<View style={styles.cardHeader}>
|
||||
<View style={styles.titleContainer}>
|
||||
<Image
|
||||
source={require('@/assets/images/icons/icon-healthy-diet.png')}
|
||||
style={styles.titleIcon}
|
||||
/>
|
||||
<Text style={styles.cardTitle}>饮食分析</Text>
|
||||
</View>
|
||||
<Text style={styles.cardSubtitle}>更新: {dayjs(nutritionSummary?.updatedAt).format('MM-DD HH:mm')}</Text>
|
||||
</View>
|
||||
|
||||
@@ -292,9 +298,20 @@ const styles = StyleSheet.create({
|
||||
alignItems: 'center',
|
||||
marginBottom: 8,
|
||||
},
|
||||
titleContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
titleIcon: {
|
||||
width: 16,
|
||||
height: 16,
|
||||
marginRight: 6,
|
||||
resizeMode: 'contain',
|
||||
},
|
||||
cardTitle: {
|
||||
fontSize: 14,
|
||||
color: '#192126',
|
||||
fontWeight: '600'
|
||||
},
|
||||
cardSubtitle: {
|
||||
fontSize: 10,
|
||||
|
||||
@@ -10,9 +10,10 @@ import {
|
||||
|
||||
import { fetchHourlyStepSamples, fetchStepCount, HourlyStepData } from '@/utils/health';
|
||||
import { logger } from '@/utils/logger';
|
||||
import dayjs from 'dayjs';
|
||||
import { Image } from 'expo-image';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { AnimatedNumber } from './AnimatedNumber';
|
||||
import dayjs from 'dayjs';
|
||||
// 使用原生View来替代SVG,避免导入问题
|
||||
// import Svg, { Rect } from 'react-native-svg';
|
||||
|
||||
@@ -110,6 +111,10 @@ const StepsCard: React.FC<StepsCardProps> = ({
|
||||
<>
|
||||
{/* 标题和步数显示 */}
|
||||
<View style={styles.header}>
|
||||
<Image
|
||||
source={require('@/assets/images/icons/icon-step.png')}
|
||||
style={styles.titleIcon}
|
||||
/>
|
||||
<Text style={styles.title}>步数</Text>
|
||||
</View>
|
||||
|
||||
@@ -213,12 +218,19 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
justifyContent: 'flex-start',
|
||||
alignItems: 'center',
|
||||
},
|
||||
titleIcon: {
|
||||
width: 16,
|
||||
height: 16,
|
||||
marginRight: 6,
|
||||
resizeMode: 'contain',
|
||||
},
|
||||
title: {
|
||||
fontSize: 14,
|
||||
color: '#192126',
|
||||
fontWeight: '600'
|
||||
},
|
||||
footprintIcons: {
|
||||
flexDirection: 'row',
|
||||
@@ -228,6 +240,7 @@ const styles = StyleSheet.create({
|
||||
chartContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
marginTop: 6
|
||||
},
|
||||
chartWrapper: {
|
||||
width: '100%',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { fetchHRVForDate } from '@/utils/health';
|
||||
import dayjs from 'dayjs';
|
||||
import { Image } from 'expo-image';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
@@ -96,6 +97,10 @@ export function StressMeter({ curDate }: StressMeterProps) {
|
||||
{/* 头部区域 */}
|
||||
<View style={styles.header}>
|
||||
<View style={styles.leftSection}>
|
||||
<Image
|
||||
source={require('@/assets/images/icons/icon-pressure.png')}
|
||||
style={styles.titleIcon}
|
||||
/>
|
||||
<Text style={styles.title}>压力</Text>
|
||||
</View>
|
||||
{/* {updateTime && (
|
||||
@@ -172,9 +177,16 @@ const styles = StyleSheet.create({
|
||||
justifyContent: 'center',
|
||||
marginRight: 6,
|
||||
},
|
||||
titleIcon: {
|
||||
width: 16,
|
||||
height: 16,
|
||||
marginRight: 6,
|
||||
resizeMode: 'contain',
|
||||
},
|
||||
title: {
|
||||
fontSize: 14,
|
||||
color: '#192126',
|
||||
fontWeight: '600'
|
||||
},
|
||||
valueSection: {
|
||||
flexDirection: 'row',
|
||||
|
||||
@@ -4,6 +4,7 @@ import { getQuickWaterAmount } from '@/utils/userPreferences';
|
||||
import { useFocusEffect } from '@react-navigation/native';
|
||||
import dayjs from 'dayjs';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
import { Image } from 'expo-image';
|
||||
import { useRouter } from 'expo-router';
|
||||
import LottieView from 'lottie-react-native';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
@@ -185,7 +186,16 @@ const WaterIntakeCard: React.FC<WaterIntakeCardProps> = ({
|
||||
|
||||
{/* 标题和加号按钮 */}
|
||||
<View style={styles.header}>
|
||||
<View style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
}}>
|
||||
<Image
|
||||
source={require('@/assets/images/icons/IconGlass.png')}
|
||||
style={styles.titleIcon}
|
||||
/>
|
||||
<Text style={styles.title}>喝水</Text>
|
||||
</View>
|
||||
{isToday && (
|
||||
<TouchableOpacity style={styles.addButton} onPress={handleQuickAddWater}>
|
||||
<Text style={styles.addButtonText}>+ {quickWaterAmount}ml</Text>
|
||||
@@ -291,10 +301,16 @@ const styles = StyleSheet.create({
|
||||
marginBottom: 4,
|
||||
minHeight: 22,
|
||||
},
|
||||
titleIcon: {
|
||||
width: 16,
|
||||
height: 16,
|
||||
marginRight: 6,
|
||||
resizeMode: 'contain',
|
||||
},
|
||||
title: {
|
||||
fontSize: 14,
|
||||
color: '#192126',
|
||||
fontWeight: '500',
|
||||
fontWeight: '600',
|
||||
},
|
||||
addButton: {
|
||||
borderRadius: 16,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Image } from 'expo-image';
|
||||
import React from 'react';
|
||||
import { StyleSheet, Text, View } from 'react-native';
|
||||
import Animated, { FadeIn, FadeOut } from 'react-native-reanimated';
|
||||
@@ -22,7 +23,17 @@ const HealthDataCard: React.FC<HealthDataCardProps> = ({
|
||||
style={[styles.card, style]}
|
||||
>
|
||||
<View style={styles.content}>
|
||||
<View style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: 14,
|
||||
}}>
|
||||
<Image
|
||||
source={require('@/assets/images/icons/icon-blood-oxygen.png')}
|
||||
style={styles.titleIcon}
|
||||
/>
|
||||
<Text style={styles.title}>{title}</Text>
|
||||
</View>
|
||||
<View style={styles.valueContainer}>
|
||||
<Text style={styles.value}>{value}</Text>
|
||||
<Text style={styles.unit}>{unit}</Text>
|
||||
@@ -51,10 +62,16 @@ const styles = StyleSheet.create({
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
titleIcon: {
|
||||
width: 16,
|
||||
height: 16,
|
||||
marginRight: 6,
|
||||
resizeMode: 'contain',
|
||||
},
|
||||
title: {
|
||||
fontSize: 14,
|
||||
color: '#192126',
|
||||
marginBottom: 14,
|
||||
fontWeight: '600',
|
||||
},
|
||||
valueContainer: {
|
||||
flexDirection: 'row',
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { fetchCompleteSleepData, formatSleepTime } from '@/utils/sleepHealthKit';
|
||||
import { Image } from 'expo-image';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
|
||||
@@ -39,6 +40,10 @@ const SleepCard: React.FC<SleepCardProps> = ({
|
||||
const CardContent = (
|
||||
<View style={[styles.container, style]}>
|
||||
<View style={styles.cardHeaderRow}>
|
||||
<Image
|
||||
source={require('@/assets/images/icons/icon-sleep.png')}
|
||||
style={styles.titleIcon}
|
||||
/>
|
||||
<Text style={styles.cardTitle}>睡眠</Text>
|
||||
</View>
|
||||
<Text style={styles.sleepValue}>
|
||||
@@ -65,11 +70,18 @@ const styles = StyleSheet.create({
|
||||
cardHeaderRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
justifyContent: 'flex-start',
|
||||
},
|
||||
titleIcon: {
|
||||
width: 16,
|
||||
height: 16,
|
||||
marginRight: 6,
|
||||
resizeMode: 'contain',
|
||||
},
|
||||
cardTitle: {
|
||||
fontSize: 14,
|
||||
color: '#192126',
|
||||
fontWeight: '600',
|
||||
},
|
||||
sleepValue: {
|
||||
fontSize: 16,
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
import { fetchWeightHistory } from '@/store/userSlice';
|
||||
import { BMI_CATEGORIES } from '@/utils/bmi';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { Image } from 'expo-image';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
@@ -72,6 +73,10 @@ export function WeightHistoryCard() {
|
||||
return (
|
||||
<TouchableOpacity style={styles.card} onPress={navigateToWeightRecords} activeOpacity={0.8}>
|
||||
<View style={styles.cardHeader}>
|
||||
<Image
|
||||
source={require('@/assets/images/icons/icon-weight.png')}
|
||||
style={styles.iconSquare}
|
||||
/>
|
||||
<Text style={styles.cardTitle}>体重记录</Text>
|
||||
</View>
|
||||
|
||||
@@ -156,6 +161,10 @@ export function WeightHistoryCard() {
|
||||
return (
|
||||
<TouchableOpacity style={styles.card} onPress={navigateToWeightRecords} activeOpacity={0.8}>
|
||||
<View style={styles.cardHeader}>
|
||||
<Image
|
||||
source={require('@/assets/images/icons/icon-weight.png')}
|
||||
style={styles.iconSquare}
|
||||
/>
|
||||
<Text style={styles.cardTitle}>体重记录</Text>
|
||||
<TouchableOpacity
|
||||
style={styles.addButton}
|
||||
@@ -345,17 +354,18 @@ const styles = StyleSheet.create({
|
||||
alignItems: 'center',
|
||||
},
|
||||
iconSquare: {
|
||||
width: 30,
|
||||
height: 30,
|
||||
width: 14,
|
||||
height: 14,
|
||||
borderRadius: 8,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginRight: 2,
|
||||
marginRight: 4,
|
||||
},
|
||||
cardTitle: {
|
||||
fontSize: 14,
|
||||
color: '#192126',
|
||||
flex: 1,
|
||||
fontWeight: '600'
|
||||
},
|
||||
headerButtons: {
|
||||
flexDirection: 'row',
|
||||
|
||||
@@ -21,8 +21,6 @@ export function useAuthGuard() {
|
||||
const currentPath = usePathname();
|
||||
const token = useAppSelector((s) => (s as any)?.user?.token as string | null);
|
||||
|
||||
console.log('useAuthGuard!!!token', token);
|
||||
|
||||
const isLoggedIn = !!token;
|
||||
|
||||
const ensureLoggedIn = useCallback(async (options?: EnsureOptions): Promise<boolean> => {
|
||||
|
||||
@@ -179,8 +179,8 @@ PODS:
|
||||
- ReactCommon/turbomodule/core
|
||||
- SocketRocket
|
||||
- Yoga
|
||||
- PurchasesHybridCommon (16.2.2):
|
||||
- RevenueCat (= 5.34.0)
|
||||
- PurchasesHybridCommon (17.7.0):
|
||||
- RevenueCat (= 5.39.0)
|
||||
- RCT-Folly (2024.11.18.00):
|
||||
- boost
|
||||
- DoubleConversion
|
||||
@@ -2545,7 +2545,7 @@ PODS:
|
||||
- React-perflogger (= 0.81.4)
|
||||
- React-utils (= 0.81.4)
|
||||
- SocketRocket
|
||||
- RevenueCat (5.34.0)
|
||||
- RevenueCat (5.39.0)
|
||||
- RNAppleHealthKit (1.7.0):
|
||||
- React
|
||||
- RNCAsyncStorage (2.2.0):
|
||||
@@ -2690,8 +2690,8 @@ PODS:
|
||||
- ReactCommon/turbomodule/core
|
||||
- SocketRocket
|
||||
- Yoga
|
||||
- RNPurchases (9.2.2):
|
||||
- PurchasesHybridCommon (= 16.2.2)
|
||||
- RNPurchases (9.4.3):
|
||||
- PurchasesHybridCommon (= 17.7.0)
|
||||
- React-Core
|
||||
- RNReanimated (4.1.0):
|
||||
- boost
|
||||
@@ -3463,7 +3463,7 @@ SPEC CHECKSUMS:
|
||||
libwebp: 02b23773aedb6ff1fd38cec7a77b81414c6842a8
|
||||
lottie-ios: a881093fab623c467d3bce374367755c272bdd59
|
||||
lottie-react-native: 86fa7488b1476563f41071244aa73d5b738faf19
|
||||
PurchasesHybridCommon: 62f852419aae7041792217593998f7ac3f8b567d
|
||||
PurchasesHybridCommon: 6bc96162fb0c061e1980f474be618c088cfd1428
|
||||
RCT-Folly: 846fda9475e61ec7bcbf8a3fe81edfcaeb090669
|
||||
RCTDeprecation: c0ed3249a97243002615517dff789bf4666cf585
|
||||
RCTRequired: 58719f5124f9267b5f9649c08bf23d9aea845b23
|
||||
@@ -3531,7 +3531,7 @@ SPEC CHECKSUMS:
|
||||
ReactAppDependencyProvider: 433ddfb4536948630aadd5bd925aff8a632d2fe3
|
||||
ReactCodegen: 1d05923ad119796be9db37830d5e5dc76586aa00
|
||||
ReactCommon: 394c6b92765cf6d211c2c3f7f6bc601dffb316a6
|
||||
RevenueCat: eb2aa042789d9c99ad5172bd96e28b96286d6ada
|
||||
RevenueCat: 4743a5eee0004e1c03eabeb3498818f902a5d622
|
||||
RNAppleHealthKit: 86ef7ab70f762b802f5c5289372de360cca701f9
|
||||
RNCAsyncStorage: 29f0230e1a25f36c20b05f65e2eb8958d6526e82
|
||||
RNCMaskedView: 5ef8c95cbab95334a32763b72896a7b7d07e6299
|
||||
@@ -3539,7 +3539,7 @@ SPEC CHECKSUMS:
|
||||
RNDateTimePicker: cda4c045beca864cebb3209ef9cc4094f974864c
|
||||
RNDeviceInfo: d863506092aef7e7af3a1c350c913d867d795047
|
||||
RNGestureHandler: 3a73f098d74712952870e948b3d9cf7b6cae9961
|
||||
RNPurchases: 7993b33416e67d5863140b5c62c682b34719f475
|
||||
RNPurchases: 1bc60e3a69af65d9cfe23967328421dd1df1763c
|
||||
RNReanimated: 9de34f0313c4177a34c079ca9fce6f1f278bff24
|
||||
RNScreens: 0bbf16c074ae6bb1058a7bf2d1ae017f4306797c
|
||||
RNSentry: f2c39f1113e22413c9bb6e3faa6b27f110d95eaf
|
||||
|
||||
32
package-lock.json
generated
@@ -58,7 +58,7 @@
|
||||
"react-native-markdown-display": "^7.0.2",
|
||||
"react-native-modal-datetime-picker": "^18.0.0",
|
||||
"react-native-popover-view": "^6.1.0",
|
||||
"react-native-purchases": "^9.2.2",
|
||||
"react-native-purchases": "^9.4.3",
|
||||
"react-native-reanimated": "~4.1.0",
|
||||
"react-native-render-html": "^6.3.4",
|
||||
"react-native-safe-area-context": "~5.6.0",
|
||||
@@ -3967,24 +3967,24 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@revenuecat/purchases-js": {
|
||||
"version": "1.11.1",
|
||||
"resolved": "https://mirrors.tencent.com/npm/@revenuecat/purchases-js/-/purchases-js-1.11.1.tgz",
|
||||
"integrity": "sha512-P0jxwUBWOIFSZQ1/NIMpbOXG3brraNDGYoCnES1r5w97yonhAw1brpKwhFKUhlq+DvAUDCG1q1d8FdTzI+MgXg==",
|
||||
"version": "1.14.0",
|
||||
"resolved": "https://mirrors.tencent.com/npm/@revenuecat/purchases-js/-/purchases-js-1.14.0.tgz",
|
||||
"integrity": "sha512-SotJ9MznBZoq91nSxLl1Oqw42k6A3JU0d73/WFbppv1IG770Q8KhuAUo1SgZC62ERCuCHme5fEdER1ZI4x8y7A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@revenuecat/purchases-js-hybrid-mappings": {
|
||||
"version": "16.2.1",
|
||||
"resolved": "https://mirrors.tencent.com/npm/@revenuecat/purchases-js-hybrid-mappings/-/purchases-js-hybrid-mappings-16.2.1.tgz",
|
||||
"integrity": "sha512-TXYw6lh5rg/kGI44kayU4TGSXKDcc35TdB0vBuZfllSokY1tnyYmP8Pm2eZamLN8ycrTuCysoPxknW2Klh1H1g==",
|
||||
"version": "17.7.0",
|
||||
"resolved": "https://mirrors.tencent.com/npm/@revenuecat/purchases-js-hybrid-mappings/-/purchases-js-hybrid-mappings-17.7.0.tgz",
|
||||
"integrity": "sha512-254jmgsCxn7Om49gdRQmh8rNVPsEjpMcKmrIwiR8D4Yfcch4bWUK23bDNvFR29Ygen9ctBkGc+CklmmKNQMQYw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@revenuecat/purchases-js": "1.11.1"
|
||||
"@revenuecat/purchases-js": "1.14.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@revenuecat/purchases-typescript-internal": {
|
||||
"version": "16.2.1",
|
||||
"resolved": "https://mirrors.tencent.com/npm/@revenuecat/purchases-typescript-internal/-/purchases-typescript-internal-16.2.1.tgz",
|
||||
"integrity": "sha512-g7FhNA6nxr9686klimlfueMQqQl34pHUHXeCKXqeuaPJOOsFc7qcOGhGZdyLGulIAgpkctrvcAbeDyBk7t5QRg==",
|
||||
"version": "17.7.0",
|
||||
"resolved": "https://mirrors.tencent.com/npm/@revenuecat/purchases-typescript-internal/-/purchases-typescript-internal-17.7.0.tgz",
|
||||
"integrity": "sha512-CGyNcupvNEnyiZTRsD98VOLRxsp6cr1NYIL8XKDO2EoSDBaDuDXyHTADbPEf0lRW2qYqwmQKZFJ61Vwl+0YiWw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@rtsao/scc": {
|
||||
@@ -12673,17 +12673,17 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-native-purchases": {
|
||||
"version": "9.2.2",
|
||||
"resolved": "https://mirrors.tencent.com/npm/react-native-purchases/-/react-native-purchases-9.2.2.tgz",
|
||||
"integrity": "sha512-j376mva8G6SLA2HPTROpUGoivfLMZVWPM7mj2bcgTS8y6NzbyQJ20Npe8V3nWc0N5YFTuknTF8pl0tWc6FqYbA==",
|
||||
"version": "9.4.3",
|
||||
"resolved": "https://mirrors.tencent.com/npm/react-native-purchases/-/react-native-purchases-9.4.3.tgz",
|
||||
"integrity": "sha512-7M8/tjZY2iZISBOqXIxxzkt7fxt86WGF2ZW/qcsEfXVaZv2UvDLs+GuYKjf0Bz+2hOuYwoJK/LF++jpUi6CRng==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"examples/purchaseTesterTypescript",
|
||||
"react-native-purchases-ui"
|
||||
],
|
||||
"dependencies": {
|
||||
"@revenuecat/purchases-js-hybrid-mappings": "16.2.1",
|
||||
"@revenuecat/purchases-typescript-internal": "16.2.1"
|
||||
"@revenuecat/purchases-js-hybrid-mappings": "17.7.0",
|
||||
"@revenuecat/purchases-typescript-internal": "17.7.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">= 16.6.3",
|
||||
|
||||
@@ -62,7 +62,7 @@
|
||||
"react-native-markdown-display": "^7.0.2",
|
||||
"react-native-modal-datetime-picker": "^18.0.0",
|
||||
"react-native-popover-view": "^6.1.0",
|
||||
"react-native-purchases": "^9.2.2",
|
||||
"react-native-purchases": "^9.4.3",
|
||||
"react-native-reanimated": "~4.1.0",
|
||||
"react-native-render-html": "^6.3.4",
|
||||
"react-native-safe-area-context": "~5.6.0",
|
||||
|
||||
@@ -92,7 +92,7 @@ export type UserState = {
|
||||
activityHistory: ActivityHistoryItem[];
|
||||
};
|
||||
|
||||
export const DEFAULT_MEMBER_NAME = '小海豹';
|
||||
export const DEFAULT_MEMBER_NAME = '';
|
||||
|
||||
const getInitialState = (): UserState => {
|
||||
const preloaded = getPreloadedUserData();
|
||||
|
||||