feat: 新增基础代谢详情页面并优化HRV数据获取逻辑

- 新增基础代谢详情页面,包含图表展示、数据缓存和防抖机制
- 优化HRV数据获取逻辑,支持实时、近期和历史数据的智能获取
- 移除WaterIntakeCard和WaterSettings中的登录验证逻辑
- 更新饮水数据管理hook,直接使用HealthKit数据
- 添加饮水目标存储和获取功能
- 更新依赖包版本
This commit is contained in:
richarjiang
2025-09-25 14:15:42 +08:00
parent 83e534c4a7
commit 79ab354f31
12 changed files with 1563 additions and 702 deletions

View File

@@ -0,0 +1,820 @@
import { DateSelector } from '@/components/DateSelector';
import { HeaderBar } from '@/components/ui/HeaderBar';
import { Colors } from '@/constants/Colors';
import { useAppSelector } from '@/hooks/redux';
import { selectUserAge, selectUserProfile } from '@/store/userSlice';
import { getMonthDaysZh, getTodayIndexInMonth } from '@/utils/date';
import { fetchBasalEnergyBurned } from '@/utils/health';
import { Ionicons } from '@expo/vector-icons';
import dayjs from 'dayjs';
import weekOfYear from 'dayjs/plugin/weekOfYear';
import { LinearGradient } from 'expo-linear-gradient';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { ActivityIndicator, Dimensions, Modal, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { BarChart } from 'react-native-chart-kit';
dayjs.extend(weekOfYear);
type TabType = 'week' | 'month';
type BasalMetabolismData = {
date: Date;
value: number | null;
};
export default function BasalMetabolismDetailScreen() {
const userProfile = useAppSelector(selectUserProfile);
const userAge = useAppSelector(selectUserAge);
// 日期相关状态
const [selectedIndex, setSelectedIndex] = useState(getTodayIndexInMonth());
const [activeTab, setActiveTab] = useState<TabType>('week');
// 说明弹窗状态
const [infoModalVisible, setInfoModalVisible] = useState(false);
// 数据状态
const [chartData, setChartData] = useState<BasalMetabolismData[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// 缓存和防抖相关参照BasalMetabolismCard
const [cacheRef] = useState(() => new Map<string, { data: BasalMetabolismData[]; timestamp: number }>());
const [loadingRef] = useState(() => new Map<string, Promise<BasalMetabolismData[]>>());
const CACHE_DURATION = 5 * 60 * 1000; // 5分钟缓存
console.log('basal metabolism chartData', chartData);
// 生成日期范围的函数
const generateDateRange = useCallback((tab: TabType): Date[] => {
const today = new Date();
const dates: Date[] = [];
switch (tab) {
case 'week':
// 获取最近7天
for (let i = 6; i >= 0; i--) {
const date = dayjs(today).subtract(i, 'day').toDate();
dates.push(date);
}
break;
case 'month':
// 获取最近30天按周分组
for (let i = 3; i >= 0; i--) {
const date = dayjs(today).subtract(i * 7, 'day').toDate();
dates.push(date);
}
break;
}
return dates;
}, []);
// 优化的数据获取函数,包含缓存和去重复请求
const fetchBasalMetabolismData = useCallback(async (tab: TabType): Promise<BasalMetabolismData[]> => {
const cacheKey = `${tab}-${dayjs().format('YYYY-MM-DD')}`;
const now = Date.now();
// 检查缓存
const cached = cacheRef.get(cacheKey);
if (cached && (now - cached.timestamp) < CACHE_DURATION) {
return cached.data;
}
// 检查是否已经在请求中(防止重复请求)
const existingRequest = loadingRef.get(cacheKey);
if (existingRequest) {
return existingRequest;
}
// 创建新的请求
const request = (async () => {
try {
const dates = generateDateRange(tab);
const results: BasalMetabolismData[] = [];
// 并行获取所有日期的数据
const promises = dates.map(async (date) => {
try {
const options = {
startDate: dayjs(date).startOf('day').toDate().toISOString(),
endDate: dayjs(date).endOf('day').toDate().toISOString()
};
const basalEnergy = await fetchBasalEnergyBurned(options);
return {
date,
value: basalEnergy || null
};
} catch (error) {
console.error('获取单日基础代谢数据失败:', error);
return {
date,
value: null
};
}
});
const data = await Promise.all(promises);
results.push(...data);
// 更新缓存
cacheRef.set(cacheKey, { data: results, timestamp: now });
return results;
} catch (error) {
console.error('获取基础代谢数据失败:', error);
return [];
} finally {
// 清理请求记录
loadingRef.delete(cacheKey);
}
})();
// 记录请求
loadingRef.set(cacheKey, request);
return request;
}, [generateDateRange, cacheRef, loadingRef, CACHE_DURATION]);
// 获取当前选中日期
const currentSelectedDate = useMemo(() => {
const days = getMonthDaysZh();
return days[selectedIndex]?.date?.toDate() ?? new Date();
}, [selectedIndex]);
// 计算BMR范围
const bmrRange = useMemo(() => {
const { gender, weight, height } = userProfile;
// 检查是否有足够的信息来计算BMR
if (!gender || !weight || !height || !userAge) {
return null;
}
// 将体重和身高转换为数字
const weightNum = parseFloat(weight);
const heightNum = parseFloat(height);
if (isNaN(weightNum) || isNaN(heightNum) || weightNum <= 0 || heightNum <= 0 || userAge <= 0) {
return null;
}
// 使用Mifflin-St Jeor公式计算BMR
let bmr: number;
if (gender === 'male') {
bmr = 10 * weightNum + 6.25 * heightNum - 5 * userAge + 5;
} else {
bmr = 10 * weightNum + 6.25 * heightNum - 5 * userAge - 161;
}
// 计算正常范围±15%
const minBMR = Math.round(bmr * 0.85);
const maxBMR = Math.round(bmr * 1.15);
return { min: minBMR, max: maxBMR, base: Math.round(bmr) };
}, [userProfile.gender, userProfile.weight, userProfile.height, userAge]);
// 获取单个日期的代谢数据
const fetchSingleDateData = useCallback(async (date: Date): Promise<BasalMetabolismData> => {
try {
const options = {
startDate: dayjs(date).startOf('day').toDate().toISOString(),
endDate: dayjs(date).endOf('day').toDate().toISOString()
};
const basalEnergy = await fetchBasalEnergyBurned(options);
return {
date,
value: basalEnergy || null
};
} catch (error) {
console.error('获取单日基础代谢数据失败:', error);
return {
date,
value: null
};
}
}, []);
// 日期选择回调
const onSelectDate = useCallback(async (index: number) => {
setSelectedIndex(index);
// 获取选中日期
const days = getMonthDaysZh();
const selectedDate = days[index]?.date?.toDate();
if (selectedDate) {
// 检查是否已经有该日期的数据
const existingData = chartData.find(item =>
dayjs(item.date).isSame(selectedDate, 'day')
);
// 如果没有数据,则获取该日期的数据
if (!existingData) {
try {
const newData = await fetchSingleDateData(selectedDate);
// 更新chartData添加新数据并按日期排序
setChartData(prevData => {
const updatedData = [...prevData, newData];
return updatedData.sort((a, b) => a.date.getTime() - b.date.getTime());
});
} catch (error) {
console.error('获取选中日期数据失败:', error);
}
}
}
}, [chartData, fetchSingleDateData]);
// Tab切换
const handleTabPress = useCallback((tab: TabType) => {
setActiveTab(tab);
}, []);
// 初始化和Tab切换时加载数据
useEffect(() => {
let isCancelled = false;
const loadData = async () => {
setIsLoading(true);
setError(null);
try {
const data = await fetchBasalMetabolismData(activeTab);
if (!isCancelled) {
setChartData(data);
}
} catch (err) {
if (!isCancelled) {
setError(err instanceof Error ? err.message : '获取数据失败');
}
} finally {
if (!isCancelled) {
setIsLoading(false);
}
}
};
loadData();
// 清理函数,防止组件卸载后的状态更新
return () => {
isCancelled = true;
};
}, [activeTab, fetchBasalMetabolismData]);
// 处理图表数据
const processedChartData = useMemo(() => {
if (!chartData || chartData.length === 0) {
return { labels: [], datasets: [] };
}
// 根据activeTab生成标签和数据
const labels = chartData.map(item => {
switch (activeTab) {
case 'week':
// 显示星期几
return dayjs(item.date).format('dd');
case 'month':
// 显示周数
const weekOfYear = dayjs(item.date).week();
const firstWeekOfYear = dayjs(item.date).startOf('year').week();
return `${weekOfYear - firstWeekOfYear + 1}`;
default:
return dayjs(item.date).format('MM-DD');
}
});
// 生成基础代谢数据集
const data = chartData.map(item => {
const value = item.value;
if (value === null || value === undefined) {
return 0; // 明确处理null/undefined值
}
// 对于非常小的正值保证至少显示1但对于0值保持为0
const roundedValue = Math.round(value);
return value > 0 && roundedValue === 0 ? 1 : roundedValue;
});
console.log('processedChartData:', { labels, data, originalValues: chartData.map(item => item.value) });
return {
labels,
datasets: [{
data
}]
};
}, [chartData, activeTab]);
return (
<View style={styles.container}>
{/* 背景渐变 */}
<LinearGradient
colors={['#f5e5fbff', '#e5fcfeff', '#eefdffff', '#e6f6fcff']}
style={styles.gradientBackground}
start={{ x: 0, y: 0 }}
end={{ x: 0, y: 1 }}
/>
{/* 头部导航 */}
<HeaderBar
title="基础代谢"
transparent
right={
<TouchableOpacity
onPress={() => setInfoModalVisible(true)}
style={styles.infoButton}
>
<Ionicons name="information-circle-outline" size={24} color="#666" />
</TouchableOpacity>
}
/>
<ScrollView
style={styles.scrollView}
contentContainerStyle={{
paddingBottom: 60,
paddingHorizontal: 20
}}
showsVerticalScrollIndicator={false}
>
{/* 日期选择器 */}
<View style={styles.dateContainer}>
<DateSelector
selectedIndex={selectedIndex}
onDateSelect={onSelectDate}
showMonthTitle={false}
disableFutureDates={true}
/>
</View>
{/* 当前日期基础代谢显示 */}
<View style={styles.currentDataCard}>
<Text style={styles.currentDataTitle}>
{dayjs(currentSelectedDate).format('M月D日')}
</Text>
<View style={styles.currentValueContainer}>
<Text style={styles.currentValue}>
{(() => {
const selectedDateData = chartData.find(item =>
dayjs(item.date).isSame(currentSelectedDate, 'day')
);
if (selectedDateData?.value) {
return Math.round(selectedDateData.value).toString();
}
return '--';
})()}
</Text>
<Text style={styles.currentUnit}></Text>
</View>
{bmrRange && (
<Text style={styles.rangeText}>
: {bmrRange.min}-{bmrRange.max}
</Text>
)}
</View>
{/* 基础代谢统计 */}
<View style={styles.statsCard}>
<Text style={styles.statsTitle}></Text>
{/* Tab 切换 */}
<View style={styles.tabContainer}>
<TouchableOpacity
style={[styles.tab, activeTab === 'week' && styles.activeTab]}
onPress={() => handleTabPress('week')}
activeOpacity={0.7}
>
<Text style={[styles.tabText, activeTab === 'week' && styles.activeTabText]}>
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.tab, activeTab === 'month' && styles.activeTab]}
onPress={() => handleTabPress('month')}
activeOpacity={0.7}
>
<Text style={[styles.tabText, activeTab === 'month' && styles.activeTabText]}>
</Text>
</TouchableOpacity>
</View>
{/* 柱状图 */}
{isLoading ? (
<View style={styles.loadingChart}>
<ActivityIndicator size="large" color="#4ECDC4" />
<Text style={styles.loadingText}>...</Text>
</View>
) : error ? (
<View style={styles.errorChart}>
<Text style={styles.errorText}>: {error}</Text>
<TouchableOpacity
style={styles.retryButton}
onPress={() => {
// 重新加载数据
setIsLoading(true);
setError(null);
fetchBasalMetabolismData(activeTab).then(data => {
setChartData(data);
setIsLoading(false);
}).catch(err => {
setError(err instanceof Error ? err.message : '获取数据失败');
setIsLoading(false);
});
}}
activeOpacity={0.7}
>
<Text style={styles.retryText}></Text>
</TouchableOpacity>
</View>
) : processedChartData.datasets.length > 0 && processedChartData.datasets[0].data.length > 0 ? (
<BarChart
data={{
labels: processedChartData.labels,
datasets: processedChartData.datasets,
}}
width={Dimensions.get('window').width - 80}
height={220}
yAxisLabel=""
yAxisSuffix="千卡"
chartConfig={{
backgroundColor: '#ffffff',
backgroundGradientFrom: '#ffffff',
backgroundGradientTo: '#ffffff',
decimalPlaces: 0,
color: (opacity = 1) => `${Colors.light.primary}${Math.round(opacity * 255).toString(16).padStart(2, '0')}`, // 使用主题紫色
labelColor: (opacity = 1) => `${Colors.light.text}${Math.round(opacity * 255).toString(16).padStart(2, '0')}`, // 使用主题文字颜色
style: {
borderRadius: 16,
},
barPercentage: 0.7, // 增加柱体宽度
propsForBackgroundLines: {
strokeDasharray: "2,2",
stroke: Colors.light.border, // 使用主题边框颜色
strokeWidth: 1
},
propsForLabels: {
fontSize: 12,
fontWeight: '500',
},
}}
style={styles.chart}
showValuesOnTopOfBars={true}
fromZero={false}
segments={4}
/>
) : (
<View style={styles.emptyChart}>
<Text style={styles.emptyChartText}></Text>
</View>
)}
</View>
</ScrollView>
{/* 基础代谢说明弹窗 */}
<Modal
animationType="fade"
transparent={true}
visible={infoModalVisible}
onRequestClose={() => setInfoModalVisible(false)}
>
<View style={styles.modalOverlay}>
<View style={styles.modalContent}>
{/* 关闭按钮 */}
<TouchableOpacity
style={styles.closeButton}
onPress={() => setInfoModalVisible(false)}
>
<Text style={styles.closeButtonText}>×</Text>
</TouchableOpacity>
{/* 标题 */}
<Text style={styles.modalTitle}></Text>
{/* 基础代谢定义 */}
<Text style={styles.modalDescription}>
BMR
</Text>
{/* 为什么重要 */}
<Text style={styles.sectionTitle}></Text>
<Text style={styles.sectionContent}>
60-75%
</Text>
{/* 正常范围 */}
<Text style={styles.sectionTitle}></Text>
<Text style={styles.formulaText}>
- BMR = 10 × (kg) + 6.25 × (cm) - 5 × + 5
</Text>
<Text style={styles.formulaText}>
- BMR = 10 × (kg) + 6.25 × (cm) - 5 × - 161
</Text>
{bmrRange ? (
<>
<Text style={styles.rangeText}>{bmrRange.min}-{bmrRange.max}/</Text>
<Text style={styles.rangeNote}>
(15%)
</Text>
<Text style={styles.userInfoText}>
{userProfile.gender === 'male' ? '男性' : '女性'}{userAge}{userProfile.height}cm{userProfile.weight}kg
</Text>
</>
) : (
<Text style={styles.rangeText}></Text>
)}
{/* 提高代谢率的策略 */}
<Text style={styles.sectionTitle}></Text>
<Text style={styles.strategyText}></Text>
<View style={styles.strategyList}>
<Text style={styles.strategyItem}>1. (2-3)</Text>
<Text style={styles.strategyItem}>2. (HIIT)</Text>
<Text style={styles.strategyItem}>3. (1.6-2.2g)</Text>
<Text style={styles.strategyItem}>4. (7-9/)</Text>
<Text style={styles.strategyItem}>5. (BMR的80%)</Text>
</View>
</View>
</View>
</Modal>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#FFFFFF',
},
gradientBackground: {
position: 'absolute',
left: 0,
right: 0,
top: 0,
bottom: 0,
},
scrollView: {
flex: 1,
},
infoButton: {
padding: 4,
},
dateContainer: {
marginTop: 16,
marginBottom: 20,
},
currentDataCard: {
backgroundColor: '#FFFFFF',
borderRadius: 16,
padding: 20,
marginBottom: 20,
shadowColor: '#000',
shadowOffset: {
width: 0,
height: 4,
},
shadowOpacity: 0.12,
shadowRadius: 12,
elevation: 6,
alignItems: 'center',
},
currentDataTitle: {
fontSize: 16,
fontWeight: '700',
color: '#192126',
marginBottom: 16,
textAlign: 'center',
},
currentValueContainer: {
flexDirection: 'row',
alignItems: 'baseline',
marginBottom: 8,
},
currentValue: {
fontSize: 36,
fontWeight: '700',
color: '#4ECDC4',
},
currentUnit: {
fontSize: 16,
color: '#666',
marginLeft: 8,
},
rangeText: {
fontSize: 14,
color: '#059669',
textAlign: 'center',
},
statsCard: {
backgroundColor: '#FFFFFF',
borderRadius: 16,
padding: 20,
shadowColor: '#000',
shadowOffset: {
width: 0,
height: 4,
},
shadowOpacity: 0.12,
shadowRadius: 12,
elevation: 6,
},
statsTitle: {
fontSize: 16,
fontWeight: '700',
color: '#192126',
marginBottom: 16,
},
tabContainer: {
flexDirection: 'row',
backgroundColor: '#F5F5F7',
borderRadius: 12,
padding: 4,
marginBottom: 20,
},
tab: {
flex: 1,
paddingVertical: 8,
paddingHorizontal: 16,
borderRadius: 8,
alignItems: 'center',
},
activeTab: {
backgroundColor: '#FFFFFF',
shadowColor: '#000',
shadowOffset: {
width: 0,
height: 1,
},
shadowOpacity: 0.1,
shadowRadius: 2,
elevation: 2,
},
tabText: {
fontSize: 14,
fontWeight: '600',
color: '#888',
},
activeTabText: {
color: '#192126',
},
chart: {
marginVertical: 8,
borderRadius: 16,
},
emptyChart: {
height: 220,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#F8F9FA',
borderRadius: 16,
marginVertical: 8,
},
emptyChartText: {
fontSize: 14,
color: '#999',
fontWeight: '500',
},
loadingChart: {
height: 220,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#F8F9FA',
borderRadius: 16,
marginVertical: 8,
},
loadingText: {
fontSize: 14,
color: '#666',
marginTop: 8,
fontWeight: '500',
},
errorChart: {
height: 220,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#FFF5F5',
borderRadius: 16,
marginVertical: 8,
padding: 20,
},
errorText: {
fontSize: 14,
color: '#E53E3E',
textAlign: 'center',
marginBottom: 12,
},
retryButton: {
backgroundColor: '#4ECDC4',
paddingHorizontal: 16,
paddingVertical: 8,
borderRadius: 8,
},
retryText: {
color: '#FFFFFF',
fontSize: 14,
fontWeight: '600',
},
// Modal styles
modalOverlay: {
flex: 1,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
justifyContent: 'flex-end',
},
modalContent: {
backgroundColor: '#FFFFFF',
borderTopLeftRadius: 20,
borderTopRightRadius: 20,
padding: 24,
maxHeight: '90%',
width: '100%',
shadowColor: '#000',
shadowOffset: {
width: 0,
height: -5,
},
shadowOpacity: 0.25,
shadowRadius: 20,
elevation: 10,
},
closeButton: {
position: 'absolute',
top: 16,
right: 16,
width: 32,
height: 32,
borderRadius: 16,
backgroundColor: '#F1F5F9',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1,
},
closeButtonText: {
fontSize: 20,
color: '#64748B',
fontWeight: '600',
},
modalTitle: {
fontSize: 24,
fontWeight: '700',
color: '#0F172A',
marginBottom: 16,
textAlign: 'center',
},
modalDescription: {
fontSize: 15,
color: '#475569',
lineHeight: 22,
marginBottom: 24,
},
sectionTitle: {
fontSize: 18,
fontWeight: '700',
color: '#0F172A',
marginBottom: 12,
marginTop: 8,
},
sectionContent: {
fontSize: 15,
color: '#475569',
lineHeight: 22,
marginBottom: 20,
},
formulaText: {
fontSize: 14,
color: '#64748B',
fontFamily: 'monospace',
marginBottom: 4,
paddingLeft: 8,
},
rangeNote: {
fontSize: 12,
color: '#9CA3AF',
textAlign: 'center',
marginBottom: 20,
},
userInfoText: {
fontSize: 13,
color: '#6B7280',
textAlign: 'center',
marginTop: 8,
marginBottom: 16,
fontStyle: 'italic',
},
strategyText: {
fontSize: 15,
color: '#475569',
marginBottom: 12,
},
strategyList: {
marginBottom: 20,
},
strategyItem: {
fontSize: 14,
color: '#64748B',
lineHeight: 20,
marginBottom: 8,
paddingLeft: 8,
},
});

View File

@@ -1,5 +1,4 @@
import { Colors } from '@/constants/Colors'; import { Colors } from '@/constants/Colors';
import { useAuthGuard } from '@/hooks/useAuthGuard';
import { useColorScheme } from '@/hooks/useColorScheme'; import { useColorScheme } from '@/hooks/useColorScheme';
import { useWaterDataByDate } from '@/hooks/useWaterData'; import { useWaterDataByDate } from '@/hooks/useWaterData';
import { getQuickWaterAmount, setQuickWaterAmount } from '@/utils/userPreferences'; import { getQuickWaterAmount, setQuickWaterAmount } from '@/utils/userPreferences';
@@ -34,7 +33,6 @@ const WaterSettings: React.FC<WaterSettingsProps> = () => {
const { selectedDate } = useLocalSearchParams<{ selectedDate?: string }>(); const { selectedDate } = useLocalSearchParams<{ selectedDate?: string }>();
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark'; const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
const colorTokens = Colors[theme]; const colorTokens = Colors[theme];
const { ensureLoggedIn } = useAuthGuard();
const [dailyGoal, setDailyGoal] = useState<string>('2000'); const [dailyGoal, setDailyGoal] = useState<string>('2000');
const [quickAddAmount, setQuickAddAmount] = useState<string>('250'); const [quickAddAmount, setQuickAddAmount] = useState<string>('250');
@@ -50,18 +48,6 @@ const WaterSettings: React.FC<WaterSettingsProps> = () => {
// 使用新的 hook 来处理指定日期的饮水数据 // 使用新的 hook 来处理指定日期的饮水数据
const { waterRecords, dailyWaterGoal, updateWaterGoal, removeWaterRecord } = useWaterDataByDate(selectedDate); const { waterRecords, dailyWaterGoal, updateWaterGoal, removeWaterRecord } = useWaterDataByDate(selectedDate);
// 检查登录状态
useEffect(() => {
const checkLoginStatus = async () => {
const isLoggedIn = await ensureLoggedIn();
if (!isLoggedIn) {
// 如果未登录,用户会被重定向到登录页面
return;
}
};
checkLoginStatus();
}, [ensureLoggedIn]);
const goalPresets = [1500, 2000, 2500, 3000, 3500, 4000]; const goalPresets = [1500, 2000, 2500, 3000, 3500, 4000];
const quickAddPresets = [100, 150, 200, 250, 300, 350, 400, 500]; const quickAddPresets = [100, 150, 200, 250, 300, 350, 400, 500];

View File

@@ -6,7 +6,7 @@ import dayjs from 'dayjs';
import { Image } from 'expo-image'; import { Image } from 'expo-image';
import { router } from 'expo-router'; import { router } from 'expo-router';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Modal, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
interface BasalMetabolismCardProps { interface BasalMetabolismCardProps {
selectedDate?: Date; selectedDate?: Date;
@@ -16,7 +16,6 @@ interface BasalMetabolismCardProps {
export function BasalMetabolismCard({ selectedDate, style }: BasalMetabolismCardProps) { export function BasalMetabolismCard({ selectedDate, style }: BasalMetabolismCardProps) {
const [basalMetabolism, setBasalMetabolism] = useState<number | null>(null); const [basalMetabolism, setBasalMetabolism] = useState<number | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [modalVisible, setModalVisible] = useState(false);
// 获取用户基本信息 // 获取用户基本信息
const userProfile = useAppSelector(selectUserProfile); const userProfile = useAppSelector(selectUserProfile);
@@ -154,7 +153,7 @@ export function BasalMetabolismCard({ selectedDate, style }: BasalMetabolismCard
<> <>
<TouchableOpacity <TouchableOpacity
style={[styles.container, style]} style={[styles.container, style]}
onPress={() => setModalVisible(true)} onPress={() => router.push(ROUTES.BASAL_METABOLISM_DETAIL)}
activeOpacity={0.8} activeOpacity={0.8}
> >
{/* 头部区域 */} {/* 头部区域 */}
@@ -179,86 +178,6 @@ export function BasalMetabolismCard({ selectedDate, style }: BasalMetabolismCard
<Text style={styles.unit}>/</Text> <Text style={styles.unit}>/</Text>
</View> </View>
</TouchableOpacity> </TouchableOpacity>
{/* 基础代谢详情弹窗 */}
<Modal
animationType="fade"
transparent={true}
visible={modalVisible}
onRequestClose={() => setModalVisible(false)}
>
<View style={styles.modalOverlay}>
<View style={styles.modalContent}>
{/* 关闭按钮 */}
<TouchableOpacity
style={styles.closeButton}
onPress={() => setModalVisible(false)}
>
<Text style={styles.closeButtonText}>×</Text>
</TouchableOpacity>
{/* 标题 */}
<Text style={styles.modalTitle}></Text>
{/* 基础代谢定义 */}
<Text style={styles.modalDescription}>
BMR
</Text>
{/* 为什么重要 */}
<Text style={styles.sectionTitle}></Text>
<Text style={styles.sectionContent}>
60-75%
</Text>
{/* 正常范围 */}
<Text style={styles.sectionTitle}></Text>
<Text style={styles.formulaText}>
- BMR = 10 × (kg) + 6.25 × (cm) - 5 × + 5
</Text>
<Text style={styles.formulaText}>
- BMR = 10 × (kg) + 6.25 × (cm) - 5 × - 161
</Text>
{bmrRange ? (
<>
<Text style={styles.rangeText}>{bmrRange.min}-{bmrRange.max}/</Text>
<Text style={styles.rangeNote}>
(15%)
</Text>
<Text style={styles.userInfoText}>
{userProfile.gender === 'male' ? '男性' : '女性'}{userAge}{userProfile.height}cm{userProfile.weight}kg
</Text>
</>
) : (
<>
<Text style={styles.rangeText}></Text>
<TouchableOpacity
style={styles.completeInfoButton}
onPress={() => {
setModalVisible(false);
router.push(ROUTES.PROFILE_EDIT);
}}
>
<Text style={styles.completeInfoButtonText}></Text>
</TouchableOpacity>
</>
)}
{/* 提高代谢率的策略 */}
<Text style={styles.sectionTitle}></Text>
<Text style={styles.strategyText}></Text>
<View style={styles.strategyList}>
<Text style={styles.strategyItem}>1. (2-3)</Text>
<Text style={styles.strategyItem}>2. (HIIT)</Text>
<Text style={styles.strategyItem}>3. (1.6-2.2g)</Text>
<Text style={styles.strategyItem}>4. (7-9/)</Text>
<Text style={styles.strategyItem}>5. (BMR的80%)</Text>
</View>
</View>
</View>
</Modal>
</> </>
); );
} }
@@ -352,128 +271,4 @@ const styles = StyleSheet.create({
color: '#64748B', color: '#64748B',
marginLeft: 6, marginLeft: 6,
}, },
// Modal styles
modalOverlay: {
flex: 1,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
justifyContent: 'flex-end',
},
modalContent: {
backgroundColor: '#FFFFFF',
borderTopLeftRadius: 20,
borderTopRightRadius: 20,
padding: 24,
maxHeight: '90%',
width: '100%',
shadowColor: '#000',
shadowOffset: {
width: 0,
height: -5,
},
shadowOpacity: 0.25,
shadowRadius: 20,
elevation: 10,
},
closeButton: {
position: 'absolute',
top: 16,
right: 16,
width: 32,
height: 32,
borderRadius: 16,
backgroundColor: '#F1F5F9',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1,
},
closeButtonText: {
fontSize: 20,
color: '#64748B',
fontWeight: '600',
},
modalTitle: {
fontSize: 24,
fontWeight: '700',
color: '#0F172A',
marginBottom: 16,
textAlign: 'center',
},
modalDescription: {
fontSize: 15,
color: '#475569',
lineHeight: 22,
marginBottom: 24,
},
sectionTitle: {
fontSize: 18,
fontWeight: '700',
color: '#0F172A',
marginBottom: 12,
marginTop: 8,
},
sectionContent: {
fontSize: 15,
color: '#475569',
lineHeight: 22,
marginBottom: 20,
},
formulaText: {
fontSize: 14,
color: '#64748B',
fontFamily: 'monospace',
marginBottom: 4,
paddingLeft: 8,
},
rangeText: {
fontSize: 16,
fontWeight: '600',
color: '#059669',
marginTop: 12,
marginBottom: 4,
textAlign: 'center',
},
rangeNote: {
fontSize: 12,
color: '#9CA3AF',
textAlign: 'center',
marginBottom: 20,
},
userInfoText: {
fontSize: 13,
color: '#6B7280',
textAlign: 'center',
marginTop: 8,
marginBottom: 16,
fontStyle: 'italic',
},
strategyText: {
fontSize: 15,
color: '#475569',
marginBottom: 12,
},
strategyList: {
marginBottom: 20,
},
strategyItem: {
fontSize: 14,
color: '#64748B',
lineHeight: 20,
marginBottom: 8,
paddingLeft: 8,
},
completeInfoButton: {
backgroundColor: '#7a5af8',
borderRadius: 12,
paddingVertical: 12,
paddingHorizontal: 24,
marginTop: 16,
alignItems: 'center',
alignSelf: 'center',
},
completeInfoButtonText: {
color: '#FFFFFF',
fontSize: 16,
fontWeight: '600',
},
}); });

View File

@@ -1,4 +1,4 @@
import { fetchHRVForDate } from '@/utils/health'; import { fetchHRVWithStatus } from '@/utils/health';
import { Image } from 'expo-image'; import { Image } from 'expo-image';
import { LinearGradient } from 'expo-linear-gradient'; import { LinearGradient } from 'expo-linear-gradient';
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
@@ -34,13 +34,24 @@ export function StressMeter({ curDate }: StressMeterProps) {
const getHrvData = async () => { const getHrvData = async () => {
try { try {
const data = await fetchHRVForDate(curDate) console.log('StressMeter: 开始获取HRV数据...', curDate);
if (data) { // 使用智能HRV数据获取功能
setHrvValue(Math.round(data.value)) const result = await fetchHRVWithStatus(curDate);
console.log('StressMeter: HRV数据获取结果:', result);
if (result.hrvData) {
setHrvValue(Math.round(result.hrvData.value));
console.log(`StressMeter: 使用${result.message}HRV值: ${result.hrvData.value}ms`);
} else {
console.log('StressMeter: 未获取到HRV数据');
// 可以设置一个默认值或者显示无数据状态
setHrvValue(0);
} }
} catch (error) { } catch (error) {
console.error('StressMeter: 获取HRV数据失败:', error);
setHrvValue(0);
} }
} }

View File

@@ -1,4 +1,3 @@
import { useAuthGuard } from '@/hooks/useAuthGuard';
import { useWaterDataByDate } from '@/hooks/useWaterData'; import { useWaterDataByDate } from '@/hooks/useWaterData';
import { getQuickWaterAmount } from '@/utils/userPreferences'; import { getQuickWaterAmount } from '@/utils/userPreferences';
import { useFocusEffect } from '@react-navigation/native'; import { useFocusEffect } from '@react-navigation/native';
@@ -28,7 +27,6 @@ const WaterIntakeCard: React.FC<WaterIntakeCardProps> = ({
selectedDate selectedDate
}) => { }) => {
const router = useRouter(); const router = useRouter();
const { ensureLoggedIn } = useAuthGuard();
const { waterStats, dailyWaterGoal, waterRecords, addWaterRecord } = useWaterDataByDate(selectedDate); const { waterStats, dailyWaterGoal, waterRecords, addWaterRecord } = useWaterDataByDate(selectedDate);
const [quickWaterAmount, setQuickWaterAmount] = useState(150); // 默认值,将从用户偏好中加载 const [quickWaterAmount, setQuickWaterAmount] = useState(150); // 默认值,将从用户偏好中加载
@@ -123,12 +121,6 @@ const WaterIntakeCard: React.FC<WaterIntakeCardProps> = ({
// 处理添加喝水 - 右上角按钮直接添加 // 处理添加喝水 - 右上角按钮直接添加
const handleQuickAddWater = async () => { const handleQuickAddWater = async () => {
// 检查用户是否已登录
const isLoggedIn = await ensureLoggedIn();
if (!isLoggedIn) {
return;
}
// 触发震动反馈 // 触发震动反馈
if (process.env.EXPO_OS === 'ios') { if (process.env.EXPO_OS === 'ios') {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
@@ -145,12 +137,6 @@ const WaterIntakeCard: React.FC<WaterIntakeCardProps> = ({
// 处理卡片点击 - 跳转到饮水设置页面 // 处理卡片点击 - 跳转到饮水设置页面
const handleCardPress = async () => { const handleCardPress = async () => {
// 检查用户是否已登录
const isLoggedIn = await ensureLoggedIn();
if (!isLoggedIn) {
return;
}
// 触发震动反馈 // 触发震动反馈
if (process.env.EXPO_OS === 'ios') { if (process.env.EXPO_OS === 'ios') {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);

View File

@@ -45,6 +45,7 @@ export const ROUTES = {
// 健康相关路由 // 健康相关路由
FITNESS_RINGS_DETAIL: '/fitness-rings-detail', FITNESS_RINGS_DETAIL: '/fitness-rings-detail',
SLEEP_DETAIL: '/sleep-detail', SLEEP_DETAIL: '/sleep-detail',
BASAL_METABOLISM_DETAIL: '/basal-metabolism-detail',
// 任务相关路由 // 任务相关路由
TASK_DETAIL: '/task-detail', TASK_DETAIL: '/task-detail',

File diff suppressed because it is too large Load Diff

View File

@@ -53,7 +53,7 @@ PODS:
- ExpoModulesCore - ExpoModulesCore
- ExpoHaptics (15.0.7): - ExpoHaptics (15.0.7):
- ExpoModulesCore - ExpoModulesCore
- ExpoHead (6.0.6): - ExpoHead (6.0.8):
- ExpoModulesCore - ExpoModulesCore
- RNScreens - RNScreens
- ExpoImage (3.0.8): - ExpoImage (3.0.8):
@@ -2657,7 +2657,7 @@ SPEC CHECKSUMS:
ExpoFont: 86ceec09ffed1c99cfee36ceb79ba149074901b5 ExpoFont: 86ceec09ffed1c99cfee36ceb79ba149074901b5
ExpoGlassEffect: 744bf0c58c26a1b0212dff92856be07b98d01d8c ExpoGlassEffect: 744bf0c58c26a1b0212dff92856be07b98d01d8c
ExpoHaptics: 807476b0c39e9d82b7270349d6487928ce32df84 ExpoHaptics: 807476b0c39e9d82b7270349d6487928ce32df84
ExpoHead: 78f14a8573ae5b882123b272c0af20a80bfa58f6 ExpoHead: 5570e5edbe54fd8f88e51e8b94bf2931caaa7363
ExpoImage: e88f500585913969b930e13a4be47277eb7c6de8 ExpoImage: e88f500585913969b930e13a4be47277eb7c6de8
ExpoImagePicker: d251aab45a1b1857e4156fed88511b278b4eee1c ExpoImagePicker: d251aab45a1b1857e4156fed88511b278b4eee1c
ExpoKeepAwake: 1a2e820692e933c94a565ec3fbbe38ac31658ffe ExpoKeepAwake: 1a2e820692e933c94a565ec3fbbe38ac31658ffe

20
package-lock.json generated
View File

@@ -16,9 +16,9 @@
"@react-native-masked-view/masked-view": "^0.3.2", "@react-native-masked-view/masked-view": "^0.3.2",
"@react-native-picker/picker": "2.11.2", "@react-native-picker/picker": "2.11.2",
"@react-native-voice/voice": "^3.2.4", "@react-native-voice/voice": "^3.2.4",
"@react-navigation/bottom-tabs": "^7.4.0", "@react-navigation/bottom-tabs": "^7.4.7",
"@react-navigation/elements": "^2.6.4", "@react-navigation/elements": "^2.6.4",
"@react-navigation/native": "^7.1.8", "@react-navigation/native": "^7.1.17",
"@reduxjs/toolkit": "^2.9.0", "@reduxjs/toolkit": "^2.9.0",
"@sentry/react-native": "~7.1.0", "@sentry/react-native": "~7.1.0",
"@types/lodash": "^4.17.20", "@types/lodash": "^4.17.20",
@@ -38,13 +38,13 @@
"expo-linking": "~8.0.8", "expo-linking": "~8.0.8",
"expo-notifications": "~0.32.11", "expo-notifications": "~0.32.11",
"expo-quick-actions": "^6.0.0", "expo-quick-actions": "^6.0.0",
"expo-router": "~6.0.6", "expo-router": "~6.0.8",
"expo-splash-screen": "~31.0.10", "expo-splash-screen": "~31.0.10",
"expo-sqlite": "^16.0.8", "expo-sqlite": "^16.0.8",
"expo-status-bar": "~3.0.8", "expo-status-bar": "~3.0.8",
"expo-symbols": "~1.0.7", "expo-symbols": "~1.0.7",
"expo-system-ui": "~6.0.7", "expo-system-ui": "~6.0.7",
"expo-task-manager": "~14.0.6", "expo-task-manager": "~14.0.7",
"expo-web-browser": "~15.0.7", "expo-web-browser": "~15.0.7",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"lottie-react-native": "^7.3.4", "lottie-react-native": "^7.3.4",
@@ -68,7 +68,6 @@
"react-native-web": "^0.21.1", "react-native-web": "^0.21.1",
"react-native-webview": "13.16.0", "react-native-webview": "13.16.0",
"react-native-wheel-picker-expo": "^0.5.4", "react-native-wheel-picker-expo": "^0.5.4",
"react-native-worklets": "0.5.1",
"react-redux": "^9.2.0" "react-redux": "^9.2.0"
}, },
"devDependencies": { "devDependencies": {
@@ -1409,6 +1408,7 @@
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.27.1.tgz", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.27.1.tgz",
"integrity": "sha512-fBJKiV7F2DxZUkg5EtHKXQdbsbURW3DZKQUWphDum0uRP6eHGGa/He9mc0mypL680pb+e/lDIthRohlv8NCHkg==", "integrity": "sha512-fBJKiV7F2DxZUkg5EtHKXQdbsbURW3DZKQUWphDum0uRP6eHGGa/He9mc0mypL680pb+e/lDIthRohlv8NCHkg==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/helper-plugin-utils": "^7.27.1" "@babel/helper-plugin-utils": "^7.27.1"
}, },
@@ -7963,14 +7963,14 @@
} }
}, },
"node_modules/expo-router": { "node_modules/expo-router": {
"version": "6.0.6", "version": "6.0.8",
"resolved": "https://mirrors.tencent.com/npm/expo-router/-/expo-router-6.0.6.tgz", "resolved": "https://mirrors.tencent.com/npm/expo-router/-/expo-router-6.0.8.tgz",
"integrity": "sha512-uSuKQanivBI9RtwmAznLI7It5aPwQLVL2tVBPAOJ70tv6BzP62SpVCf0I8o0j9PmEzORPRLrU2LbQOL962yBHg==", "integrity": "sha512-cx6vFvBrfPNHpNbN2ij2mF5JKE4JXyq+dJVmWNqt7JplA0aohOOKXS/KQ9vQy88HpnrcJMuYqUNHp44aWyce7g==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@expo/metro-runtime": "^6.1.2", "@expo/metro-runtime": "^6.1.2",
"@expo/schema-utils": "^0.1.7", "@expo/schema-utils": "^0.1.7",
"@expo/server": "^0.7.4", "@expo/server": "^0.7.5",
"@radix-ui/react-slot": "1.2.0", "@radix-ui/react-slot": "1.2.0",
"@radix-ui/react-tabs": "^1.1.12", "@radix-ui/react-tabs": "^1.1.12",
"@react-navigation/bottom-tabs": "^7.4.0", "@react-navigation/bottom-tabs": "^7.4.0",
@@ -12547,6 +12547,7 @@
"resolved": "https://registry.npmjs.org/react-native-worklets/-/react-native-worklets-0.5.1.tgz", "resolved": "https://registry.npmjs.org/react-native-worklets/-/react-native-worklets-0.5.1.tgz",
"integrity": "sha512-lJG6Uk9YuojjEX/tQrCbcbmpdLCSFxDK1rJlkDhgqkVi1KZzG7cdcBFQRqyNOOzR9Y0CXNuldmtWTGOyM0k0+w==", "integrity": "sha512-lJG6Uk9YuojjEX/tQrCbcbmpdLCSFxDK1rJlkDhgqkVi1KZzG7cdcBFQRqyNOOzR9Y0CXNuldmtWTGOyM0k0+w==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/plugin-transform-arrow-functions": "^7.0.0-0", "@babel/plugin-transform-arrow-functions": "^7.0.0-0",
"@babel/plugin-transform-class-properties": "^7.0.0-0", "@babel/plugin-transform-class-properties": "^7.0.0-0",
@@ -12571,6 +12572,7 @@
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
"integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==",
"license": "ISC", "license": "ISC",
"peer": true,
"bin": { "bin": {
"semver": "bin/semver.js" "semver": "bin/semver.js"
}, },

View File

@@ -20,9 +20,9 @@
"@react-native-masked-view/masked-view": "^0.3.2", "@react-native-masked-view/masked-view": "^0.3.2",
"@react-native-picker/picker": "2.11.2", "@react-native-picker/picker": "2.11.2",
"@react-native-voice/voice": "^3.2.4", "@react-native-voice/voice": "^3.2.4",
"@react-navigation/bottom-tabs": "^7.4.0", "@react-navigation/bottom-tabs": "^7.4.7",
"@react-navigation/elements": "^2.6.4", "@react-navigation/elements": "^2.6.4",
"@react-navigation/native": "^7.1.8", "@react-navigation/native": "^7.1.17",
"@reduxjs/toolkit": "^2.9.0", "@reduxjs/toolkit": "^2.9.0",
"@sentry/react-native": "~7.1.0", "@sentry/react-native": "~7.1.0",
"@types/lodash": "^4.17.20", "@types/lodash": "^4.17.20",
@@ -42,13 +42,13 @@
"expo-linking": "~8.0.8", "expo-linking": "~8.0.8",
"expo-notifications": "~0.32.11", "expo-notifications": "~0.32.11",
"expo-quick-actions": "^6.0.0", "expo-quick-actions": "^6.0.0",
"expo-router": "~6.0.6", "expo-router": "~6.0.8",
"expo-splash-screen": "~31.0.10", "expo-splash-screen": "~31.0.10",
"expo-sqlite": "^16.0.8", "expo-sqlite": "^16.0.8",
"expo-status-bar": "~3.0.8", "expo-status-bar": "~3.0.8",
"expo-symbols": "~1.0.7", "expo-symbols": "~1.0.7",
"expo-system-ui": "~6.0.7", "expo-system-ui": "~6.0.7",
"expo-task-manager": "~14.0.6", "expo-task-manager": "~14.0.7",
"expo-web-browser": "~15.0.7", "expo-web-browser": "~15.0.7",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"lottie-react-native": "^7.3.4", "lottie-react-native": "^7.3.4",
@@ -72,7 +72,6 @@
"react-native-web": "^0.21.1", "react-native-web": "^0.21.1",
"react-native-webview": "13.16.0", "react-native-webview": "13.16.0",
"react-native-wheel-picker-expo": "^0.5.4", "react-native-wheel-picker-expo": "^0.5.4",
"react-native-worklets": "0.5.1",
"react-redux": "^9.2.0" "react-redux": "^9.2.0"
}, },
"devDependencies": { "devDependencies": {

View File

@@ -518,32 +518,70 @@ async function fetchHeartRateVariability(options: HealthDataOptions): Promise<HR
if (result && result.data && Array.isArray(result.data) && result.data.length > 0) { if (result && result.data && Array.isArray(result.data) && result.data.length > 0) {
let selectedSample: any = null; let selectedSample: any = null;
let bestQuality = -1;
console.log('result~~~', result); console.log(`获取到 ${result.data.length} 个HRV样本`);
// 优先使用优化后的最佳质量值对应的样本 // 首先尝试使用最佳质量值
if (result.bestQualityValue && typeof result.bestQualityValue === 'number') { if (result.bestQualityValue && typeof result.bestQualityValue === 'number') {
const qualityValue = validateHRVValue(result.bestQualityValue); const qualityValue = validateHRVValue(result.bestQualityValue);
if (qualityValue !== null) { if (qualityValue !== null) {
// 找到对应的最佳质量样本 // 找到质量分数最高的样本
selectedSample = result.data[result.data.length - 1]; for (const sample of result.data) {
const sampleQuality = sample.qualityScore || 0;
const sampleValue = validateHRVValue(sample.value);
logSuccess('HRV数据最佳质量', { if (sampleValue !== null && sampleQuality > bestQuality) {
value: qualityValue, bestQuality = sampleQuality;
totalSamples: result.data.length, selectedSample = sample;
recordedAt: selectedSample.endDate }
}); }
if (selectedSample) {
logSuccess('HRV数据最佳质量', {
value: qualityValue,
qualityScore: bestQuality,
totalSamples: result.data.length,
recordedAt: selectedSample.endDate
});
}
} }
} }
// 如果没有找到最佳质量样本,使用第一个有效样本 // 如果没有找到最佳质量样本,或者最佳质量值无效,重新评估所有样本
if (!selectedSample) { if (!selectedSample) {
for (const sample of result.data) { console.log('重新评估所有样本以找到最佳数据...');
// 按质量分数、手动测量标志和时间排序
const sortedSamples = result.data.sort((a: any, b: any) => {
const qualityA = a.qualityScore || 0;
const qualityB = b.qualityScore || 0;
const isManualA = a.isManualMeasurement || false;
const isManualB = b.isManualMeasurement || false;
// 手动测量优先
if (isManualA && !isManualB) return -1;
if (!isManualA && isManualB) return 1;
// 质量分数优先
if (qualityA !== qualityB) return qualityB - qualityA;
// 时间优先(最新的优先)
const dateA = new Date(a.endDate || a.startDate).getTime();
const dateB = new Date(b.endDate || b.startDate).getTime();
return dateB - dateA;
});
// 选择第一个有效样本
for (const sample of sortedSamples) {
const sampleValue = validateHRVValue(sample.value); const sampleValue = validateHRVValue(sample.value);
if (sampleValue !== null) { if (sampleValue !== null) {
selectedSample = sample; selectedSample = sample;
console.log('使用有效HRV样本:', { bestQuality = sample.qualityScore || 0;
console.log('选择最佳HRV样本:', {
value: sampleValue, value: sampleValue,
qualityScore: bestQuality,
isManual: sample.isManualMeasurement,
source: sample.source?.name, source: sample.source?.name,
recordedAt: sample.endDate recordedAt: sample.endDate
}); });
@@ -733,8 +771,35 @@ export async function fetchTodayHealthData(): Promise<TodayHealthData> {
export async function fetchHRVForDate(date: Date): Promise<HRVData | null> { export async function fetchHRVForDate(date: Date): Promise<HRVData | null> {
console.log('开始获取指定日期HRV数据...', date); console.log('开始获取指定日期HRV数据...', date);
// 首先尝试获取指定日期的HRV数据
const options = createDateRange(date); const options = createDateRange(date);
return fetchHeartRateVariability(options); const hrvData = await fetchHeartRateVariability(options);
// 如果当天没有数据尝试获取最近7天内的最新数据
if (!hrvData) {
console.log('指定日期无HRV数据尝试获取最近7天内的数据...');
const endDate = new Date(date);
const startDate = new Date(date);
startDate.setDate(startDate.getDate() - 7); // 往前推7天
const recentOptions = {
startDate: startDate.toISOString(),
endDate: endDate.toISOString()
};
const recentHrvData = await fetchHeartRateVariability(recentOptions);
if (recentHrvData) {
console.log('获取到最近7天内的HRV数据:', recentHrvData);
return recentHrvData;
} else {
console.log('最近7天内也无HRV数据');
}
}
return hrvData;
} }
export async function fetchTodayHRV(): Promise<HRVData | null> { export async function fetchTodayHRV(): Promise<HRVData | null> {
@@ -1189,3 +1254,117 @@ export async function fetchHRVWithAnalysis(date: Date): Promise<{ hrvData: HRVDa
} }
} }
// 智能HRV数据获取 - 优先获取实时数据,如果没有则获取历史数据
export async function fetchSmartHRVData(date: Date): Promise<HRVData | null> {
console.log('开始智能HRV数据获取...', date);
try {
// 1. 首先尝试获取最近2小时的实时数据
console.log('1. 尝试获取最近2小时的实时HRV数据...');
const recentHRV = await fetchRecentHRV(2);
if (recentHRV) {
console.log('✅ 成功获取到实时HRV数据:', recentHRV);
// 检查数据是否足够新1小时内
const dataTime = new Date(recentHRV.recordedAt).getTime();
const now = Date.now();
const oneHour = 60 * 60 * 1000;
if (now - dataTime <= oneHour) {
console.log('✅ 实时数据足够新,直接使用');
return recentHRV;
} else {
console.log('⚠️ 实时数据较旧,继续寻找更好的数据');
}
}
// 2. 如果没有实时数据或数据太旧,尝试获取当天的数据
console.log('2. 尝试获取当天的HRV数据...');
const todayHRV = await fetchHRVForDate(date);
if (todayHRV) {
console.log('✅ 成功获取到当天HRV数据:', todayHRV);
return todayHRV;
}
// 3. 如果当天没有数据尝试获取最近3天的数据
console.log('3. 尝试获取最近3天的HRV数据...');
const endDate = new Date(date);
const startDate = new Date(date);
startDate.setDate(startDate.getDate() - 3);
const recentOptions = {
startDate: startDate.toISOString(),
endDate: endDate.toISOString()
};
const recentData = await fetchHeartRateVariability(recentOptions);
if (recentData) {
console.log('✅ 成功获取到最近3天的HRV数据:', recentData);
return recentData;
}
// 4. 如果仍然没有数据返回null
console.log('❌ 未找到任何HRV数据');
return null;
} catch (error) {
console.error('智能HRV数据获取失败:', error);
return null;
}
}
// 获取HRV数据并附带详细的状态信息
export async function fetchHRVWithStatus(date: Date): Promise<{
hrvData: HRVData | null;
status: 'realtime' | 'recent' | 'historical' | 'none';
message: string;
}> {
try {
const hrvData = await fetchSmartHRVData(date);
if (!hrvData) {
return {
hrvData: null,
status: 'none',
message: '未找到HRV数据'
};
}
const dataTime = new Date(hrvData.recordedAt).getTime();
const now = Date.now();
const oneHour = 60 * 60 * 1000;
const oneDay = 24 * 60 * 60 * 1000;
let status: 'realtime' | 'recent' | 'historical';
let message: string;
if (now - dataTime <= oneHour) {
status = 'realtime';
message = '实时HRV数据';
} else if (now - dataTime <= oneDay) {
status = 'recent';
message = '近期HRV数据';
} else {
status = 'historical';
message = '历史HRV数据';
}
return {
hrvData,
status,
message
};
} catch (error) {
console.error('获取HRV状态失败:', error);
return {
hrvData: null,
status: 'none',
message: '获取HRV数据失败'
};
}
}

View File

@@ -3,6 +3,7 @@ import AsyncStorage from '@/utils/kvStore';
// 用户偏好设置的存储键 // 用户偏好设置的存储键
const PREFERENCES_KEYS = { const PREFERENCES_KEYS = {
QUICK_WATER_AMOUNT: 'user_preference_quick_water_amount', QUICK_WATER_AMOUNT: 'user_preference_quick_water_amount',
WATER_GOAL: 'user_preference_water_goal',
NOTIFICATION_ENABLED: 'user_preference_notification_enabled', NOTIFICATION_ENABLED: 'user_preference_notification_enabled',
FITNESS_EXERCISE_MINUTES_INFO_DISMISSED: 'user_preference_fitness_exercise_minutes_info_dismissed', FITNESS_EXERCISE_MINUTES_INFO_DISMISSED: 'user_preference_fitness_exercise_minutes_info_dismissed',
FITNESS_ACTIVE_HOURS_INFO_DISMISSED: 'user_preference_fitness_active_hours_info_dismissed', FITNESS_ACTIVE_HOURS_INFO_DISMISSED: 'user_preference_fitness_active_hours_info_dismissed',
@@ -11,6 +12,7 @@ const PREFERENCES_KEYS = {
// 用户偏好设置接口 // 用户偏好设置接口
export interface UserPreferences { export interface UserPreferences {
quickWaterAmount: number; quickWaterAmount: number;
waterGoal: number;
notificationEnabled: boolean; notificationEnabled: boolean;
fitnessExerciseMinutesInfoDismissed: boolean; fitnessExerciseMinutesInfoDismissed: boolean;
fitnessActiveHoursInfoDismissed: boolean; fitnessActiveHoursInfoDismissed: boolean;
@@ -19,6 +21,7 @@ export interface UserPreferences {
// 默认的用户偏好设置 // 默认的用户偏好设置
const DEFAULT_PREFERENCES: UserPreferences = { const DEFAULT_PREFERENCES: UserPreferences = {
quickWaterAmount: 150, // 默认快速添加饮水量为 150ml quickWaterAmount: 150, // 默认快速添加饮水量为 150ml
waterGoal: 2000, // 默认每日饮水目标为 2000ml
notificationEnabled: true, // 默认开启消息推送 notificationEnabled: true, // 默认开启消息推送
fitnessExerciseMinutesInfoDismissed: false, // 默认显示锻炼分钟说明 fitnessExerciseMinutesInfoDismissed: false, // 默认显示锻炼分钟说明
fitnessActiveHoursInfoDismissed: false, // 默认显示活动小时说明 fitnessActiveHoursInfoDismissed: false, // 默认显示活动小时说明
@@ -30,12 +33,14 @@ const DEFAULT_PREFERENCES: UserPreferences = {
export const getUserPreferences = async (): Promise<UserPreferences> => { export const getUserPreferences = async (): Promise<UserPreferences> => {
try { try {
const quickWaterAmount = await AsyncStorage.getItem(PREFERENCES_KEYS.QUICK_WATER_AMOUNT); const quickWaterAmount = await AsyncStorage.getItem(PREFERENCES_KEYS.QUICK_WATER_AMOUNT);
const waterGoal = await AsyncStorage.getItem(PREFERENCES_KEYS.WATER_GOAL);
const notificationEnabled = await AsyncStorage.getItem(PREFERENCES_KEYS.NOTIFICATION_ENABLED); const notificationEnabled = await AsyncStorage.getItem(PREFERENCES_KEYS.NOTIFICATION_ENABLED);
const fitnessExerciseMinutesInfoDismissed = await AsyncStorage.getItem(PREFERENCES_KEYS.FITNESS_EXERCISE_MINUTES_INFO_DISMISSED); const fitnessExerciseMinutesInfoDismissed = await AsyncStorage.getItem(PREFERENCES_KEYS.FITNESS_EXERCISE_MINUTES_INFO_DISMISSED);
const fitnessActiveHoursInfoDismissed = await AsyncStorage.getItem(PREFERENCES_KEYS.FITNESS_ACTIVE_HOURS_INFO_DISMISSED); const fitnessActiveHoursInfoDismissed = await AsyncStorage.getItem(PREFERENCES_KEYS.FITNESS_ACTIVE_HOURS_INFO_DISMISSED);
return { return {
quickWaterAmount: quickWaterAmount ? parseInt(quickWaterAmount, 10) : DEFAULT_PREFERENCES.quickWaterAmount, quickWaterAmount: quickWaterAmount ? parseInt(quickWaterAmount, 10) : DEFAULT_PREFERENCES.quickWaterAmount,
waterGoal: waterGoal ? parseInt(waterGoal, 10) : DEFAULT_PREFERENCES.waterGoal,
notificationEnabled: notificationEnabled !== null ? notificationEnabled === 'true' : DEFAULT_PREFERENCES.notificationEnabled, notificationEnabled: notificationEnabled !== null ? notificationEnabled === 'true' : DEFAULT_PREFERENCES.notificationEnabled,
fitnessExerciseMinutesInfoDismissed: fitnessExerciseMinutesInfoDismissed !== null ? fitnessExerciseMinutesInfoDismissed === 'true' : DEFAULT_PREFERENCES.fitnessExerciseMinutesInfoDismissed, fitnessExerciseMinutesInfoDismissed: fitnessExerciseMinutesInfoDismissed !== null ? fitnessExerciseMinutesInfoDismissed === 'true' : DEFAULT_PREFERENCES.fitnessExerciseMinutesInfoDismissed,
fitnessActiveHoursInfoDismissed: fitnessActiveHoursInfoDismissed !== null ? fitnessActiveHoursInfoDismissed === 'true' : DEFAULT_PREFERENCES.fitnessActiveHoursInfoDismissed, fitnessActiveHoursInfoDismissed: fitnessActiveHoursInfoDismissed !== null ? fitnessActiveHoursInfoDismissed === 'true' : DEFAULT_PREFERENCES.fitnessActiveHoursInfoDismissed,
@@ -74,6 +79,34 @@ export const getQuickWaterAmount = async (): Promise<number> => {
} }
}; };
/**
* 设置每日饮水目标
* @param goal 饮水目标(毫升)
*/
export const setWaterGoalToStorage = async (goal: number): Promise<void> => {
try {
// 确保值在合理范围内500ml - 5000ml
const validGoal = Math.max(500, Math.min(5000, goal));
await AsyncStorage.setItem(PREFERENCES_KEYS.WATER_GOAL, validGoal.toString());
} catch (error) {
console.error('设置每日饮水目标失败:', error);
throw error;
}
};
/**
* 获取每日饮水目标
*/
export const getWaterGoalFromStorage = async (): Promise<number> => {
try {
const goal = await AsyncStorage.getItem(PREFERENCES_KEYS.WATER_GOAL);
return goal ? parseInt(goal, 10) : DEFAULT_PREFERENCES.waterGoal;
} catch (error) {
console.error('获取每日饮水目标失败:', error);
return DEFAULT_PREFERENCES.waterGoal;
}
};
/** /**
* 设置消息推送开关 * 设置消息推送开关
* @param enabled 是否开启消息推送 * @param enabled 是否开启消息推送